为什么不继承List<T>?

1706
当我规划我的程序时,我经常从以下思路开始:

A football team is just a list of football players. Therefore, I should represent it with:

var football_team = new List<FootballPlayer>();

The ordering of this list represent the order in which the players are listed in the roster.

但是后来我意识到,团队除了球员名单之外还有其他属性需要记录。例如,本赛季的得分总和、当前预算、队服颜色、代表团队名称的字符串等等。 于是我想:

Okay, a football team is just like a list of players, but additionally, it has a name (a string) and a running total of scores (an int). .NET does not provide a class for storing football teams, so I will make my own class. The most similar and relevant existing structure is List<FootballPlayer>, so I will inherit from it:

class FootballTeam : List<FootballPlayer> 
{ 
    public string TeamName; 
    public int RunningTotal 
}

但事实证明指南表明你不应该继承List<T>。我对这个指南有两个方面感到彻底困惑。

为什么不行?

显然List在性能上进行了优化。是怎样的优化?如果我扩展List会导致什么性能问题?具体会出现什么问题?

另一个原因是,List由Microsoft提供,我无法控制它,所以在公开API之后我不能更改它。但我很难理解这一点。什么是公开API,我为什么要关心它?如果我的当前项目没有并且不太可能有这种公开API,我可以安全地忽略这个指南吗?如果我继承了List,然后事实证明我需要一个公开API,我会遇到什么困难?

为什么这很重要?列表就是列表,有什么可能会改变呢?我可能想要改变什么呢?

最后,如果微软不希望我从List继承,那为什么不把这个类设为sealed呢?

我还能用什么?

显然,对于自定义集合,微软提供了一个Collection类,应该扩展它而不是List。但这个类非常简单,没有很多有用的东西,例如AddRangejvitor83的答案提供了该特定方法的性能理由,但是慢速的AddRange怎么比没有AddRange好呢?

继承自 Collection 比继承自 List 更加繁琐,且我看不到任何好处。毫无疑问,微软不会要求我做额外的工作而没有理由,所以我不能帮助感觉我在某种程度上误解了什么,继承 Collection 实际上并不是解决我的问题的正确方法。 我已经看到一些建议,如实现 IList。但这是数十行样板代码,对我毫无益处。 最后,有人建议将 List 包装在某些东西中:
class FootballTeam 
{ 
    public List<FootballPlayer> Players; 
}
这里有两个问题:
  1. 这使我的代码变得冗长。现在我必须调用 my_team.Players.Count 而不是只调用 my_team.Count。幸运的是,使用 C#,我可以定义索引器使索引透明,并转发内部 List 的所有方法... 但那是很多代码!为所有这些工作我得到了什么?
  2. 这根本没有任何意义。一个足球队没有一个球员列表。它就是球员列表。你不会说 "John McFootballer 加入了 SomeTeam 的球员"。你会说 "John 加入了 SomeTeam"。你不会将一个字母添加到 "字符串的字符" 中,你会将一个字母添加到字符串中。你不会将一本书添加到图书馆的书中,你会将一本书添加到图书馆中。
我知道底层发生的事情可以说是 "向 Y 的内部列表添加 X",但这似乎是一种非常反直觉的思考方式。 我的问题(总结): 什么是正确的C#表示数据结构的方式,从“逻辑”(也就是说,“对人类思维而言”)上来说,它只是一个带有一些额外功能的things列表? 从List<T>继承是否总是不可接受的?它何时是可以接受的?为什么/为什么不?当决定是否继承List<T>时,程序员必须考虑什么?

