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

Why should one not derive from c++ std string class?

我想问一个具体的点,在有效的C++。

上面写着:

A destructor should be made virtual if a class needs to act like a polymorphic class. It further adds that since std::string does not have a virtual destructor, one should never derive from it. Also std::string is not even designed to be a base class, forget polymorphic base class.

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

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

此外,如果有一个纯粹为可重用性而定义的基类,并且有许多派生类型,那么有没有任何方法可以防止客户端执行Base* p = new Derived(),因为这些类不打算以多态方式使用?


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

I do not understand what specifically is required in a class to be eligible for being a base clas (not a polymorphic one)?

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

  • 私有继承,用于混合和使用模板的面向方面编程。
  • 公共继承,仅用于多态情况。编辑:好吧,我想这也可以用在一些混合场景中——比如boost::iterator_facade,当crtp使用时就会出现。

如果你不想做一些多态的事情,完全没有理由在C++中公开一个类。该语言带有作为该语言标准特性的自由函数,您应该在这里使用自由函数。

这样想——您真的想强迫代码的客户机转换为使用一些专有的字符串类,仅仅是因为您想附加一些方法吗?因为与Java或C语言(或大多数类似的面向对象语言)不同,当您在C++中派生类时,基类的大多数用户需要知道这种类型的更改。在爪哇/C语言中,类通常是通过引用访问的,它们类似于C++的指针。因此,有一个间接的层次涉及到分离类的客户机,允许您在不知道其他客户机的情况下替换派生类。

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

1
2
3
4
5
6
7
8
9
10
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保留空间。在爪哇和C中,这不是问题,因为涉及自动存储的唯一的东西是引用类型,并且引用总是相同大小。不是这样的C++。

长话短说——不要用继承来粘贴C++中的方法。这不是惯用语,会导致语言问题。尽可能使用非友元、非成员函数,然后是组合。不要使用继承,除非你是模板元编程或者想要多态行为。有关更多信息,请参见Scott Meyers的"有效C++项目23:首选非成员非友元函数"到"成员函数"。

编辑:下面是一个更完整的例子,展示了切片问题。你可以在codepad.org上看到它的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#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);
}


提供一般性建议的反面(当没有明显的详细性/生产力问题时,这是合理的)。好的。合理使用方案

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

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

这听起来可能有很大的限制,但在现实世界中,有很多程序与此场景匹配。好的。背景讨论:相对优势

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

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

如果潜在的问题涉及到对象的使用,而您只是无法想象任何人试图根据您对对象在程序中的可访问性、范围和使用性质的了解来尝试使用这些对象,或者您可以生成编译时错误以供危险使用(例如,派生类大小与基大小匹配的断言,这将阻止添加新的数据内存)。否则任何其他的事情都可能是过早的工程。以简洁、直观、简洁的设计和代码轻松取胜。好的。型考虑派生sans虚拟析构函数的原因

假设您有一个从B公开派生的类D,那么不费吹灰之力,就可以在D上进行操作(除了构造,但是即使有很多构造函数,您也可以通过为每个不同数量的构造函数参数使用一个模板来提供有效的转发:例如template D(const T1& x1, const T2& t2) : B(t1, t2) { }。更好的通用解决方案在C++0X变量模板中。好的。型

此外,如果b更改了,那么默认情况下d会暴露这些更改-保持同步-但可能需要有人检查d中引入的扩展功能,以查看它是否仍然有效,以及客户端的使用情况。好的。型

换言之:基类和派生类之间的显式耦合减少,但基类和客户端之间的耦合增加。好的。型

这往往不是你想要的,但有时它是理想的,而其他时候则是无问题的(见下一段)。对基础的更改会迫使更多的客户机代码在分布在整个代码基础中的位置进行更改,有时更改基础的人甚至可能无法访问客户机代码来相应地查看或更新它。但有时情况会更好:如果作为派生类提供者的"中间人",您希望基类的更改能够传递给客户机,并且您通常希望客户机能够(有时是被迫的)在基类更改时更新其代码,而无需您经常参与,那么公共派生可能是理想的。当类本身不是一个独立的实体,而是一个加在基上的细值时,这是很常见的。好的。型

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

总之,公共派生是一种快速获取或近似派生类理想的、熟悉的基类接口的方法-以一种对维护者和客户机编码者都简明且自明无误的方式-具有作为成员函数可用的附加功能(imho-与sutter,Alexandrescu e明显不同tc-可以帮助可用性、可读性和提高生产力的工具,包括ides)好的。型C++编码标准-萨特和AlxEngRESCU-CONS检查

C++编码标准的第35项列出了从EDCOX1(1)派生的场景中的问题。随着场景的发展,它很好地说明了公开一个大型但有用的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,具有独立的支持功能。好的。

(希望我们能同意这种组合是可接受的实践,因为它提供了封装、类型安全以及比std::string更丰富的潜在API。)好的。

所以,假设您正在编写一些新的代码,并从OO的角度开始考虑概念实体。也许在一个bug跟踪系统中(我想说的是jira),其中之一就是说一个问题ID。数据内容是文本的-由一个字母项目ID、一个连字符和一个递增的问题编号组成:例如"myapp-1234"。问题ID可以存储在一个std::string中,并且在问题ID上会有许多非常小的文本搜索和操作需要,这些操作是std::string上已经提供的内容的一个很大的子集,还有一些是为了更好的度量(例如,获取项目ID组件,提供下一个可能的问题ID(myapp-1235))。好的。

关于萨特和亚历山德里斯科的问题清单…好的。

Nonmember functions work well within existing code that already manipulates strings. If instead you supply a super_string, you force changes through your code base to change types and function signatures to super_string.

Ok.

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

通过使用公共派生,现有代码可以隐式地将基类string作为string访问,并继续保持其一贯的行为。没有具体的理由认为现有的代码会使用来自super_string的任何附加功能(在我们的例子中,问题是id)。实际上,它通常是较低级别的支持代码,它预先存在于要为其创建super_string的应用程序中,因此忽略了扩展函数提供的需求。例如,假设有一个非成员函数to_upper(std::string&, std::string::size_type from, std::string::size_type to),它仍然可以应用于Issue_Id。好的。

因此,除非非成员支持函数被清理或扩展,而代价是将其与新代码紧密耦合,否则就不需要触及它。如果正在对它进行大修以支持问题ID(例如,使用对数据内容格式的洞察,只支持大写字母字符),那么通过创建一个重载al a to_upper(Issue_Id&),并坚持派生或组合方法al,确保它确实是通过Issue_Id传递的,这可能是一件好事。降低型安全。无论是使用super_string还是合成,都不会对工作或可维护性造成影响。to_upper_leading_alpha_only(std::string&)可重用的独立支持功能不太可能有多大的用处——我不记得上次我想要这样的功能时的情形。好的。

在任何地方使用std::string的冲动与接受所有参数作为变量容器或void*的本质上没有区别,因此您不必更改接口来接受任意数据,但它使得易于出错的实现和较少的自文档化和编译器可验证代码。好的。

Interface functions that take a string now need to: a) stay away from super_string's added functionality (unuseful); b) copy their argument to a super_string (wasteful); or c) cast the string reference to a super_string reference (awkward and potentially illegal).

