不使用指针的C++多态性

46
假设我有一个基类Animal,其中包含虚函数和一些派生类(如Cat、Dog等)。实际的派生类包含4-8个字节的数据。我想存储一个std::list,其中实际上包含的是派生对象。我希望避免使用new在堆上创建许多小对象。
有没有设计模式可用于实现这一点?
我的实现想法是:创建std::deque,std::deque等;存储std::list,其中包含来自deques的指针;我使用std::deque,因为它假定具有良好的内存管理和对象块。

3
为什么要“避免使用new在堆上创建许多小对象”?如果这是一个瓶颈,为什么不致力于更有效的内存管理例程? - Chris Lutz
1
如果不使用堆,则必须使用栈 - 即它们必须始终在作用域内! - Ed Heal
你可以在堆上分配一个大的 char 数组,并使用放置 new,但请记住,使用 new 在堆上创建许多小对象比使用 new 在堆上创建许多大对象更好。而且,如果不使用指针,唯一使用多态的方法是使用引用。 - Seth Carnegie
我刚刚从使用向量(我知道需要的大小)转回到两个POD成员-一个是指针,另一个是大小。在某些实现中,向量具有越界检查,而这一切开始变得比仅仅存储大小更加复杂。 - John
1
在像Java这样的语言中,所有对象都是在堆上创建的(即动态内存),而“对象”实际上是指向对象的“引用”。所有引用(如C中的指针)具有相同的大小(例如8个字节),因此将不同类型的对象的引用添加到容器中没有问题。出于同样的原因,在C++中,您可以使用指针进行多态性。相反,对象本身可能具有不同的字节大小,这就是无法将某种类型的对象分配给另一种类型的变量的原因-因此,多态性仅对指针启用。 - SomethingSomething
5个回答

44

最终答案是否定的。

多态只适用于非值类型:引用和指针。由于引用只能绑定一次,所以您不能真正将它们用于标准容器中。这就只剩下了指针。

您正在错误的方向上解决问题。如果您担心分配大量小对象的开销(我认为这是一个合理的担忧。也就是说,您有实际的分析数据或足够的经验来知道这对您的具体应用程序而言是个问题),那么您应该解决这个问题。改变如何为这些对象分配内存。创建一个小的分配堆之类的东西。

不可否认,在 C++0x 之前的分配器在这方面有些欠缺,因为它们必须是无状态的。但是对于您的目的,您应该能够处理它。


从您的编辑内容:

这是一个糟糕的主意。从std::deque的任何位置擦除都会使您std::list中的每个指针无效。

根据您的评论,这个想法是可行的。然而,为不同种类的对象拥有所有这些不同的内存块似乎违反了继承的整个目的。毕竟,您不能只写一个新类型的Animal,然后将其插入std::list;您必须为其提供内存管理。

您确定继承基础的多态是您在这里需要的吗?您确定其他方法不会同样有效吗?


1
可能我应该提一下,所有的对象将会同时被删除。 - Andrei Bozantan
1
@Andrei:是的,知道这点非常重要 ;) - Nicol Bolas

