在计划我的计划时,我通常会从这样的一系列想法开始:
足球队只是足球运动员的名单。因此,我应以以下方式表示:var football_team=新列表<FootballPlayer>();此列表的顺序表示球员在名册中的排列顺序。
但我后来意识到,除了球员名单之外,球队还有其他必须记录的财产。例如,本赛季总得分、当前预算、制服颜色、代表球队名称的字符串等。。
所以我想:
好吧,足球队就像一个球员列表,但除此之外,它还有一个名字(一个字符串)和一个连续的总得分(一个整数)。NET没有提供存储足球队的类,所以我将创建自己的类。最相似和最相关的现有结构是List<FootballPlayer>,因此我将从中继承:class FootballTeam:列表<FootballPlayer>{ 公共字符串TeamName;公共int RunningTotal}
但事实证明,一条准则说你不应该从List<t>继承。我在两个方面完全被这条准则搞糊涂了。
为什么不呢?
显然,List在某种程度上优化了性能。为什么呢如果我扩展列表,会导致什么性能问题?到底会发生什么?
我看到的另一个原因是List是由Microsoft提供的,我无法控制它,所以在暴露了一个“公共API”之后,我无法稍后更改它。但我很难理解这一点。什么是公共API?我为什么要关心?如果我当前的项目没有也不可能有这个公共API,我可以放心地忽略这个准则吗?如果我确实继承了List,结果发现我需要一个公共API,我会遇到什么困难?
为什么这很重要?列表是一个列表。什么可能会改变?我可能想要改变什么?
最后,如果微软不想让我继承List,他们为什么不把这个类封起来呢?
我还能用什么?
显然,对于自定义集合,Microsoft提供了一个Collection类,该类应该扩展而不是List。但是这个类非常简单,没有很多有用的东西,例如AddRange。jvitor83的答案为该特定方法提供了性能基础,但如何使缓慢的AddRange不比没有AddRange更好?
从收藏中继承比从列表中继承要多得多,我看不出有什么好处。当然,微软不会无缘无故地让我做额外的工作,所以我忍不住觉得我在某种程度上误解了什么,而继承Collection实际上并不是解决我问题的正确方法。
我看到了一些建议,比如实施IList。只是没有。这是几十行样板代码,我什么也没得到。
最后,一些人建议将列表包装为:
class FootballTeam
{
public List<FootballPlayer> Players;
}
这有两个问题:
这使我的代码变得不必要地冗长。我现在必须调用my_team.Players.Count而不是my_team.Count。幸运的是,使用C#,我可以定义索引器以使索引透明,并转发内部List的所有方法。。。但这是很多代码!我做这些工作能得到什么?这显然没有任何意义。足球队没有球员名单。这是球员名单。你不会说“约翰·麦克足球先生加入了SomeTeam的球员”。你说“John加入了SomeTeam”。您不向“字符串的字符”添加字母,而是向字符串添加字母。您不向图书馆的图书添加图书,而是向图书馆添加图书。
我意识到“幕后”发生的事情可以说是“将X添加到Y的内部列表中”,但这似乎是一种非常反直觉的思考世界的方式。
我的问题(总结)
正确的C#表示数据结构的方式是什么?数据结构“逻辑上”(也就是说,“对人类的大脑”)只是一个带有一些铃铛和口哨的列表?
从列表<T>继承总是不可接受的吗?什么时候可以接受?为什么?程序员在决定是否从List<T>继承时,必须考虑什么?
这取决于上下文
当你把你的球队作为一个球员列表时,你把足球队的“想法”归结为一个方面:你把“球队”缩小到你在球场上看到的人。这一预测仅在特定情况下是正确的。在不同的背景下,这可能是完全错误的。想象一下你想成为球队的赞助商。所以你必须和团队的经理谈谈。在这种情况下,团队将被列入其经理名单。这两个列表通常不会重叠太多。其他情况包括当前玩家与前玩家等。
语义不清晰
因此,将一个团队视为其球员列表的问题在于,它的语义取决于上下文,并且当上下文发生变化时无法扩展。此外,很难表达您所使用的上下文。
类是可扩展的
当您使用只有一个成员的类(例如IListactivePlayers)时,您可以使用成员的名称(以及其注释)来明确上下文。当有其他上下文时,只需添加一个额外的成员。
类更复杂
在某些情况下,创建一个额外的类可能是过度的。每个类定义都必须通过类加载器加载,并由虚拟机缓存。这会消耗运行时性能和内存。当你有一个非常具体的背景时,可以将足球队作为球员列表。但在这种情况下,您应该只使用IList,而不是从它派生的类。
结论/注意事项
当你有一个非常具体的背景时,可以将一个团队作为一个球员列表。例如,在方法内部,完全可以写:
IList<Player> footballTeam = ...
使用F#时,甚至可以创建类型缩写:
type FootballTeam = IList<Player>
但当背景更广泛甚至不清楚时,你不应该这样做。当您创建一个新的类,而该类将来可能使用的上下文不清楚时,情况尤其如此。警告标志是当你开始向你的班级添加额外的属性(球队名称、教练等)时。这是一个明确的标志,表明班级的使用环境不是固定的,将来会改变。在这种情况下,您不能将球队视为球员列表,但您应该将球员列表(当前活跃、未受伤等)建模为球队的属性。
设计>实施
您公开的方法和财产是一个设计决策。您从中继承的基类是一个实现细节。我觉得回到从前是值得的。
对象是数据和行为的集合。
因此,您的第一个问题应该是:
这个对象在我创建的模型中包含哪些数据?该对象在该模型中表现出什么行为?这在未来会发生什么变化?
记住,继承意味着“isa”(是a)关系,而组合意味着“hasa”(hasa)关系。在您的视图中选择适合您的情况的应用程序,记住随着应用程序的发展,事情可能会走向何方。
考虑在考虑具体类型之前先考虑界面,因为有些人发现这样更容易将大脑置于“设计模式”。
这并不是每个人在日常编码中都有意识地做到的。但是,如果你在考虑这类话题,你就在踩设计的水。意识到这一点可以让人解放。
考虑设计规范
看看MSDN或Visual Studio上的List<T>和IList<T>。查看它们公开了哪些方法和财产。在你看来,这些方法都像是有人想对足球队做的吗?
足球队逆转()对你有意义吗?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>.Count和ILIst<StaffMember>.Cunt等,否则它们只会让人感到痛苦,而不会让人感到困惑。你可以看到这是怎么回事;沿着这条路思考时的直觉很可能会告诉你,朝着这个方向前进是错误的(无论对错,如果你这样做,你的同事也可能会这样做!)
简短的回答(太迟了)
关于不从集合类继承的准则不是C#特定的,您可以在许多编程语言中找到它。这是公认的智慧,而不是法律。一个原因是,在实践中,在可理解性、可实现性和可维护性方面,组合通常被认为优于继承。在现实世界/领域对象中,找到有用的、一致的“hasa”关系比找到有用的和一致的“isa”关系更常见,除非你深入抽象,尤其是随着时间的推移和代码中对象的精确数据和行为的改变。这不应该导致您总是排除从集合类继承;但它可能是暗示性的。
如果足球队有一支预备队和主力队呢?
class FootballTeam
{
List<FootballPlayer> Players { get; set; }
List<FootballPlayer> ReservePlayers { get; set; }
}
你将如何建模?
class FootballTeam : List<FootballPlayer>
{
public string TeamName;
public int RunningTotal
}
这种关系显然是有一个,而不是一个。
还是退役球员?
class FootballTeam
{
List<FootballPlayer> Players { get; set; }
List<FootballPlayer> ReservePlayers { get; set; }
List<FootballPlayer> RetiredPlayers { get; set; }
}
根据经验,如果您想从集合继承,请将类命名为SomethingCollection。
你的SomethingCollection在语义上有意义吗?只有当你的类型是Something的集合时才这样做。
就足球队而言,这听起来不太对。团队不仅仅是集合。正如其他答案所指出的,一个团队可以有教练、教练等。
FootballCollection听起来像是足球的集合,也可能是足球用品的集合。TeamCollection,团队的集合。
FootballPlayerCollection听起来像是一个球员集合,如果你真的想这样做的话,它将是继承自List<FootballPlayer>的类的有效名称。
真正的列表<FootballPlayer>是一个非常好的类型。如果您从方法返回IList<FootballPlayer>,则可能是这样。
总而言之
问问你自己
X是Y吗?或者有X和Y?我的类名是什么意思?
这里有很多很好的答案,但我想谈谈我没有提到的东西:面向对象的设计是关于增强对象的能力。
您希望将所有规则、附加工作和内部细节封装在适当的对象中。以这种方式,与此交互的其他对象不必担心这一切。事实上,您希望更进一步,积极防止其他对象绕过这些内部。
从列表继承时,所有其他对象都可以将您视为列表。他们可以直接访问添加和删除玩家的方法。你会失去控制;例如:
假设你想通过了解球员是退役、辞职还是被解雇来区分球员何时离开。您可以实现一个RemovePlayer方法,该方法采用适当的输入枚举。然而,通过从List继承,您将无法阻止对Remove、RemoveAll甚至Clear的直接访问。结果,你实际上取消了足球队课程的资格。
关于封装的其他想法。。。您提出了以下问题:
这使我的代码变得不必要地冗长。我现在必须调用my_team.Players.Count而不是my_team.Count。
你是对的,这对于所有使用你团队的客户来说都是不必要的冗长。然而,这个问题与你将名单球员暴露给所有人的事实相比是很小的,这样他们就可以在没有你同意的情况下玩弄你的球队。
你接着说:
这显然没有任何意义。足球队没有球员名单。这是球员名单。你不会说“约翰·麦克足球先生加入了SomeTeam的球员”。你说“John加入了SomeTeam”。
第一点你错了:去掉“列表”这个词,很明显一支球队确实有球员。然而,你用第二个击中了要害。你不希望客户端调用ateam.Players.Add(…)。你确实希望他们调用ateam.AddPlayer(…),而你的实现(可能还有其他事情)会在内部调用Players.Add(…)。
希望您能够看到封装对于增强对象的能力是多么重要。您希望让每个类都能很好地完成其工作,而不必担心其他对象的干扰。
仅仅因为我认为其他答案与足球队是“是”列表<足球运动员>还是“有”列表<橄榄球运动员>的切线基本一致,这并不能回答这个问题。
OP主要要求澄清从列表<T>继承的指南:
一条准则说,你不应该继承List<t>。为什么不呢?
因为List<T>没有虚拟方法。在您自己的代码中,这不是一个问题,因为您通常可以相对轻松地切换实现,但在公共API中,这可能是一个更大的问题。
什么是公共API?我为什么要关心?
公共API是您向第三方程序员公开的接口。思考框架代码。请记住,引用的指南是“.NET Framework设计指南”,而不是“.NET应用程序设计指南”。这是有区别的,而且一般来说,公共API设计要严格得多。
如果我当前的项目没有也不可能有这个公共API,我可以放心地忽略这个准则吗?如果我确实继承了List,结果发现我需要一个公共API,我会遇到什么困难?
差不多,是的。您可能需要考虑其背后的基本原理,看看它是否适用于您的情况,但如果您没有构建公共API,那么您不需要特别担心API问题,例如版本控制(这是其中的一个子集)。
如果您将来添加了公共API,您要么需要从实现中抽象出API(不直接暴露List<T>),要么违反指南,可能会带来未来的痛苦。
为什么这很重要?列表是一个列表。什么可能会改变?我可能想要改变什么?
这取决于上下文,但由于我们以足球队为例,假设你不能添加足球运动员,如果这会导致球队超过工资上限。一种可能的添加方式是:
class FootballTeam : List<FootballPlayer> {
override void Add(FootballPlayer player) {
if (this.Sum(p => p.Salary) + player.Salary > SALARY_CAP)) {
throw new InvalidOperationException("Would exceed salary cap!");
}
}
}
啊。。。但不能重写Add,因为它不是虚拟的(出于性能原因)。
如果您在一个应用程序中(基本上,这意味着您和所有调用程序都是一起编译的),那么现在可以改为使用IList<T>并修复任何编译错误:
class FootballTeam : IList<FootballPlayer> {
private List<FootballPlayer> Players { get; set; }
override void Add(FootballPlayer player) {
if (this.Players.Sum(p => p.Salary) + player.Salary > SALARY_CAP)) {
throw new InvalidOperationException("Would exceed salary cap!");
}
}
/* boiler plate for rest of IList */
}
但是,如果你已经公开暴露给第三方,你只是做了一个破坏性的更改,这将导致编译和/或运行时错误。
TL;DR-指南适用于公共API。对于私有API,您可以随心所欲。
首选接口而非类
类应该避免从类派生,而是实现所需的最小接口。
继承中断封装
从类派生会破坏封装:
公开有关如何实现集合的内部详细信息声明可能不合适的接口(公共函数和财产集)
除此之外,这使得重构代码变得更加困难。
类是实现细节
类是一个实现细节,应该对代码的其他部分隐藏。简而言之,System.List是一种抽象数据类型的具体实现,现在和将来可能合适,也可能不合适。
从概念上讲,System.List数据类型被称为“List”这一事实有点转移视线。System.List<T>是一个可变的有序集合,它支持用于添加、插入和删除元素的摊余O(1)操作,以及用于检索元素数量或按索引获取和设置元素的O(2)操作。
接口越小,代码越灵活
在设计数据结构时,界面越简单,代码就越灵活。只需看看LINQ有多强大就可以演示这一点。
如何选择接口
当你想“列出”时,你应该先对自己说:“我需要代表一批棒球运动员”。假设你决定用一个类来建模。您首先应该做的是决定这个类需要公开的接口的最小数量。
可以帮助指导此过程的一些问题:
我需要计数吗?如果没有,则考虑实施IEnumerable<T>此集合在初始化后是否会更改?如果没有,请考虑IReadonlyList<T>。我可以通过索引访问项目是否重要?考虑ICollection<T>我向集合中添加项目的顺序是否重要?也许是ISet<T>?如果您确实想要这些东西,那么继续执行IList<T>。
这样,您就不会将代码的其他部分与棒球运动员集合的实现细节相耦合,只要您尊重接口,您就可以自由更改实现方式。
通过采用这种方法,您将发现代码变得更容易阅读、重构和重用。
避免煮沸板的注意事项
在现代IDE中实现接口应该很容易。右键单击并选择“Implement Interface”。然后,如果需要,将所有实现转发给成员类。
也就是说,如果你发现你正在编写大量的样板,这可能是因为你暴露的函数比你应该暴露的更多。这也是你不应该从类继承的原因。
您还可以设计对您的应用程序有意义的较小接口,也许只需要几个助手扩展函数就可以将这些接口映射到您需要的任何其他接口。这是我在LinqArray库的IArray接口中采用的方法。