为什么不应该派生自c ++ std字符串类?

我想问一下有效C ++中的特定观点。

它说:

如果一个类需要充当多态类,则应该将析构函数设为虚拟的。 它进一步补充说,由于Base* p = new Derived()没有虚拟析构函数,因此永远不要从中派生它。 std::string甚至都没有设计成基类,而忘记了多态基类。

我不明白类中要成为基础类(不是多态类)的具体要求是什么?

我不应该从Base* p = new Derived()类派生的唯一原因是它没有虚拟析构函数吗? 出于可重用性的目的,可以定义一个基类,并且可以从中继承多个派生类。 那么,是什么使std::string甚至没有资格作为基础类?

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

Sriram Subramanian asked 2019-10-07T06:43:23Z
8个解决方案
55 votes

我认为这句话反映了这里的困惑(强调我的意思):

我不明白要成为基本类别(不是多态类别),班上具体需要什么?

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

  • 私有继承,用于模板的mixin和面向方面的编程。
  • 公共继承,仅用于多态情况。 编辑:好的,我想这也可以在一些混合场景中使用-例如std::string-在使用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.");
}

如果您将自己的字符串传递给此方法,则无论传递了std::string的哪个子类,都会调用std::string的副本构造函数进行复制,而不是派生对象的副本构造函数进行复制。 这可能导致您的方法与附加到字符串的任何内容之间不一致。 函数StringToNumber不能简单地获取任何派生对象并复制该对象,仅仅是因为派生对象的大小可能不同于std::string的大小,但是该函数已编译为仅在自动存储中为std::string保留空间。 在Java和C#中,这不是问题,因为涉及自动存储之类的唯一内容就是引用类型,并且引用的大小始终相同。 在C ++中不是这样。

长话短说-不要使用继承来处理C ++中的方法。 这不是惯用语言,会导致语言问题。 尽可能使用非朋友,非成员函数,然后进行合成。 除非您要进行模板元编程或需要多态行为,否则不要使用继承。 有关更多信息,请参见Scott Meyers的Effective C ++ Item 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);
}
Billy ONeal answered 2019-10-07T06:44:52Z
24 votes

为一般建议提供反面(在没有明显的冗长/生产率问题时,这是合理的)...

合理使用的方案

在至少一种情况下,从没有虚拟析构函数的基础派生公开是一个不错的决定:

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

这听起来可能很严格,但是现实世界中有很多情况都适合这种情况。

背景讨论:相对优点

编程是折衷方案。 在编写概念上更“正确”的程序之前:

  • 考虑它是否需要增加复杂性和混淆真实程序逻辑的代码,因此尽管更有效地处理了一个特定问题,但总体上更容易出错,
  • 权衡实际成本与问题的可能性和后果,以及
  • 考虑“投资回报率”以及您接下来可能会做些什么。

如果潜在问题涉及对象的使用,而您只是无法想象有人试图了解它们在程序中的可访问性,范围和使用性质,或者您可能会生成危险的编译时错误(例如,断言 派生类的大小与基类的大小相匹配,这将阻止添加新的数据成员),那么其他任何事情都可能为时过早。 轻松获得简洁,直观,简洁的设计和代码。

考虑派生而没有虚拟析构函数的原因

假设您有一个公开自B的D类。不费吹灰之力就可以在D上进行B的操作(除了构造,但即使构造函数很多,您通常也可以通过为一个B提供一个模板来提供有效的转发。 每个不同数量的构造函数参数:例如std::map<>。C++ 0x可变参数模板中更好的广义解决方案。)

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

改写一下:减少了基类和派生类之间的显式耦合,但是增加了基类和客户端之间的耦合。

这通常不是您想要的,但有时是理想的,而有时则不是问题(请参阅下一段)。 对基础的更改迫使更多的客户代码在整个代码库中分布的位置发生更改,有时更改基础的人员甚至可能无法访问客户代码以进行相应的查看或更新。 有时候还是比较好:如果您是派生类提供者(即“中间人”)希望将基类更改传递给客户端,并且通常希望客户端能够(有时被迫)在客户端更新代码时进行更新。 基类的更改无需您不断参与,那么公共派生可能是理想的。 当您的类本身并不是一个独立的实体,而是为基础增加价值时,这是很常见的。

其他时候,基类接口是如此稳定,以至于耦合可以被认为是没有问题的。 对于标准容器之类的类尤其如此。

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

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

C ++编码标准的第35项列出了从std::map<>派生的方案的问题。随着方案的发展,很好地说明了暴露大型但有用的API的负担,但是好坏是基本API都非常稳定-是其中的一部分 标准库。 稳定的基础是一种常见的情况,但是不多于一个不稳定的基础,对这两种情况都应进行良好的分析。 在考虑本书的问题清单时,我将把这些问题的适用性与诸如此类的情况进行对比:

