为什么不应该从c ++ std string类派生?

我想问一下有效的C ++中的一个特定的问题。

它说:

如果一个类需要像一个多态类一样行为,那么析构函数应该是虚拟的。 它进一步补充说,由于std::string没有虚拟析构函数,所以不应该从它派生出来。 另外std::string甚至没有被devise成基类,忘记了多态基类。

我不明白在一个类中需要什么具体的基础类(不是多态的)?

是不是从std::string类派生的唯一原因是它没有虚拟析构函数? 为了可重用性,可以定义一个基类,并且多个派生类可以inheritance它。 那么是什么让std::string甚至没有资格作为基类?

另外,如果有一个纯粹定义为可重用目的的基类,并且有许多派生types,是否有任何方法来阻止客户端执行Base* p = new Derived()因为这些类不意味着被多态使用?

我认为这个陈述反映了这里的困惑(重点是我的):

我不明白在一个类中需要什么具体的基础类( 不是多态的 )?

在惯用的C ++中,从类派生有两个用途:

  • 私有inheritance,用于混合和使用模板的面向方面的编程。
  • 公共inheritance, 用于多态情况编辑 :好的,我猜这也可以用在一些mixin的情况下 – 如boost::iterator_facade – 当CRTP正在使用时显示。

如果你不试图做一些多态的事情,绝对没有理由在C ++中公开派生类。 该语言作为该语言的标准function提供了免费function,免费function就是您应该在这里使用的function。

想想这样 – 你真的想强制你的代码的客户转换为使用一些专有的string类,只是因为你想要坚持几个方法? 因为与Java或C#(或者大多数类似的面向对象的语言)不同,当你在C ++中派生一个类时,基类的大多数用户需要知道这种改变。 在Java / C#中,类通常通过引用来访问,类似于C ++的指针。 因此,涉及到一个间接的级别,使您的class级的客户脱钩,使您能够替代派生class级,而无需其他客户知道。

但是,在C ++中,类是值types – 与大多数其他OO语言不同。 最简单的方法就是所谓的切片问题 。 基本上可以考虑:

 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."); } 

如果您将自己的string传递给此方法,则将调用std::string的复制构造函数来创build副本, 而不是您的派生对象的副本构造函数 – 不pipe传递了哪个std::string子类。 这可能会导致您的方法和附加到该string的任何内容不一致。 函数StringToNumber不能简单地把你的派生的对象和复制,只是因为你的派生对象可能有一个不同的大小比std::string – 但这个函数被编译为只保留一个std::string的空间自动存储。 在Java和C#中,这不是一个问题,因为所涉及的自动存储只是引用types,并且引用的大小始终相同。 在C ++中不是这样。

长话短说 – 不要用inheritance来加强C ++中的方法。 这不是惯用的,会导致语言问题。 尽可能使用非朋友,非成员function,然后进行组合。 除非你是模板元编程或者想要多态行为,否则不要使用inheritance。 有关更多信息,请参阅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); } 

为一般性build议提供反面意见(当没有明显的冗长/生产力问题时,这是正确的)…

情景合理使用

至less有一种情况是,从没有虚拟破坏者的基地派生公共派生是一个好的决定:

  • 您需要一些专用的用户定义types(类)提供的types安全性和代码可读性好处,
  • 现有的基础是存储数据的理想select,并允许客户端代码也希望使用的低级操作
  • 你想要重用支持这个基类的函数的方便
  • 您明白,您的数据逻辑上需要的任何额外不variables只能在代码中作为派生types显式访问数据,并根据devise中“自然”发生的程度以及您可以信任的客户端的多less代码来理解和配合逻辑理想的不variables,你可能希望派生类的成员函数来重新validation期望(以及抛出或其他)
  • 派生类添加了一些操作数据的高度types特定的便利函数,例如自定义search,数据过滤/修改,数据stream,统计分析,(替代)迭代器
  • 将客户端代码耦合到基类比耦合到派生类更合适(因为基类要么是稳定的,要么是对其的改变反映了对派生类的核心function的改进)
    • 换句话说:您希望派生类继续公开与基类相同的API,即使这意味着客户端代码被迫更改,而不是以允许基础派生API和派生API生长的方式将其隔离同步
  • 你不会在负责删除它们的部分代码中混合指向基类和派生对象的指针

这可能听起来相当严格,但是真实世界中有很多情况符合这种情况。

背景讨论:相对优点

编程是关于妥协。 在你写一个更概念的“正确”的程序之前:

  • 考虑是否需要增加复杂性和代码来混淆真实的程序逻辑,因此,尽pipe更加强大地处理一个特定的问题,
  • 权衡实际成本与问题的可能性和后果
  • 考虑“投资回报”,还有什么你可以用你的时间做的。