Ok.

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

super_string's member functions don't have any more access to string's internals than nonmember functions because string probably doesn't have protected members (remember, it wasn't meant to be derived from in the first place)

Ok.

是的,但有时候这是件好事。许多基类没有受保护的数据。公共的string接口是操作内容所需的全部,而有用的功能(如上述假定的get_project_id()可以用这些操作优美地表达出来。从概念上讲,很多时候我从标准容器中派生出来,我不想沿着现有的线路扩展或定制它们的功能——它们已经是"完美的"容器——相反,我想添加另一个特定于我的应用程序的行为维度,并且不需要私有访问。因为它们已经是很好的容器,所以它们可以很好地重用。好的。

If super_string hides some of string's functions (and redefining a nonvirtual function in a derived class is not overriding, it's just hiding), that could cause widespread confusion in code that manipulates strings that started their life converted automatically from super_strings.

Ok.

对于组合也是如此——而且更可能发生,因为代码不会默认地传递信息,从而保持同步,在某些情况下,对于运行时多态层次结构也是如此。在最初看起来可互换的类中表现不同的samed命名函数-只是令人讨厌。这实际上是正确OO编程的常见注意事项,同样也不是放弃类型安全等好处的充分理由。好的。

What if super_string wants to inherit from string to add more state [explanation of slicing]

Ok.

同意——这不是一个好的情况,在某个地方,我个人倾向于划一条线,因为它经常通过指向基础的指针将删除问题从理论领域转移到非常实际的领域——析构函数不会被其他成员调用。尽管如此,切片通常可以做想要做的事情——假设派生super_string的方法不是为了更改其继承的功能,而是添加另一个特定于应用程序功能的"维度"……好的。

Admittedly, it's tedious to have to write passthrough functions for the member functions you want to keep, but such an implementation is vastly better and safer than using public or nonpublic inheritance.

Ok.

好吧,当然同意单调的生活……好的。成功派生sans虚拟析构函数的指南

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

总结

这种推导并非没有问题,因此除非最终结果证明了方法的合理性,否则不要考虑它。也就是说,我断然拒绝任何在特定情况下不能安全、适当地使用它的说法——这只是一个在哪里划界的问题。好的。个人经验

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


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

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

非多态性的另一个问题是,如果将派生类传递给需要字符串参数的函数,则额外的功能将被切掉,对象将再次被视为普通字符串。


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

1
2
3
4
5
6
class StringDerived : public std::string {
//...
private:
  static void* operator new(size_t size);
  static void operator delete(void *ptr);
};

但是这样你就可以限制自己不受任何动态的StringDerived对象的影响。


Why should one not derive from c++ std string class?

因为这是不必要的。如果您想使用DerivedString进行功能扩展,我认为在派生std::string时没有任何问题。唯一的一点是,您不应该在两个类之间进行交互(即不要使用string作为DerivedString的接收器)。

Is there any way to prevent client from doing Base* p = new Derived()

对。确保在Derived类内提供Base方法周围的inline包装。例如

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


不从非多态类派生的原因有两个:

  • 技术:它介绍切片错误(因为在C++中,除非另有说明,否则我们通过值)
  • 功能性:如果它是非多态的,你可以通过组成和一些功能转发来达到相同的效果。

如果您希望向std::string添加新功能,那么首先考虑使用自由函数(可能是模板),就像boost string算法库那样。

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

编辑:

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

此外,构图还为您提供了很好地包装原始类方法的机会。如果您选择继承(public),并且方法不是虚拟的(这里就是这种情况),这是不可能的。


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

对吗?

如果我错了,请纠正我。


C++标准声明,如果基类析构函数不是虚的,则删除基类的对象,该对象指向派生类的对象,然后导致未定义的行为。

C++标准节5.3.5/3:

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

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

为什么不从字符串类派生?一般来说,应该避免从任何标准容器类派生,因为它们没有虚拟析构函数,这使得不可能以多态方式删除对象。至于字符串类,字符串类没有任何虚拟函数,因此没有任何可以重写的内容。你所能做的就是隐藏一些东西。

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