为什么不应该继承自C++标准字符串类?

70

我想询问的是Effective C++书中提到的一个具体观点。

它说:

如果一个类需要像多态类一样工作,那么析构函数应该被设为虚函数。它进一步补充说,由于std::string没有虚析构函数,因此永远不应该从它继承。另外std::string甚至都不设计成基类,更别提是多态基类了。

我不理解一个类要符合什么条件才能成为基类(非多态基类)?

我不能从std::string类派生的唯一原因是它没有虚析构函数吗?为了可重用性可以定义一个基类,并且多个派生类可以从它继承。那么什么使得std::string连作为基类的资格都没有呢?

此外,如果有一个纯粹为可重用性而定义的基类和许多派生类型,有没有办法阻止客户端使用Base* p = new Derived(),因为这些类并不意味着要用多态方式使用?

8个回答

66

我认为以下陈述反映了这里的混淆 (重点在于 "not a polymorphic one"):

我不明白一个类要满足什么条件才能成为基类 (不是多态的)?

在惯用的C++中,有两种从类派生的用法:

  • 私有继承,用于使用模板的 mixin 和面向方面编程。
  • 公有继承,仅用于多态情况。 编辑:好吧,我猜这也可以用于一些 mixin 场景,比如当使用CRTP时会出现的情况,例如boost::iterator_facade

在C ++中,如果您不想做某些多态操作,则没有理由公开从类派生。语言自带标准特性——自由函数,并且应该在此处使用自由函数。

这样想 —— 您真的想强制代码客户端转换到使用某个专有的字符串类,只是因为您想添加几种方法吗?因为与 Java 或 C#(或大多数相似的面向对象的语言)不同,当您从类派生一个类时,大多数基类用户需要了解这种更改。在 Java/C# 中,类通常通过引用访问,这类似于 C++ 的指针。因此,涉及到一层间接性,可以将您的类的客户端进行解耦,使您能够替换一个派生类而无需其他客户端知道。

然而,在C ++中,类是值类型——与大多数其他面向对象语言不同。最容易看到这一点的方法是考虑切割问题。基本上,请考虑:

int StringToNumber(std::string copyMeByValue)
{
    std::istringstream converter(copyMeByValue);
    int result;
    if (converter >> result)
    {
        return result;
    }
    throw std::logic_error("That is not a number.");
}
如果你向这个方法传递自己的字符串,将会调用std::string的复制构造函数来进行一次复制,而不是调用你派生对象的复制构造函数,无论传递了哪个std::string子类。这可能导致你的方法与字符串相关联的任何内容之间不一致。函数StringToNumber不能简单地接受你的派生对象并进行复制,因为你的派生对象可能与std::string的大小不同,但是该函数编译时只为自动存储中的std::string预留空间。在Java和C#中,这不是问题,因为自动存储涉及的唯一类型是引用类型,而引用始终具有相同的大小。但在C++中,则不然。
长话短说,不要在C++中使用继承来添加方法。这不是惯用法,并且会导致语言上的问题。在可能的情况下,请使用非友元、非成员函数,然后再使用组合。除非你正在进行模板元编程或需要多态行为,否则不要使用继承。有关更多信息,请参见Scott Meyers的 Effective C++的第23项:优先使用非成员、非友元函数而不是成员函数。
编辑:这里有一个更完整的示例,展示了切片问题。您可以在codepad.org上查看其输出。
#include <ostream>
#include <iomanip>

struct Base
{
    int aMemberForASize;
    Base() { std::cout << "Constructing a base." << std::endl; }
    Base(const Base&) { std::cout << "Copying a base." << std::endl; }
    ~Base() { std::cout << "Destroying a base." << std::endl; }
};

struct Derived : public Base
{
    int aMemberThatMakesMeBiggerThanBase;
    Derived() { std::cout << "Constructing a derived." << std::endl; }
    Derived(const Derived&) : Base() { std::cout << "Copying a derived." << std::endl; }
    ~Derived() { std::cout << "Destroying a derived." << std::endl; }
};

int SomeThirdPartyMethod(Base /* SomeBase */)
{
    return 42;
}

int main()
{
    Derived derivedObject;
    {
        //Scope to show the copy behavior of copying a derived.
        Derived aCopy(derivedObject);
    }
    SomeThirdPartyMethod(derivedObject);
}

