只读交叉 class 引用:第 22 条军规
Readonly cross-class references: A Catch-22
我们有一组 classes 专门使用 readonly
字段来帮助确保 classes 在整个软件范围内安全稳定,尤其是跨线程.众所周知,只读数据是一件好事™。
特别是,这些 classes 使用 readonly
来确保在代码库中工作的其他开发人员不能更改它们 - 即使是意外 - 包括在相同 class 的方法内:这是一项安全检查,可以让技能较低或代码库知识较少的编码人员远离麻烦,这就是为什么这些 class 更喜欢使用 readonly
而不是只使用 属性 和private set
方法。没有比编译器本身告诉您“NO”更好的安全检查了。
但是当 一切 都是 readonly
时,就会出现一个有趣的第二十二条军规,也就是说,如果假定引用,parent/child 关系就变得不可能了指向两种方式。考虑以下代码:
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
无法实例化,直到你有一个完整的子集合可以附加到它,但是每个 Child
不能在没有有效的 Parent
的情况下实例化以将其挂起。
(在我们的例子中,数据形成了树,但这个问题出现在许多其他场景中:同一问题的变体出现在 readonly
双链表、readonly
DAG 中, readonly
图表等。)
通常,我使用某种技巧解决了这个 Catch-22。我过去使用的选项包括:
从 Child.Parent
中删除 readonly
,并使用注释告诉人们不要写入该字段。 这行得通,但它依赖于人类的信任,而不是编译器的锤子告诉人们不要做他们不应该做的事情。
将 Child.Parent
变成带有 internal set
的 属性。 这可行,但允许其他方法同样的程序集也可能改变 Child.Parent
,这违反了让一切都成为 readonly
.
的设计目标
用private set
把Child.Parent
做成属性,再加一个internal Child.SetParent()
方法 这可行,但它允许 Child
上的方法可能直接更改 Parent
,并且仍然为同一程序集中其他地方的代码更改 Child.Parent
提供漏洞。虽然 Parent()
构造函数仍然是 O(n),但它的恒定时间性能要差得多。
使用 Child.Clone()
方法使 Parent()
构造函数克隆每个 Child
实例。 这可行,但它违反了您传入的实例是 Parent
将存储的实例的预期。如果克隆是深度克隆,它还可能使 Parent()
构造函数 运行 的时间可能比预期的 O(n) 时间差得多,这违反了对 Parent
构造性能的预期。
使 Parent()
构造函数采用实际的 IList
,而不是采用 IEnumerable
并创建 readonly
副本. 这行得通,但它相信调用者不会在构造 Parent
之后使用该列表。它还更改了 Parent()
构造函数的预期行为,您可以传入任何类型的集合,它将 "just work."
使 Parent()
构造函数使用反射来更新 Child.Parent
。 这可行,但它是偷偷摸摸的 "magical" ,违背了Child
构造后Child.Parent
不会改变的预期,而且比直接写字段慢很多
简而言之,对于这个问题,我找不到一个简单的答案。我过去曾在不同的时间点使用过上述每种解决方案,但它们都非常令人反感,我想知道是否有人有更好的答案。
所以,总而言之:对于 Catch-22 是否有更好的解决方案,即完全 readonly
交叉 class 引用?
(是的,我知道在某些方面,我正在尝试使用技术来解决人的问题,但这不是重点:问题是交叉 class readonly
模式可以实现功能化,而不是它是否应该首先存在。)
我认为这更多是设计问题而不是语言问题。
Child
class 真的需要了解 Parent
吗?
如果是这样,那么它需要什么样的信息或功能。可以移到Parent
吗?
如果按照"Tell not ask"rule/principle,那么在Parent
class中就可以调用Child
方法,将Parent
的信息作为参数传递。
如果您仍然采用当前方法,那么您的 Parent
和 Child
class 具有 "circular" 依赖性,可以通过引入新的 class 将它们结合起来。
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());
}
}
您真正谈论的是不可变的 类,而不仅仅是只读字段。因此,要解决您的问题,请查看 Microsoft's immutable collections 做什么。
他们所做的是他们有“builder classes”,这是唯一允许打破不变性规则的东西,允许这样做是因为它所做的更改在 [= 外部不可见26=] 直到你在上面调用 ToImmutable()
。
您可以对 Parent.Builder
做类似的事情,它可以有一个 public void AddChild(Foo foo, Bar bar, Baz baz)
将子级添加到父级的内部状态。
添加完所有内容后,您在构建器上调用 public Parent ToImmutalbe()
,它 returns 一个 Parent
对象,其中包含所有不可变的子对象。
正如我在评论中提到的,另一种不需要损害 parent/children 引用的 readonly-ness 的可能性是通过以下方式构建 "Child" objects传递给 "Parent" object:
的工厂方法
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
构造函数的工厂,例如只需创建一个 parent 和 5 children 你可以使用类似的东西:
private IEnumerable<Child> CreateChildren(Parent parent)
{
for (var i = 0; i < 5; i++)
{
yield return new Child(parent);
}
}
...
var parent = new Parent(CreateChildren);
...
正如评论中也提到的那样,这种机制并非没有潜在的缺点 - 你必须确保你的 parent object 在调用 child 工厂之前完全初始化构造函数,因为它可以在 parent object 上执行操作(调用方法、访问属性等),因此 parent object 必须处于以下状态这不会导致意外行为。如果有人从你的 parent object 派生,事情会变得更加复杂,因为派生的 class 永远不会在调用 child 工厂之前完全初始化(因为基础 class 在派生的 class).
中的构造函数之前被调用
因此,您的里程可能会有所不同,由您来确定这种方法的好处是否超过成本。
我们有一组 classes 专门使用 readonly
字段来帮助确保 classes 在整个软件范围内安全稳定,尤其是跨线程.众所周知,只读数据是一件好事™。
特别是,这些 classes 使用 readonly
来确保在代码库中工作的其他开发人员不能更改它们 - 即使是意外 - 包括在相同 class 的方法内:这是一项安全检查,可以让技能较低或代码库知识较少的编码人员远离麻烦,这就是为什么这些 class 更喜欢使用 readonly
而不是只使用 属性 和private set
方法。没有比编译器本身告诉您“NO”更好的安全检查了。
但是当 一切 都是 readonly
时,就会出现一个有趣的第二十二条军规,也就是说,如果假定引用,parent/child 关系就变得不可能了指向两种方式。考虑以下代码:
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
无法实例化,直到你有一个完整的子集合可以附加到它,但是每个 Child
不能在没有有效的 Parent
的情况下实例化以将其挂起。
(在我们的例子中,数据形成了树,但这个问题出现在许多其他场景中:同一问题的变体出现在 readonly
双链表、readonly
DAG 中, readonly
图表等。)
通常,我使用某种技巧解决了这个 Catch-22。我过去使用的选项包括:
从
Child.Parent
中删除readonly
,并使用注释告诉人们不要写入该字段。 这行得通,但它依赖于人类的信任,而不是编译器的锤子告诉人们不要做他们不应该做的事情。将
Child.Parent
变成带有internal set
的 属性。 这可行,但允许其他方法同样的程序集也可能改变Child.Parent
,这违反了让一切都成为readonly
. 的设计目标
用
private set
把Child.Parent
做成属性,再加一个internal Child.SetParent()
方法 这可行,但它允许Child
上的方法可能直接更改Parent
,并且仍然为同一程序集中其他地方的代码更改Child.Parent
提供漏洞。虽然Parent()
构造函数仍然是 O(n),但它的恒定时间性能要差得多。使用
Child.Clone()
方法使Parent()
构造函数克隆每个Child
实例。 这可行,但它违反了您传入的实例是Parent
将存储的实例的预期。如果克隆是深度克隆,它还可能使Parent()
构造函数 运行 的时间可能比预期的 O(n) 时间差得多,这违反了对Parent
构造性能的预期。使
Parent()
构造函数采用实际的IList
,而不是采用IEnumerable
并创建readonly
副本. 这行得通,但它相信调用者不会在构造Parent
之后使用该列表。它还更改了Parent()
构造函数的预期行为,您可以传入任何类型的集合,它将 "just work."使
Parent()
构造函数使用反射来更新Child.Parent
。 这可行,但它是偷偷摸摸的 "magical" ,违背了Child
构造后Child.Parent
不会改变的预期,而且比直接写字段慢很多
简而言之,对于这个问题,我找不到一个简单的答案。我过去曾在不同的时间点使用过上述每种解决方案,但它们都非常令人反感,我想知道是否有人有更好的答案。
所以,总而言之:对于 Catch-22 是否有更好的解决方案,即完全 readonly
交叉 class 引用?
(是的,我知道在某些方面,我正在尝试使用技术来解决人的问题,但这不是重点:问题是交叉 class readonly
模式可以实现功能化,而不是它是否应该首先存在。)
我认为这更多是设计问题而不是语言问题。
Child
class 真的需要了解 Parent
吗?
如果是这样,那么它需要什么样的信息或功能。可以移到Parent
吗?
如果按照"Tell not ask"rule/principle,那么在Parent
class中就可以调用Child
方法,将Parent
的信息作为参数传递。
如果您仍然采用当前方法,那么您的 Parent
和 Child
class 具有 "circular" 依赖性,可以通过引入新的 class 将它们结合起来。
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());
}
}
您真正谈论的是不可变的 类,而不仅仅是只读字段。因此,要解决您的问题,请查看 Microsoft's immutable collections 做什么。
他们所做的是他们有“builder classes”,这是唯一允许打破不变性规则的东西,允许这样做是因为它所做的更改在 [= 外部不可见26=] 直到你在上面调用 ToImmutable()
。
您可以对 Parent.Builder
做类似的事情,它可以有一个 public void AddChild(Foo foo, Bar bar, Baz baz)
将子级添加到父级的内部状态。
添加完所有内容后,您在构建器上调用 public Parent ToImmutalbe()
,它 returns 一个 Parent
对象,其中包含所有不可变的子对象。
正如我在评论中提到的,另一种不需要损害 parent/children 引用的 readonly-ness 的可能性是通过以下方式构建 "Child" objects传递给 "Parent" object:
的工厂方法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
构造函数的工厂,例如只需创建一个 parent 和 5 children 你可以使用类似的东西:
private IEnumerable<Child> CreateChildren(Parent parent)
{
for (var i = 0; i < 5; i++)
{
yield return new Child(parent);
}
}
...
var parent = new Parent(CreateChildren);
...
正如评论中也提到的那样,这种机制并非没有潜在的缺点 - 你必须确保你的 parent object 在调用 child 工厂之前完全初始化构造函数,因为它可以在 parent object 上执行操作(调用方法、访问属性等),因此 parent object 必须处于以下状态这不会导致意外行为。如果有人从你的 parent object 派生,事情会变得更加复杂,因为派生的 class 永远不会在调用 child 工厂之前完全初始化(因为基础 class 在派生的 class).
中的构造函数之前被调用因此,您的里程可能会有所不同,由您来确定这种方法的好处是否超过成本。