继承:为什么改变对象的类型被认为是不良设计?

9
在建模领域类时,我发现Entity Framework可以建模继承关系,但不支持将基类型实例提升为派生类型,即无法更改数据库中现有Person行的类型为它的派生类型Employee。
显然,我并不是第一个对此感到困惑的人,因为这个问题已经在Stack Overflow上被问过和回答过多次(例如,见这里这里)。
正如这些答案所指出的那样,Entity Framework不支持这个功能,因为面向对象编程也不允许这样做:一旦创建了一个实例,就不能更改它的运行时类型。
从技术角度来看,我可以理解这一点。一旦为对象分配了一块内存,后来再添加一些额外的字节来保存派生类型的字段可能需要整个内存块重新分配,这将导致对象的指针发生变化,进而引入更多问题。因此,我明白这很难实现,因此在C#中不支持。
然而,除了技术组件之外,答案(以及这里,在第1页的最后一段)似乎也意味着当你想要更改对象的运行时类型时,这被认为是不良设计,并说你不应该需要这种类型的更改,而应该使用组合来代替。
老实说,我不明白为什么——我认为希望像处理Person实例一样处理Employee实例(即通过从Person继承Employee)是完全有效的,即使在某个时间点上,Person将被聘用为Employee,或者Employee辞职并重新成为Person。
从概念上讲,我认为这没有任何问题?
有人能解释一下吗?
public class Person
{
    public string Name { get; set; }
}

public class Employee: Person
{
    public int EmployeeNr { get; set; }

    public decimal Salary { get; set; }
}

......但这不是吗?

public class Person
{
    public string Name { get; set; }

    public EmployeeInfo EmployeeInfo { get; set; }
}

public class EmployeeInfo
{
    public int EmployeeNr { get; set; }

    public decimal Salary { get; set; }
}

使用接口还可以让您对类强制执行一些规则。 - GameAlchemist
思考一下:如何正确设置Employee可能有的额外属性?例如,如果每个Employee都有一个Salary属性,如果你不知道其薪水,你该如何将任意一个Person晋升为有效的Employee?更一般地说:在按合同设计时,如何满足对Employee实例施加的附加规则(例如,具有有效的薪水)? - Mattias Buelens
谁说你的代码(`public class Person { public string Name { get; set; } }public class Employee: Person { public int EmployeeNr { get; set; }public decimal Salary { get; set; }}`)是糟糕的设计?在我看来,它看起来毫无争议。问题出在哪里? - Jeppe Stig Nielsen
@Jeppe,因为你将可雇用性的特质归结于人类,这在未来可能不再成立。通常情况下,这是一个连接现实世界面向对象示例的不太好的例子,但我在我的回答中试图解决这个问题。 - CodeCaster
@Jeppe 这并不是特定于数据库的问题,而是关于设计的一般性问题。我只是想不出在什么情况下会(实际上)使用 Employee employee = (Employee)new Person(); - CodeCaster
显示剩余3条评论
3个回答

2
考虑以下代码:
Person person   = new Person { Name = "Foo" };
Person employee = new Employee { Name = "Bar", EmployeeNr = 42 };

然而,虽然employee被声明为Person,但它所引用的实例实际上是Employee类型。这意味着你可以将其强制转换为该类型:
Employee employee2 = (Employee)employee;

当你尝试使用person时:
Employee employee3 = (Employee)person;

你会在运行时得到一个异常:

无法将类型为“Person”的对象强制转换为类型为“Employee”的对象。

因此,从 C# 的实现来看,你是正确的,这是技术上不可能的。但你可以通过在 Employee 中创建一个构造函数来解决这个问题:
public Employee(Person person, int employeeNr, decimal salary)
{
    this.Name = person.Name;
    EmployeeNr = employeeNr;
    Salary = salary;
}

如您所见,您将不得不自己实例化EmployeePerson部分。这是繁琐且容易出错的代码,但现在我们来谈谈您实际的问题:为什么这可能被认为是劣质的设计?
答案在于偏爱组合而非继承

面向对象编程中的组合优于继承(或复合重用原则)是一种技术,通过包含实现所需功能的其他类而不是通过继承来实现多态行为和代码重用。

精髓在于:

优先考虑组合而非继承是一种设计原则,可以使设计具有更高的灵活性,在长期内为业务域类提供更稳定的业务域。换句话说,HAS-A关系比IS-A关系更好。