3
应该通过组合来实现这一点。共同的部分应该在一个或多个类中进行定义。 - Billy ONeal
2
@davka:有两个原因:1. 因为你将经常遇到切片问题。除非必须,否则没有理由增加这种痛苦。2. 因为如果你试图这样做,你的类很可能违反了SRP,并且应该被拆分。如果需要执行需要访问类内部的操作,则向原始类添加成员函数。如果不需要,则使用组合或非成员函数。(出于这个原因,我建议即使在C#和Java中也要这样做,即使切片不是问题) - Billy ONeal
1
至少有一个原因可以从std::string继承:为语义上不同但由字符串表示的实体创建类。继承的类允许永远不会意外混淆一个实体和另一个实体 - 这在重构时是极大的帮助。类定义本身甚至可以为空,即没有析构函数问题。 - DarkWanderer
1
@Dark:实际上,这是从std::string继承的一个可怕的理由,因为它允许你的额外语义含义被静默地剥离字符串,因为你的新类型总是隐式转换为字符串。如果你有具有语义含义的数据,请随意包装一个std::string,但不要从它继承。 - Billy ONeal
1
但我们先假设它是规则的例外,而不是反例。 - DarkWanderer
显示剩余21条评论

29
为了提供一个反面的建议(当没有明显的冗长/生产力问题时,一般的建议是正确的),以下至少有一种情况可以从没有虚析构函数的基类派生出公共派生(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 *` 容器没有什么不同,因此您不必更改接口以接受任意数据,但这会导致错误容易实现和更少的自我记录和编译器可验证代码。 界面函数现在需要:
  1. 远离 `super_string` 的附加功能(无用);
  2. 将其参数复制到 `super_string` 中(浪费);或
  3. 将字符串引用强制转换为 `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背景、他们对管理重新编译依赖性或在运行时行为、测试设施等方面切换时的偏好)习惯性地使用它们,因此需要更加关注通过基类指针进行安全操作。

11

如果你真的想要继承它(不讨论为什么你想这样做),我认为你可以通过使Derived类的operator new方法私有来防止直接在堆上实例化该类:

class StringDerived : public std::string {
//...
private:
  static void* operator new(size_t size);
  static void operator delete(void *ptr);
}; 

但是这样会限制您无法使用任何动态的StringDerived对象。


你可以这样做,但这并不能真正回答提问者的问题。 - Billy ONeal
6
@Billy:它确实回答了楼主的第二个问题,请看“另外...” - davka
2
现在我知道如何防止在特定类上使用new/delete运算符。 - Viet
或者,一个公共的static void* operator new(std::size_t) = delete;会提供更好的编译器诊断信息。 - undefined

10

析构函数不仅不是虚函数,std::string根本没有任何虚函数和受保护成员。这使得派生类很难修改其功能。

那么为什么要从它派生出来呢?

非多态性的另一个问题是,如果您将派生类传递给期望字符串参数的函数,那么您的额外功能将被切掉,并且该对象将再次被视为普通字符串。


7
一个基类不一定需要有虚函数才能从它派生出子类。为了增加代码的可重用性,通常会从另一个类派生出子类。唯一的限制是不能将该类用作多态类。 - Sriram Subramanian
@Siriam - 确实如此,但很难从字符串中看出任何代码可重用性的原因。它已经有太多成员函数了,所以进一步扩展似乎不是最好的想法。您还应该考虑到当教科书说“永远不要这样做”时,实际上意味着“你几乎永远不应该这样做”。 - Bo Persson
9
在C++中,除非您需要修改基类的内部状态,否则没有理由从一个类派生出来。您对std::basic_string<CharT, Allocator<CharT>>做的任何扩展都不会涉及到内部成员。因此,没有必要进行任何继承。C++为添加此类功能提供了免费函数。使用免费函数进行扩展(例如,如boost::algorithm::string所做的那样)- 这就是符合C++语言习惯的方式。 - Billy ONeal

4
为什么不应该从c++ std字符串类派生?
因为这是没有必要的。如果你想用DerivedString进行功能扩展,那么我觉得从std::string派生没有任何问题。唯一需要注意的是,不要在两个类之间进行交互(即不要将string作为DerivedString的接收器)。
有没有办法防止客户端执行Base* p = new Derived()。确保在Derived类中提供inline包装器来包装Base方法。例如:
class Derived : protected Base { // 'protected' to avoid Base* p = new Derived
  const char* c_str () const { return Base::c_str(); }
//...
};

1
@Billy:非成员函数是可行的,但对于程序员来说,处理类型操作的两种接口样式很痛苦。集成开发环境往往为成员函数提供更好的生产力增强完成功能。C++03 对这种痛苦没有提供任何好的解决方案,因为组合也很痛苦。 - Tony Delroy
@Tony:举个例子,我认为大多数C++程序员在使用STL时不会遇到问题--但STL充满了对类型进行操作的自由函数。想要对一个“vector”进行排序?你需要调用自由函数“std::sort”。 - Billy ONeal
1
@Billy:一致性和工具生产力被认为“不需要美观”?这有什么关系吗?;-P。重要的不是能处理什么,而是使其简单、优雅和直观,以完成常见且有用的任务。我认为C++是我所知道的最好的选择,但它也可以改进。C++0x将会改进,但其中许多提案最初都遭到了与你上面展示的相同的激情反对,以保护现状。而你的例子则违反了“学会如何对vector进行排序,现在正在尝试std::sort(my_list....)”;-) - Tony Delroy
@Tony:我的观点是,我不同意生产力会受到影响。如果一个人的生产力受到“漂亮程度”的影响,那么他应该改变语言。然而,一致性在这里完全与“漂亮”有关。语法——即使它是否美观——是不一致的,但实际上所做的事情是一样的——在两种情况下都调用了一个函数。至于std::list——有很好的理由让sort成为list的成员函数——也就是说,为了编写高效的排序算法,需要访问列表的内部。 - Billy ONeal
至于C++0x,我不相信标准中所做的任何更改都会改变事情 - 尽可能使用非友元非成员函数仍然是首选。 - Billy ONeal
显示剩余4条评论

