为了提供一个反面的建议(当没有明显的冗长/生产力问题时,一般的建议是正确的),以下至少有一种情况可以从没有虚析构函数的基类派生出公共派生(public derivation):
- 您想要使用专用用户定义类型(类)提供的某些类型安全性和代码可读性好处
- 现有的基类最适合存储数据,并允许客户端代码也要使用低级操作
- 您想要重用支持该基类的函数的方便性
- 您理解您的数据可能需要的任何附加不变量只能在以派生类型明确访问数据的代码中强制执行,并且根据在设计中将“自然”发生的程度以及您可以信任客户端代码理解并与逻辑理想不变量合作的程度,您可能希望派生类的成员函数重新验证期望(并抛出错误等)
- 派生类添加了一些高度特定于类型的方便函数,这些函数对数据进行操作,例如定制搜索、数据过滤/修改、流式处理、统计分析、(备选)迭代器
- 客户端代码与基类的耦合比与派生类的耦合更为合适(因为基类要么稳定,要么对其的更改反映出对派生类的核心功能的改进)
- 您不会在负责删除它们的代码部分中混合使用指向基础对象和派生对象的指针
这听起来可能很受限制,但是在实际程序中有许多符合此情况的案例。
编程就是妥协。在编写更概念上“正确”的程序之前,请考虑以下几点:
- 考虑它是否需要增加复杂性和通过混淆真正的程序逻辑而更容易出错的代码,因此尽管处理一些特定问题更加健壮,但整体上更容易出现错误。
- 权衡实际成本与问题可能性和后果,并考虑“投资回报率”以及您可以用时间做什么其他事情。
如果潜在问题涉及您无法想象任何人尝试使用的对象用法,给定您对其可访问性、范围和在程序中使用的性质的见解,或者您可以为危险使用生成编译时错误(例如,断言派生类大小与基类的大小相匹配,这将防止添加新数据成员),那么任何其他东西都可能是过度工程化。 对于清晰、直观、简洁的设计和代码,请轻松获得胜利。假设你有一个公开继承于B的类D。在没有额外工作的情况下,可以在D上执行B的操作(除了构造函数,但即使有很多构造函数,您通常也可以通过为每个不同数量的构造函数参数提供一个模板来实现有效的转发:例如,
template <typename T1, typename T2> D(const T1& x1, const T2& t2) : B(t1, t2) { }
。在 C++0x 的可变模板中具有更好的通用解决方案)。此外,如果B发生更改,则默认情况下D会暴露这些更改 - 保持同步 - 但某人可能需要审查引入到D中的扩展功能,以查看它是否仍然有效,并且客户端使用情况。换句话说,基类和派生类之间存在
明显减少的耦合,但基类和客户端之间的耦合增加。
这通常不是您想要的,但有时它是理想的,其他时候则不是问题(请参见下一段落)。基类的更改会导致代码库中分布在各处的客户端代码更改,有时修改基类的人甚至可能无法访问客户端代码以进行相应的审查或更新。但有时情况会更好:如果您作为派生类提供者 -“中间人”-希望基类更改传递到客户端,并且通常希望客户端能够-有时是被迫-在基类更改时更新代码,而不需要您不断参与,则公开继承可能是理想的。当您的类不是独立实体而是对基类的轻微增值时,这种情况很常见。
其他情况下,基类接口非常稳定,耦合可能被视为不成问题。这尤其适用于像标准容器这样的类。
总之,公开继承是一种快速获得或近似于派生类的理想,熟悉的基类接口的方式-以对维护者和客户端编码者都是简明且自证不疑的方式-并提供额外的功能作为成员函数(在我看来-这显然与Sutter、Alexandrescu等人不同-可以帮助可读性和提高生产力的工具,包括IDE)。
《C++编程规范》中的第35条列出了从
std::string
派生的场景的问题。就情境而言,它很好地说明了公开大而有用的API的负担,但是基本API的稳定性非常高-是标准库的一部分,因此既好也坏。稳定的基类是常见情况,但与不稳定的情况一样常见,并且良好的分析应该与这两种情况相关。在考虑书中列出的问题清单时,我将具体比较这些问题适用于例如:a) 使用公有派生,现有代码可以隐式地将基类string作为string访问,并继续像以前一样工作。没有明确的理由认为现有代码想要使用超级字符串(在我们的例子中是Issue_Id)中的任何其他功能...实际上,它通常是低级支持代码,针对创建超级字符串的应用程序而言是早已存在的,因此无视于扩展函数提供的需求。例如,假设有一个非成员函数to_upper(std::string&, std::string::size_type from, std::string::size_type to),它仍然可以应用于Issue_Id。
b) 使用带虚析构函数的字符串更安全。这意味着可以在不引起内存泄漏的情况下删除派生类对象。但是,这种方法可能需要进行类型转换和调整现有代码。
c) 组合方法可接受,因为它提供了封装,类型安全性以及比std :: string更丰富的API。
d) 在所有地方使用std :: string,并提供独立的支持函数,非成员函数可以很好地在现有代码中工作,但忽略了类型安全性的好处。因此,除非正在以将其与新代码紧密耦合的代价清理或扩展非成员支持函数,否则不需要更改它。如果正在重大改进以支持问题 ID(例如,使用数据内容格式的洞察力仅将前导字母字符大写),那么最好通过创建类似于 `to_upper(Issue_Id&)` 的重载并坚持采用推导或组合方法来确保确实传递了 `Issue_Id`。使用 `super_string` 还是组合对工作量或可维护性没有影响。一个 `to_upper_leading_alpha_only(std::string&)` 可重用的独立支持函数不太可能有多少用处 - 我想不起上次想要这样的功能是什么时候了。
到处都使用 `std::string` 的冲动在本质上与接受所有参数作为变体或 `void *` 容器没有什么不同,因此您不必更改接口以接受任意数据,但这会导致错误容易实现和更少的自我记录和编译器可验证代码。
界面函数现在需要:
- 远离 `super_string` 的附加功能(无用);
- 将其参数复制到 `super_string` 中(浪费);或
- 将字符串引用强制转换为 `super_string` 引用(笨拙且可能非法)。
这似乎在重新审视第一个点 - 需要重构以使用新功能的旧代码,尽管这次是客户端代码而不是支持代码。如果函数希望开始将其参数视为与新操作相关的实体,则应该从那种类型开始接受其参数,并且客户端应该使用该类型生成并接受它们。组合也存在完全相同的问题。否则,如果遵循我下面列出的准则,
c)
可以是实用且安全的,尽管它很丑陋。
`super_string` 的成员函数没有比非成员函数更多地访问 `string` 的内部,因为 `string` 可能没有保护成员(请记住,它一开始就不打算从中派生)。
是的,但有时这是一件好事。许多基类没有受保护数据。公共的 `string` 接口足以操作内容,有用的功能(例如上面描绘的 `get_project_id()`)可以优雅地表示为这些操作的实现。在概念上,许多时候我想从标准容器派生,我不想沿着现有的线路扩展或自定义它们的功能 - 它们已经是“完美”的容器 - 而是想添加另一种特定于我的应用程序的行为维度,并且不需要私人访问。正因为它们已经是好容器,所以它们可以很好地重用。如果
super_string
隐藏了一些
string
的函数(而在派生类中重新定义非虚函数并不是重载,它只是隐藏),那么这可能会在操作在其生命周期内从
super_string
自动转换而来的
string
的代码中引起普遍混乱。
对于组合也是如此-发生的可能性更大,因为代码不会默认通过并因此保持同步,并且在某些情况下,具有运行时多态层次结构的相似的命名函数在最初看起来可互换的类中表现不同-非常令人讨厌。这实际上是正确面向对象编程的常规注意事项,而且再次不足以放弃类型安全等优点的充分理由。
如果
super_string
想要继承自
string
以添加更多的
状态 [有关切片的解释] ,那怎么办?
同意-这不是一个好的情况,在这里我个人倾向于划清界限,因为它经常将通过指向基类的指针删除的问题从理论领域移动到实践领域-析构函数不会为附加成员调用。尽管如此,切片通常可以做到所需的效果-通过派生
super_string
来添加另一个应用特定功能的“维度”,而不是更改其继承的功能...
诚然,编写要保留的成员函数的传递函数很麻烦,但这样的实现比使用公共或非公共继承要好得多且更安全。
成功使用无虚析构函数的指南
- 理想情况下,避免在派生类中添加数据成员:切片的变体可能会意外删除数据成员、损坏它们、无法初始化等。
- 更甚者,避免使用非 POD 数据成员:通过基类指针进行删除本来就是技术上未定义的行为,但对于非 POD 类型而言,未能运行其析构函数更有可能导致资源泄漏、错误引用计数等实际问题。
- 尊重 Liskov 替换原则/不能强健地维护新的不变量。
- 例如,在从 std::string 派生时,您不能拦截几个函数并期望您的对象保持大写:任何通过 std::string& 或 ...* 访问它们的代码都可以使用 std::string 的原始函数实现来更改值)
- 派生以模拟应用程序中的高级实体,通过某些使用但不与基础功能冲突的功能扩展继承的功能; 不要期望或尝试更改基类型授予的基本操作-以及访问这些操作
- 意识到耦合性:即使基类发展出不适当的功能,也无法删除基类而不影响客户端代码,例如,你的派生类的可用性取决于基类的持续适宜性
- 有时,即使使用组合,由于性能、线程安全问题或缺乏值语义,您仍需要公开数据成员-因此,与公共派生的封装损失并不具体更糟
- 越来越多使用潜在派生类的人对其实现妥协毫无所知,您就越不能承担让它们变得危险。
- 因此,与程序员在应用程序级别和/或在“私有”实现/库中常规使用功能的局部使用相比,具有许多临时非正式用户的低层次广泛部署的库应更加警惕危险的派生。
总结
这种派生不是没有问题,因此除非最终结果值得这样做,请不要考虑它。 话虽如此,我坚决反对任何声称它不能在特定情况下安全和适当地使用的说法——这只是一个画界限的问题。
个人经验
我有时候会使用
std::map<>
,
std::vector<>
,
std::string
等STL容器 - 我从未因为切割或基类指针的删除而受到伤害,我也为更重要的事情节省了很多时间和精力。我不会将这些对象存储在异构多态容器中。但是,您需要考虑所有使用该对象的程序员是否知道这些问题并且可能按照相应的方式编程。就个人而言,我喜欢仅在需要时使用堆和运行时多态性来编写代码,而有些人(由于Java背景、他们对管理重新编译依赖性或在运行时行为、测试设施等方面切换时的偏好)习惯性地使用它们,因此需要更加关注通过基类指针进行安全操作。
std::string
继承的一个可怕的理由,因为它允许你的额外语义含义被静默地剥离字符串,因为你的新类型总是隐式转换为字符串。如果你有具有语义含义的数据,请随意包装一个std::string
,但不要从它继承。 - Billy ONeal