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


24

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

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();
    }
}

これは、構成とビルダーパターンの良い例のように感じます。
グレッグブルクハート

また、各部屋が他の部屋を認識しないように、部屋をレベルまたはステージのレイアウトから切り離すべきかどうかも疑問に思います。
グレッグブルクハート

1
@RockAnthonyJohnson私は本当にそれを再帰的とは呼びませんが、それは適切ではありません。なぜそれが問題なのですか?これは非常に一般的です。実際、ほぼすべてのデータ構造が構築されます。リンクリストまたはバイナリツリーについて考えてください。これらはすべて再帰的なデータ構造であり、Room例も同様です。
ガーデンヘッド

2
@RockAnthonyJohnson不変のデータ構造は、少なくとも関数型プログラミングでは非常に一般的です。これが、リンクリストの定義方法ですtype List a = Nil | Cons of a * List a。そして、バイナリツリー:type Tree a = Leaf a | Cons of Tree a * Tree a。ご覧のとおり、どちらも自己参照型(再帰的)です。部屋を定義する方法は次のとおりtype Room = Nil | Open of {name: string, south: Room, east: Room, north: Room, west: Room}です。
ガーデンヘッド

1
興味がある場合は、HaskellまたはOCamlを時間をかけて学習してください。それはあなたの心を広げます;)また、データ構造と「ビジネスオブジェクト」の間に明確な境界線がないことにも留意してください。Roomクラスとaの定義がList 上記のHaskellにどれほど似ているかを見てください。
ガーデンヘッド

回答:


10

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

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

2つのフェーズを使用する

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

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

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

遅延初期化の使用

依存関係への参照の代わりに、必要に応じて依存関係への参照を返すデリゲートを渡します。依存関係が取得されると、デリゲートが再び呼び出されることはありません。

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

この手法の(小さな)欠点は、オブジェクトグラフの初期化中にのみ使用されるデリゲートへのポインターを格納するために必要なストレージスペースを無駄にしなければならないことです。

これを実装する汎用の「遅延参照」クラスを作成することもできます。これにより、メンバーごとに再実装する必要がなくなります。

Javaで記述されたこのようなクラスは次のとおりです。C#で簡単に転写できます

(私Function<T>Func<T>C#のデリゲートのようなものです)

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 );
    }
}

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


1
マイク、あなたは素晴らしい。元の投稿を更新して、遅延初期化について投稿した内容に基づいた実装を含めました。
ロックアンソニージョンソン

1
.Netライブラリは、Lazy <T>という名前の遅延参照を提供します。なんて素敵なの!これは、codereview.stackexchange.com / questions / 145039
ロックアンソニージョンソン

16

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

のようなもの、ルームオブジェクト自体の外側のルーム間のリンクを維持する方がはるかに簡単で管理しやすいImmutableDictionary<Tuple<int, int>, Room>です。そうすれば、循環参照を作成する代わりに、この辞書に単一の簡単に更新可能な一方向の参照を追加するだけです。


不変オブジェクトについて話していたことに留意してください。したがって、更新はありません。
ロックアンソニージョンソン

4
不変オブジェクトの更新について話すときは、更新された属性で新しいオブジェクトを作成し、古いオブジェクトの代わりに新しいスコープでその新しいオブジェクトを参照することを意味します。しかし、それは毎回言うのが少し面倒です。
カールビーレフェルト

カール、許してください。私はいまだに機能原理の初心者です。
ロックアンソニージョンソン

2
これは正しい答えです。一般に、循環依存関係は破壊され、サードパーティに委任される必要があります。不変になる可変オブジェクトの複雑なビルドアンドフリーズシステムをプログラミングするよりもはるかに簡単です。
ベンジャミンホジソン

私はこれを数より+ 1の...不変かどうかを与えることがしたい、「外部」リポジトリまたはインデックス(またはなしで何でも)、これらすべての部屋が適切にフックアップ得ることはかなり不必要に複雑になります。そして、これは禁止していないRoomから登場それらの関係を持っています。ただし、インデックスから単に読み取るゲッターでなければなりません。
svidgen

12

機能的なスタイルでこれを行う方法は、実際に構築しているもの、つまりラベル付きエッジを持つ有向グラフを認識することです。

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」呼び出しごとに、新しい異なる不変のダンジョンが返されます。部屋は部屋の北と南が何であるかを知りません。本はそれが胸にあることを知りません。ダンジョンには、これらの事実を知っており、そのどれもが存在しないので、事は循環参照には問題がありません。


1
私は有向グラフと流fluentなビルダー(およびDSL)を研究しました。どのように有向グラフを作成できるかはわかりますが、これは関連する2つのアイデアを見た最初の例です。見逃した本やブログ記事はありますか?または、これは質問の問題を解決するという理由だけで有向グラフを生成しますか?
candied_orange

@CandiedOrange:これは、APIの外観のスケッチです。実際に、その基礎となる不変の有向グラフデータ構造を構築するには多少の作業が必要ですが、難しくはありません。不変の有向グラフは、不変のノードのセットと不変の(開始、終了、ラベル)トリプルのセットであるため、すでに解決された問題の構成に減らすことができます。
エリックリッパー

私が言ったように、私はDSLと有向グラフの両方を研究しました。この2つをまとめたものを読んだり書いたりしたのか、それともこの特定の質問に答えるためにそれらをまとめたのかを把握しようとしています。あなたがそれらを一緒に置く何かを知っているなら、あなたが私にそれを指し示すことができるなら、私はそれが好きです。
candied_orange

@CandiedOrange:特にありません。私は何年も前に、バックトラッキングの数独ソルバーを作成するための不変の無向グラフについてブログシリーズを書きました。そして最近、ウィザードとダンジョンの領域における可変データ構造のオブジェクト指向設計の問題についてブログシリーズを書きました。
エリックリッパー

3

鶏肉と卵が正しい。これは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#でこの問題を解決する理想的な方法が本当にわかりません。問題を隠す方法を知っています。時にはそれで十分です。

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


新しいパターンを紹介してくれた+1。まず、凍結融解について聞いたことがあります。
ロックアンソニージョンソン

a.freeze()ImmutableA型を返すことができます。基本的にビルダーパターンになります。
ブライアンチェン

@BryanChenを実行するbと、古いmutableへの参照を保持したままになりaます。アイデアはということですa し、bあなたがシステムの残りの部分にそれらを解放する前に、互いの不変のバージョンを指している必要があります。
candied_orange

@RockAnthonyJohnsonこれもエリック・リッパートがポプシクル不変性と呼んだものです。
発見

1

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

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

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.