122
那么,你的问题是否真正是关于何时使用继承(一个正方形是矩形,因为在请求一般性矩形时提供正方形总是合理的),以及何时使用组合(一个足球队有一个足球球员列表,还有其他同样“基本”的属性,如名字)?如果在代码的其他地方传递了一个FootballTeam而不是简单的列表,那么其他程序员会感到困惑吗? - Flight Odyssey
103
如果我告诉你,一个足球队是一家经营公司,拥有一份球员合同清单,而不仅仅是一份带有额外属性的球员名单,你会怎么想? - STT LCU
107
很简单。足球队是球员花名册吗?显然不是,因为正如你所说,还有其他相关属性。足球队是否拥有球员花名册?是的,所以应该包含一个列表。除此之外,组合通常比继承更可取,因为后者更容易修改。如果你同时发现继承和组合都很合理,那就选择组合。 - Tobberoth
56
假设你有一个名为SquashRectangle的方法,它接受一个矩形作为参数,将其高度减半,宽度加倍。现在将一个宽高都为4的矩形和一个边长为4的正方形(即也是一个矩形)传递给该方法。调用SquashRectangle后,这两个形状的尺寸分别是多少?对于矩形而言,显然会变成2x8。如果正方形变成了2x8那么它就不再是正方形;如果它没有变成2x8那么同一操作应用于同一形状就会产生不同的结果。因此结论是:可变的正方形并不是一种矩形。 - Eric Lippert
46
你的陈述是完全错误的;真正的子类必须拥有超类功能的“超集合”。一个字符串需要做所有一个对象可以做的事情,甚至更多 - Eric Lippert
显示剩余17条评论
29个回答

1824
这里有一些好的答案,我想补充以下几点。 C#中正确表示一个数据结构的方式是什么?从“逻辑上”(也就是“人类思维”)来说,它只是一个带有一些花哨功能的事物列表。 请让任何熟悉足球存在的10个非计算机程序员填写空白: 足球队是一种特殊的_____ 是否有人说过“带有一些花哨功能的足球运动员列表”,或者他们都说“体育团队”、“俱乐部”或“组织”?你认为足球队是“一种球员列表”的观点只存在于你的人类思维中。 List<T>是一种机制。足球队是一种业务对象——即代表程序业务领域中的某个概念的对象。不要混淆!足球队是一种团队;它拥有一个花名册,而花名册是一个球员列表。花名册并不是球员列表的一种特殊类型。花名册就是一个球员列表。因此,请创建一个名为Roster的属性,它是一个List<Player>。并在此时将其设置为ReadOnlyList<Player>,除非你认为所有了解足球队的人都可以从花名册中删除球员。

List<T>继承总是不可接受的吗?

对谁不可接受?对我来说不是。

什么情况下是可以接受的?

当你正在构建一个扩展List<T>机制的机制时。

当决定是否继承 List<T> 时,程序员需要考虑什么?

我是在构建一个 机制 还是一个 业务对象

但那是很多代码!为所有这些工作我得到了什么?

你花费的时间打出问题比写相关成员的转发方法多50倍。显然你不害怕啰嗦,我们正在谈论的是一小段代码;只需要几分钟。

更新

我仔细思考后,发现不将足球队伍建模为球员列表还有另一个原因。实际上,将足球队伍建模为“拥有”球员列表可能也不是一个好主意。团队作为/拥有球员列表的问题在于您所拥有的是团队在某个时刻的“快照”。我不知道您对这个类的业务案例是什么,但如果我有一个代表足球队的类,我希望问它一些问题,比如“2003年至2013年间有多少海鹰队球员因受伤而缺席比赛?”或者“哪位丹佛球员曾为其他球队效力,并以年度累计码数增长最大?”或“今年Piggers是否全程晋级?” 也就是说,对我来说,足球队伍似乎可以很好地建模为“历史事实的集合”,例如球员何时被招募、受伤、退役等。显然,当前球员名单是一个重要的事实,可能应该放在最前面,但可能还有其他有趣的事情,您想要使用这个对象需要更具历史视角。