2
有两个简单的原因不从非多态类派生:
技术上:它会导致切片错误(因为在C++中,我们默认按值传递)
功能上:如果它是非多态的,你可以通过组合和一些函数转发来实现相同的效果
如果你想要给std::string添加新功能,首先考虑使用自由函数(可能是模板),就像Boost String Algorithm库所做的那样。
如果你想要添加新的数据成员,那么请嵌入(组合)到你自己设计的类中,并正确地封装类的访问。
编辑:
@Tony 正确地指出了我引用的“功能”原因对大多数人来说可能毫无意义。在良好的设计中,有一个简单的经验法则,即当你可以从几种解决方案中选择时,应该考虑具有较弱耦合性的解决方案。组合比继承具有较弱的耦合性,因此应该在可能的情况下优先选择组合。
此外,组合还可以让你很好地封装原始类的方法。如果选择继承(公有)而且方法不是虚函数(这里就是这种情况),那么这是不可能实现的。

1
“Functional”并不是不做的理由,而是一种表明它不是必需的评论。话虽如此,虽然C++的转发“能力”使组合成为可能,但最初确实很麻烦,并且维护负担很重,这就是为什么很多人想要考虑并使用(安全程度不同)派生方法的原因... - Tony Delroy
@Tony: 对,我总是忘记并不是每个人都会记得当你可以选择继承和组合时,应该选择组合,因为它是一种更弱的关系。我会扩展回答。 - Matthieu M.

0

C++标准规定,如果基类的析构函数不是虚函数,并且您删除指向派生类对象的基类对象,则会导致未定义的行为。

C++标准第5.3.5/3节:

如果操作数的静态类型与其动态类型不同,则静态类型应为操作数的动态类型的基类,并且静态类型应具有虚拟析构函数,否则行为未定义。

明确非多态类和需要虚拟析构函数
使析构函数成为虚函数的目的是通过delete-expression方便地进行多态对象的删除。如果没有多态对象的删除,则不需要虚拟析构函数。

为什么不从String类派生?
通常应避免从任何标准容器类派生,因为它们没有虚拟析构函数,这使得无法通过多态方式删除对象。
至于字符串类,字符串类没有任何虚拟函数,因此您无法覆盖任何内容。您能做的最好的事情就是隐藏一些东西。

如果你想要一个类似字符串的功能,最好编写自己的类而不是继承 std::string。

2
我正在谈论非多态性。 - Sriram Subramanian

0

一旦您将任何成员(变量)添加到派生的std :: string类中,如果您尝试使用派生的std :: string类的实例与std好处,则会系统地破坏堆栈吗?因为stdc ++函数/成员具有其堆栈指针[索引]固定[和调整]到(基本std :: string)实例大小的大小/边界。

对吗?

如果我错了,请纠正我。


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