C++技术:类型擦除与纯多态性

C++ Techniques: Type-Erasure vs. Pure Polymorphism

本文关键字:多态性 擦除 类型 技术 C++      更新时间:2023-10-16

比较这两种技术的优点/缺点是什么?更重要的是:为什么以及何时应该使用一个而不是另一个?这只是个人品味/偏好的问题吗?

尽我所能,我没有找到其他明确解决我问题的帖子。在关于多态性和/或类型擦除的实际使用的许多问题中,以下问题似乎是最接近的,或者看起来是这样,但它也没有真正解决我的问题:

C++ -&CRTP .类型擦除与多态性

请注意,我非常了解这两种技术。为此,我在下面提供了一个简单、自包含的工作示例,如果觉得没有必要,我很乐意将其删除。但是,该示例应阐明这两种技术对我的问题的含义。我对讨论命名法不感兴趣。另外,我知道编译和运行时多态性之间的区别,尽管我认为这与问题无关。请注意,我对性能差异的兴趣较小,如果有的话。但是,如果基于性能有一个惊人的论点,我会很好奇地阅读它。特别是,我想听听具体的例子(没有代码),这些例子实际上只适用于两种方法之一。

看看下面的例子,一个主要的区别是内存管理,对于多态性,它仍然在用户端,而对于类型擦除,它被整齐地隐藏起来,需要一些引用计数(或提升)。话虽如此,根据使用场景,可以通过使用带有向量的智能指针来改善多态示例的情况 (?),尽管对于任意情况,这很可能是不切实际的 (?)。另一个可能有利于类型擦除的方面可能是通用接口的独立性,但为什么这实际上是一个优势(?)。

下面给出的代码是用MS VisualStudio 2008测试(编译和运行)的,只需将以下所有代码块放入单个源文件中即可。它也应该在 Linux 上使用 gcc 编译,或者我希望/假设,因为我认为没有理由不 (?) :-)为了清楚起见,我在这里拆分/划分了代码。

这些头文件应该足够了,对吧(?)。

#include <iostream>
#include <vector>
#include <string>

简单的引用计数,以避免提升(或其他)依赖性。此类仅在下面的类型擦除示例中使用。

class RefCount
{
RefCount( const RefCount& );
RefCount& operator= ( const RefCount& );
int m_refCount;
public:
RefCount() : m_refCount(1) {}
void Increment() { ++m_refCount; }
int Decrement() { return --m_refCount; }
};

这是简单的类型擦除示例/插图。它部分复制和修改自以下文章。主要是我试图使它尽可能清晰和直接。 http://www.cplusplus.com/articles/oz18T05o/

class Object {
struct ObjectInterface {
virtual ~ObjectInterface() {}
virtual std::string GetSomeText() const = 0;
};
template< typename T > struct ObjectModel : ObjectInterface {
ObjectModel( const T& t ) : m_object( t ) {}
virtual ~ObjectModel() {}
virtual std::string GetSomeText() const { return m_object.GetSomeText(); }
T m_object;
};
void DecrementRefCount() {
if( mp_refCount->Decrement()==0 ) {
delete mp_refCount; delete mp_objectInterface;
mp_refCount = NULL; mp_objectInterface = NULL;
}
}
Object& operator= ( const Object& );
ObjectInterface *mp_objectInterface;
RefCount *mp_refCount;
public:
template< typename T > Object( const T& obj )
: mp_objectInterface( new ObjectModel<T>( obj ) ), mp_refCount( new RefCount ) {}
~Object() { DecrementRefCount(); }
std::string GetSomeText() const { return mp_objectInterface->GetSomeText(); }
Object( const Object &obj ) {
obj.mp_refCount->Increment(); mp_refCount = obj.mp_refCount;
mp_objectInterface = obj.mp_objectInterface;
}
};
struct MyObject1 { std::string GetSomeText() const { return "MyObject1"; } };
struct MyObject2 { std::string GetSomeText() const { return "MyObject2"; } };
void UseTypeErasure() {
typedef std::vector<Object> ObjVect;
typedef ObjVect::const_iterator ObjVectIter;
ObjVect objVect;
objVect.push_back( Object( MyObject1() ) );
objVect.push_back( Object( MyObject2() ) );
for( ObjVectIter iter = objVect.begin(); iter != objVect.end(); ++iter )
std::cout << iter->GetSomeText();
}