为了保持与您现实世界的示例相符,将来我们可能需要雇用Android,他们肯定不会继承自Person,但可以使用EmployeeInfo记录。
关于您的Entity Framework具体问题:
我认为,如果在某个时刻一个人将被聘为员工,或者一个员工辞职成为一个人,希望像使用Person实例一样处理Employee实例(即通过从Person继承Employee),这是完全有效的。
如果现在您只知道将雇用人类(不要忘记YAGNI),我无法想象会出现这个问题的示例。当您想显示所有人时,可以迭代该存储库:
using (var context = new MyContext())
{
    foreach (var person in context.Persons)
    {
        PrintPerson(person);
    }
}

当您想要列出所有员工时,您可以使用相同的方法,但是需要使用员工存储库:
using (var context = new MyContext())
{
    foreach (var Employee in context.Employees)
    {
        PrintEmployee(Employee);
    }
}

如果在某些情况下你想知道某个人是否是雇员,你就必须再次查询数据库:
public Empoyee FindEmployeeByName(string name)
{
    using (var context = new MyContext())
    {
        return context.Employees.FirstOrDefault(e => e.Name == name);
    }
}

然后你可以使用一个 Person 实例进行调用:
Empoyee employee = FindEmployeeByName(person.Name);

谢谢你对组合优先于继承的澄清,我不知道有一篇维基百科文章讲这个... - Leon Bouquiet

2
你过分分析了这个问题。多态在.NET中得到了很好的支持,你永远不可能安全地向上转型,友好的InvalidCastException始终会提醒你出错了。甚至可以在C++中安全地完成(dynamic_cast<>),这并不是一个以缺乏让你自残方式而闻名的语言。
它的运行时支持是关键。数据库引擎没有任何支持。
它是世纪之交风险投资的游乐场。由于互联网繁荣,风险投资已经自由流动。面向对象的数据库被承诺为每个人的喷气背包。但这也破产了,它们中没有一个能够成为主导。你听说过Gemstone、db4o、Caché、odbpp吗?与现有引擎竞争是一个艰巨的任务,在那个市场领域,性能和可靠性是一切,要赶上20年的精细调整是非常困难的。因此,在数据访问层中添加对其的支持是毫无意义的。

在句子“永远没有任何方式可以不安全地向上转型,友好的InvalidCastException..”中,“upcast”应该改为“downcast”吗?不正确的向上转型会导致编译器错误。不安全的向下转型会导致InvalidCastException。可能只是我读错了。 - acarlon
两个术语都被使用。重力倾向于确保某物的“基底”在底部。 - Hans Passant
啊,好的。有点困惑。我总是因为使用更直观的重力方式进行向下/向上转换而受到指责。 - acarlon

2
坦率地说,我不明白为什么——我认为希望像处理Person实例一样来处理Employee实例(即通过从Person继承Employee),即使在某个时候一个Person会被雇佣成为Employee,或者一个Employee辞职并重新变成Person。在概念上,我认为这没什么问题?
我理解这是指Person -> Employee -> Person?也就是说,他/她/它仍然是一个人,但被归属为"雇员",然后被"降级"回一个普通的人?
当对象在运行时不改变类型时,例如当创建一个股权对象时,您可以确信不想将其转换为FRA,即使两者都是证券子类时,继承非常有用。但是每当可能在运行时发生更改时,例如行为时,请尝试使用组合。在我的前一个例子中,证券子类可能会继承自顶层资产类别,但用于对资产进行市场价值评估的方法应该通过行为模式(例如策略模式)放置在那里,因为您可能想要在一个运行时期间使用不同的方法。
对于您所描述的情况,您不应该使用继承。尽可能地倾向于组合而非继承。如果某些东西在运行时发生了变化,它绝对不应该通过继承来实现!可以将其比作生命,人类出生为人类,死亡为人类,猫出生为猫,死亡为猫,某种类型的对象应该作为一种类型创建并作为相同类型进行垃圾回收。不要改变某物的本质,而是通过行为组合来改变它的行为或知识。我建议您查看 Design Patterns: Elements of Reusable Object-Oriented Software,它详细介绍了一些非常有用的设计模式。

所以如果我理解正确,因为通过组合在运行时更容易改变行为而不是通过继承,所以应该倾向于使用组合? 对于在AppDomain的生命周期内不需要更改的行为,继承就可以了? - Leon Bouquiet
@Astrotrain 这正是我所指的 :) - flindeberg

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