7
所谓的“机制”和“业务对象”的区别并没有解释为什么List<T>不应该扩展为“业务对象”。每个对象都是“机制”,尽管不总是像这样通用的元素那样清晰简单。 - peterh
129
@Mehrdad: 老实说,如果你的应用程序需要考虑例如虚方法的性能负担,那么任何现代语言(如C#,Java等)都不适合你。我现在就有一台笔记本电脑,可以同时运行我小时候玩过的每个街机游戏的模拟器。这让我不必担心偶尔会有一些间接操作。 - Eric Lippert
7
显然当前球员名单是一个重要的事实,可能应该放在中心位置,但可能还有其他有趣的事情你想对这个对象进行处理,需要更多历史角度。- 考虑YAGNI原则?当然,在不远或远的未来你可能需要查看那些信息...但是现在需要吗?为了未来可能存在的需求而构建(这种需求可能永远不会存在...)是浪费努力。 - WernerCD
51
现在我们肯定处于“组合而非继承”的光谱末端。火箭由火箭部件组成,它不是“特殊种类的零件清单”!我建议你进一步简化它;为什么要用列表,而不是IEnumerable<T>——一个序列? - Eric Lippert
13
@MattJohnson: 感谢提供链接。面向对象编程(OOP)教学中的一个讽刺之处是,“银行账户”通常是给学生们的早期练习之一。当然,他们被教授如何建模它,与银行软件中真正的银行账户建模方式完全相反。在真实的软件中,账户是借记和贷记的不断追加列表,可以计算余额,而不是可变余额,更新时被销毁并替换的。 - Eric Lippert
显示剩余13条评论

210
哇,你的帖子提出了一系列问题和观点。微软给出的大多数理由都是非常恰当的。让我们从关于 List<T> 的所有内容开始。
  • List<T> 是高度优化的。它的主要用途是作为对象的私有成员。
  • Microsoft 没有封装它,因为有时您可能想创建一个友好名称的类:class MyList<T, TX> : List<CustomObject<T, Something<TX>> { ... }。现在只需执行 var list = new MyList<int, string>(); 即可。
  • CA1002: 不要公开泛型列表:基本上,即使您计划将此应用程序用作唯一开发人员,也值得使用良好的编码实践进行开发,以便它们变成您的第二天性。如果需要任何消费者具有索引列表,则仍允许将列表公开为 IList<T>。这样可以稍后在类中更改实现。
  • Microsoft 使 Collection<T> 非常通用,因为它是一个通用的概念...名称说明了一切;它只是一个集合。还有更精确的版本,如 SortedCollection<T>ObservableCollection<T>ReadOnlyCollection<T> 等,每个版本都实现了 IList<T> 但没有实现 List<T>
  • Collection<T> 允许成员(即 Add、Remove 等)被重写,因为它们是虚拟的。而 List<T> 则不允许。
  • 你提出的最后一个问题很到位。足球队不仅仅是球员列表,所以它应该是一个包含球员列表的类。考虑组合 vs 继承。足球队 一个球员列表(名单),它不是一个球员列表。

如果我要编写这段代码,那么这个类可能看起来像这样:

public class FootballTeam<T>//generic class
{
    // Football team rosters are generally 53 total players.
    private readonly List<T> _roster = new List<T>(53);

    public IList<T> Roster
    {
        get { return _roster; }
    }

    // Yes. I used LINQ here. This is so I don't have to worry about
    // _roster.Length vs _roster.Count vs anything else.
    public int PlayerCount
    {
        get { return _roster.Count(); }
    }

    // Any additional members you want to expose/wrap.
}

5
除了无法使用别名创建通用类型外,所有类型都必须是具体的。 在我的示例中,“List<T>”被扩展为一个私有内部类,该内部类使用泛型简化了“List<T>”的使用和声明。如果扩展仅为“class FootballRoster:List<FootballPlayer> {}”,那么我可以使用别名。 我想值得注意的是,您可以添加其他成员/功能,但由于无法覆盖“List<T>”的重要成员,因此受到限制。 - myermian
8
如果这是程序员交流平台(Programmers.SE),我会同意当前答案的投票范围,但既然这是一个Stack Overflow的贴子,这个回答至少试图回答C# (.NET)特定的问题,尽管还存在一些明显的通用设计问题。 - Mark Hurd
18
Collection<T> 允许成员(例如 Add、Remove 等)被重写,这就是不从 List 继承的好理由。其他答案有太多哲学内容了。 - Navin
2
如果您调用 List<T>.GetEnumerator(),则会得到一个名为Enumerator的结构体。如果您调用 IList<T>.GetEnumerator(),则会得到一个类型为IEnumerable<T>的变量,其中包含所述结构体的装箱版本。在前一种情况下,foreach将直接调用方法。在后一种情况下,所有调用都必须通过接口进行虚拟调度,使每个调用变慢。 (在我的机器上大约慢两倍。) - Jonathan Allen
4
你完全错了。foreach 并不在乎 Enumerator 是否实现了 IEnumerable<T> 接口。只要它能在 Enumerator 上找到所需的方法,它就不会使用 IEnumerable<T>。如果你真的反编译代码,你就能看到 foreach 在遍历 List<T> 时避免使用虚拟分派调用,但在遍历 IList<T> 时会使用。 - Jonathan Allen
显示剩余11条评论

176
class FootballTeam : List<FootballPlayer> 
{ 
    public string TeamName; 
    public int RunningTotal;
}

之前的代码的意思是:街上一群人踢足球,他们碰巧有一个名字。类似于这样:

人们踢足球

无论如何,这段代码(来自于m-y的回答)

public class FootballTeam
{
    // A team's name
    public string TeamName; 

    // Football team rosters are generally 53 total players.
    private readonly List<T> _roster = new List<T>(53);

    public IList<T> Roster
    {
        get { return _roster; }
    }

    public int PlayerCount
    {
        get { return _roster.Count(); }
    }

    // Any additional members you want to expose/wrap.
}

意思:这是一个拥有管理、球员、管理员等团队的足球队伍。类似于:

展示曼彻斯特联队成员(和其他属性)的图片

这就是你的逻辑用图片呈现的方式...


16
我认为你提到的第二个例子应该是一个足球俱乐部,而不是一个足球队。足球俱乐部有管理人员和行政管理等工作。根据这个来源,“足球队”是指一组球员的集体名称……这些团队可以被选中参加与对手球队的比赛,代表一个足球俱乐部等等。因此,我的观点是,一个团队就是一个球员名单(或更准确地说是一组球员)。 - Ben
5
更优化的代码:private readonly List<T> _roster = new List<T>(44); - SiD
2
需要导入 System.Linq 命名空间。 - Davide Cannizzo
1
@Ben 我认为Nean Der Thal的回答是正确的。一个足球队包括管理层(教练、助理等)、球员(重要的一点是:“被选中参加比赛的球员”,因为并不是所有球队的球员都会被选中参加例如欧洲冠军联赛的比赛)、管理员等。一个足球俱乐部就像是办公室里的人、体育场、球迷、俱乐部董事会,最后但同样重要的是:多个团队(例如一线队、女子队、青年队等)。不要相信维基百科上的一切 ;) - E. Verdi
1
@E.Verdi 我认为第二张图片背景中有一个体育场,正如你所建议的那样,它让它看起来像代表俱乐部而不仅仅是一个团队。归根结底,这些术语的定义只是语义学(我可能称之为团队,其他人可能称之为名单等)。我认为重点是你如何对数据进行建模取决于它将用于什么。这是一个很好的观点,我认为这些图片有助于说明这一点 :) - Ben