如果潜在的问题涉及对象的使用,那么你根本无法想象任何人试图给出你对程序中的可访问性,范围和使用性质的洞察,或者你可以为危险的使用产生编译时错误(例如,派生类的大小与基数相匹配,这将阻止添加新的数据成员),那么其他任何可能过早的过度工程。 以简洁,直观,简洁的devise和代码赢得胜利。

考虑派生sans虚拟析构的原因

假设你有一个从D公开派生出来的D类。没有任何努力,B上的操作在D上是可能的(除了构造,但是即使有很多构造函数,你也可以通过一个模板来提供有效的转发每个不同数量的构造函数参数:例如, template <typename T1, typename T2> D(const T1& x1, const T2& t2) : B(t1, t2) { }更好的在C ++ 0x可变参数模板中的广义解决scheme。

此外,如果B发生了变化,那么默认情况下,D公开了这些变化 – 保持同步 – 但是某些人可能需要审查D中引入的扩展function,以查看它是否仍然有效, 以及客户端的使用情况。

改变这一点: 减less了基类和派生类之间的显式耦合,但增加了基类和客户之间的耦合

这往往不是你想要的,但有时候这是理想的,而其他时候则是非问题(见下一段)。 对基地的更改迫使在整个代码库中分布的地方发生更多的客户代码更改,有时候,更改基地的人甚至可能无法访问客户代码来相应地检查或更新。 有时候更好一些:如果你作为派生类提供者 – “中间人” – 想让基类更改馈送给客户端,并且你通常希望客户端能够 – 有时被迫 – 更新他们的代码。基类的变化,而不需要你不断的参与,那么公开的推导可能是理想的。 当你的class级本身不是一个独立的实体时,这是很常见的,但是这个基础是一个很小的增值。

其他时候,基类接口非常稳定,耦合可能被认为是不成问题的。 对于像标准容器这样的类尤其如此。

总而言之,公共派生是一种快速的方式来获得或近似派生类的理想,熟悉的基类接口 – 以一种对维护者和客户端编码者的简洁和明显正确的方式 – 具有作为成员函数提供的附加function其中恕我直言,这与Sutter,Alexandrescu等明显不同 – 可以帮助可用性,可读性和协助提高生产力的工具,包括IDE)

C ++编码标准 – Sutter&Alexandrescu – cons审查

C ++编码标准第35项列出了从std::string派生的场景的问题。 随着情况的发展,它很好地说明了暴露一个大的但有用的API的负担,但是,由于基础API是非常稳定的 – 作为标准库的一部分,这两者都是好的和坏的。 一个稳定的基础是一个普遍的情况,但不是比一个易变的普通情况更普遍,一个好的分析应该涉及这两种情况。 在考虑本书的问题清单的同时,我将具体对比这些问题的适用性,

a) class Issue_Id : public std::string { ...handy stuff... }; 公众推导,我们有争议的用法
b) class Issue_Id : public string_with_virtual_destructor { ...handy stuff... }; < – 更安全的OO派生
c) class Issue_Id { public: ...handy stuff... private: std::string id_; }; class Issue_Id { public: ...handy stuff... private: std::string id_; }; < – 构图方法
d)随处使用std::string ,具有独立的支持function

(希望我们可以同意这个构造是可以接受的做法,因为它提供了封装,types安全以及超越std::string API的潜在丰富的API。)

所以,假设你正在编写一些新的代码,并开始考虑OO意义上的概念实体。 也许在一个错误跟踪系统(我正在考虑JIRA),其中一个是Issue_Id。 数据内容是文本的 – 由字母项目ID,连字符和递增问题编号组成,例如“MYAPP-1234”。 问题ids可以存储在一个std::string ,并且在问题id上将会有很多很less的文本search和操作操作 – 已经在std::string上提供的大部分子集,还有一些更好的度量(例如获取项目ID组件,提供下一个可能的问题ID(MYAPP-1235))。

在Sutter和Alexandrescu的问题清单…

非成员函数在已经处理string的现有代码中工作良好。 如果你提供了一个super_string ,你可以通过你的代码强制更改types和函数签名为super_string

这个主张(以及下面的大部分主要错误)的根本错误是它促进了只使用几种types的便利,而忽略了types安全的好处。 它expression了对上述d)的偏好,而不是深入c)或b)作为a)的替代scheme。 编程艺术涉及平衡不同types的优点和缺点,以达到合理的重用,性能,方便和安全。 下面的段落详细说明了这一点。