a)std::map<> <-公共衍生,我们有争议的用法
b)std::vector<> <-更安全的OO推导
c)std::string <-一种组合方法
d)随处使用std::string,具有独立的支持功能

(希望我们可以同意这种组合是可以接受的做法,因为它提供了封装,类型安全性,并且具有比std::map<>更高的潜在丰富API。)

因此,假设您正在编写一些新代码,并开始从面向对象的角度考虑概念实体。 也许在错误跟踪系统中(我在考虑JIRA),其中之一就是Issue_Id。 数据内容为文本-由字母项目ID,连字符和递增发行号组成:例如 “ MYAPP-1234”。 可以将问题ID存储在std::map<>中,并且对问题ID会需要大量的小巧的文本搜索和操作操作-std::vector<>中已经提供了大量的子集,并且有很多措施可以很好地解决问题(例如,获取项目ID组件 ,提供下一个可能的问题ID(MYAPP-1235)。

Sutter和Alexandrescu的问题清单...

非成员函数在已处理std::map<>s的现有代码中可以很好地工作。 相反,如果您提供std::vector<>,则您将强制通过代码库进行更改以将类型和功能签名更改为std::string

此声明(以及下面的大多数声明)的根本错误在于,它忽略了类型安全性的好处而提高了仅使用几种类型的便利性。 它表示对上述d)的偏好,而不是深入了解c)或b)作为a)的替代品。 编程技巧涉及平衡不同类型的利弊,以实现合理的重用,性能,便利性和安全性。 下面的段落对此进行了详细说明。

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

因此,除非清理或扩展非成员支持功能(将其紧密耦合到新代码的刻意代价),否则就无需动手。 如果要对它进行大修以支持问题ID(例如,使用对数据内容格式的深入了解仅使用大写的前导字母字符),那么通过创建重载来确保它确实已通过std::map<>,这可能是一件好事 ala std::vector<>,并遵循允许类型安全的派生或组合方法。 无论使用2558408134108906906还是使用合成物,对工作量或可维护性都没有影响。 std::string可重用的独立支持功能不太有用-我不记得上一次我想要这样的功能。

在任何地方都使用std::map<>的冲动在质量上与将所有参数接受为变量或std::vector<>s的容器在质量上没有什么不同,因此您不必更改接口即可接受任意数据,但是这样做易于出错,实现了自说明性,并且 编译器可验证的代码。

现在,采用字符串的接口函数需要:a)远离std::map<>的新增功能(无用); b)将其参数复制到super_string(浪费); 或c)将字符串引用转换为super_string引用(笨拙且可能非法)。

这似乎正在重新讨论第一点-需要重构旧代码以使用新功能,尽管这次是客户端代码而不是支持代码。 如果函数要开始将其参数视为与新操作相关的实体,则应开始将其参数作为该类型,客户端应生成该参数并使用该类型接受它们。 存在完全相同的组成问题。 否则,如果遵循以下列出的准则,尽管std::map<>很难看,但它既实用又安全。

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

是的,但这有时是一件好事。 许多基类没有受保护的数据。 公共std::map<>接口是操作内容所需要的全部,并且有用的功能(例如,上面假设的std::vector<>)可以用这些操作很好地表达。 从概念上讲,很多时候我是从标准容器中派生的,我不想沿现有路线扩展或自定义其功能-它们已经是“完美”的容器-而是我想添加行为的另一个维度 到我的应用程序,并且不需要私人访问。 这是因为它们已经是很好的容器,可以很好地重用。

如果std::map<>隐藏了std::vector<>的某些功能(并且在重载类中重新定义非虚拟功能并没有覆盖,它只是在隐藏),这可能会引起广泛的混乱,操纵std::strings(从std::strings开始自动转换其生命)的代码。

合成也是如此-而且更可能发生,因为代码没有默认通过传递事物并因此保持同步,并且在某些情况下也适用于运行时多态层次结构。 具有Samed命名功能的函数在最初看起来可互换的类中表现不同-太讨厌了。 这实际上是正确的OO编程的通常警告,而又又不是放弃类型安全性等优点的充分理由。

如果std::map<>要继承std::vector<>以添加更多状态该怎么办?

同意-这不是一个很好的情况,在我个人倾向于划定界限的地方,因为它经常通过从理论领域到非常实际的基础将指针移到删除问题上-不需要为其他成员调用析构函数。 尽管如此,切片仍然可以经常做所需的事情-鉴于派生std::map<>的方法不是更改其继承的功能,而是添加应用程序特定功能的另一个“维度”。

诚然,必须为要保留的成员函数编写传递函数是很麻烦的,但是这种实现比使用公共或非公共继承要好得多,也更安全。

好吧,当然要对乏味表示同意。

