详解C++值多态中的传统多态与类型擦除

时间:2021-05-19

引言

我有一个显示屏模块:

模块上有一个128*64的单色显示屏,一个单片机(B)控制它显示的内容。单片机的I²C总线通过四边上的排针排母连接到其他单片机(A)上,A给B发送指令,B绘图。

B可以向屏幕逐字节发送显示数据,但是不能读取,所以程序中必须设置显存。一帧需要1024字节,但是单片机B只有512字节内存,其中只有256字节可以分配为显存。解决这个问题的方法是在B的程序中把显示屏分成4个区域,保存所有要绘制的图形的信息,每次在256字节中绘制1/4屏,分批绘制、发送。

简而言之,我需要维护多个类型的数据。稍微具体点,我要把它们放在一个类似于数组的结构中,然后遍历数组,绘制每一个元素。

不同的图形,用相同的方式来对待,这是继承与多态的最佳实践。我可以设计一个Shape类,定义virtual void draw() const = 0;,每收到一个指令就new一个Line、Rectangle等类型的对象出来,放入std::vector<Shape*>中,在遍历中对每个Shape*指针调用->draw()。

但是对不起,今天我跟new杠上了。单片机程序注重运行时效率,除了初始化以外,没事最好别瞎new。每个指令new一下,清屏指令一起delete,恐怕不大合适吧!

我需要值多态,一种不需要指针或引用,通过对象本身就可以表现出的多态。

背景

我得先介绍一点知识,一些刚上完C++入门课程的新手不可能了解的,却是深入C++底层和体会C++设计思想所必需的知识,正因为有了这些知识我才能想出“值多态”然后把它实现出来。如果你对这些知识了如指掌,或是已经迫不及待地想知道我是怎么实现值多态的,可以直接拉到下面实现一节。

多态

多态,是指为不同类型的实体提供统一的接口,或用相同的符号来代表多种不同的类型。C++里有很多种多态:

先说编译期多态。非模板函数重载是一种多态,用相同的名字调用的函数可能是不同的,取决于参数类型。如果你需要一个函数名字能够多处理一种类型,你就得多写一个重载,这样的多态是封闭式多态。好在新的重载不用和原有的函数写在一起。

模板是一种开放式多态——适配一种新的类型是对那个新的类型提要求,而模板是不改动的。相比于后文中的运行时多态,C++鼓励模板,“STL”的“T”就足以说明这一点。瞧,标准库的算法都是模板函数,而不是像《设计模式》中那样让各种迭代器继承自Iterator<T>基类。

模板多态的弊端在于模板参数T类型的对象必须是即取即用的,函数返回以后就没了,不能持久地维护。如果需要,那得使用类型擦除。

运行时多态大致可以分为继承一套和类型擦除一套,它们都是开放式多态。继承、虚函数这些东西,又称OOP,我在本文标题中称之为“传统多态”,我认为是没有异议的。面向对象编程语言的四个特点,抽象、封装、继承、多态,大家都熟记于心(有时候少了抽象),以致于有些人说到多态就是虚函数。的确,很多程序中广泛使用继承,但既然function/bind已经“救赎”了,那就要学它们、用它们,还要学它们的设计和思想,在合理范围内取代继承这一套工具,因为它们的确有很多问题——“蝙蝠是鸟也是兽,水上飞机能飞也能游”,多重继承、虚继承、各种overhead……连Lippman都看不下去了:

继承的另一个主要问题,也是本文主要针对的问题,是多态需要一层间接,即指针或引用。仍然以迭代器为例,如果begin方法返回一个指向新new出来的Iterator<T>对象的指针,客户在使用完迭代器后还得记得把它delete掉,或者用std::lock_guard一般的RAII类来负责迭代器的delete工作,总之需要多操一份心。

因此在现代C++中,基于类型擦除的多态逐渐占据了上风。类型擦除是用一个类来包装多种具有相似接口的对象,在功能上属于多态包装器,如std::function就是一个多态函数包装器,原计划在C++20中标准化的polymorphic_value是一个多态值包装器——与我的意图很接近。后面会详细讨论这些。

私以为,这两种运行时多态,只有语义上的不同。

虚函数的实现

《深度探索C++对象模型》中最吸引人的部分莫过于虚函数的实现了。尽管C++标准对于虚函数的实现方法没有作出任何规定和假设,但是用指向虚函数表(vtable)的指针来实现多态是这个小圈子里心照不宣的秘密。

假设有两个类:

class Base{public: Base(int i) : i(i) { } virtual ~Base() { } virtual void func() const { std::cout << "Base: " << i << std::endl; }private: int i;};class Derived : public Base{public: Derived(int i, int j) : Base(i), j(j) { } virtual ~Derived() { } virtual void func() const override { std::cout << "Derived: " << j << std::endl; }private: int j;};

这两个类的实例在内存中的布局可能是这样:

如果你把一个Derived实例的指针赋给Base*的变量,然后调用func(),程序会把这个指针指向的对象当作Base的实例,解引用它的第二格,在vtable中下标为2的位置找到func的函数指针,然后把this指针传入调用它。虽然被当成Base实例,但该对象的vtable实际指向的是Derived类的vtable,因此被调用的函数是Derived::func,基于继承的多态就是这样实现的。

