为什么不能从c ++ std字符串类派生?

Sri*_*ian 61 c++ string inheritance stl

我想询问有效C++中的具体要点.

它说:

如果一个类需要像多态类一样运行,那么析构函数应该是虚拟的.它进一步补充说,由于std::string没有虚拟析构函数,因此永远不应该从中派生出来.还std::string甚至没有设计成一个基类,忘记多态基类.

我不明白一个类中具体要求什么才有资格成为基类(不是多态的)?

我不应该从std::string类派生的唯一原因是它没有虚拟析构函数吗?为了可重用性,可以定义基类,并且多个派生类可以从中继承.那么什么使得std::string甚至没有资格成为基类?

此外,如果存在纯粹为可重用性目的而定义的基类,并且有许多派生类型,是否有任何方法可以阻止客户端执行,Base* p = new Derived()因为这些类并不是要多态地使用?

Bil*_*eal 56

我认为这句话反映了这里的混乱(强调我的):

我不明白课程中具体要求什么才有资格成为基础课程(不是多态的)?

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

  • 私有继承,用于mixins和使用模板的面向方面编程.
  • 公共继承,用于多态情况.编辑:好的,我想这可以在一些mixin场景中使用 - 比如boost::iterator_facade- 在CRTP正在使用时出现.

如果你没有尝试做多态的事情,那么绝对没有理由在C++中公开派生一个类.该语言带有免费功能作为语言的标准功能,免费功能是您应该在这里使用的功能.

可以这样想想 - 你是否真的想强迫你的代码客户转换为使用一些专有的字符串类只是因为你想要使用几种方法?因为与Java或C#(或大多数类似的面向对象语言)不同,当您在C++中派生类时,大多数基类用户需要知道这种更改.在Java/C#中,类通常通过引用访问,类似于C++的指针.因此,存在一定程度的间接性,它将类的客户端分离,允许您在没有其他客户端知道的情况下替换派生类.

但是,在C++中,类是值类型 - 与大多数其他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.");
}
Run Code Online (Sandbox Code Playgroud)

如果将自己的字符串传递给此方法,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);
}
Run Code Online (Sandbox Code Playgroud)

  • @davka:应该用作文代替.常见的位应该是它们自己的一个或多个类. (3认同)
  • @davka:两个原因:1.因为你会定期遇到切片问题.除非必须,否则没有理由加上这种疼痛.2.因为如果你试图这样做,你的课程可能违反了[SRP](http://en.wikipedia.org/wiki/Single_responsibility_principle),并且应该分开.如果您需要执行需要对类进行内部访问的操作,请将成员函数添加到原始类.如果不这样做,则使用组合或非成员函数.(因此我建议在C#和Java中执行此操作,即使切片不是问题) (2认同)

Ton*_*roy 24

提供反面的一般建议(当没有特别的冗长/生产力问题时,这是明智的)......

合理使用的场景

至少有一种情况是,没有虚拟析构函数的基础的公共派生可以是一个很好的决策:

  • 您想要一些由专用的用户定义类型(类)提供的类型安全和代码可读性优势
  • 现有基础是存储数据的理想基础,并允许客户端代码也希望使用的低级操作
  • 您希望重用支持该基类的函数的便利性
  • 您了解您的数据逻辑上需要的任何其他不变量只能在显式访问数据作为派生类型的代码中强制执行,并且取决于您在设计中"自然"发生的程度,以及您可以信任多少客户端要理解并与逻辑上理想的不变量合作的代码,您可能希望派生类的成员函数重新验证期望(并抛出或其他)
  • 派生类添加了一些高度特定于类型的便利函数,这些函数在数据上运行,例如自定义搜索,数据过滤/修改,流式传输,统计分析,(替代)迭代器
  • 将客户端代码耦合到基础比耦合到派生类更合适(因为基础是稳定的,或者对它的更改反映了对功能的改进,也是派生类的核心)
    • 另一种方式:你希望派生类继续公开与基类相同的API,即使这意味着客户端代码被迫改变,而不是以某种方式隔离它,允许基本和派生的API生长同步
  • 你不会在负责删除它们的部分代码中混合指向基础和派生对象的指针

这可能听起来相当严格,但在现实世界的程序中有很多与这种情况相匹配的情况.

背景讨论:相对优点

编程就是妥协.在编写更概念性的"正确"程序之前:

  • 考虑是否需要增加复杂性和代码来模糊真实的程序逻辑,因此尽管更加强大地处理一个特定问题,但整体上更容易出错
  • 权衡实际成本与问题的可能性和后果,以及
  • 考虑"投资回报"以及你可以用你的时间做些什么.

如果潜在问题涉及对象的使用,您无法想象任何人试图在程序中使用它们的可访问性,范围和使用性质,或者您可以生成编译时错误以供危险使用(例如,断言派生类大小与基数相匹配,这会阻止添加新数据成员),然后其他任何事情都可能过早地过度工程化.轻松赢取干净,直观,简洁的设计和代码.

考虑派生的原因是虚拟析构函数

假设你有一个公开派生自B的D类.没有努力,B上的操作可以在D上进行(除了构造,但即使有很多构造函数,你通常可以通过一个模板提供有效的转发.每个不同数量的构造函数参数:例如template <typename T1, typename T2> D(const T1& x1, const T2& t2) : B(t1, t2) { }.C++ 0x可变参数模板中更好的通用解决方案.)

此外,如果B发生更改,则默认情况下D会公开这些更改 - 保持同步 - 但有人可能需要查看D中引入的扩展功能以查看它是否仍然有效以及客户端使用情况.

换句话说:基类和派生类之间的显式耦合减少了,但是基类和客户端之间的耦合增加了.

这通常不是你想要的,但有时它是理想的,有时则是非问题(见下一段).对基础的更改会在分布在整个代码库中的位置更改客户端代码更改,有时更改基础的人员甚至可能无法访问客户端代码以相应地查看或更新它.有时它会更好:如果你作为派生类提供者 - "中间人" - 希望基类更改能够提供给客户端,并且你通常希望客户能够 - 有时被迫 - 在更新代码时更新代码基类改变而不需要经常参与,然后公共推导可能是理想的.当你的班级本身不是一个独立的实体,而是基础的一个薄的增值时,这是很常见的.

其他时候,基类接口是如此稳定,以至于耦合可能被视为非问题.对于像标准容器这样的类尤其如此.

总而言之,公共派生是获取或近似派生类的理想,熟悉的基类接口的一种快速方法 - 以对维护者和客户端编码器简洁且不言而喻正确的方式 - 具有可用作成员函数的附加功能(恕我直言 - 这显然与Sutter,Alexandrescu等不同 - 可以提高可用性,可读性和协助提高生产力的工具,包括IDE)

C++编码标准 - Sutter和Alexandrescu - 缺点检查

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_; };< - 一种组合方法
d)使用std::string无处不在,具有独立支持功能