没有虚拟析构函数的成功派生准则

  • 理想情况下,避免在派生类中添加数据成员:切片的变体可能会意外删除数据成员,破坏它们,无法对其进行初始化...
  • 更重要的是-避免使用非POD数据成员:通过基类指针进行删除在技术上始终是未定义的行为,但是如果非POD类型无法运行其析构函数,则很可能会出现非理论性的问题,例如资源泄漏,错误的引用计数 等等
  • 尊敬Liskov换人校长/您无法稳固地维护新不变式
    • 例如,从std::map<>派生时,您不能截取一些函数并希望您的对象保持大写:通过std::vector<>std::string访问它们的任何代码都可以使用2558408134108906906499的原始函数实现来更改值)
    • 派生模型以在应用程序中建模更高级别的实体,以使用但不与基础冲突的某些功能扩展继承的功能; 不要期望或尝试更改基本类型授予的基本操作-以及对这些操作的访问权限
  • 请注意这种耦合:即使基类演变为具有不适当的功能,也不能在不影响客户端代码的情况下删除基类,即,派生类的可用性取决于基类的持续适用性
    • 有时,即使您使用组合,由于性能,线程安全性问题或缺少值语义,也需要公开数据成员-因此,从公共派生中丢失封装并不会明显恶化
  • 使用潜在派生类的人越可能不知道其实现的危害,则使他们面临危险的能力越低
    • 因此,比起程序员在应用程序级别和/或“私有”实现/库中常规使用该功能进行本地化使用而言,具有许多临时用户的底层广泛部署的库应更加警惕危险的派生

摘要

这样的推导并非没有问题,因此除非最终结果证明手段合理,否则不要考虑它。 就是说,我断然拒绝任何不能在特定情况下安全且适当地使用它的说法-这只是在哪里划清界限。

个人经验

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

Tony D answered 2019-10-07T06:53:09Z
10 votes

析构函数不仅不是虚拟的,而且std :: string根本不包含任何虚拟函数,也不包含受保护的成员。 这使得派生类很难修改其功能。

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

非多态性的另一个问题是,如果将派生类传递给需要字符串参数的函数,则多余的功能将被分割掉,并且该对象将再次被视为纯字符串。

Bo Persson answered 2019-10-07T06:53:52Z
9 votes

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

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

但是通过这种方式,您就无法使用任何动态StringDerived对象。

beduin answered 2019-10-07T06:54:29Z
4 votes

为什么不应该派生自c ++ std字符串类?

因为没有必要。 如果要使用DerivedString进行功能扩展; 我在导出inline时没有发现任何问题。唯一的是,您不应该在两个类之间进行交互(即不要将Base用作Derived的接收器)。

有什么办法可以防止客户做inline

是。 确保在Derived类内提供围绕Base方法的inline包装器。 例如

class Derived : protected Base { // 'protected' to avoid Base* p = new Derived
  const char* c_str () const { return Base::c_str(); }
//...
};
iammilind answered 2019-10-07T06:55:19Z
2 votes

不派生于非多态类的原因有两个简单的原因:

  • 技术方面:它引入了切片错误(因为在C ++中,除非另有说明,否则我们按值传递)
  • 功能性:如果它是非多态的,则可以通过合成和某些功能转发来达到相同的效果

如果您想为std::string添加新功能,请首先考虑使用自由函数(可能是模板),就像Boost String Algorithm库一样。

如果您希望添加新的数据成员,则可以通过将其(组合)嵌入到自己设计的类中来正确包装类访问。

编辑:

@Tony正确地注意到,我引用的功能性原因对大多数人而言可能毫无意义。 好的设计有一个简单的经验法则,那就是说,当您可以从多个解决方案中选择一种解决方案时,应考虑耦合性较弱的解决方案。 组合与继承的耦合较弱,因此在可能的情况下应首选。

同样,合成使您有机会很好地包装原始的class方法。 如果选择继承(公共)并且方法不是虚拟的(在这种情况下),则这是不可能的。

Matthieu M. answered 2019-10-07T06:56:38Z
0 votes

C ++标准指出,如果基类析构函数不是虚拟的,而您删除了基类的对象,该对象指向派生类的对象,则它将导致未定义的行为。

C ++标准第5.3.5 / 3节:

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

要明确了解非多态类和虚拟析构函数的需求
使析构函数虚拟化的目的是通过delete-expression促进对象的多态删除。 如果没有对象的多态删除,则不需要虚拟析构函数。

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

如果您想拥有类似功能的字符串,则应该编写自己的类,而不是从std :: string继承。

Alok Save answered 2019-10-07T06:58:09Z
0 votes

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

对?

请改正我,如果我错了。

Bretzelus answered 2019-10-07T06:58:54Z
translate from https://stackoverflow.com:/questions/6006860/why-should-one-not-derive-from-c-std-string-class