144

这是一个经典的组合继承的例子。

在这种情况下:

团队是具有添加行为的球员列表

还是

团队本身就是一个包含球员列表的对象。

通过扩展List,您会在许多方面受到限制:

  1. 您无法限制访问(例如,阻止人们更改名单)。您获得所有List方法,无论您是否需要/想要它们。

  2. 如果您还想拥有其他事物的列表怎么办?例如,团队有教练、经理、球迷、设备等。其中一些可能是他们自己的列表。

  3. 您限制了继承的选择。例如,您可能希望创建一个通用的Team对象,然后从中继承BaseballTeam、FootballTeam等。要从List继承,您需要从Team继承,但这意味着所有类型的团队都被强制使用相同的名单实现。

组合 - 包括一个对象,提供您想要在对象内部实现的行为。

继承 - 您的对象成为具有您想要的行为的对象的实例。

两者都有其用途,但这是一个明显的情况,组合更可取。


1
进一步解释第二点,一个列表不应该拥有另一个列表是没有意义的。 - Cruncher
9
他想要实现列表的行为。他有两种选择,一种是扩展列表(继承),另一种是在对象内包含一个列表(组合)。也许你误解了问题或回答的某部分,而不是55个人都错了而你是对的? :) - Tim B
7
是的。这是一种只有一个超类和子类的退化情况,但我给出了更一般的解释来回答他的具体问题。他可能只有一个团队,并且该团队可能始终只有一个列表,但选择仍然是继承列表或将其作为内部对象包含。一旦你开始包括多种类型的团队(足球、板球等),并且它们不仅仅是球员名单,你就会看到如何在完整构成与继承之间做出选择。在这种情况下,审视全局非常重要,以避免早期做出错误的选择,这将导致后期进行大量的重构。 - Tim B
这是一个关于组合与继承的问题。原帖中提到的话题最终表明,在他的思考中,他并不真正理解“是一个”和“有一个”关系之间的区别。原帖作者还误解了行为与实现之间的区别。 - Andrew Rondeau
3
OP并没有要求组合,但使用案例是一个明显的X:Y问题例子,其中破坏了、误用或者不完全理解四个面向对象编程原则之一。更清晰的答案要么是编写一个专门的集合(如堆栈或队列),但它似乎不适合这种用例,要么就是理解组合。足球队不是足球运动员列表。 无论OP是否明确要求,如果他们不理解抽象、封装、继承和多态性,他们将无法理解答案,因此导致X:Y混乱。 - Julia McGuigan
显示剩余2条评论

