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

对于配置和构建器模式来说,这感觉很好。
Greg Burghardt

我还想知道房间是否应该与关卡或舞台的布局脱钩,以使每个房间都不了解其他房间。
Greg Burghardt

1
@RockAnthonyJohnson我不会真的称其为自反,但这并不相关。但是为什么这是一个问题呢?这是非常普遍的。实际上,这就是几乎所有数据结构的构建方式。考虑一下链表或二叉树。它们都是递归数据结构,您的Room示例也是如此。
gardenhead

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}
garden子

1
如果您有兴趣,请花时间学习Haskell或OCaml;它会扩大您的思维;)还请记住,数据结构与“业务对象”之间没有明确的界限。看看 我上面写的Haskell中Room类和a 的定义有多相似List
gardenhead

Answers:


10

显然,您不能完全使用发布的代码来执行此操作,因为在某个时候,您将需要构造一个对象,该对象需要连接到尚未构造的另一个对象。

我可以想到两种方式(以前使用过)来做到这一点:

使用两个阶段

首先构造所有对象,而没有任何依赖关系,并且一旦它们全部构造完毕,便将它们连接起来。这意味着对象需要在其生命中经历两个阶段:一个非常短的可变阶段,然后是一个在整个生命周期中持续存在的不可变阶段。

在对关系数据库进行建模时,您可能会遇到完全相同的问题:一个表具有指向另一个表的外键,而另一个表可能具有指向第一个表的外键。在关系数据库中处理此问题的方式是,外键约束可以(通常)通过额外的方式指定ALTER TABLE ADD FOREIGN KEY语句分开语句来CREATE TABLE。因此,首先创建所有表,然后添加外键约束。

关系数据库与您要执行的操作之间的区别在于,关系数据库继续允许 ALTER TABLE ADD/DROP FOREIGN KEY在表的整个生命周期中语句,而您可能会设置'IamImmutable'标志,并在实现所有依赖关系后拒绝任何进一步的更改。

使用延迟初始化

您可以通过委托来代替对依赖项的引用在需要时将的引用返回。一旦获取了依赖项,就不会再调用该委托。

委托通常将采用lambda表达式的形式,因此与将依赖项实际传递给构造函数相比,它看起来仅稍微冗长一些。

该技术的(微小)缺点是,您必须浪费存储指针所需要的存储空间,这些指针仅在对象图的初始化期间使用。

您甚至可以创建一个通用的“惰性引用”类来实现此目的,这样您就不必为每个成员中的每个成员重新实现它。

这是用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/上
Rock Anthony Johnson

16

Mike Nakis的答案中的惰性初始化模式对于两个对象之间的一次性初始化非常有效,但是对于频繁更新的多个相互关联的对象则显得笨拙。

将房间对象本身之外的房间之间的链接保持为像这样更简单,更易于管理ImmutableDictionary<Tuple<int, int>, Room>。这样,您无需创建循环引用,而只需向此字典添加单个,易于更新的单向引用。


请记住,谈论的是不可变的对象,因此没有更新。
安东尼·约翰逊

4
当人们谈论更新不可变对象时,他们的意思是创建具有更新属性的新对象,并在新范围内引用该新对象来代替旧对象。每次都这样说有点乏味。
Karl Bielefeldt

卡尔,请原谅我。我对功能原理仍然不满意,哈哈。
安东尼·约翰逊

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
我研究了有向图和流利的构建器(和DSL)。我可以看到这如何建立有向图,但这是我第一次看到两个相关的想法。有没有我想念的书或博客文章?还是仅仅因为解决了问题而生成有向图?
candied_orange

@CandiedOrange:这是API外观的草图。实际上,在其基础上构建不可变有向图数据结构将需要一些工作,但并不困难。不变的有向图只是节点的不可变集合和(开始,结束,标签)三元组的不可变集合,因此可以将其简化为已解决的问题的组合。
埃里克·利珀特

就像我说的,我已经研究了DSL和有向图。我正在尝试弄清您是否已经阅读或编写了将两者组合在一起的书,或者您是否只是将它们组合在一起来回答这个特定问题。如果您知道外面有什么东西可以将它们组合在一起,那么如果您能指出我的想法,我将非常喜欢。
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类型。这使得它基本上是构建器模式。
Bryan Chen

@BryanChen如果这样做,则b保留对旧可变对象的引用a。这个想法是,a b你释放他们的系统的其余部分之前,应指向对方的不可改变的版本。
candied_orange

@RockAnthonyJohnson这也是埃里克·利珀特(Eric Lippert)所谓的冰棒不可变性
斑点

1

一些聪明的人已经对此发表了意见,但是我只是认为了解房间的邻居不是房间的责任。

我认为知道房间在哪里是建筑物的责任。如果房间确实需要知道其邻居,则将INeigbourFinder传递给它。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.