我能否在C++中使用值语义的多态容器?

38
作为一般规则,在C++中,我更喜欢使用值语义而不是指针语义(即使用vector<Class>而不是vector<Class*>)。通常,轻微的性能损失可以通过不必记住删除动态分配对象来弥补。

不幸的是,当您想要存储从共同基类派生的各种对象类型时,值集合将无法正常工作。请参见下面的示例。

#include <iostream>

using namespace std;

class Parent
{
    public:
        Parent() : parent_mem(1) {}
        virtual void write() { cout << "Parent: " << parent_mem << endl; }
        int parent_mem;
};

class Child : public Parent
{
    public:
        Child() : child_mem(2) { parent_mem = 2; }
        void write() { cout << "Child: " << parent_mem << ", " << child_mem << endl; }

        int child_mem;
};

int main(int, char**)
{
    // I can have a polymorphic container with pointer semantics
    vector<Parent*> pointerVec;

    pointerVec.push_back(new Parent());
    pointerVec.push_back(new Child());

    pointerVec[0]->write(); 
    pointerVec[1]->write(); 

    // Output:
    //
    // Parent: 1
    // Child: 2, 2

    // But I can't do it with value semantics

    vector<Parent> valueVec;

    valueVec.push_back(Parent());
    valueVec.push_back(Child());    // gets turned into a Parent object :(

    valueVec[0].write();    
    valueVec[1].write();    

    // Output:
    // 
    // Parent: 1
    // Parent: 2

}

我的问题是:我是否可以既使用值语义(value semantics)又使用多态容器(polymorphic containers)?还是必须使用指针?

9个回答

29

由于不同类的对象大小不同,如果将它们存储为值,则可能会遇到切片问题。

一个合理的解决方案是存储容器安全的智能指针。我通常使用安全的boost::shared_ptr来存储,注意std::auto_ptr不安全。

vector<shared_ptr<Parent>> vec;
vec.push_back(shared_ptr<Parent>(new Child()));

shared_ptr使用引用计数,因此它只有在所有引用都被移除后才会删除底层实例。


3
boost::ptr_vector通常是std::vector<boost::shared_ptr<T>>更便宜、更简单的替代品。 - ben
4
这个回答没有涉及值语义。shared_ptr<T> 提供了对从 T 派生的类的引用语义,例如 shared_ptr<Base> a, b; b.reset(new Derived1); a = b; 并不会复制 Derived1 对象。 - Aaron
5
我从未说过我的解决方案涉及值语义。我说它是“一个合理的解决方案”。如果你知道如何实现多态值语义,那就来领取你的诺贝尔奖吧。 - 1800 INFORMATION
2
有许多 clone_ptrvalue_ptr 类型可以实现这一点。 - Puppy
4
请注意,从C++11开始,shared_ptr现在位于std命名空间中。您应该使用这个而不是Boost的。 - Lynn

12

我只想指出,vector<Foo>通常比vector<Foo*>更有效率。在vector<Foo>中,所有的Foos都会相邻地存储在内存中。假设有一个冷数据页表和缓存,第一次读取将把页面添加到数据页表(TLB)并将向量的一块加载到L#缓存中;后续读取将使用热缓存和已加载的TLB,偶尔出现缓存失效和TLB错误。

与之相反,在vector<Foo*>中:当你填充向量时,你从你的内存分配器获得Foo*'s。假设你的分配器不是非常聪明(tcmalloc?),或者你慢慢地填充向量,每个Foo的位置很可能相距甚远,也许只相差几百字节,也许相差数兆字节。

在最坏的情况下,当你扫描一个vector<Foo*>并解引用每个指针时,你将遇到一个TLB错误和缓存失效--这将比如果你有一个vector<Foo>要慢得多。(嗯,在最糟糕的情况下,每个Foo都被换出到磁盘,每次读取都需要磁盘寻找()和读取()将页面移回RAM中.)

所以,在适当的情况下继续使用vector<Foo>. :-)


4
考虑缓存的加一!这个问题在未来变得更加相关。 - Konrad Rudolph
2
这取决于Foo :: Foo(const Foo&)的成本,因为值语义容器将需要在插入时调用它。 - AndrewR
好的观点--但只要您在追加,这不是问题。(您将有log2(n)个额外的副本。)此外,大多数访问都是读取而不是写入; 有时遭受偶尔昂贵的写入仍然可以通过使读取更快来获得净胜利。 - 0124816

10

可以的。

boost.ptr_container库提供了标准容器的多态值语义版本。您只需要传入一个指向堆分配对象的指针,容器就会获取所有权并提供值语义的所有后续操作,除了回收所有权外,使用智能指针几乎可以获得值语义的所有好处。


5
在寻找解决这个问题的答案时,我遇到了这个和一个类似的问题。在其他问题的答案中,您会发现两个建议的解决方案:
  1. 使用std :: optional或boost :: optional和访问者模式。此解决方案使添加新类型变得困难,但易于添加新功能。
  2. 使用类似于Sean Parent在他的演讲中提出的包装器类。此解决方案使添加新功能变得困难,但易于添加新类型。

该包装器定义了您需要为类定义的接口,并保存指向其中一个对象的指针。接口的实现是由自由函数完成的。

以下是此模式的示例实现:

class Shape
{
public:
    template<typename T>
    Shape(T t)
        : container(std::make_shared<Model<T>>(std::move(t)))
    {}