而如果你把一个Derived实例赋给Base变量,只有i会被拷贝,vtable会初始化成Base的vtable,j则被丢掉了。调用它的func,Base::func会执行,而且很可能是直接而非通过函数指针调用的。

这种实现可以推及到继承树(强调“树”,即单继承)的情况。至于多重继承中的指针偏移和虚继承中的子对象指针,过于复杂,我就不介绍了。

vtable指针不拷贝是虚函数指针语义的罪魁祸首,不过这也是不得已而为之的,拷贝vtable指针会引来更大的麻烦:如果Base实例中有Derived虚函数表指针,调用func就会访问该对象的第三格,但第三格是无效的内存空间。相比之下,把维护指针的任务交给程序员是更好的选择。

类型擦除

不拷贝vtable就不能实现值语义,拷贝vtable又会有访问的问题,那么是什么原因导致了这个问题呢?是因为Base和Derived实例的大小不同。实现了类型擦除的类也使用了与vtable相同或类似的多态实现,而作为一个而非多个类,类型擦除类的大小是确定的,因此可以拷贝vtable或其类似物,也就可以实现值语义。C++想方设法让类类型表现得像内置类型一样,这是类型擦除更深刻的意义。

类型擦除,顾名思义,就是把对象的类型擦除掉,让你在不知道它的类型的情况下对它执行一些操作。举个例子,std::function有一个带约束的模板构造函数,你可以用它来包装任何参数类型匹配的可调用对象,在构造函数结束后,不光是你,std::function也不知道它包装的是什么类型的实例,但是operator()就可以调用那个可调用对象。我在一篇文章中剖析过std::function的实现,当然它还有很多种实现方法,其他类型擦除类的实现也都大同小异,它们都包含两个要素:可能带约束的模板构造函数,以及函数指针,无论是可见的(直接维护)还是不可见的(使用继承)。

为了获得更真切的感受,我们来写一个最简单的类型擦除:

class MyFunction{private: class FunctorWrapper { public: virtual ~FunctorWrapper() = default; virtual FunctorWrapper* clone() const = 0; virtual void call() const = 0; }; template<typename T> class ConcreteWrapper : public FunctorWrapper { public: ConcreteWrapper(const T& functor) : functor(functor) { } virtual ~ConcreteWrapper() override = default; virtual ConcreteWrapper* clone() const { return new ConcreteWrapper(*this); } virtual void call() const override { functor(); } private: T functor; };public: MyFunction() = default; template<typename T> MyFunction(T&& functor) : ptr(new ConcreteWrapper<T>(functor)) { } MyFunction(const MyFunction& other) : ptr(other.ptr->clone()) { } MyFunction& operator=(const MyFunction& other) { if (this != &other) { delete ptr; ptr = other.ptr->clone(); } return *this; } MyFunction(MyFunction&& other) noexcept : ptr(std::exchange(other.ptr, nullptr)) { } MyFunction& operator=(MyFunction&& other) noexcept { if (this != &other) { delete ptr; ptr = std::exchange(other.ptr, nullptr); } return *this; } ~MyFunction() { delete ptr; } void operator()() const { if (ptr) ptr->call(); } FunctorWrapper* ptr = nullptr;};

MyFunction类中维护一个FunctorWrapper指针,它指向一个ConcreteWrapper<T>实例,调用虚函数来实现多态。虚函数有析构、clone和call三个,它们分别用于MyFunction的析构、拷贝和函数调用。

类型擦除类的实现中总会保留一点类型信息。MyFunction类中关于T的类型信息表现在FunctorWrapper的vtable中,本质上是函数指针。类型擦除类也可以跳过继承的工具,直接使用函数指针实现多态。无论使用哪种实现,类型擦除类总是可以被拷贝或移动或两者兼有,多态性可以由对象本身体现。

不是每一滴牛奶都叫特仑苏,也不是每一个类的实例都能被MyFunction包装。MyFunction对T的要求是可以拷贝、可以用operator()() const调用,这些称为类型T的“affordance”。说到affordance,普通的模板函数也对模板类型有affordance,比如std::sort要求迭代器可以随机存取,否则编译器会给你一堆冗长的错误信息。C++20引入了concept和requires子句,对编译器和程序员都是有好处的。

每个类型擦除类的affordance都在写成的时候确定下来。affordance被要求的方式不是继承某个基类,而只看你这个类是否有相应的方法,就像Python那样,只要函数接口匹配上就可以了。这种类型识别方式称为“duck typing”,来源于“duck test”,意思是“If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck”。

类型擦除类要求的affordance通常都是一元的,也就是成员函数的参数中不含T,比如对于包装整数的类,你可以要求T + 42,但是无法要求T + U,一个类型擦除类的实例是不知道另一个属于同一个类但是构造自不同类型对象的实例的信息的。我觉得这条规则有一个例外,operator==是可以想办法支持的。

MyFunction类虽然实现了值多态,但还是使用了new和delete语句。如果可调用对象只是一个简单的函数指针,是否有必要在堆上开辟空间?

SBO

小的对象保存在类实例中,大的对象交给堆并在实例中维护指针,这种技巧称为小缓冲优化(Small Buffer Optimization, SBO)。大多数类型擦除类都应该使用SBO以节省内存并提升效率,问题在于SBO与继承不共存,维护每个实例中的一个vtable或几个函数指针是件挺麻烦的事,还会拖慢编译速度。

但是在内存和性能面前,这点工作量能叫事吗?