(希望我们可以同意这种组合是可以接受的做法,因为它提供了封装,类型安全以及可能丰富的API,超过了std::string.)

所以,假设您正在编写一些新代码并开始考虑OO意义上的概念实体.也许在bug跟踪系统中(我在考虑JIRA),其中一个就是一个Issue_Id.数据内容是文本的 - 由字母项目ID,连字符和递增的问题编号组成:例如"MYAPP-1234".问题ID可以存储在a中std::string,并且在问题ID上将需要大量繁琐的文本搜索和操作操作 - 已经提供的那些中的大部分内容std::string以及用于良好测量的更多内容(例如,获取项目id组件,提供下一个可能的问题ID(MYAPP-1235)).

关于Sutter和Alexandrescu的问题清单......

非成员函数在已经操作strings的现有代码中运行良好.如果您提供a super_string,则强制通过代码库进行更改以更改类型和功能签名super_string.

这种说法的根本错误(以及下面的大多数说法)是它提供了仅使用少数类型的便利性,忽略了类型安全的好处.它表达了对上述d)的偏好,而不是洞察c)或b)作为a)的替代品.编程的艺术涉及平衡不同类型的优缺点,以实现合理的重用,性能,便利性和安全性.以下段落详细说明了这一点.

使用公共派生,现有代码可以隐式访问基类string作为a string,并继续像往常一样行事.没有特别的理由认为现有的代码会想要使用任何其他功能super_string(在我们的例子中是Issue_Id)......实际上它通常是较低级别的支持代码预先存在您正在创建的应用程序super_string,并且因此无视扩展功能所提供的需求.例如,假设有一个非成员函数to_upper(std::string&, std::string::size_type from, std::string::size_type to)- 它仍然可以应用于Issue_Id.

因此,除非非故障支持功能正在以将其与新代码紧密耦合的故意成本进行清理或扩展,否则无需触及.如果正在大修支持问题IDS(例如,使用洞察数据内容格式为大写只领先字母字符),那么它可能是一个很好的事情,以确保它真正被传递了一个Issue_Id通过创建一个超负荷翼to_upper(Issue_Id&)并坚持允许类型安全的派生或组合方法.是否使用super_string或使用组合对努力或可维护性没有影响.一个to_upper_leading_alpha_only(std::string&)可重复使用的独立的支持功能是不可能多大用处-我不记得我最后一次想这样的功能.

std::string任何地方使用的冲动在质量上都不同于接受所有参数作为变量或void*s的容器,因此您不必更改接口以接受任意数据,但它会导致容易出错的实现并减少自我记录和编译器 - 可验证的代码.

接受字符串的接口函数现在需要:a)远离super_string添加的功能(不可用); b)将他们的参数复制到super_string(浪费); 或者c)将字符串引用强制转换为super_string引用(笨拙且可能非法).