就我而言,这似乎使用多态性实现几乎相同,或者可能不是(?

struct ObjectInterface {
virtual ~ObjectInterface() {}
virtual std::string GetSomeText() const = 0;
};
struct MyObject3 : public ObjectInterface {
std::string GetSomeText() const { return "MyObject3"; } };
struct MyObject4 : public ObjectInterface {
std::string GetSomeText() const { return "MyObject4"; } };
void UsePolymorphism() {
typedef std::vector<ObjectInterface*> ObjVect;
typedef ObjVect::const_iterator ObjVectIter;
ObjVect objVect;
objVect.push_back( new MyObject3 );
objVect.push_back( new MyObject4 );
for( ObjVectIter iter = objVect.begin(); iter != objVect.end(); ++iter )
std::cout << (*iter)->GetSomeText();
for( ObjVectIter iter = objVect.begin(); iter != objVect.end(); ++iter )
delete *iter;
}

最后用于同时测试上述所有内容。

int main() {
UseTypeErasure();
UsePolymorphism();
return(0);
}

C++样式的基于虚拟方法的多态性:

  1. 您必须使用类来保存数据。
  2. 每个类都必须考虑到您特定的多态性。
  3. 每个类都有一个共同的二进制级依赖项,这限制了 编译器创建每个类的实例。
  4. 要抽象的数据必须显式描述描述以下接口: 您的需求。

C++样式模板的擦除(使用基于虚拟方法的多态性进行擦除):

  1. 您必须使用模板来谈论您的数据。
  2. 您正在处理的每个数据块可能与其他选项完全无关。
  3. 类型擦除工作在公共头文件中完成,这会占用编译时间。
  4. 擦除的每个类型都有自己的模板实例化,这可能会使二进制大小膨胀。
  5. 您正在提取的数据不需要直接依赖于您的需求。

现在,哪个更好? 好吧,这取决于上述事情在您的特定情况下是好是坏。

作为一个明确的例子,std::function<...>使用类型擦除,它允许它接受函数指针、函数引用、在编译时生成类型的一大堆基于模板的函数的输出、具有运算符 () 的函子的 myraids 和 lambda。 所有这些类型彼此无关。 而且因为它们与virtual operator()无关,当它们在std::function上下文之外使用时,它们所代表的抽象可以被编译掉。 如果没有键入擦除,您将无法执行此操作,而且您可能不想这样做。

另一方面,仅仅因为一个类有一个名为DoFoo的方法,并不意味着它们都做同样的事情。 使用多态性,它不仅仅是您调用的任何DoFoo,而是来自特定接口的DoFoo

至于你的示例代码...您的GetSomeText应该在多态情况下virtual ... override

没有必要仅仅因为您使用的是类型擦除而引用计数。 没有必要仅仅因为您使用的是多态性而不使用引用计数。

您的Object可以包装T*,就像您在另一种情况下存储vector原始指针的方式一样,手动销毁其内容(相当于必须调用 delete)。 您的Object可以包裹std::shared_ptr<T>,而在另一种情况下,您可能会vectorstd::shared_ptr<T>。 你的Object可以包含一个std::unique_ptr<T>,相当于在另一种情况下有一个std::unique_ptr<T>向量。 你的ObjectObjectModel可以从T中提取复制构造函数和赋值运算符,并将它们暴露给Object,允许你的Object具有完整的值语义,这对应于多态情况下Tvector

这里有一种观点:这个问题似乎是在问人们应该如何在后期绑定("运行时多态性")和早期绑定("编译时多态性")之间进行选择。

正如 KerrekSB 在他的评论中指出的那样,你可以对后期绑定做一些事情,而早期绑定是不现实的。 策略模式(解码网络 I/O)或抽象工厂模式(运行时选择的类工厂)的许多用途都属于此类别。

如果这两种方法都是可行的,那么选择就是一个涉及权衡的问题。 在C++应用程序中,我在早期绑定和后期绑定之间看到的主要权衡是实现可维护性、二进制大小和性能。

至少有一些人认为任何形状或形式的C++模板都是无法理解的。 或者可能对模板进行其他一些不那么引人注目的保留。 C++模板有许多小陷阱("我什么时候需要使用'typename'和'template'关键字?")和不明显的技巧(想到SFINAE)。

另一个权衡是优化。 当您提前绑定时,您可以为编译器提供有关程序的更多信息,因此它可以(可能)更好地进行优化。 当你绑定得很晚时,编译器(可能)不会提前知道那么多信息——其中一些信息可能在其他编译单元中,因此优化器不能做那么多。

另一个权衡是程序大小。 至少在C++,使用"编译时多态性"有时会膨胀二进制大小,因为编译器为每个使用的专用化创建、优化和发出不同的代码。 相反,绑定后期时,只有一个代码路径。

比较在不同背景下进行的相同权衡很有趣。 以Web应用程序为例,其中使用(某种类型的)多态性来处理浏览器之间的差异,并可能用于国际化(i18n)/本地化。 现在,手写的JavaScript Web应用程序可能会在这里使用相当于后期绑定的方法,方法是在运行时检测功能以找出要做什么。 像jQuery这样的库采取了这种策略。

另一种方法是为每个可能的浏览器/i18n 可能性编写不同的代码。 虽然这听起来很荒谬,但远非闻所未闻。Google Web Toolkit 使用这种方法。 GWT有其"延迟绑定"机制,用于将编译器的输出专用于不同的浏览器和不同的本地化。 GWT的"延迟绑定"机制使用早期绑定:GWT Java-to-JavaScript编译器找出可能需要多态性的所有可能方式,并为每个方式吐出一个完全不同的"二进制"。

权衡是相似的。 围绕如何使用延迟绑定扩展 GWT 可能会令人头疼; 在编译时拥有知识允许GWT的编译器分别优化每个专业化,可能会产生更好的性能,并且每个专业化的大小更小;由于所有预编译的专用化,整个 GWT 应用程序的大小最终可能是可比 jQuery 应用程序的许多倍。

这里没有人提到(?)的运行时泛型的一个好处是,生成并注入到正在运行的应用程序中的代码可以使用该应用程序中其他所有内容已经在使用的相同ListHashmap / Dictionary等。你为什么要这样做,是另一个问题。