class MyFunction{private: static constexpr std::size_t size = 16; static_assert(size >= sizeof(void*), ""); struct Data { Data() = default; char dont_use[size]; } data; template<typename T> static void functorConstruct(Data& dst, T&& src) { using U = typename std::decay<T>::type; if (sizeof(U) <= size) new ((U*)&dst) U(std::forward<U>(src)); else *(U**)&dst = new U(std::forward<U>(src)); } template<typename T> static void functorDestructor(Data& data) { using U = typename std::decay<T>::type; if (sizeof(U) <= size) ((U*)&data)->~U(); else delete *(U**)&data; } template<typename T> static void functorCopyCtor(Data& dst, const Data& src) { using U = typename std::decay<T>::type; if (sizeof(U) <= size) new ((U*)&dst) U(*(const U*)&src); else *(U**)&dst = new U(**(const U**)&src); } template<typename T> static void functorMoveCtor(Data& dst, Data& src) { using U = typename std::decay<T>::type; if (sizeof(U) <= size) new ((U*)&dst) U(*(const U*)&src); else *(U**)&dst = std::exchange(*(U**)&src, nullptr); } template<typename T> static void functorInvoke(const Data& data) { using U = typename std::decay<T>::type; if (sizeof(U) <= size) (*(U*)&data)(); else (**(U**)&data)(); } template<typename T> static void (*const vtables[4])(); void (*const* vtable)() = nullptr;public: MyFunction() = default; template<typename T> MyFunction(T&& obj) : vtable(vtables<T>) { functorConstruct(data, std::forward<T>(obj)); } MyFunction(const MyFunction& other) : vtable(other.vtable) { if (vtable) ((void (*)(Data&, const Data&))vtable[1])(this->data, other.data); } MyFunction& operator=(const MyFunction& other) { this->~MyFunction(); vtable = other.vtable; new (this) MyFunction(other); return *this; } MyFunction(MyFunction&& other) noexcept : vtable(std::exchange(other.vtable, nullptr)) { if (vtable) ((void (*)(Data&, Data&))vtable[2])(this->data, other.data); } MyFunction& operator=(MyFunction&& other) noexcept { this->~MyFunction(); new (this) MyFunction(std::move(other)); return *this; } ~MyFunction() { if (vtable) ((void (*)(Data&))vtable[0])(data); } void operator()() const { if (vtable) ((void (*)(const Data&))vtable[3])(this->data); }};template<typename T>void (*const MyFunction::vtables[4])() ={ (void (*)())MyFunction::functorDestructor<T>, (void (*)())MyFunction::functorCopyCtor<T>, (void (*)())MyFunction::functorMoveCtor<T>, (void (*)())MyFunction::functorInvoke<T>,};

(如果你能完全看懂这段代码,说明你的C语言功底非常扎实!如果看不懂,实现中有一个可读性更好的版本。)

现在的MyFunction类就充当了原来的FunctorWrapper,用vtable实现多态性。每当MyFunction实例被赋以一个可调用对象时,vtable被初始化为指向vtables<T>,用于T类型的vtable(这里用到了C++14的变量模板)的指针。vtable中包含4个函数指针,分别进行T实例的析构、拷贝、移动和调用。

以析构函数functorDestructor<T>为例,U是T经std::decay后的类型,用于处理函数转换为函数指针等情况。MyFunction类中定义了size字节的空间data,用于存放小的可调用对象或大的可调用对象的指针之一,functorDestructor<T>知道具体是哪种情况:当sizeof(U) <= size时,data存放可调用对象本身,把data解释为U并调用其析构函数~U();当sizeof(U) > size时,data存放指针,把data解释为U*并delete它。其他函数原理相同,注意new ((U*)&dst) U(std::forward<U>(src));是定位new语句。

除了参数为T的构造函数以外,MyFunction的其他成员函数都通过vtable来调用T的方法,因为它们都不知道T是什么。在拷贝时,与FunctorWrapper子类的实例被裁剪不同,MyFunction的vtable一起被拷贝,依然实现了值多态——还避免了一部分new,符合我的意图。但是这还没有结束。

polymorphic_value

polymorphic_value是一个实现了值多态的类模板,原定于在C++20中标准化,但是C++20没有收录,预计会进入C++23标准(那时候我还写不写C++都不一定呢)。到目前为止,我对polymorphic_value源码的理解还处于一知半解的状态,只能简要地介绍一下。

polymorphic_value的模板参数T是一个类类型,任何T、T的子类U、polymorphic_value<U>的实例都可以用来构造polymorphic_value对象。polymorphic_value对象可以拷贝,其中的值也被拷贝,并且可以传播const(通过const polymorphic_value得到的是const T&),这使它区别于unique_ptr和shared_ptr;polymorphic_value又与类型擦除不同,因为它尊重继承,没有使用duck typing。

然而,一个从2017年开始的,添加SBO的issue,一直没有人回复——这反映出polymorphic_value的实现并不简单——目前的版本中,无论对象的大小,polymorphic_value总会new一个control_block出来;对于从一个不同类型的polymorphic_value构造出的实例,还会出现指针套指针的情况(delegating_control_block),对运行时性能有很大影响。个人认为,SBO可以把两个问题一并解决,这也侧面反映出继承工具存在的问题。

接口