    friend void draw(const Shape &shape)
    {
        shape.container->drawImpl();
    }
    // add more functions similar to draw() here if you wish
    // remember also to add a wrapper in the Concept and Model below

private:
    struct Concept
    {
        virtual ~Concept() = default;
        virtual void drawImpl() const = 0;
    };

    template<typename T>
    struct Model : public Concept
    {
        Model(T x) : m_data(move(x)) { }
        void drawImpl() const override
        {
            draw(m_data);
        }
        T m_data;
    };

    std::shared_ptr<const Concept> container;
};

不同的形状随后被实现为常规的结构体/类。您可以自由选择使用成员函数或自由函数(但您将需要更新上述实现以使用成员函数)。我更喜欢自由函数:
struct Circle
{
    const double radius = 4.0;
};

struct Rectangle
{
    const double width = 2.0;
    const double height = 3.0;
};

void draw(const Circle &circle)
{
    cout << "Drew circle with radius " << circle.radius << endl;
}

void draw(const Rectangle &rectangle)
{
    cout << "Drew rectangle with width " << rectangle.width << endl;
}

现在,您可以将CircleRectangle对象都添加到同一个std::vector<Shape>中:

int main() {
    std::vector<Shape> shapes;
    shapes.emplace_back(Circle());
    shapes.emplace_back(Rectangle());
    for (const auto &shape : shapes) {
        draw(shape);
    }
    return 0;
}

这种模式的缺点是界面需要大量的样板代码,因为每个函数都需要定义三次。优点是您可以获得复制语义。
int main() {
    Shape a = Circle();
    Shape b = Rectangle();
    b = a;
    draw(a);
    draw(b);
    return 0;
}

这将产生:
Drew rectangle with width 2
Drew rectangle with width 2

如果您担心 `shared_ptr`,可以将其替换为 `unique_ptr`。但是,它将不再可复制,您必须移动所有对象或手动实现复制。Sean Parent在他的讲话中详细讨论了这个问题,并在上述答案中展示了一个实现。

在我看来,这真的应该是被接受的答案,因为你提到的包装器(我开始称之为“多态值类型”)是提供了最符合惯用法和最强大的解决方案。std::any失去了任何概念(例如从公共基类派生的概念)的容器元素。我基本上在一个类似的问题中提出了与你相同的建议。多态值类型获胜! - Louis Langholtz

5

你可能还需要考虑boost::any。我用它来处理异构容器。当读取值时,需要执行一个any_cast。如果失败,它将抛出一个bad_any_cast异常。如果发生这种情况,你可以捕获并转到下一个类型。

相信如果你尝试将派生类转换为其基类,它会抛出一个bad_any_cast异常。我试过了:

  // But you sort of can do it with boost::any.

  vector<any> valueVec;

  valueVec.push_back(any(Parent()));
  valueVec.push_back(any(Child()));        // remains a Child, wrapped in an Any.

  Parent p = any_cast<Parent>(valueVec[0]);
  Child c = any_cast<Child>(valueVec[1]);
  p.write();
  c.write();

  // Output:
  //
  // Parent: 1
  // Child: 2, 2

  // Now try casting the child as a parent.
  try {
      Parent p2 = any_cast<Parent>(valueVec[1]);
      p2.write();
  }
  catch (const boost::bad_any_cast &e)
  {
      cout << e.what() << endl;
  }

  // Output:
  // boost::bad_any_cast: failed conversion using boost::any_cast

话虽如此,我也会首选shared_ptr!只是想让你知道这可能会引起一些兴趣。


2
请看static_castreinterpret_cast
在《C++程序设计语言》第3版中,Bjarne Stroustrup在第130页上进行了描述。第6章中有一个关于此的完整部分。
您可以将父类重新转换为子类。这需要您知道每个类是哪一个。在书中,Stroustrup博士谈到了不同的技术来避免这种情况。

不要这样做。这会破坏您一开始试图实现的多态性!


2
大多数容器类型都希望抽象特定的存储策略,无论是链表、向量、基于树的还是其他什么。因此,你会在拥有和使用上述蛋糕(即蛋糕是假的(注:必须得开个玩笑))时遇到问题。
那么该怎么办呢?好吧,有一些巧妙的选择,但大多数都会缩减为几个主题或它们的组合变体:挑选或发明适当的智能指针,在某种聪明的方式下使用模板或模板模板,为包含元素提供通用接口以实现每个包含元素双重分派的钩子。
你的两个目标之间存在基本的紧张关系,因此你应该决定自己想要什么,然后尝试设计一些基本符合你需求的东西。如果足够聪明地使用引用计数、按需复制和常量性,并通过预处理器、模板和C++的静态初始化规则的组合(对于工厂),可以做一些不错且出乎意料的技巧,使指针看起来像值。
我过去曾花费一些时间设想如何使用虚拟代理/信封-信件/引用计数指针的可爱技巧来完成类似于C++中值语义编程的基础。
我认为这是可以做到的,但你必须在C++中提供一个相当封闭的、类似于C#托管代码的世界(虽然你可以在需要时打破底层的C++)。因此,我对你的思路非常有同情。

2
只是要在所有已经说过的1800信息上补充一件事。
你可能想看一下Scott Mayers的"更有效的C++",其中的"第3项:永远不要把数组多态化"可以帮助你更好地理解这个问题。

1

我正在使用自己模板化的集合类,具有公开值类型语义,但在内部存储指针。它使用自定义迭代器类,当取消引用时获取值引用而不是指针。复制集合会进行深层次项目副本,而不是重复的指针,这就是大部分开销所在的地方(考虑到我获得的东西,这只是一个非常小的问题)。

这是一个可能适合您需求的想法。


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