web-dev-qa-db-ja.com

C#で不変オブジェクト間の循環参照をモデル化する方法

次のコード例では、部屋を表す不変オブジェクトのクラスがあります。北、南、東、西は他の部屋への出口を表しています。

public sealed class Room
{
    public Room(string name, Room northExit, Room southExit, Room eastExit, Room westExit)
    {
        this.Name = name;
        this.North = northExit;
        this.South = southExit;
        this.East = eastExit;
        this.West = westExit;
    }

    public string Name { get; }

    public Room North { get; }

    public Room South { get; }

    public Room East { get; }

    public Room West { get; }
}

ご覧のとおり、このクラスは再帰循環参照を使用して設計されています。しかし、クラスは不変なので、「鶏または卵」の問題に悩まされています。私は経験豊富な関数型プログラマーがこれにどう対処するか知っていると確信しています。 C#ではどのように処理できますか?

私はテキストベースのアドベンチャーゲームのコーディングに努めていますが、学習のためだけに関数型プログラミングの原則を使用しています。私はこの概念にこだわっており、いくつかの助けを借りることができます!!!ありがとう。

更新:

遅延初期化に関するMike Nakisの回答に基づいた実際の実装は次のとおりです。

using System;

public sealed class Room
{
    private readonly Func<Room> north;
    private readonly Func<Room> south;
    private readonly Func<Room> east;
    private readonly Func<Room> west;

    public Room(
        string name, 
        Func<Room> northExit = null, 
        Func<Room> southExit = null, 
        Func<Room> eastExit = null, 
        Func<Room> westExit = null)
    {
        this.Name = name;

        var dummyDelegate = new Func<Room>(() => { return null; });

        this.north = northExit ?? dummyDelegate;
        this.south = southExit ?? dummyDelegate;
        this.east = eastExit ?? dummyDelegate;
        this.west = westExit ?? dummyDelegate;
    }

    public string Name { get; }

    public override string ToString()
    {
        return this.Name;
    }

    public Room North
    {
        get { return this.north(); }
    }

    public Room South
    {
        get { return this.south(); }
    }

    public Room East
    {
        get { return this.east(); }
    }

    public Room West
    {
        get { return this.west(); }
    }        

    public static void Main(string[] args)
    {
        Room kitchen = null;
        Room library = null;

        kitchen = new Room(
            name: "Kitchen",
            northExit: () => library
         );

        library = new Room(
            name: "Library",
            southExit: () => kitchen
         );

        Console.WriteLine(
            $"The {kitchen} has a northen exit that " +
            $"leads to the {kitchen.North}.");

        Console.WriteLine(
            $"The {library} has a southern exit that " +
            $"leads to the {library.South}.");

        Console.ReadKey();
    }
}
24

明らかに、投稿したコードを使用してそれを行うことはできません。ある時点で、まだ構築されていない別のオブジェクトに接続する必要があるオブジェクトを構築する必要があるためです。

これを行うには、私が考えることができる(以前に使用した)2つの方法があります。

2つのフェーズの使用

すべてのオブジェクトが最初に構築され、依存関係はありません。すべてのオブジェクトが構築されると、それらが接続されます。これは、オブジェクトが2つのフェーズを通過する必要があることを意味します。非常に短い可変フェーズと、その後の残りのライフタイムを通じて続く不変フェーズです。

リレーショナルデータベースをモデル化するときに、まったく同じ種類の問題に遭遇する可能性があります。1つのテーブルには別のテーブルを指す外部キーがあり、他のテーブルには最初のテーブルを指す外部キーがある場合があります。リレーショナルデータベースでこれを処理する方法は、外部キー制約がALTER TABLE ADD FOREIGN KEYステートメントとは別の追加のCREATE TABLEステートメントで指定できる(通常は指定できる)ことです。したがって、最初にすべてのテーブルを作成し、次に外部キー制約を追加します。

リレーショナルデータベースとあなたがやりたいことの違いは、リレーショナルデータベースはテーブルの存続期間を通じてALTER TABLE ADD/DROP FOREIGN KEYステートメントを許可し続けますが、すべての依存関係があると、おそらく 'IamImmutable`フラグを設定し、それ以上の変更を拒否します実現されました。

遅延初期化の使用

依存関係への参照の代わりに、必要なときに依存関係への参照を返すdelegateを渡します。依存関係がフェッチされると、デリゲートは再び呼び出されることはありません。

デリゲートは通常ラムダ式の形式をとるので、実際に依存関係をコンストラクターに渡すよりも少しだけ冗長に見えます。

このテクニックの(小さな)欠点は、オブジェクトグラフの初期化中にのみ使用されるデリゲートへのポインターを格納するために必要なストレージスペースを浪費する必要があることです。

これを実装する汎用の「遅延参照」クラスを作成することもできるので、メンバーの1つ1つに対して再実装する必要はありません。

これはJavaで記述されたそのようなクラスであり、C#で簡単に転記できます