我要实现3个类:Shape,值多态的基类;Line,包含4个整数作为坐标,用于演示SBO的第一种情形;Rectangle,包含4个整数和一个bool值,后者指示矩形是否填充,用于演示第二种情形。它们的行为要像STL中的类一样,有默认构造函数、析构函数、拷贝、移动构造和赋值、swap,还要支持operator==和draw。operator==在两参数类型不同时返回false,相同时比较其内容;draw是一个多态的函数,在演示程序中输出图形的信息。

一个简单的实现是用std::function加上适配器:

#include <iostream>#include <functional>#include <new>struct Point{ int x; int y;};std::ostream& operator<<(std::ostream& os, const Point& point){ os << point.x << ", " << point.y; return os;}class Shape{private: template<typename T> class Adapter { public: Adapter(const T& shape) : shape(shape) { } void operator()() const { shape.draw(); } private: T shape; };public: template<typename T> Shape(const T& shape) : function(Adapter<T>(shape)) { } void draw() const { function(); }private: std::function<void()> function;};class Line{public: Line() { } Line(Point p0, Point p1) : endpoint{ p0, p1 } { } Line(const Line&) = default; Line& operator=(const Line&) = default; void draw() const { std::cout << "Drawing a line: " << endpoint[0] << "; " << endpoint[1] << std::endl; }private: Point endpoint[2];};class Rectangle{public: Rectangle() { } Rectangle(Point v0, Point v1, bool filled) : vertex{ v0, v1 }, filled(filled) { } Rectangle(const Rectangle&) = default; Rectangle& operator=(const Rectangle&) = default; void draw() const { std::cout << "Drawing a rectangle: " << vertex[0] << "; " << vertex[1] << "; " << (filled ? "filled" : "blank") << std::endl; }private: Point vertex[2]; bool filled;};

下面的实现与这段代码的思路是一样的,但是更加“纯粹”。

实现