使用公共派生,现有的代码可以隐式地以stringforms访问基类的string ,并继续像往常一样运行。 没有具体的理由认为现有的代码会想要使用super_string任何附加function(在我们的例子中是Issue_Id)…事实上,它通常是低级别的支持代码预先存在您为其创buildsuper_string的应用程序,因此忽视了扩展function提供的需求。 例如,假设有一个非成员函数to_upper(std::string&, std::string::size_type from, std::string::size_type to) – 它仍然可以应用于Issue_Id

因此,除非非会员支持function正在被清理或扩展,否则将其紧密地耦合到新代码的代价是不必要的。 如果正在为支持问题标识而进行彻底检查(例如,对数据内容格式的洞察仅用于大写的前导字母字符),那么通过创build一个过载来确保它确实被传递给Issue_Id可能是一件好事ala to_upper(Issue_Id&)并坚持派生或组合方法允许types安全。 是否使用super_string或组合对于努力或可维护性没有影响。 to_upper_leading_alpha_only(std::string&)可重复使用的独立支持函数不太可能to_upper_leading_alpha_only(std::string&) – 我不记得上一次我想要这样一个函数。

在任何地方使用std::string的冲动与将所有参数作为变体或void* s的容器接受没有本质的区别,因此您不必将接口更改为接受任意数据,但这会导致容易出错的实现,更less的自我logging和编译器可validation的代码。

现在需要string的接口函数需要:a)远离super_string的附加function(无用); b)将他们的参数复制到super_string(浪费); 或者c)将string引用强制转换为super_string引用(尴尬且可能非法)。

这似乎正在重新审视需要重构以使用新function的第一个旧代码,虽然这次是客户端代码而不是支持代码。 如果函数想开始将它的参数作为新操作相关的实体来处理,那么它应该开始将它的参数作为该types来使用,并且客户端应该生成它们并使用该types来接受它们。 存在完全相同的问题的组成。 否则, c)如果遵循下面列出的指导方针,可能是实际的和安全的,尽pipe它是丑陋的。

super_string的成员函数没有比非成员函数更多的string内部访问权限,因为string可能没有受保护的成员(请记住,它并不是从第一个位置开始的)

没错,但有时候这是件好事。 许多基类没有受保护的数据。 公共string接口是操作内容所需要的,而且有用的function(例如上面假设的get_project_id() )可以用这些操作来优雅地expression。 从概念上说,很多时候我从标准容器派生出来的,我不想在现有的线上扩展或者定制他们的function – 他们已经是“完美的”容器 – 而是我想增加另一个特定的行为维度到我的应用程序,并且不需要私人访问。 这是因为它们已经是很好的容器了,很容易重用。

如果super_string隐藏了一些string的函数(并且在派生类中重新定义非虚函数并不是重载,那只是隐藏),这可能会导致代码中的广泛混淆,这些代码操纵string s,从而自动从super_string转换为super_string

对于构成也是如此 – 更可能发生,因为代码不会默认传递事物,因此保持同步,在一些运行时多态层次结构的情况下也是如此。 Samed命名函数的行为在类中初始显示可以互换 – 只是讨厌。 对于正确的面向对象程序devise来说,这实际上是一个非常小心的事情,再也没有充分的理由放弃types安全等方面的好处。

如果super_string想要从stringinheritance添加更多的状态呢 [切片的解释]

同意 – 不是一个好的情况,而且我个人倾向于画线,因为它经常通过指向基础的指针从理论领域移动到非常实际的位置,而不是为其他成员调用析构函数。 尽pipe如此,切片通常可以做什么想要的 – 考虑到派生super_string不要改变其inheritance的function,而是添加应用程序特定function的另一个“维度”的方法….

无可否认,必须为要保留的成员函数编写直通function,但这样的实现比使用公有或非公有inheritance要好得多,也更安全。

那么,一定会认同这个单调乏味的

