只读跨类引用:第 22 条军规



我们有一组专门使用readonly字段的类来帮助确保这些类在整个软件范围内(尤其是跨线程)的安全和稳定。 众所周知,只读数据是一件好事™。

特别是,这些类使用readonly来确保在代码库中工作的其他开发人员无法更改它们(即使是偶然的),包括在同一类中的方法中:这是一种安全检查,可以使技能较低或代码库知识较少的编码人员远离麻烦,这就是为什么这些类更喜欢使用readonly,而不仅仅是具有private set方法的属性。 没有比编译器本身告诉您">NO">更好的安全检查了。


但是有一个有趣的 Catch-22 在一切都readonly时出现,那就是如果引用应该指向两个方向,父子关系就变得不可能了。 请考虑以下代码:

public class Parent
{
public readonly IList<Child> Children;
public Parent(IEnumerable<Children> children)
{
Children = Array.AsReadOnly(children.ToArray());
}
}
public class Child
{
public readonly Parent Parent;
public Child(Parent parent)
{
Parent = parent;
}
}

这是合法的 C# 代码。 它将编译。 这是对关系进行建模的正确方法。

但是,要正确实例化ParentChild是不可能的:在你有一个完整的子集合附加到它之前,Parent无法实例化,但是如果没有有效的Parent来挂它,就无法实例化每个Child

(在我们的例子中,数据形成树,但这个问题在许多其他场景中出现:同一问题的变体显示在readonly双向链表、readonlyDAG、readonly图等中。


通常,我使用某种黑客来解决这个 Catch-22。 我过去使用过的选项包括:

  • Child.Parent中删除readonly,并使用注释告诉人们不要写入该字段。这是有效的,但它依赖于人类的信任,而不是编译器的锤子告诉人们不要做他们不应该做的事情。

  • 使Child.Parent成为具有internal set的属性。这有效,但允许同一程序集中的其他方法也可能更改Child.Parent,这违反了readonly所有内容的设计目标。

  • Child.Parent变成具有private set的属性,并添加internal Child.SetParent()方法。这有效,但它允许Child上的方法可能直接更改Parent,并且仍然为同一程序集中其他位置的代码提供漏洞以更改Child.Parent。 虽然Parent()构造函数仍然是 O(n),但其常量时间性能要差得多。

  • 使Parent()构造函数使用Child.Clone()方法克隆每个Child实例。这有效,但它违反了您传入的实例是Parent将存储的实例的预期。 如果克隆是深度克隆,它还会使Parent()构造函数的运行时间可能比预期的 O(n) 时间差得多,这违反了对Parent构建性能的预期。

  • 使Parent()构造函数采用实际IList,而不是采用IEnumerable并创建readonly副本。这有效,但它相信调用方在构造Parent后不会使用该列表。 它还更改了Parent()构造函数的预期行为,您可以在任何类型的集合中传递该构造函数,并且它将"正常工作"。

  • 使Parent()构造函数使用反射来更新Child.Parent这行得通,但它是偷偷摸摸和"神奇的",它违反了Child.Parent在构造Child后不会改变的期望,而且它比直接写入字段要慢得多。

简而言之,对于这个问题,我无法找到一个简单的答案。 我过去在不同时间点使用过上述每种解决方案,但它们都非常令人反感,我想知道是否有人有更好的答案。


那么,总而言之:对于专门readonly跨类引用的 Catch-22,您是否找到了更好的解决方案?


(是的,我知道在某些方面,我试图使用技术来解决人的问题,但这不是真正的重点:问题是跨类readonly模式是否可以发挥作用,而不是它是否应该首先存在。

正如我的评论中提到的,另一种不需要损害父/子引用只读性的可能性是通过传递到"父"对象的工厂方法来构造"子"对象:

public class Parent
{
private readonly Child[] _children;
public Parent(Func<Parent, IEnumerable<Child>> childFactory)
{
_children = childFactory(this).ToArray();
}
}
public class Child
{
private readonly Parent _parent;
public Child(Parent parent)
{
_parent = parent;
}
}

有很多方法可以生成传递给Parent构造函数的工厂,例如,要创建一个具有 5 个子项的父级,您可以使用以下内容:

private IEnumerable<Child> CreateChildren(Parent parent)
{
for (var i = 0; i < 5; i++)
{
yield return new Child(parent);
}
}
...
var parent = new Parent(CreateChildren);
...

正如注释中也提到的,这种机制并非没有潜在的缺点 - 在构造函数中调用子工厂之前,必须确保父对象已完全初始化,因为它可能会对父对象执行操作(调用方法、访问属性等),因此父对象必须处于不会导致意外行为的状态。如果有人从您的父对象派生,事情会变得更加复杂,因为在调用子工厂之前,派生类永远不会完全初始化(因为基类中的构造函数是在派生类中的构造函数之前调用的)。

因此,您的里程可能会有所不同,由您决定这种方法的好处是否超过成本。

你真正说的是不可变的类,而不仅仅是只读字段。因此,要解决您的问题,请查看Microsoft的不可变集合的作用。

他们所做的是他们有"构建器类",这是唯一允许打破不变性规则的东西,它被允许这样做,因为它所做的更改在类外部不可见,直到你调用ToImmutable()它。

您可以对具有public void AddChild(Foo foo, Bar bar, Baz baz)Parent.Builder执行类似的操作,该将子项添加到父项的内部状态中。

添加所有内容后,您在构建器上调用public Parent ToImmutalbe(),它会返回一个Parent对象,该对象构建了所有不可变的子对象。

我认为这更多的是设计问题而不是语言问题。

Child班真的需要了解Parent吗?
如果是这样,那么它需要什么样的信息或功能。可以移到Parent吗?
如果你遵循"告诉不问"的规则/原则,那么在类Parent你可以调用Child方法并将Parent的信息作为参数传递。

如果您仍然停留当前的方法,那么由于您的ParentChild类具有"循环"依赖关系,可以通过引入将它们两者结合起来的新类来解决。

public class Family
{
private readonly Parent _parent;
private readonly ReadOnlyCollection<Child> _childs;
public Family(Parent parent, IEnumerable<Child> childs)
{
_parent = parent;
_childs = new ReadOnlyCollection<Child>(childs.ToList());
}
}

最新更新