75
正如所有人所指出的那样,一个球队并不是球员名单。这个错误在许多地方都会被许多人犯下,可能在各种专业水平上。通常问题很微妙,有时非常严重,就像这个案例一样。这样的设计是不好的,因为它们违反了Liskov替换原则。互联网上有许多很好的文章解释这个概念,例如http://en.wikipedia.org/wiki/Liskov_substitution_principle 总之,在父/子类关系中应该保留两个规则:
  • 子类不应该要求比完全定义父类更少的特征。
  • 父类不应该需要除了完全定义子类之外的任何特征。
换句话说,父类是子类的必要定义,而子类是父类的充分定义。 以下是一种思考自己的解决方案并应用上述原则的方法,可以帮助避免这种错误。应该通过验证父类的所有操作是否在结构上和语义上对派生类有效来测试自己的假设。
  • 足球队是足球运动员的列表吗?(列表的所有属性是否适用于团队)
    • 团队是一组同质实体吗?是的,团队是由球员组成的集合
    • 球员的包含顺序是否描述了团队的状态,并且团队确保除非明确更改,否则保留该序列?不是,不是
    • 球员是否预计根据他们在团队中的顺序位置被包括/删除?不是

正如您所看到的,仅适用于列表的第一个特征适用于团队。因此,团队不是列表。列表将是管理团队的实现细节,因此应仅用于存储球员对象并使用Team类的方法进行操作。

此时,我想强调的是,在大多数情况下,不应使用List实现Team类;应使用Set数据结构(例如HashSet)来实现它。