#include <iostream>#include <new>#include <type_traits>#include <utility>struct Point{ int x; int y; bool operator==(const Point& rhs) const { return this->x == rhs.x && this->y == rhs.y; }};std::ostream& operator<<(std::ostream& os, const Point& point){ os << point.x << ", " << point.y; return os;}class Shape{protected: using FuncPtr = void (*)(); using FuncPtrCopy = void (*)(Shape*, const Shape*); static constexpr std::size_t funcIndexCopy = 0; using FuncPtrDestruct = void (*)(Shape*); static constexpr std::size_t funcIndexDestruct = 1; using FuncPtrCompare = bool (*)(const Shape*, const Shape*); static constexpr std::size_t funcIndexCompare = 2; using FuncPtrDraw = void (*)(const Shape*); static constexpr std::size_t funcIndexDraw = 3; static constexpr std::size_t funcIndexTotal = 4; class ShapeData { public: static constexpr std::size_t size = 16; template<typename T> struct IsLocal : std::integral_constant<bool, (sizeof(T) <= size) && std::is_trivially_copyable<T>::value> { }; private: char placeholder[size]; template<typename T, typename U = void> using EnableIfLocal = typename std::enable_if<IsLocal<T>::value, U>::type; template<typename T, typename U = void> using EnableIfHeap = typename std::enable_if<!IsLocal<T>::value, U>::type; public: ShapeData() { } template<typename T, typename... Args> EnableIfLocal<T> construct(Args&& ... args) { new (reinterpret_cast<T*>(this)) T(std::forward<Args>(args)...); } template<typename T, typename... Args> EnableIfHeap<T> construct(Args&& ... args) { this->access<T*>() = new T(std::forward<Args>(args)...); } template<typename T> EnableIfLocal<T> destruct() { this->access<T>().~T(); } template<typename T> EnableIfHeap<T> destruct() { delete this->access<T*>(); } template<typename T> EnableIfLocal<T, T&> access() { return reinterpret_cast<T&>(*this); } template<typename T> EnableIfHeap<T, T&> access() { return *this->access<T*>(); } template<typename T> const T& access() const { return const_cast<ShapeData*>(this)->access<T>(); } }; Shape(const FuncPtr* vtable) : vtable(vtable) { }public: Shape() { } Shape(const Shape& other) : vtable(other.vtable) { if (vtable) reinterpret_cast<FuncPtrCopy>(vtable[funcIndexCopy])(this, &other); } Shape& operator=(const Shape& other) { if (this != &other) { if (vtable) reinterpret_cast<FuncPtrDestruct>(vtable[funcIndexDestruct]) (this); vtable = other.vtable; if (vtable) reinterpret_cast<FuncPtrCopy>(vtable[funcIndexCopy]) (this, &other); } return *this; } Shape(Shape&& other) noexcept : vtable(other.vtable), data(other.data) { other.vtable = nullptr; } Shape& operator=(Shape&& other) noexcept { swap(other); return *this; } ~Shape() { if (vtable) reinterpret_cast<FuncPtrDestruct>(vtable[funcIndexDestruct])(this); } void swap(Shape& other) noexcept { using std::swap; swap(this->vtable, other.vtable); swap(this->data, other.data); } bool operator==(const Shape& rhs) const { if (this->vtable == nullptr || this->vtable != rhs.vtable) return false; return reinterpret_cast<FuncPtrCompare>(vtable[funcIndexCompare]) (this, &rhs); } bool operator!=(const Shape& rhs) const { return !(*this == rhs); } void draw() const { if (vtable) reinterpret_cast<FuncPtrDraw>(vtable[funcIndexDraw])(this); }protected: const FuncPtr* vtable = nullptr; ShapeData data; template<typename T> static void defaultCopy(Shape* dst, const Shape* src) { dst->data.construct<T>(src->data.access<T>()); } template<typename T> static void defaultDestruct(Shape* shape) { shape->data.destruct<T>(); } template<typename T> static bool defaultCompare(const Shape* lhs, const Shape* rhs) { return lhs->data.access<T>() == rhs->data.access<T>(); }};namespace std{ void swap(Shape& lhs, Shape& rhs) noexcept { lhs.swap(rhs); }}class Line : public Shape{private: struct LineData { Point endpoint[2]; LineData() { } LineData(Point p0, Point p1) : endpoint{ p0, p1 } { } bool operator==(const LineData& rhs) const { return this->endpoint[0] == rhs.endpoint[0] && this->endpoint[1] == rhs.endpoint[1]; } bool operator!=(const LineData& rhs) const { return !(*this == rhs); } }; static_assert(ShapeData::IsLocal<LineData>::value, "");public: Line() : Shape(lineVtable) { data.construct<LineData>(); } Line(Point p0, Point p1) : Shape(lineVtable) { data.construct<LineData>(p0, p1); } Line(const Line&) = default; Line& operator=(const Line&) = default; Line(Line&&) = default; Line& operator=(Line&&) = default; ~Line() = default;private: static const FuncPtr lineVtable[funcIndexTotal]; static ShapeData& accessData(Shape* shape) { return static_cast<Line*>(shape)->data; } static const ShapeData& accessData(const Shape* shape) { return accessData(const_cast<Shape*>(shape)); } static void lineDraw(const Shape* line) { auto& data = static_cast<const Line*>(line)->data.access<LineData>(); std::cout << "Drawing a line: " << data.endpoint[0] << "; " << data.endpoint[1] << std::endl; }};const Shape::FuncPtr Line::lineVtable[] ={ reinterpret_cast<Shape::FuncPtr>(Shape::defaultCopy<LineData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultDestruct<LineData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultCompare<LineData>), reinterpret_cast<Shape::FuncPtr>(Line::lineDraw),};class Rectangle : public Shape{private: struct RectangleData { Point vertex[2]; bool filled; RectangleData() { } RectangleData(Point v0, Point v1, bool filled) : vertex{ v0, v1 }, filled(filled) { } bool operator==(const RectangleData& rhs) const { return this->vertex[0] == rhs.vertex[0] && this->vertex[1] == rhs.vertex[1] && this->filled == rhs.filled; } bool operator!=(const RectangleData& rhs) const { return !(*this == rhs); } }; static_assert(!ShapeData::IsLocal<RectangleData>::value, "");public: Rectangle() : Shape(rectangleVtable) { data.construct<RectangleData>(); } Rectangle(Point v0, Point v1, bool filled) : Shape(rectangleVtable) { data.construct<RectangleData>(v0, v1, filled); } Rectangle(const Rectangle&) = default; Rectangle& operator=(const Rectangle&) = default; Rectangle(Rectangle&&) = default; Rectangle& operator=(Rectangle&&) = default; ~Rectangle() = default;private: static const FuncPtr rectangleVtable[funcIndexTotal]; static ShapeData& accessData(Shape* shape) { return static_cast<Rectangle*>(shape)->data; } static const ShapeData& accessData(const Shape* shape) { return accessData(const_cast<Shape*>(shape)); } static void rectangleDraw(const Shape* rect) { auto& data = accessData(rect).access<RectangleData>(); std::cout << "Drawing a rectangle: " << data.vertex[0] << "; " << data.vertex[1] << "; " << (data.filled ? "filled" : "blank") << std::endl; }};const Shape::FuncPtr Rectangle::rectangleVtable[] ={ reinterpret_cast<Shape::FuncPtr>(Shape::defaultCopy<RectangleData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultDestruct<RectangleData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultCompare<RectangleData>), reinterpret_cast<Shape::FuncPtr>(Rectangle::rectangleDraw),};template<typename T>Shape test(const T& s0){ s0.draw(); T s1 = s0; s1.draw(); T s2; s2 = s1; s2.draw(); Shape s3 = s0; s3.draw(); Shape s4; s4 = s0; s4.draw(); Shape s5 = std::move(s0); s5.draw(); Shape s6; s6 = std::move(s5); s6.draw(); return s6;}int main(){ Line line({ 1, 2 }, { 3, 4 }); auto l2 = test(line); Rectangle rect({ 5, 6 }, { 7, 8 }, true); auto r2 = test(rect); std::swap(l2, r2); l2.draw(); r2.draw();}

对象模型

之前提到,传统多态与类型擦除的本质是相同的,都使用了函数指针,放在vtable或对象中。在Shape的继承体系中,Line和Rectangle都是具体的类,写两个vtable非常容易,所以我采用了vtable的实现。

Line和Rectangle继承自Shape,为了在值拷贝时不被裁剪,三个类的内存布局必须相同,也就是说Line和Rectangle不能定义新的数据成员。Shape预留了16字节空间供子类使用,存储Line的数据或指向Rectangle数据的指针,后者是我特意安排用于演示的(两个static_assert只是为了确保演示到位,并非我对两个子类的内存布局有什么假设)。

