从STL容器继承实现,而不是委派,这样做可以吗?

88

我有一个类,它将std::vector适配成特定领域对象的容器。我想向用户公开大部分std::vector API,以便他们可以使用熟悉的方法(size,clear,at等)和标准算法在容器上操作。这似乎是我设计中经常出现的一种模式:

class MyContainer : public std::vector<MyObject>
{
public:
   // Redeclare all container traits: value_type, iterator, etc...

   // Domain-specific constructors
   // (more useful to the user than std::vector ones...)

   // Add a few domain-specific helper methods...

   // Perhaps modify or hide a few methods (domain-related)
};

我知道在重用类进行实现时,倾向于使用组合而非继承的做法,但肯定有一个限度!如果我将所有东西委派给std::vector,那么将会有(根据我的计算)32个转发函数!

所以我的问题是...在这种情况下继承实现真的很糟糕吗?有哪些风险?是否有更安全的实现方式可以少打这么多代码?我使用实现继承是异端吗? :)

编辑:

那么,明确一下用户不应该通过std::vector指针来使用MyContainer怎么样:

// non_api_header_file.h
namespace detail
{
   typedef std::vector<MyObject> MyObjectBase;
}

// api_header_file.h
class MyContainer : public detail::MyObjectBase
{
   // ...
};

Boost库似乎总是这样处理。

编辑2:

其中一种建议是使用自由函数。我将在此处展示伪代码:

typedef std::vector<MyObject> MyCollection;
void specialCollectionInitializer(MyCollection& c, arguments...);
result specialCollectionFunction(const MyCollection& c);
etc...

更符合面向对象风格的做法:

typedef std::vector<MyObject> MyCollection;
class MyCollectionWrapper
{
public:
   // Constructor
   MyCollectionWrapper(arguments...) {construct coll_}

   // Access collection directly
   MyCollection& collection() {return coll_;} 
   const MyCollection& collection() const {return coll_;}

   // Special domain-related methods
   result mySpecialMethod(arguments...);

private:
   MyCollection coll_;
   // Other domain-specific member variables used
   // in conjunction with the collection.
}

6
太好了!又有机会推广我的博客http://punchlet.wordpress.com/了。基本上,编写自由函数,忘记“更多面向对象”的包装方法。这并不是更加面向对象——如果是的话,就应该使用继承,但在这种情况下你可能不应该这样做。请记住,面向对象编程!=类。 - anon
2
@Neil:但是,但是...全局函数是邪恶的!!!一切都是对象! ;) - Emile Cormier
5
如果你将它们放在命名空间中,它们就不会成为全局变量。 - anon
1
如果你真的想要暴露整个向量接口,那么在C++中使用组合并通过getter(具有const和非const版本)公开对向量的引用可能更好。在Java中,你只需继承,但是在Java中,一些笨蛋可能会忽略你的文档,通过错误的指针删除你的对象(或再次继承并搞砸它),然后抱怨。对于有限的受众群体来说也许可以,但如果用户可能是动态多态 freaks,或最近的 ex-Java 程序员,那么你正在设计一个你可以相当确定他们会误解的接口。 - Steve Jessop
2
你无法完全防止人们忽略文档。如果发现这种误用在Java和C++中造成的问题一样多,我不会感到惊讶。 - Roger Pate
显示剩余2条评论
8个回答

79

风险是通过指向基类的指针进行解除分配deletedelete[]和可能的其他解除分配方法)。由于这些类(dequemapstring等)没有虚拟析构函数,因此仅使用指向这些类的指针无法正确清理它们:

struct BadExample : vector<int> {};
int main() {
  vector<int>* p = new BadExample();
  delete p; // this is Undefined Behavior
  return 0;
}

话虽如此,如果你愿意确保永远不会意外这样做,那么继承它们就没有太大的缺点——但在某些情况下,这是一个很大的条件。其他缺点包括与实现特定和扩展冲突(其中一些可能不使用保留标识符)以及处理臃肿的接口(特别是string)。然而,在某些情况下,继承是有意义的,例如容器适配器(如stack)具有受保护成员c(它们适配的基础容器),几乎只能从派生类实例中访问。

与其使用继承或组合,考虑编写自由函数,这些函数接受迭代器对或容器引用,并对其进行操作。几乎所有的<algorithm>都是这种情况的例子;尤其是make_heappop_heappush_heap,它们使用自由函数而不是特定于域的容器。

因此,对于数据类型,请使用容器类,并为特定于域的逻辑调用自由函数。但是,您仍然可以使用typedef实现一些模块化,这样可以简化声明它们并提供单个点以进行更改:

typedef std::deque<int, MyAllocator> Example;
// ...
Example c (42);
example_algorithm(c);
example_algorithm2(c.begin() + 5, c.end() - 5);
Example::iterator i; // nested types are especially easier

请注意,value_type和allocator可以更改而不影响后续使用typedef的代码,甚至容器也可以从deque更改为vector。

41

您可以结合私有继承和“using”关键字来解决上述大部分问题:私有继承是“基于实现”的,因为它是私有的,所以您不能持有指向基类的指针。

#include <string>
#include <iostream>

class MyString : private std::string
{
public:
    MyString(std::string s) : std::string(s) {}
    using std::string::size;
    std::string fooMe(){ return std::string("Foo: ") + *this; }
};

int main()
{
    MyString s("Hi");
    std::cout << "MyString.size(): " << s.size() << std::endl;
    std::cout << "MyString.fooMe(): " << s.fooMe() << std::endl;
}