成功导出sans虚拟析构函数的准则

  • 理想情况下,避免在派生类中添加数据成员:切片变体可能会意外删除数据成员,破坏它们,无法初始化它们…
  • 甚至更多 – 避免非POD数据成员:无论如何,通过基类指针删除在技术上是未定义的行为,但非PODtypes不能运行它们的析构函数更有可能在资源泄漏,不良引用计数等等
  • 尊敬Liskovreplace校长/你不能强有力地保持新的不variables
    • 例如,在从std::string派生你不能拦截一些函数,并希望你的对象保持大写:任何代码,通过std::string&...*访问它们可以使用std::string的原始函数实现来改变这个值)
    • 派生在你的应用程序中build立一个更高层次的实体,用一些使用但不冲突的function扩展inheritance的function; 不要期望或试图改变基本types授予的基本操作 – 以及对这些操作的访问
  • 注意耦合:即使基类演化为不适当的function,基类也不能被删除而不影响客户端代码,也就是说,派生类的可用性取决于基类的持续适用性
    • 有时,即使使用合成,由于性能,线程安全性问题或缺乏价值语义的原因,您也需要公开数据成员 – 所以公开派生封装的损失并没有明显的变差
  • 使用潜在派生类的人可能不会意识到其执行方式的妥协,就越不会让他们变得危险
    • 因此,具有许多临时临时用户的低级别广泛部署的库应该对危险派生更加谨慎,而不是程序员在应用程序级和/或“私有”实现/库中例行地使用该function的本地化使用

概要

这样的推导不是没有问题,所以不要考虑它,除非最终的结果certificate了方法的正当性。 也就是说,我断然拒绝任何在特定情况下不能安全适当地使用的说法 – 这只是一个画线的问题。

个人经验

我有时源自std::map<>std::vector<>std::string等 – 我从来没有被切片或删除通过基类指针的问题,我已经烧了为更重要的事情节省了大量的时间和精力。 我不会将这些对象存储在异构多态容器中。 但是,您需要考虑是否所有使用该对象的程序员都知道这些问题,并且可能会进行相应的编程。 我个人喜欢编写我的代码,只在需要时才使用堆和运行时多态性,而有些人(由于Java背景,pipe理重新编译依赖关系或在运行时行为,testing设施之间切换的首选方法)习惯性地使用它们因此需要通过基类指针更加关心安全操作。

不仅析构函数不是虚拟的,std :: string根本不包含虚函数,也没有受保护的成员。 这使派生类非常难以修改其function。

那你为什么要从中得出结论呢?

非多态的另一个问题是,如果将派生类传递给期望string参数的函数,那么额外的function将被切掉,对象将被再次视为纯string。

如果你真的想从它派生(不讨论你为什么要这样做),我认为你可以通过使它的operator new private来防止Derived类直接堆实例化:

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

但是这样可以限制你的dynamicStringDerived对象。

为什么不应该从c ++ std string类派生?

因为这是没有必要的 。 如果你想使用DerivedString进行function扩展; 我在派生std::string没有看到任何问题。 唯一的是,你不应该在两个类之间进行交互(即不要使用string作为DerivedString的接收者)。

有什么办法可以防止客户端做Base* p = new Derived()

是的 。 确保你在Derived类中提供Base方法的内inline包装。 例如

 class Derived : protected Base { // 'protected' to avoid Base* p = new Derived const char* c_str () const { return Base::c_str(); } //... }; 

没有从非多态类派生出来有两个简单的原因:

  • 技术 :它引入了切片错误(因为在C ++中,除非另外指定,否则我们传值)
  • function :如果它是非多态的,你可以通过合成和一些函数转发来达到同样的效果

如果你想添加新的function到std::string ,那么首先考虑使用免费函数(可能是模板),就像Boost Stringalgorithm库一样。

如果你想添加新的数据成员,那么通过将它embedded到你自己devise的类中(Composition)来正确地包装类访问。

编辑

@托尼正确地注意到,我引用的function原因对大多数人来说可能是没有意义的。 有一个简单的经验法则,就是在devise好的时候,说当你可以select几个解决scheme的时候,你应该考虑那个耦合程度较弱的解决scheme。 构图具有较弱的耦合,即inheritance,因此在可能的情况下应该是优选的。

此外,作文给了你很好的包装原始的类方法的机会。 这是不可能的,如果你selectinheritance(公共)和方法不是虚拟的(这是这里的情况)。

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

C ++标准第5.3.5 / 3节:

如果操作数的静态types与其dynamictypes不同,则静态types应该是操作数dynamictypes的基类,静态types应该具有虚拟析构函数或行为未定义。

在非多态类和虚析构函数的需求上要清楚
使析构函数虚拟的目的是通过delete-expression来促进对象的多态删除。 如果没有对象的多态删除,那么你不需要虚拟析构函数。

为什么不从String类派生?
通常应避免从任何标准容器类派生,因为它们不具有虚拟析构函数,这使得不可能多态地删除对象。
至于string类,string类没有任何虚函数,所以没有什么可以重写。 你能做的最好的就是隐藏一些东西。

如果你想拥有一个像函数一样的string,你应该写一个你自己的类,而不是从std :: stringinheritance。