SBO类型

ShapeData是Shape中的数据空间,储存值或指针由ShapeData和数据类型共同决定,如果把决定的任务交给具体的数据类型,ShapeData是很难修改大小的,因此我把ShapeData设计为一个带有模板函数的类型,以数据类型为模板参数T,提供构造、析构、访问的操作,各有两个版本,具体调用哪个可以交给编译器来决定,从而提高程序的可维护性。

std::function同样使用SBO,在阅读其源码时我发现,两种情形的分界线可以不只是数据类型的大小,还有is_trivially_copyable等,这样做的好处是移动和swap可以使用接近默认的行为。

class ShapeData{public: static constexpr std::size_t size = 16; static_assert(size >= sizeof(void*), ""); template<typename T> struct IsLocal : std::integral_constant<bool, (sizeof(T) <= size) && std::is_trivially_copyable<T>::value> { };private: char placeholder[size]; template<typename T, typename U = void> using EnableIfLocal = typename std::enable_if<IsLocal<T>::value, U>::type; template<typename T, typename U = void> using EnableIfHeap = typename std::enable_if<!IsLocal<T>::value, U>::type;public: ShapeData() { } template<typename T, typename... Args> EnableIfLocal<T> construct(Args&& ... args) { new (reinterpret_cast<T*>(this)) T(std::forward<Args>(args)...); } template<typename T, typename... Args> EnableIfHeap<T> construct(Args&& ... args) { this->access<T*>() = new T(std::forward<Args>(args)...); } template<typename T> EnableIfLocal<T> destruct() { this->access<T>().~T(); } template<typename T> EnableIfHeap<T> destruct() { delete this->access<T*>(); } template<typename T> EnableIfLocal<T, T&> access() { return reinterpret_cast<T&>(*this); } template<typename T> EnableIfHeap<T, T&> access() { return *this->access<T*>(); } template<typename T> const T& access() const { return const_cast<ShapeData*>(this)->access<T>(); }};

EnableIfLocal和EnableIfHeap用了SFNIAE的技巧(这里有个类似的例子)。我习惯用SFINAE,如果你愿意的话也可以用tag dispatch。

虚函数表

C99标准6.3.2.3 clause 8:

A pointer to a function of one type may be converted to a pointer to a function of another type and back again; the result shall compare equal to the original pointer. If a converted pointer is used to call a function whose type is not compatible with the pointed-to type, the behavior is undefined.

言下之意是所有函数指针大小相同。C++标准没有这样的规定,但是我作出这种假设(成员函数指针不包含在内)。据我所知,在所有的主流平台中,这种假设都是成立的。于是,我定义类型using FuncPtr = void (*)();,以FuncPtr数组为vtable,可以存放任意类型的函数指针。

vtable中存放4个函数指针,它们分别负责对象的拷贝(没有移动)、析构、比较(operator==)和draw。函数指针的类型各不相同,但是与子类无关,可以在Shape中定义,简化后面的代码。每个函数指针的下标显然不能用0、1、2等magic number,也在Shape中定义了常量,方便维护。与default关键字类似地,Shape提供了前三个函数的默认实现,绝大多数情况下不用另写。

