通过继承扩展C++标准库?

58

普遍认为C++标准库不适合使用继承进行扩展。当然,我和其他人都批评那些建议从类(如std::vector)派生的人。然而,这个问题:c++ exceptions, can what() be NULL? 让我意识到标准库中至少有一个部分是打算通过继承来扩展的——std::exception

因此,我的问题有两个部分:

  1. 是否还有其他标准库类旨在被派生?

  2. 如果从标准库类(例如std::exception)派生,是否受到ISO标准所描述的接口的限制?例如,使用一个异常类,其what()成员函数未返回NTBS的程序(例如返回空指针)是否符合标准?

10个回答

43

很好的问题。我真希望标准更明确地说明旨在使用什么。也许应该有一个 C++ 原理文档与语言标准一同出现。无论如何,这是我的做法:

(a) 我不知道是否存在这样的列表。相反,我使用以下列表来确定一个标准库类型是否可能被设计为可继承:

  • 如果它没有任何虚拟方法,则不应将其用作基类。这排除了像 std::vector 这样的类。
  • 如果它具有虚拟方法,则可以将其用作基类。
  • 如果有许多 friend 语句悬浮在周围,则要避免使用它,因为可能存在封装问题。
  • 如果它是一个模板,请在继承之前仔细检查,因为您可能可以使用特化进行定制。
  • 策略机制的存在(例如,std::char_traits)是一个相当好的线索,表明您不应将其用作基类。

不幸的是,我不知道是否有一个好的、全面的或者明确的列表。通常我会凭直觉去选择。

(b) 我会应用 LSP。如果有人在您的异常上调用 what(),那么它的可观测行为应该与 std::exception 的行为一致。我认为这不是一个符合标准的问题,而是一个正确性问题。标准并不要求子类可以替换基类。这只是一个最佳实践。


你提到了不要从没有虚成员的类继承,但是你也提到了策略类。那么(例如),从分配器(私有地)继承有什么问题吗? - Konrad Rudolph
3
我认为需要有一些继承的扩展,比如 std::stackstd::queue 这类拥有 protected 成员的类,因为保护成员仅是为了允许子类读取数据。当然,在操作时需要非常小心。 - Evan Teran

18

流库是为继承而设计的 :)


8
关于第b部分,来自17.3.1.2“需求”,第1段:
该库可由C++程序进行扩展。每个条款(如适用)均描述了此类扩展必须满足的要求。这些扩展通常是以下之一: - 模板参数 - 派生类 - 符合接口约定的容器、迭代器和/或算法
虽然17.3是信息性的而非绑定性的,但委员会对派生类行为的意图是明确的。
对于其他非常相似的扩展点,有明确的要求: - 17.1.15“所需行为”涵盖替换(operator new等)和处理程序函数(终止处理程序等),并将所有不符合规范的行为抛入UB领域。 - 17.4.3.6/1:“在某些情况下(替换函数、处理程序函数、用于实例化标准库模板组件的类型上的操作),C++标准库依赖于由C++程序提供的组件。如果这些组件不满足其要求,则标准不对实现施加任何要求。”
在最后一点中,我不清楚括号列表是否是详尽无遗的,但考虑到下一段对每个提到的情况都进行了具体处理,说当前文本旨在覆盖派生类可能有些牵强。此外,17.4.3.6/1的文本在2008年的草案中没有改变(它在17.6.4.8中),我没有看到任何问题涉及它或派生类的虚拟方法。

6
简洁的规则是:“任何类都可以用作基类;在没有虚方法(包括虚析构函数)的情况下安全使用它完全由派生作者负责。” 在 std::exception 的子类中添加非 POD 成员与在 std::vector 的派生类中添加非 POD 成员一样是用户错误。认为容器不“打算”成为基类是文学教授所称的“作者意图谬误”的工程实例。
IS-A 原则占主导地位。除非 D 可以在 B 的公共接口中的每个方面替代 B,包括对 B 指针的删除操作,否则不要从 B 派生 D。如果 B 有虚方法,则此限制较少繁琐;但如果 B 只有非虚方法,则仍然可以通过继承进行专业化,这是可能且合法的。
C++ 是多范式的。模板库使用继承,甚至使用没有虚析构函数的类的继承,并因此通过示例证明了这些构造是安全和有用的。它们是否被打算使用是一个心理问题。

5
C++标准库不是一个单一的单元。它是将多个不同的库组合和采纳而来的结果(C标准库的很大一部分、iostreams库和STL是三个主要构建块,每个都有独立的规范)。库中的STL部分通常不用于派生,因为它使用通用编程,并且通常避免面向对象编程。iostreams库则更加传统的面向对象编程,内部大量使用继承和动态多态性,用户也应该使用相同的机制进行扩展。自定义流通常通过从流类本身或其内部使用的streambuf类派生来编写。这两者都有虚拟方法,可以在派生类中重写。std::exception是另一个例子。

就像D.Shawley所说,我会将LSP应用于您的第二个问题。始终可以将基类替换为派生类是合法的。如果我调用exception::what(),它必须遵循exception类指定的契约,无论exception对象来自何处,或者它实际上是一个已经向上转型的派生类。在这种情况下,该契约是标准承诺返回NTBS。如果您使派生类表现不同,那么您将违反标准,因为类型为std::exception的对象不再返回NTBS。


4
回答问题2):
我认为,是的,他们将受到ISO标准界面描述的约束。例如,该标准允许全局重新定义operator newoperator delete。但是,标准保证operator delete在空指针上是无操作。
不遵守这一点肯定是未定义的行为(至少对斯科特·迈尔斯来说是如此)。我认为我们可以通过类比来说,标准库的其他领域也是如此。

4

functional库中,一些东西例如greater<>less<>mem_fun_t是从unary_operator<>binary_operator<>派生而来的。但是,我记得那只会给你一些typedef。


3

我知道这个问题很久了,但我想在这里添加我的评论。

自几年前以来,我使用一个继承自std::string的class CfgValue,尽管文档(或某些书籍或某些标准文档,我现在没有源代码)说,用户不应该继承自std::string。并且这个类含有一个“TODO:从std::string中删除继承”的注释多年。

Class CfgValue只是添加了一些构造函数和设置器和getter,以便快速转换字符串、数值和布尔值,并将utf8(保留在std::string中)转换为ucs2(保留在std::wstring中)编码等。

我知道,有许多不同的方法可以做到这一点,但对于用户来说,它非常方便,而且它工作得很好(这意味着它不会破坏任何stdlib概念、异常处理等):

class CfgValue : public std::string {
public:
    ...
    CfgValue( const int i ) : std::string() { SetInteger(i); }
    ...
    void SetInteger( int i );
    ...
    int GetInteger() const;
    ...
    operator std::wstring() { return utf8_to_ucs16(*this); }
    operator std::wstring() const { return utf8_to_ucs16(*this); }
    ...
};

3

对于第二个问题,我认为答案是肯定的。标准规定std::exception的what成员必须返回非空值。无论我有一个堆栈、引用还是指向std::exception的指针值,what()的返回值都受标准限制。

当然,返回NULL是可能的。但我认为这样的类不符合标准。


1
关于问题2),根据C++标准,派生的异常类必须指定不抛出 i.e. throw() 说明,并且要返回非空。这意味着在许多情况下,派生的异常类不应使用 std::string,因为 depending on the implementation,std::string 本身可能会抛出异常。

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