3
我必须提到,“private”继承仍然是继承,因此比组合关系更强。值得注意的是,这意味着更改类的实现将必然破坏二进制兼容性。 - Matthieu M.
9
私有继承和私有数据成员在更改时都会破坏二进制兼容性,并且除了少数友元之外(应该很少用),通常很容易在它们之间切换——使用哪个通常由实现细节决定。另外请参阅“基于成员的类设计技巧”。 - Roger Pate
对于好奇的人 - 基于成员的惯用语:http://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Base-from-Member - Emile Cormier
1
@MatthieuM。对于大多数应用程序来说,破坏ABI根本不是问题。甚至有些库为了更好的性能而没有使用Pimpl。 - mip

16

众所周知,STL容器没有虚析构函数,因此从它们继承最多是不安全的。我一直认为使用模板进行通用编程是一种不同于继承的面向对象编程风格。算法定义了它们所需的接口,这是在静态语言中最接近鸭子类型的方式。

无论如何,我确实有一些东西要添加到讨论中。我以前创建自己的模板特化的方式是定义像以下类这样的基类来使用。

template <typename Container>
class readonly_container_facade {
public:
    typedef typename Container::size_type size_type;
    typedef typename Container::const_iterator const_iterator;

    virtual ~readonly_container_facade() {}
    inline bool empty() const { return container.empty(); }
    inline const_iterator begin() const { return container.begin(); }
    inline const_iterator end() const { return container.end(); }
    inline size_type size() const { return container.size(); }
protected: // hide to force inherited usage only
    readonly_container_facade() {}
protected: // hide assignment by default
    readonly_container_facade(readonly_container_facade const& other):
        : container(other.container) {}
    readonly_container_facade& operator=(readonly_container_facade& other) {
        container = other.container;
        return *this;
    }
protected:
    Container container;
};

template <typename Container>
class writable_container_facade: public readable_container_facade<Container> {
public:
    typedef typename Container::iterator iterator;
    writable_container_facade(writable_container_facade& other)
        readonly_container_facade(other) {}
    virtual ~writable_container_facade() {}
    inline iterator begin() { return container.begin(); }
    inline iterator end() { return container.end(); }
    writable_container_facade& operator=(writable_container_facade& other) {
        readable_container_facade<Container>::operator=(other);
        return *this;
    }
};

这些类与STL容器拥有相同的接口。将修改和非修改操作分成不同的基类,我很喜欢这种效果。对于const-correctness来说,这有一个非常好的影响。唯一的缺点是,如果您想要将其用于关联容器,您必须扩展接口。尽管如此,我还没有遇到过这种需求。

不错!我可能会使用它。但是其他人让我重新考虑了采用容器的想法,所以也许我不会使用它。 :) - Emile Cormier
话虽如此,过度使用模板编程可能会导致同样糟糕的代码混乱、庞大的库、功能隔离不良以及无法理解的编译时错误。 - Erik Aronesty

6
除了虚拟析构函数之外,继承和包含的决定应该是基于你正在创建的类的设计决策。除非你能明确地说出你正在创建的类是容器的一种,否则你不应该仅仅因为它比包含一个容器并添加一些看起来像简单包装的添加和删除函数更容易而继承容器功能。例如,教室类通常会包含学生对象,但是对于大多数目的来说,教室不是学生列表的一种,因此你不应该从列表中继承。

5
在这种情况下,继承是一个不好的想法:STL容器没有虚析构函数,因此可能会遇到内存泄漏问题(此外,这表明STL容器本来就不适合被继承)。
如果您只需要添加一些功能,可以在全局方法或带有容器成员指针/引用的轻量级类中声明它。当然,这不能隐藏方法:如果这确实是您想要的,那么唯一的选择就是重新声明整个实现。

你仍然可以通过在头文件中不声明方法,而是只在实现中声明它们,通过将它们作为虚拟类中的非公共静态方法(从中您可以提供友好关系,并且这适用于必须是头文件的模板),或者将它们放在“detail”或类似命名空间中来隐藏它们。(所有三种方法与传统的私有方法一样有效。) - Roger Pate
Jherico:你是在和我说话还是和Stijn说话?无论哪种方式,我认为你误解了我们其中一个人。 - Roger Pate
@roger,我同意Jherico的观点,但我不确定我是否理解你的意思:你是在谈论如何隐藏std::vector中的方法还是其他东西?另外,将一个方法放入另一个命名空间中如何使其被隐藏?只要在头文件中声明,任何人都可以访问它,这并不像private关键字隐藏那样真正地隐藏。 - stijn
Stijn:这就是我所指出的私有访问的问题,它并不真正隐藏,因为任何可以访问头文件的人都可以阅读源代码或在编译器命令行上使用“-Dprivate=public”。像 private 这样的访问限定符大多是文档,也恰好被强制执行。 - Roger Pate
Steve:虽然你提出了一个重要观点,即所有实施DRM和加密的人都需要意识到... ;) 我并不是说它作为文档就不再有用了——好的文档总是有用的!——只是人们不应该期望从中获得更多。 - Roger Pate
显示剩余3条评论

1

这个更容易实现:

typedef std::vector<MyObject> MyContainer;

3
我理解了,但我希望进行的操作是:用简明的方式使 typedef (std::vector<MuObject> + mods) MyContainer 更加一致。 - Emile Cormier

1

无论如何,转发方法都将被内联。这种方式不会提高性能。事实上,您可能会获得更差的性能。


0

始终考虑组合优于继承。

考虑以下情况:

class __declspec(dllexport) Foo : 
  public std::multimap<std::string, std::string> {};

然后 std::multimap 的符号将被导出到您的 dll 中,这可能会导致编译错误 "std::multimap 已经定义"。


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