class Shape{protected: using FuncPtr = void (*)(); using FuncPtrCopy = void (*)(Shape*, const Shape*); static constexpr std::size_t funcIndexCopy = 0; using FuncPtrDestruct = void (*)(Shape*); static constexpr std::size_t funcIndexDestruct = 1; using FuncPtrCompare = bool (*)(const Shape*, const Shape*); static constexpr std::size_t funcIndexCompare = 2; using FuncPtrDraw = void (*)(const Shape*); static constexpr std::size_t funcIndexDraw = 3; static constexpr std::size_t funcIndexTotal = 4; // ...public: // ...protected: const FuncPtr* vtable = nullptr; ShapeData data; template<typename T> static void defaultCopy(Shape* dst, const Shape* src) { dst->data.construct<T>(src->data.access<T>()); } template<typename T> static void defaultDestruct(Shape* shape) { shape->data.destruct<T>(); } template<typename T> static bool defaultCompare(const Shape* lhs, const Shape* rhs) { return lhs->data.access<T>() == rhs->data.access<T>(); }};

方法适配

所有具有多态性质的函数都得通过调用虚函数表中的函数来执行操作,这包括析构、拷贝构造、拷贝赋值(没有移动)、operator==和draw。

class Shape{protected: // ... Shape(const FuncPtr* vtable) : vtable(vtable) { }public: Shape() { } Shape(const Shape& other) : vtable(other.vtable) { if (vtable) reinterpret_cast<FuncPtrCopy>(vtable[funcIndexCopy])(this, &other); } Shape& operator=(const Shape& other) { if (this != &other) { if (vtable) reinterpret_cast<FuncPtrDestruct>(vtable[funcIndexDestruct]) (this); vtable = other.vtable; if (vtable) reinterpret_cast<FuncPtrCopy>(vtable[funcIndexCopy]) (this, &other); } return *this; } Shape(Shape&& other) noexcept : vtable(other.vtable), data(other.data) { other.vtable = nullptr; } Shape& operator=(Shape&& other) noexcept { swap(other); return *this; } ~Shape() { if (vtable) reinterpret_cast<FuncPtrDestruct>(vtable[funcIndexDestruct])(this); } void swap(Shape& other) noexcept { using std::swap; swap(this->vtable, other.vtable); swap(this->data, other.data); } bool operator==(const Shape& rhs) const { if (this->vtable == nullptr || this->vtable != rhs.vtable) return false; return reinterpret_cast<FuncPtrCompare>(vtable[funcIndexCompare]) (this, &rhs); } bool operator!=(const Shape& rhs) const { return !(*this == rhs); } void draw() const { if (vtable) reinterpret_cast<FuncPtrDraw>(vtable[funcIndexDraw])(this); }protected: // ...};namespace std{ void swap(Shape& lhs, Shape& rhs) noexcept { lhs.swap(rhs); }}

拷贝构造函数拷贝vtable和数据,析构函数销毁数据,拷贝赋值函数先析构再拷贝。operator==先检查两个参数的vtable是否相同,只有相同,两个参数才是同一类型,才能进行后续比较。draw调用vtable中的对应函数。所有方法都会先检查vtable是否为nullptr,因为Shape是一个抽象类的角色,一个Shape对象是空的,任何操作都不执行。

比较特殊的是移动和swap。由于ShapeData data中存放的是is_trivially_copyable的数据类型或指针,都是“位置无关”(可以trivially拷贝)的,因此swap中data可以直接复制。(swap在这么不trivial的情况下都能默认,给swap整一个运算符不好吗?)

移动赋值把*this和other交换,把析构*this的任务交给other。移动构造也相当于swap,不过this->vtable == nullptr。其实我还可以写copy-and-swap:

Shape& operator=(Shape other){ swap(other); return *this;}

用以替换Shape& operator=(const Shape&)和Shape& operator=(Shape&&),可惜Shape& operator=(Shape)不属于C++规定的特殊成员函数,子类不会继承其行为。

子类继承以上所有函数。我非常想写上final以防止子类覆写,但是这些函数并不是C++语法上的虚函数。所以我们获得了virtual的拷贝构造和draw,实现了值多态。

讨论

我翻开C++标准一查,这标准没有实现细节,方方正正的每页上都写着“undefined behavior”几个词。我横竖睡不着,仔细看了半夜,才从字缝里看出字来,满本都写着一个词是“trade-off”。如果要用一句话概括值多态,那就是“更多义务,更多权利”。

安全

Shape的实现代码中充斥着强制类型转换,很容易引起对其类型安全性的质疑。这是多虑,因为LineData和lineVtable是始终绑定在一起的,虚函数不会访问到非对应类型的数据。即使在这一点上出错,只要数据类型是比较trivial的(不包含指针之类的),起码程序不会崩溃。不过类型安全性的前提是基类与派生类的大小相同,如果客户违反了这一点,那我只好使出C/C++传统艺能——undefined behavior了。

类型安全不等同于“类型正确”——我随便起的名字。在上面的演示程序中,如果我std::swap(line, rect),line就会存储一个Rectangle实例,但line在语法上却是一个Line实例!也就是说,Line和Rectangle只能在定义变量时保证类型正确,在此之后它们就和Shape通假了。

类型安全保证不会访问到非法的地址空间,那么内存泄漏是否会发生?构造时按照SBO的第二种情况new,而析构时按照第一种情况trivially析构,这种情况是不可能发生的。首先前提是数据类型与vtable配对,在此基础上vtable中拷贝与析构配对。这些函数选择哪个版本是在编译期决定的,这更加让人放心。

还有异常安全。只要客户遵守一些异常处理的规则,使得Shape的析构函数能够被调用,就能确保不会有资源未释放。

性能

空间上,值多态难免浪费空间。预留的数据区域需要足够大,才能存下大多数类型的数据,对于其中较小的有很多空间被浪费,对于大到放不进的只存放一个指针,也是一种浪费。富有创意的你还可以把一部分trivial的数据放在本地,其他的维护一个指针,但是那样也太麻烦了吧。

时间上,值多态的动态部分有更好的表现。相比于基于继承的类型擦除,值多态在创建对象时少一次new,使用时少一次解引用;相比于函数指针的类型擦除,值多态在创建值多态只需维护一个vtable指针。相比于虚函数,值多态的初衷就是避免new和delete。不过,虚函数是编译器负责的,编译器要是有什么猥琐优化,那我认输。

但是值多态的静态部分不尽人意。在传统多态中,如果一个多态实例的类型在编译期可以确定,那么虚函数会静态决议,不通过vtable而直接调用函数。在值多态中,子类可以覆写基类的普通“虚函数”,提升运行时性能,但是对于拷贝控制函数,无论子类是否覆写,编译器总会调用基类的对应函数,而它们的任务是多态拷贝,子类没有必要,有时也不能覆写,更无法静态决议了。不过考虑到line非Line的情况,还是老老实实用动态决议吧。

时间和空间有权衡的余地。为了让更多子类的数据可以放在本地,基类中的数据空间可以保留得大一些,但是也会浪费更多空间;可以把vtable中的函数指针直接放在对象中,多占用一些空间,换来每次使用时减少一次解引用;拷贝、析构和比较可以合并为一个函数以节省空间,但是需要多一个参数指明何种操作。总之,传统艺能implementation-defined。

扩展

我要给Line加上一个子类ThickLine,表示一定宽度的直线。在计算机的屏幕上绘制倾斜曲线常用Bresenham算法,我对它不太熟悉,希望程序能打印一些调试信息,所以给Line加上一个虚函数debug(而Rectangle绘制起来很容易)。当然,不是C++语法上的虚函数。

class Line : public Shape{protected: static constexpr std::size_t funcIndexDebug = funcIndexTotal; using FuncPtrDebug = void (*)(const Line*); static constexpr std::size_t funcIndexTotalLine = funcIndexTotal + 1; struct LineData { Point endpoint[2]; LineData() { } LineData(Point p0, Point p1) : endpoint{ p0, p1 } { } bool operator==(const LineData& rhs) const { return this->endpoint[0] == rhs.endpoint[0] && this->endpoint[1] == rhs.endpoint[1]; } bool operator!=(const LineData& rhs) const { return !(*this == rhs); } }; Line(const FuncPtr* vtable) : Shape(vtable) { }public: Line() : Shape(lineVtable) { data.construct<LineData>(); } Line(Point p0, Point p1) : Shape(lineVtable) { data.construct<LineData>(p0, p1); } Line(const Line&) = default; Line& operator=(const Line&) = default; Line(Line&&) = default; Line& operator=(Line&&) = default; ~Line() = default; void debug() const { if (vtable) reinterpret_cast<FuncPtrDebug>(vtable[funcIndexDebug])(this); }private: static const FuncPtr lineVtable[funcIndexTotalLine]; static ShapeData& accessData(Shape* shape) { return static_cast<Line*>(shape)->data; } static const ShapeData& accessData(const Shape* shape) { return accessData(const_cast<Shape*>(shape)); } static void lineDraw(const Shape* line) { auto& data = static_cast<const Line*>(line)->data.access<LineData>(); std::cout << "Drawing a line: " << data.endpoint[0] << "; " << data.endpoint[1] << std::endl; } static void lineDebug(const Line* line) { std::cout << "Line debug:\n\t"; lineDraw(line); }};const Shape::FuncPtr Line::lineVtable[] ={ reinterpret_cast<Shape::FuncPtr>(Shape::defaultCopy<LineData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultDestruct<LineData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultCompare<LineData>), reinterpret_cast<Shape::FuncPtr>(Line::lineDraw), reinterpret_cast<Shape::FuncPtr>(Line::lineDebug),};class ThickLine : public Line{protected: struct ThickLineData { LineData lineData; int width; ThickLineData() { } ThickLineData(Point p0, Point p1, int width) : lineData{ p0, p1 }, width(width) { } ThickLineData(LineData data, int width) : lineData(data), width(width) { } bool operator==(const ThickLineData& rhs) const { return this->lineData == rhs.lineData && this->width == rhs.width; } bool operator!=(const ThickLineData& rhs) const { return !(*this == rhs); } };public: ThickLine() : Line(thickLineVtable) { data.construct<ThickLineData>(); } ThickLine(Point p0, Point p1, int width) : Line(thickLineVtable) { data.construct<ThickLineData>(p0, p1, width); } ThickLine(const ThickLine&) = default; ThickLine& operator=(const ThickLine&) = default; ThickLine(ThickLine&&) = default; ThickLine& operator=(ThickLine&&) = default; ~ThickLine() = default;private: static const FuncPtr thickLineVtable[funcIndexTotalLine]; static ShapeData& accessData(Shape* shape) { return static_cast<ThickLine*>(shape)->data; } static const ShapeData& accessData(const Shape* shape) { return accessData(const_cast<Shape*>(shape)); } static void thickLineDraw(const Shape* line) { auto& data = static_cast<const ThickLine*>(line)->data.access<ThickLineData>(); std::cout << "Drawing a thick line: " << data.lineData.endpoint[0] << "; " << data.lineData.endpoint[1] << "; " << data.width << std::endl; } static void thickLineDebug(const Line* line) { std::cout << "ThickLine debug:\n\t"; thickLineDraw(line); }};const Shape::FuncPtr ThickLine::thickLineVtable[] ={ reinterpret_cast<Shape::FuncPtr>(Shape::defaultCopy<ThickLineData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultDestruct<ThickLineData>), reinterpret_cast<Shape::FuncPtr>(Shape::defaultCompare<ThickLineData>), reinterpret_cast<Shape::FuncPtr>(ThickLine::thickLineDraw), reinterpret_cast<Shape::FuncPtr>(ThickLine::thickLineDebug),};

在非抽象类Line中加入数据比想象中困难。Line的构造函数会把SBO数据段作为LineData来构造,但是ThickLine需要的是ThickLineData,在LineData上再次构造ThickLine是不安全的,因此我仿照Shape给Line加上一个protected构造函数,并把LineData开放给ThickLine,定义ThickLineData,其中包含LineData。

这个例子说明,值多态不只适用于一群派生类直接继承一个抽象基类的情况,可以扩展到任何单继承的继承链/树,包括继承抽象类与非抽象类,其中后者稍微麻烦一些,需要基类把数据类型开放给派生类,让派生类将基类数据与新增数据进行组合。这一定程度上破坏了基类的封装

声明:本页内容来源网络,仅供用户参考;我单位不保证亦不表示资料全面及准确无误,也不保证亦不表示这些资料为最新信息,如因任何原因,本网内容或者用户因倚赖本网内容造成任何损失或损害,我单位将不会负任何法律责任。如涉及版权问题,请提交至online#300.cn邮箱联系删除。

相关文章