不変性がそれほど重要なのはなぜですか

こんにちは、Habr!



今日は、不変性のトピックに触れて、この問題をもっと真剣に検討する価値があるかどうかを確認したいと思います。



不変のオブジェクトは、プログラミングにおいて計り知れないほど強力な現象です。不変性は、あらゆる種類の同時実行の問題や多数のバグを回避するのに役立ちますが、不変の構造を理解するのは難しい場合があります。それらが何であるか、そしてそれらをどのように使用するかを見てみましょう。



まず、単純なオブジェクトを見てみましょう。



class Person {
    public String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
}


ご覧のとおり、オブジェクトPersonはコンストラクターで1つのパラメーターを受け取り、それをパブリック変数に入れますnameしたがって、次のようなことができます。



Person p = new Person("John");
p.name = "Jane";


簡単ですよね?いつでも、必要に応じてデータを読み取ったり変更したりできます。しかし、この方法にはいくつかの問題があります。それらの最初で最も重要なことは、クラスname変数を使用することです。したがって、クラスの内部ストレージをパブリックAPIに取り返しのつかないほど導入します。つまり、アプリケーションの重要な部分を書き直さない限り、クラス内での名前の格納方法を変更する方法はありません。



一部の言語(C#など)は、この問題を回避するためにゲッター関数を挿入する機能を提供しますが、ほとんどのオブジェクト指向言語では、明示的に行動する必要があります:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}


ここまでは順調ですね。名前の内部ストレージを、たとえば姓名に変更したい場合は、次のようにすることができます。



class Person {
    private String firstName;
    private String lastName;
    
    public Person(
        String firstName,
        String lastName
    ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public String getName() {
        return firstName + " " + lastName;
    }
}


このような名前の表現に関連する深刻な問題を詳しく調べなければ、APIが外部からgetName()変更されいないことは明らかです



名前の設定はどうですか?名前を取得するだけでなく、このように設定するには、何を追加する必要がありますか?



class Person {
    private String name;
    
    //...
    
    public void setName(String name) {
        this.name = name;
    }
    
    //...
}


名前をもう一度変更できるようになったので、一見すると見栄えがします。しかし、この方法でデータを変更することには根本的な欠陥があります。それには、哲学的と実践的な2つの側面があります。



哲学的な問題から始めましょう。オブジェクトはPerson、人を表すことを目的としています。実際、人の姓は変更される可能性がありますが、この目的のためchangeName関数に名前を付ける方がよいでしょう。そのような名前は、同じ人の名前を変更することを意味するからです。また、セッターのように振る舞うだけでなく、人の姓を変更するためのビジネスロジックも含める必要があります。この名前setNameは、personオブジェクトに格納されている名前を自発的かつ強制的に変更できるという完全に論理的な結論につながり、そのために何も取得されません。



2番目の理由は、練習に関係しています。変更可能な状態(変更される可能性のある保存データ)にはバグが発生しやすいということです。このオブジェクトPerson取得して、インターフェイスを定義しましょうPersonStorage



interface PersonStorage {
    public void store(Person person);
    public Person getByName(String name);
}


注:これPersonStorageは、オブジェクトが格納されている場所(メモリ内、ディスク上、またはデータベース内)を正確に示すものではありません。また、インターフェイスは、格納するオブジェクトのコピーを作成するための実装を必要としません。したがって、興味深いバグが発生する可能性があります。



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);


現在、パーソンストアには何人いますか?一つか二つ?また、今メソッドを適用した場合、誰getByNameに返されますか?



ご覧のとおり、ここでは2つのオプションが可能です。PersonStorageオブジェクトをコピーしますPerson。この場合、2つのレコードが保存されますPerson。または、これを行わず、渡されたオブジェクトへの参照のみを保存します。2番目のケースでは、名前の付いたオブジェクトが1つだけ保存され“Jane”ます。2番目のオプションの実装は次のようになります。



class InMemoryPersonStorage implements PersonStorage {
    private Set<Person> persons = new HashSet<>();

    public void store(Person person) {
        this.persons.add(person);
    }
}


さらに悪いことに、保存されているデータは、関数を呼び出さなくても変更できますstoreリポジトリには元のオブジェクトへの参照のみが含まれているため、名前を変更すると、保存されているバージョンも変更されます。



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");


したがって、本質的には、可変状態を処理しているという理由だけで、バグがプログラムに忍び寄ります。ストレージにコピーを作成する作業を明示的に書き留めることでこの問題を回避できることは間違いありませんが、不変のオブジェクトを操作するというはるかに簡単な方法があります。例を考えてみましょう:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    
    public Person withName(String name) {
        return new Person(name);
    }
}


ご覧のとおり、メソッドの代わりに、オブジェクトの新しいコピーを作成するメソッドがsetName使用されるwithNameようになりましたPerson毎回新しいコピーを作成する場合、変更可能な状態なしで、対応する問題なしで作成します。もちろん、これにはいくらかのオーバーヘッドが伴いますが、最新のコンパイラーはそれを処理でき、パフォーマンスの問題が発生した場合は後で修正できます。



覚えておいてください:

時期尚早の最適化はすべての悪の根源です(Donald Knuth)


ライブオブジェクトを参照する永続性レベルは壊れた永続性レベルであると主張することができますが、そのようなシナリオは現実的です。悪いコードは存在し、不変性はこの種の失敗を防ぐのに役立つ貴重なツールです。



オブジェクトがアプリケーションの複数のレイヤーを通過する、より複雑なシナリオでは、バグがコードを簡単に溢れさせ、不変性が状態のバグの発生を防ぎます。この種の例には、たとえば、メモリ内キャッシングや順不同の関数呼び出しが含まれます。



不変性が並列処理にどのように役立つか



不変性が役立つもう1つの重要な領域は、並列処理です。より正確には、マルチスレッド。マルチスレッドアプリケーションでは、複数行のコードが並行して実行され、同時に同じメモリ領域にアクセスします。非常に単純なリストを考えてみましょう。



if (p.getName().equals("John")) {
    p.setName(p.getName() + "Doe");
}


このコード自体はバグがありませんが、並行して実行すると、プリエンプトが開始され、乱雑になる可能性があります。上記のコードスニペットがコメントでどのように見えるかを確認してください。



if (p.getName().equals("John")) {

    //     ,     John
    
    p.setName(p.getName() + "Doe");
}


これはレースコンディションです。最初のスレッドは名前が等しいかどうかをチェックします“John”が、2番目のスレッドは名前を変更します。名前が等しいと仮定して、最初のスレッドは引き続き実行されますJohn



もちろん、ロックを使用して、常に1つのスレッドのみがコードの重要な部分に入るようにすることもできますが、これがボトルネックになる可能性があります。ただし、オブジェクトが不変である場合、同じオブジェクトが常にpに格納されるため、そのようなシナリオは開発できません。別のスレッドが変更に影響を与えたい場合、最初のスレッドにはない新しいコピーを作成します。



結果



基本的に、アプリケーションで可変状態が最小化されていることを常に確認することをお勧めします。使用する場合は、適切に設計されたAPIで厳しく制限し、アプリケーションの他の領域に漏れないようにしてください。状態を含むコードの数が少ないほど、状態エラーが発生する可能性は低くなります。



もちろん、州にまったく頼らなければ、ほとんどのプログラミングの問題は解決できません。ただし、すべてのデータ構造がデフォルトで不変であると見なすと、コード内のランダムなバグははるかに少なくなります。本当にコードに可変性を導入することを余儀なくされている場合は、それを慎重に行い、結果を検討する必要があり、すべてのコードをそれで開始する必要はありません。



All Articles