我们有一组专门使用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# 代码。 它将编译。 这是对关系进行建模的正确方法。
但是,要正确实例化Parent
或Child
是不可能的:在你有一个完整的子集合附加到它之前,Parent
无法实例化,但是如果没有有效的Parent
来挂它,就无法实例化每个Child
。
(在我们的例子中,数据形成树,但这个问题在许多其他场景中出现:同一问题的变体显示在readonly
双向链表、readonly
DAG、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
的信息作为参数传递。
如果您仍然停留当前的方法,那么由于您的Parent
和Child
类具有"循环"依赖关系,可以通过引入将它们两者结合起来的新类来解决。
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());
}
}