8
列表(List)和集合(Set)的概念区分得好。这似乎是一个常见的错误。当集合中只能有一个元素实例时,使用集合或字典作为实现首选。拥有两名球员具有相同名称的团队是可以的,但不能同时包含同一球员两次。 - Fred Mitchell
1
虽然一些好点子值得赞赏,但更重要的是问“一个数据结构是否可以合理地支持所有有用的东西(最好是短期和长期)”,而不是“这个数据结构是否做了比有用的更多的事情”。 - Tony Delroy
2
@TonyD 嗯,我提出的观点并不是说应该检查“数据结构是否做了超出有用范围的事情”。而是要检查“父级数据结构是否执行了与子类行为相悖、无意义或违反直觉的操作”。 - Satyan Raina
3
@TonyD,从父类继承无关特征会存在问题,因为在许多情况下会未能通过负面测试。程序员可能会从猩猩中继承Human{ eat(); run(); write(); },认为让人类能够荡秋千这个额外的特性没有问题。但在游戏世界中,你的人类突然开始越过所有所谓的陆地障碍,只是靠摇摆到树上而已。除非明确规定,一个实用的人类不应该能够荡秋千。这种设计使API容易被滥用和混淆。 - Satyan Raina
2
@TonyD 我并不建议Player类应该从HashSet派生。我建议Player类在大多数情况下应该通过组合使用HashSet来实现,这完全是一个实现级别的细节,而不是设计级别的(这就是为什么我将其作为我的答案的旁注提到的原因)。如果有充分的理由,它也可以使用列表来实现。所以回答你的问题,是否需要通过关键字进行O(1)查找?不需要。因此,也不应该从HashSet扩展Player。 - Satyan Raina
显示剩余2条评论

59

如果FootballTeam有一支预备队和主力队伍一起怎么办?

class FootballTeam
{
    List<FootballPlayer> Players { get; set; }
    List<FootballPlayer> ReservePlayers { get; set; }
}

你会用什么进行建模?

class FootballTeam : List<FootballPlayer> 
{ 
    public string TeamName; 
    public int RunningTotal 
}

关系显然是 has a 而不是 is a

或者是 RetiredPlayers

class FootballTeam
{
    List<FootballPlayer> Players { get; set; }
    List<FootballPlayer> ReservePlayers { get; set; }
    List<FootballPlayer> RetiredPlayers { get; set; }
}

通常情况下,如果您想要从集合继承,请将类命名为SomethingCollection

您的SomethingCollection是否在语义上合理?只有当您的类型Something的集合时才这样做。

对于FootballTeam来说不太合适。一个Team不仅仅是一个Collection。一个Team可以有教练、训练师等,正如其他答案所指出的那样。

FootballCollection听起来像是足球或者足球器材的收藏品。而TeamCollection则是团队的集合。

FootballPlayerCollection听起来像是球员的集合,如果您确实想这样做,那么这是从List<FootballPlayer>继承的类的一个有效名称。

事实上,List<FootballPlayer>是一个很好的处理方式。如果您从方法中返回它,则可以考虑使用。

总之:

问问自己:

  1. XY吗?还是XY吗?

  2. 我的类名是否表达了它们的含义?


如果每个球员的类型都可以归类为“防守球员”、“进攻球员”或“其他球员”,那么拥有一个类型可能是合理有用的,该类型可用于期望List<Player>但也包括类型为IList<DefensivePlayer>IList<OffensivePlayer>IList<Player>的成员DefensivePlayersOffsensivePlayersSpecialPlayers。可以使用单独的对象来缓存不同的列表,但将它们封装在与主列表相同的对象中似乎更清晰[使用列表枚举器的失效...]。 - supercat
作为主列表已更改并且下一个访问时子列表将需要重新生成的提示。 - supercat
3
虽然我同意所说的观点,但看到有人在业务对象中提供设计建议并建议公开 getter 和 setter 暴露一个具体的 List<T>,这真的让我的灵魂痛苦不堪。 - sara

55

设计 > 实现

你暴露哪些方法和属性是一个设计决策。你从哪个基类继承是一个实现细节。我认为把注意力放回到前者是值得的。

