在基类中声明子类类型,是好还是不好?

18

最近我遇到了一些代码,它在基类中将子类型声明为枚举。以下是一个简单的示例:

public enum EmployeeType
{
    Manager,
    Secretary
}

public class Employee
{
    public string Code { get; set; }

    public EmployeeType Type { get; set; }
}

public class Manager : Employee
{
    public void Manage()
    {
        // Managing
    }
}

public class Secretary : Employee
{
    public void SetMeeting()
    {
        // Setting meeting
    }
}
根据我的开发经验,我写了一篇关于继承和基类中子类类型的文章(链接),声明这是一种不好的实践或设计。我认为这样做是不好的,因为基类应该对其子类保持中立。它不应该有关于其子类的任何信息,原因至少有两个:
  1. 可扩展性:该设计不能很好地扩展,因为如果你想定义另一个派生类,例如 Developer,你还需要更新 EmployeeType 枚举,而你可能无法访问它。
  2. 自相矛盾的定义:现在你可以编写以下代码:
  3. Secretary secretary = new Secretary();
    secretary.EmployeeType = EmployeeType.Manager;
    /*
    This is absurd semantically. But syntactically, it's possible.
    */
    

然而,当我阅读 维基百科关于继承的文章时,我没有找到任何答案来回答我的问题。

尽管一开始可能会引起争议,但我认为继承应该已经成熟到有一个确定的答案来解决这个困境。 这段代码是不好的,有坏味道的代码吗?还是可以接受和合理的?为什么?


我认为关键问题是它在这里用来做什么?如果我在Java中看到相同的代码,我会认为程序员不理解面向对象编程... - Robin Green
1
通常这是不好的设计。但在某些情况下(通常是子类型集合有限的情况下),它可以成为一种有用的模式,类似于 F# 提供的区分联合。 - CodesInChaos
1
Type 的公共 setter 几乎总是一个坏主意。我上面的评论假设有一个虚拟 getter 没有 setter,或者至少有一个受保护的 setter。 - CodesInChaos
我进行了更新,可能值得一看。 - Mohsen Heydari
5个回答

7
我认为问题可以通过回归“何时使用继承”的哲学来解决。
据说:“将继承看作是一个‘is a’关系”
当你在两个类之间建立继承关系时,你可以利用动态绑定和多态性。
动态绑定意味着在运行时基于对象的类来调用哪个方法实现。
多态性意味着你可以使用超类类型的变量来持有对其子类或其任何子类的对象的引用。
动态绑定和多态的主要优点之一是它们可以帮助使代码更易于更改。如果您有一个使用超类类型(例如Employee)的变量的代码片段,那么稍后您可以创建一个全新的子类(例如Manager),旧的代码片段将在不更改的情况下与新子类的实例一起工作。如果Manager覆盖了代码片段调用的任何Employee方法,动态绑定将确保执行Managers的这些方法的实现。即使当编写和编译代码片段时,Manager类不存在,这也是正确的。