(私のFunction<T>はC#のFunc<T>デリゲートに似ています)

package saganaki.util;

import Java.util.Objects;

/**
 * A {@link Function} decorator which invokes the given {@link Function} only once, when actually needed, and then caches its result and never calls it again.
 * It behaves as if it is immutable, which includes the fact that it is thread-safe, provided that the given {@link Function} is also thread-safe.
 *
 * @param <T> the type of object supplied.
 */
public final class LazyImmutable<T> implements Function<T>
{
    private static final boolean USE_DOUBLE_CHECK = false; //TODO try with "double check"
    private final Object lock = new Object();
    @SuppressWarnings( "FieldAccessedSynchronizedAndUnsynchronized" )
    private Function<T> supplier;
    @SuppressWarnings( "FieldAccessedSynchronizedAndUnsynchronized" )
    private T value;

    /**
     * Constructor.
     *
     * @param supplier the {@link Function} which will supply the supplied object the first time it is needed.
     */
    public LazyImmutable( Function<T> supplier )
    {
        assert supplier != null;
        assert !(supplier instanceof LazyImmutable);
        this.supplier = supplier;
        value = null;
    }

    @Override
    public T invoke()
    {
        if( USE_DOUBLE_CHECK )
        {
            if( supplier != null )
                doCheck();
            return value;
        }

        doCheck();
        return value;
    }

    private void doCheck()
    {
        synchronized( lock )
        {
            if( supplier != null )
            {
                value = supplier.invoke();
                supplier = null;
            }
        }
    }

    @Override
    public String toString()
    {
        if( supplier != null )
            return "(lazy)";
        return Objects.toString( value );
    }
}

このクラスはスレッドセーフであることが想定されており、「ダブルチェック」機能は同時実行の場合の最適化に関連しています。マルチスレッド化を計画していない場合は、それらすべてを取り除くことができます。このクラスをマルチスレッド設定で使用する場合は、「ダブルチェックイディオム」を必ずお読みください。 (これはこの質問の範囲を超えた長い議論です。)

10
Mike Nakis

Mike Nakisの回答の遅延初期化パターンは、2つのオブジェクト間の1回限りの初期化では問題なく機能しますが、頻繁に更新される複数の相互に関連するオブジェクトでは扱いにくくなります。

部屋間のリンクoutside部屋オブジェクト自体をImmutableDictionary<Tuple<int, int>, Room>のようなものに保つ方がはるかに簡単で管理しやすくなります。こうすることで、循環参照を作成する代わりに、このディクショナリに単一の簡単に更新可能な一方向の参照を追加するだけです。

16
Karl Bielefeldt

関数型のスタイルでこれを行う方法は、実際に何を構築しているかを認識することです。ラベル付きのエッジを持つ有向グラフです。

Room library = new Room("Library");
Room ballroom = new Room("Ballroom");
Thing chest = new Thing("Treasure chest");
Thing book = new Thing("Ancient Tome");
Dungeon dungeon = Dungeon.Empty
  .WithRoom(library)
  .WithRoom(ballroom)
  .WithThing(chest)
  .WithThing(book)
  .WithPassage("North", library, ballroom)
  .WithPassage("South", ballroom, library)
  .WithContainment(library, chest)
  .WithContainment(chest, book);

ダンジョンは、多数の部屋や物、およびそれらの間の関係を追跡するデータ構造です。 "with"を呼び出すたびに、新しいdifferent不変のダンジョンが返されます。部屋はそれらの北と南が何であるかを知りません。本はそれが胸にあることを知りません。 ダンジョンはそれらの事実を知っており、thatは循環参照がないので問題はありません。

12
Eric Lippert

鶏肉と卵は正しいです。これはc#では意味がありません:

A a = new A(b);
B b = new B(a);

しかし、これは:

A a = new A();
B b = new B(a);
a.setB(b);

しかし、それはAが不変ではないことを意味します!

あなたはカンニングすることができます:

C c = new C();
A a = new A(c);
B b = new B(c);
c.addA(a);
c.addB(b);

それが問題を隠しています。確かにAとBは不変の状態ですが、不変ではないものを参照しています。これは、それらを不変にするというポイントを簡単に無効にすることができます。 Cが少なくとも必要なだけスレッドセーフであることを願っています。

凍結融解と呼ばれるパターンがあります。

A a = new A();
B b = new B(a);
a.addB(b);
a.freeze();

現在、「a」は不変です。 「A」ではなく「a」です。なぜ大丈夫ですか?冷凍される前の「a」について他に何も知らない限り、誰が気にかけますか?

Thaw()メソッドがありますが、「a」は変更されません。 'a'の変更可能なコピーを作成し、更新して凍結することもできます。

このアプローチの欠点は、クラスが不変性を強制しないことです。以下の手順です。型から不変かどうかはわかりません。

私はこの問題をC#で解決するための理想的な方法を本当に知りません。私は問題を隠す方法を知っています。時にはそれで十分です。

そうでない場合は、この問題を完全に回避するために別のアプローチを使用します。例:状態パターンの実装方法 ここ を見てください。あなたは彼らが循環参照としてそれをするだろうと思うでしょうが、彼らはしません。状態が変化するたびに、新しいオブジェクトをクランクアウトします。ガベージコレクターを乱用する方が、鶏から卵を取り出す方法を理解する方が簡単な場合があります。

3
candied_orange

一部の賢い人々はこれについてすでに意見を述べていますが、私はその隣人が何であるかを知ることは部屋の責任ではないと思います

部屋がどこにあるかを知るのは建物の責任だと思います。部屋が本当に隣人を知る必要がある場合は、INeigbourFinderを渡します。

1
tymtam