一个对象是数据和行为的集合。

所以你的第一个问题应该是:

  • 在我创建的模型中,这个对象包含哪些数据?
  • 这个对象在该模型中表现出什么样的行为?
  • 这可能会在未来发生什么变化?

请记住,继承意味着“是一个”(is-a)关系,而组合意味着“有一个”(has-a)关系。根据您的视角选择适合您情况的方式,并考虑应用程序演变的方向。

在思考具体类型之前,可以考虑接口设计,因为有些人觉得这样更容易进入“设计模式”的思维状态。

这并不是每个人在日常编码中都有意识到的事情。但如果您正在考虑这种主题,那么您就是在探索设计领域。意识到这一点可能会很解放。

考虑设计细节

查看 MSDN 或 Visual Studio 上的 List<T>IList<T>。看看它们暴露了哪些方法和属性。这些方法看起来像是你想要在 FootballTeam 上执行的操作吗?

footballTeam.Reverse() 对您有意义吗?footballTeam.ConvertAll<TOutput>() 看起来像是您想要的东西吗?

这不是一个诡计问题,答案可能真的是“是”。如果您实现/继承了 List<Player>IList<Player>,那么您就会被限定于它们;如果这符合您的模型理念,请这样做。

如果您决定是的,这是有道理的,并且您希望将您的对象视为球员集合/列表(行为),因此希望实现 ICollection<Player>IList<Player>,请务必这样做。理论上:

class FootballTeam : ... ICollection<Player>
{
    ...
}

如果你想让你的对象包含一个球员集合/列表(数据),因此你希望该集合或列表成为属性或成员,则请这样做。理论上:

class FootballTeam ...
{
    public ICollection<Player> Players { get { ... } }
}

您可能希望让人们只能枚举球员集合,而不能进行计数、添加或删除等操作。考虑使用 IEnumerable<Player> 是完全有效的选择。

您可能感觉在您的模型中这些接口都没有用处。这种情况不太可能发生(IEnumerable<T> 在许多情况下都很有用),但仍然有可能。

任何试图告诉你其中一个选项在每种情况下都是绝对和明确的错误的人都是误导的。同样地,试图告诉你其中一个选项在每种情况下都是绝对和明确的正确的人也是误导的。

进入实现阶段

一旦您决定了数据和行为,您就可以决定如何实现。这包括通过继承或组合依赖哪些具体类。

这可能不是一个重要的步骤,人们经常混淆设计和实现,因为您完全可以在几秒钟内在脑海中运行它并开始打字。

思考实验

一个人工的例子:正如其他人提到的那样,一个团队并不总是“只是”一组球员。您是否为团队维护了一组比赛得分?在您的模型中,团队是否可以与俱乐部互换使用?如果是这样,并且您的团队是一个球员集合,那么它也可能是一个员工集合和/或一个得分集合。然后你就会得到:

class FootballTeam : ... ICollection<Player>, 
                         ICollection<StaffMember>,
                         ICollection<Score>
{
    ....
}

虽然设计上可以这样做,但在C#中您无法通过继承List<T>来实现所有这些功能,因为C#“仅”支持单一继承。(如果您在C++中尝试过这种糊弄方法,那么您可能认为这是一件好事。)通过继承一个集合和通过组合另一个集合进行实现可能会让人感到不舒服。而且,像Count这样的属性会让用户感到困惑,除非您显式地实现了ILIst<Player>.CountIList<StaffMember>.Count等内容,即使如此,它们也只是令人痛苦而不是困惑。您可以看到问题的所在,当您思考这个方向时,直觉很可能会告诉您,朝这个方向前进感觉是错误的(而且正确或错误地说,如果您以这种方式实施,您的同事们也可能会这样认为!)

简短的回答(太晚了)