现在回到问题,为什么我们需要在Employee中使用EmployeeType?
可能有一些我们想在Employee中实现的函数,比如CalculateSalary()。
CalculateSalary(){
if (EmployeeType == EmployeeType.Manager) // Add management percent }

诱人的理由,但是如果员工既是经理又是主管怎么办?这会变得很复杂!简单来说,我们可以将CalculateSalary()实现为抽象方法,但我们将失去目标!这里有Coad的规则:子类表示是一种特殊的而不是是扮演的角色Liskov替换原则是一个测试,用于“我应该从这个类型继承吗?”以上的经验法则是:

  • 是否需要TypeB暴露TypeA的完整接口(所有公共方法,不少于这些),以便在期望TypeA的地方使用TypeB?表示继承。
    例如,塞斯纳双翼飞机将暴露比飞机更多的完整接口。因此,它适合从飞机派生。
  • 是否只需要TypeB暴露TypeA部分行为?表示需要组合。
    例如,鸟可能只需要飞机的飞行行为。在这种情况下,将其提取为接口/类/两者之一,并将其作为两个类的成员是有意义的。

来自javaworld的脚注:
确保继承模型is-a关系。
不要仅仅为了代码重用而使用继承。
不要仅仅为了实现多态而使用继承。
优先使用组合而非继承。

我认为在这种情况下,经理是员工扮演的角色,这个角色可能会随着时间而改变,这个角色将被实现为一个组合。

作为领域模型的另一个方面,并将其映射到关系数据库。将继承映射到SQL模型非常困难(你最终会创建不总是使用的列,使用视图等)。一些ORM尝试解决这个问题,但很快就变得复杂了。
例如Hibernate的每类层次一个表方法,违反第二范式,很可能导致与你的样本不一致。

Secretary secretary = new Secretary();
secretary.EmployeeType = EmployeeType.Manager;


5
这绝对是一个糟糕的做法。撇开继承的可疑用法(与使用组合相比,继承本身就被广泛视为不良实践),它通过在父类和子类之间引入令人讨厌的循环依赖关系来破坏了依赖倒置原则
在重新设计代码时,我期望解决三个问题:
  1. 我将通过从基类中删除枚举来消除循环依赖。
  2. 我将使Employee成为接口。
  3. 我将审查删除公共setter,因为它们允许应用程序的任何方面修改员工记录的核心方面,使得测试和跟踪错误比必要的困难得多。

3
在我看来,那并没有真正回答问题,只是回答了“在需要依赖反转的情况下,这样做是否是不好的实践?”这个问题。 - O. R. Mapper
1
@O.R.Mapper 依赖反转原则始终是可取的,因为它减少了耦合。问题是,违反依赖反转原则是否是糟糕代码设计的例子。正确答案是“是”。 - David Arno
2
@DavidArno,我不同意。基于项目管理三角形,有时在真实的、实际的世界中你没有足够的资源去使用它。我同意理论上总是好的。但在实践中,有时为了敏捷性和降低成本,你应该忽略它。 - Saeed Neamati
1
@DavidArno:所以,正如您所说,只有在需要低耦合度的情况下才需要依赖反转,因此并非所有情况都需要。实际上,这个问题比“违反依赖反转原则是否是糟糕代码设计的例子”更具体,但即使如此,很可能也无法用清晰的“是”或“否”来回答。 - O. R. Mapper
1
@DavidArno: 当然,处理任务有多种方法。一般来说,我更喜欢仔细选择合适的技术,并知道为什么我选择它们,而不是盲目地跟随它们。这可以避免在产品设计上做出不幸的决策,也可以避免在没有具体论据支持我的观点时诉诸于一般的指责。 - O. R. Mapper
显示剩余2条评论

4

“代码味道”听起来很糟糕,我会说是设计不良。但通常我会做类似这样的事情,我觉得这是可以接受的。

public enum EmployeeType
{
    UnKnown,//this can be used for extensiblity
    Manager,
    Secretary,    
}

public abstract class Employee//<--1
{
    public string Code { get; set; }

    public abstract EmployeeType Type { get; } //<--2
}

public class Manager : Employee
{
    public override EmployeeType Type { get { EmployeeType.Manager; } }//<--3
    public void Manage()
    {
        // Managing
    }
}

public class Secretary : Employee
{
    public override EmployeeType Type { get { EmployeeType.Secretary; } }//<--4
    public void SetMeeting()
    {
        // Setting meeting
    }
}

Then a factory of it..

EmployeeFactory.Create(employeeType);

我已经在原始代码中做了"4"处更改,现在看起来有点像"工厂模式"。可能代码的作者误解了"工厂模式",但谁知道呢?

3
好的,我会尽力进行翻译。这段话的意思是:请求下投票者留言解释原因,这样我可以知道我的错误在哪里。 - Sriram Sakthivel
1
你添加“Unknown”并不能解决这个问题,因为你在这里创建的模式违反了良好面向对象设计的核心原则:依赖倒置原则。 - David Arno
1
我认为为了防止不良影响,您仍应该将子类的“Type”属性或子类本身设置为“sealed”。顺便说一句,我通常觉得“Type”很容易与对象的(编程).NET类型混淆,而不是(业务逻辑)类型,因此我通常将这种属性称为“*Kind”。 - O. R. Mapper
1
是的,我明白了... 我更习惯于Java和PHP,所以通常我会坚持使用字符串版本,并在出现任何问题时抛出“异常”... - Henrique Barcelos
1
@HenriqueBarcelos 字符串比较也是一件麻烦的事情。你需要注意文化差异,末尾的空格可能会出问题,还有 Unicode 字符等等。因此我选择枚举方法。 - Sriram Sakthivel
显示剩余8条评论

2

我认为这种做法并不好,但在某些情况下可能会有所需要或有用。如果是这样的情况,您至少可以通过使基类的属性只读并通过子类的构造函数设置来防止发生一些不好的事情。

public enum EmployeeType
{
    Manager,
    Secretary
}

public class Employee
{
    public Employee(EmployeeType type)
    {
        this.Type = type;
    }

    public string Code { get; set; }

    public EmployeeType Type { get; private set; }
}

public class Manager : Employee
{
    public Manager()
        : base(EmployeeType.Manager)
    {
    }

    public void Manage()
    {
        // Managing
    }
}

public class Secretary : Employee
{
    public Secretary()
        : base(EmployeeType.Secretary)
    {
    }

    public void SetMeeting()
    {
        // Setting meeting
    }
}

为了防止使用错误类型实例化Employee,可以将其设置为protected。
一般来说,这个问题比较广泛,意味着它真的取决于整体设计,我会通过重构/重新设计类结构和/或逻辑来避免发生这种情况,该逻辑期望Type属性。

但在某些情况下,这并不是一个好的做法。不要试图通过使用受保护的方法等方式来修改它。 - David Arno
3
我不同意,这个问题太宽泛了无法正确回答。你的评论只是一个观点。例如,你可能需要使用另一个组件/库,该组件/库需要类似这样的操作,而且你无法阻止它发生,那怎么办呢?总的来说,如果所有的代码都是你编写的,你根本不应该做出这样的操作,而应该重构/重新设计你的代码(就像我写的那样)。 - MichaC

0

我认为在某些场景下这是一个很好的设计,而且它确实是.NET基类库的各个部分中看到的一种经常出现的模式。

我认为暗示它可能是不良设计的误解是类层次结构必须是可扩展的。然而,首先和最重要的是,继承关系只是表达了一些子类型是超类型的特化,并且子类型与超类型不同,但与(接口)超类型兼容。

由继承相关的类型族群并不一定提供任何可能让第三方添加任何附加类型的机会。当这样的类型族群表示标准中定义的数据结构时,通常是如此,但并非唯一如此。

如果继承层次结构仅限于第三方扩展,则使用enum值表示当前类的类型是没有问题的。严格来说,这样的属性不应该是必需的,但由于像C#这样的语言没有任何切换类型的可能性,提供一个enum值可以提高处理类型族中不同类型的代码的可读性,从而构成良好的API设计。在这方面,它也提高了可维护性,因为同一类型族的未来版本可能会重构类型层次结构。仅针对enum值进行检查的代码将对此类更改保持不可知(并且不会引入诸如针对(string)类型名称而不是Type对象本身进行检查的错误借口)。

.NET BCL在各个地方都使用了这种模式,例如:


请注意,所有这些都是只读属性。但在这种情况下不是这样的。 - Sriram Sakthivel
1
@SriramSakthivel: 确实,所示代码不是很干净 - 我个人也会显式地将Employee声明为abstract,并给它一个protected(或可能是internal,以防止任何第三方继承)的构造函数,同时可能将ManagerSecretary设置为sealed。然而,我理解OP主要关注的是在enum类型中反映子类的做法,在我看来,这比你正确指出的API清洁度问题更重要。 - O. R. Mapper
1
BCL充满了糟糕的设计,比如在这种情况下以及在许多集合类中违反了Liskov替换原则。仅仅因为BCL设计不良并不是设计自己代码不良的借口。 - David Arno
1
@O.R.Mapper 每个设计良好的软件背后的核心原则是松耦合。通过让基类依赖于它的子类,您将这些类耦合在一起。良好的软件设计不应该仅在方便时才使用; 它应该成为您编写的每行代码的核心。 - David Arno
1
@DavidArno:在我看来,软件开发的核心原则之一是选择适合手头任务的正确工具。有时候,松耦合可能是正确的工具,而在其他情况下则不是。我的个人观点是,在没有针对实际情况进行调整的情况下,盲目遵循一个原则是没有意义的。 - O. R. Mapper
显示剩余2条评论

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