9
我知道这个问题已经很老了,但我找到了一个相当不错的解决方案。
假设:你预先知道所有的派生类(根据你的编辑,这是正确的)。
技巧:使用boost::variant (http://www.boost.org/doc/libs/1_57_0/doc/html/variant.html)
示例类:
class Animal {
public:
    virtual void eat() = 0;
};

class Cat : public Animal {
    virtual void eat() final override {
        std::cout << "Mmh, tasty fish!" << std::endl;
    }
};

class Dog: public Animal {
    virtual void eat() final override {
        std::cout << "Mmh, tasty bone!" << std::endl;
    }
};

示例变体/访问者:

typedef boost::variant<Cat, Dog> AnimalVariant;

class AnimalVisitor : public boost::static_visitor<Animal&> {
public:
    Animal& operator()(Cat& a) const {
        return a;
    }

    Animal& operator()(Dog& a) const {
        return a;
    }
};

示例用法:

std::vector<AnimalVariant> list;
list.push_back(Dog());
list.emplace_back(Cat());

for(int i = 0; i < 5; i++) {
    for(auto& v : list) {
        Animal& a = v.apply_visitor(AnimalVisitor());
        a.eat();
    }
}

输出示例

Mmh, tasty bone!
Mmh, tasty fish!
Mmh, tasty bone!
Mmh, tasty fish!
Mmh, tasty bone!
Mmh, tasty fish!
Mmh, tasty bone!
Mmh, tasty fish!
Mmh, tasty bone!
Mmh, tasty fish!

是的,这就是我所谓的“交错”方法,当所有类的大小相同时,它是最有效的,因为填充是空的。Brian Coleman的方法在类型之间有很大差异时最好。这是可以通过sigma(标准偏差)元函数在编译时进行评估,并使用mpl::if_或其他方式选择,或者使用SFINAE或简单的特化,以使用变体实现低sigma,而对于高sigma则使用池。 - v.oddou
我在我的微控制器上做了非常类似的事情,因为所有new的用法都不被鼓励...但是Boost在微控制器上不可用,所以我写了自己的variant类。 - DarthRubik
1
使用C++14,我们可以放弃对 AnimalVisitor 的定义并使用lambda表达式:Animal& a = v.apply_visitor([](const auto& pet) -> Animal& { return pet; }); - F Pereira

2
如果您担心分配大量小堆对象,那么在容器的选择上,向量(vector)可能比列表(list)和双端队列(deque)更好。每次将对象插入列表时,列表都会在堆上分配一个节点,而向量将在堆的连续内存区域中存储所有对象。
如果您有:
std::vector<Dog> dogs;
std::vector<Cat> cats;

std::vector<Animal*> animals;

void addDog(Dog& dog, std::vector<Dog>& dogs, std::vector<Animal*>& animals) {
  dogs.push_back(dog);
  animals.push_back(&dog);
}

然后所有的狗和猫都被存储在堆上两个连续的内存区域中。

确切地说,这被称为池化方法。我正在考虑将此分配模式泛化到一些辅助类中,该类将采用模板类型列表。我想提供的另一种模式是“交错”模式,它使用maxof(sizeof(all types))通过制作variant向量来实现。 - v.oddou
2
这根本不起作用。如果有人想要做到这一点,必须使用像std::deque这样的容器,它不会移动已插入的元素(并使指针无效)。 - nondefault
5
这种方法无法奏效的另一个原因是 dogs.push_back(dog) 会复制一份狗的副本。被推入到动物列表中的指针指向的是参数所引用的原始狗,而不是同一只狗!这个指针可能会因为调用者的操作而失效。 - Andy Borrell
animals.push_back(&dog); 应该改为 animals.push_back(dogs.back());,原因在上面的注释中已经提到。 - gmargari

0

这是对使用变体(无论是Boost的还是自c++17以来STL的变体)的建议进行跟进,如果所有可能的子类在编译时已知(这种情况也称为“封闭集合多态性”)。

必须使用访问者模式来访问对象接口有点烦人(在我看来),这就是为什么我编写了一种变体类型,它公开了基类接口(甚至是基本类型运算符)。 它可以在https://github.com/Krzmbrzl/polymorphic_variant找到(需要C++17,因为它是围绕std::variant构建的)。

克隆@Draziv的示例:

类定义

class Animal {
public:
    virtual void eat() = 0;
};

class Cat : public Animal {
    virtual void eat() final override {
        std::cout << "Mmh, tasty fish!" << std::endl;
    }
};

class Dog: public Animal {
    virtual void eat() final override {
        std::cout << "Mmh, tasty bone!" << std::endl;
    }
};

使用示例

pv::polymorphic_variant< Animal, Dog, Cat > variant;
variant->eat();

variant = Cat{};
variant->eat();

输出:

Mmh, tasty bone!
Mmh, tasty fish!

0

你可能可以使用一个简单的包装器类来处理包含每个情况所需数据的超级集合。这将包含指向共享策略对象的指针,该对象包含不同行为的代码。因此,猫是具有物种名称为“cat”、捕食动物喂养策略等的PolyAnimal类对象。

更好地解决潜在问题的方法可能是设置适当的自定义分配器以实现更自然的设计。


使用策略模式的解决方案是我的第二个想法(有趣的是,即使你不知道设计模式,当你编写代码时它们也会出现:)),但当我开始实施解决方案时,它开始感觉有些不自然。当然,在进一步思考后,我可能会选择自定义分配器。 - Andrei Bozantan

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