这似乎是重新审视第一点 - 需要重构以使用新功能的旧代码,尽管这次是客户端代码而不是支持代码.如果函数想要开始将其参数视为与新操作相关的实体,那么它应该开始将其参数作为该类型,并且客户端应该生成它们并使用该类型接受它们.组成存在完全相同的问题.否则,c)如果遵循下面列出的指导方针,则可以实用且安全,尽管它很难看.

super_string的成员函数没有比非成员函数更多的字符串内部访问权限,因为字符串可能没有受保护的成员(记住,它不是从一开始就派生的)

没错,但有时这是件好事.很多基类没有受保护的数据.公共string界面是操纵内容所需的全部,并且有用的功能(例如,get_project_id()上面假设的)可以根据这些操作优雅地表达.从概念上讲,很多时候我都是从标准容器派生出来的,我不想在现有的产品线上扩展或定制它们的功能 - 它们已经是"完美"的容器 - 而是我想要添加另一个特定的行为维度到我的应用程序,并不需要私人访问.这是因为它们已经是好的容器,它们很好地重复使用.

如果super_string隐藏一些string函数(并且在派生类中重新定义非虚函数不会覆盖,它只是隐藏),这可能会导致代码中的广泛混淆,这些代码操纵stringsuper_strings 开始自动转换的s.

对于组合也是如此 - 并且更可能发生,因为代码不会默认传递事物并因此保持同步,并且在某些具有运行时多态层次结构的情况下也是如此.Samed命名的函数在初始看起来可以互换的类中表现不同 - 只是讨厌.这实际上是正确OO编程的常用注意事项,并且再次没有足够的理由放弃类型安全等方面的好处.

如果super_string想继承string添加更多状态 [切片的解释] 怎么办?

同意 - 不是一个好的情况,在某个地方我个人倾向于划清界限,因为它经常通过从理论领域到实用的指针来移除删除的问题 - 不会为其他成员调用析构函数.尽管如此,切片通常可以做你想要的 - 给出了super_string不改变其继承功能的方法,而是添加应用程序特定功能的另一个"维度"....

不可否认,必须为要保留的成员函数编写passthrough函数是很繁琐的,但是这样的实现比使用公共或非公共继承更好,更安全.

嗯,当然同意单调乏味....

成功推导的准则没有虚拟析构函数

  • 理想情况下,避免在派生类中添加数据成员:切片的变体可能会意外删除数据成员,破坏它们,无法初始化它们......
  • 更重要的是 - 避免非POD数据成员:通过基类指针删除无论如何都是技术上未定义的行为,但是如果非POD类型无法运行其析构函数,则更有可能出现资源泄漏的非理论问题,错误的引用计数等等
  • 尊重Liskov Substitution Principal /你不能强有力地保持新的不变量
    • 例如,在派生时std::string你不能拦截一些函数并期望你的对象保持大写:任何通过一个std::string&或者...*可以使用std::string原始函数实现来改变它的代码来访问它们
    • 派生为在应用程序中建立更高级别的实体,扩展继承的功能,使用一些使用但不与基础冲突的功能; 不要期望或试图改变基本类型授予的基本操作 - 以及对这些操作的访问权限
  • 请注意耦合:即使基类演变为具有不适当的功能,也不能在不影响客户端代码的情况下删除基类,即派生类的可用性取决于基础的持续适当性
    • 有时即使你使用组合,你也需要因为性能,线程安全问题或缺乏价值语义而暴露数据成员 - 所以公共派生的封装损失并没有明显更糟
  • 使用潜在派生类的人越不会意识到它的实施妥协,你就越不能使它们变得危险
    • 因此,与程序员在应用程序级别和/或"私有"实现/库中常规使用功能的本地化使用相比,具有许多临时临时用户的低级别广泛部署的库应该更加警惕危险的派生

摘要

这种推导并非没有问题,所以除非最终结果证明手段合理,否则不要考虑它.也就是说,我断然拒绝任何声称在特定情况下不能安全和适当地使用它 - 这只是在哪里划线.

个人经验

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


Bo *_*son 10

不仅是析构函数不是虚拟的,的std :: string不包含虚函数可言,也没有保护成员.这使派生类很难修改其功能.

那你为什么要从中得到它?

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

  • @Sriram:除非你需要修改基类的内部状态,否则没有理由从C++中派生类.您对`std :: basic_string <CharT,Allocator <CharT >>`的任何扩展都不会触及内部成员.因此,不需要任何派生.C++具有免费功能,仅用于添加此类功能.为扩展使用一个自由函数(例如`boost :: algorithm :: string`) - 这就是在ideomatic C++中完成的. (9认同)
  • 基类不需要具有从中派生的虚函数.对于代码可重用性,通常从另一个类派生.唯一的限制是你不应该把这个类用作多态的. (6认同)

bed*_*uin 9

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

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

但是这样你就可以限制自己从任何动态StringDerived对象.

  • @Billy:它确实回答了OP**第二个问题,请参阅"另外......" (5认同)
  • +1现在我知道如何防止在特定类上使用new/delete运算符. (2认同)