关于不从集合类继承的指导原则并不是C#特有的,您会发现它存在于许多编程语言中。它是经验之谈,而不是法律。一个原因是,在实践中,组合在可理解性、可实现性和可维护性方面通常胜过继承。对于真实世界/领域对象来说,通常会发现有用和一致的“hasa”关系而不是有用和一致的“isa”关系,除非您深入到抽象的内部,尤其是随着时间的推移和代码中对象的数据和行为的精确性发生变化。这并不意味着您总是要排除从集合类继承;但这可能是有提示性的。


42

首先,这与可用性有关。如果使用继承,则Team类将公开仅设计用于对象操作的行为(方法)。例如,AsReadOnly()CopyTo(obj)方法对于团队对象没有意义。您可能希望使用更具描述性的AddPlayers(players)方法,而不是AddRange(items)方法。

如果要使用LINQ,则实现诸如ICollection<T>IEnumerable<T>等通用接口将更有意义。

正如提到的那样,组合是正确的方法。只需将球员列表实现为私有变量即可。


39

让我改写一下你的问题,这样你就可以从不同的角度看待这个主题。

当我需要代表一个足球队时,我理解它基本上是一个名称。比如:"The Eagles"。

string team = new string();

后来我意识到团队也有球员。

我为什么不能扩展字符串类型,使其也包含一个球员列表?

你解决问题的切入点是任意的。试着想一下,一个团队拥有什么属性,而不是它什么。

在这样做之后,你可以看看它是否与其他类共享属性。并考虑继承。


2
这是一个很好的观点-我们可以把团队看作是一个名字。然而,如果我的应用旨在处理球员的行动,那么这种想法就不太明显了。无论如何,最终似乎问题归结为组合与继承。 - Superbest
7
这是一种看待问题的有价值方式。另外请注意:一个富有的人拥有几支足球队,他把其中一支作为礼物送给了一个朋友,这个朋友改变了球队的名字,解雇了教练,并更换了所有球员。朋友的球队与男子的球队在绿茵场上碰面了,当男子的球队输了比赛时,他说:“我不敢相信你用我给你的球队打败了我!”这个男人正确吗?你会如何检查呢? - user1852503

33

取决于上下文

当你将团队看作球员列表时,你只是将足球队的"概念"投射到一个方面:你将"团队"缩小为你在场上看到的人。这种投射仅在特定上下文中正确。在不同的上下文中,这可能完全错误。想象一下,你想成为该团队的赞助商。所以你必须和团队经理交谈。在这个上下文中,团队被投射到其经理的列表中。而这两个列表通常没有太多重叠。其他上下文包括现任与前任球员等。

含义不清晰

因此,将团队视为其球员列表的问题在于其语义取决于上下文,并且在上下文更改时无法扩展。此外,很难表达您使用的上下文。

类是可扩展的

当您使用仅具有一个成员的类(例如IList activePlayers)时,您可以使用成员的名称(以及其注释)来明确上下文。当有其他上下文时,您只需添加一个额外的成员即可。

类更加复杂

在某些情况下,创建额外的类可能会过度。每个类定义都必须通过类加载器加载,并将被虚拟机缓存。这将消耗您的运行时性能和内存。当您有非常特定的上下文时,将足球队视为球员名单可能是可以接受的。但在这种情况下,您应该只使用IList,而不是从中派生的类。

结论/注意事项

当您有非常特定的上下文时,将团队视为球员列表是可以接受的。例如,在方法内编写以下内容是完全可以的:

IList<Player> footballTeam = ...

使用 F# 时,甚至可以创建类型缩写:

type FootballTeam = IList<Player>
但是如果上下文更广泛或者甚至不明确,就不应该这样做。特别是当您创建一个新类时,它将来可能使用的上下文不清晰时,就更不能这样做了。一个警告信号是当您开始向类中添加其他属性(例如团队名称、教练等)时。这清楚地表明类将被用于的上下文是不确定的,并且将来会发生变化。在这种情况下,您不能将团队视为球员列表,而应将当前活跃、未受伤等球员列表建模为团队的属性。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,