c++ 值多态类型擦除 详解C++值多态中的传统多态与类型擦除
jerry_fuyi 人气:1引言
我有一个显示屏模块:
模块上有一个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
。
这个例子说明,值多态不只适用于一群派生类直接继承一个抽象基类的情况,可以扩展到任何单继承的继承链/树,包括继承抽象类与非抽象类,其中后者稍微麻烦一些,需要基类把数据类型开放给派生类,让派生类将基类数据与新增数据进行组合。这一定程度上破坏了基类的封装性,解决办法是把方法定义在数据类型中,让值多态类起适配器的作用。
单继承并不能概括所有“is-a”的关系,有时多重继承和虚继承是必要的,值多态能否支持呢?答曰:不可能,因为多继承下的派生类的实例的大小大于任何一个基类,这与值多态要求基类与派生类内存布局一致相矛盾。这应该是值多态最明显的局限性了吧。
模式
没有强制子类不定义数据成员的手段带来潜在的安全问题,编译器自动调用基类拷贝函数使静态决议不再可能,派生类甚至还要破坏基类数据的封装性,这些问题有没有解决方案呢?在C语言中,类似的问题被Cfront编译器解决,很容易想到值多态是否可以成为一种编程语言的默认多态行为。我认为是可以的,它尤其适合比较小的设备,但是有些问题需要考虑。
刚刚证明了单继承可行而多继承不可行,这种编程语言只能允许单继承。那么介于单继承和多继承之间的,去除了数据成员的累赘的多继承,类似于Java和C#中的interface
,是否可行呢?我没有细想,隐隐约约感觉是有解决方案的。
基类中预留多少数据空间?如果由程序员来决定,程序员胡乱写个数字,单片机有8、16、32位的,这样做使代码可移植性降低。或者由编译器来决定,比如要使50%的子类数据可以放在本地。这看起来很和谐,但是思考一下你会发现它对链接器不友好。更糟糕的是,如果有这样的定义:
class A { }; class B { }; class A1 : public A { B b; }; class B1 : public B { A a; };
要决定A
的大小,就得先决定B
的;要决定B
的大小,还得先决定A
的……嗯,可以出一道算法题了。
想那么多干什么,说得好像我学过编译原理似的。
次于语法,值多态是否可以一般化,写成一个通用的库?polymorphic_value
是一个现成但不完美的答案,它的主要问题在于不能通过polymorphic_value<D>
实例直接构造polymorphic_value<B>
实例(其中D
是B
的派生类),这会导致极端情况下调用一个方法的时间复杂度为\(O(h)\)(其中\(h\)为继承链的长度)。还有一个小细节是裸的值多态永远胜于任何类库的:可以直接写shape.draw()
而无需shape->draw()
,后者形如指针的语义有一些误导性。不过polymorphic_value
支持多继承与虚继承,这是值多态永远比不上的。
我苦思冥想了很久,觉得就算C++究极进化成了C++++也不可能存在一个类模板能对值多态类的设计有什么帮助,唯有退而求其次地用宏。Shape
一家可以简化成这样:
class Shape { VP_BASE(Shape, 16, 1); static constexpr std::size_t funcIndexDraw = 0; public: void draw() const { if (vtable) VP_BASE_VFUNCTION(void(*)(const Shape*), funcIndexDraw)(this); } }; VP_BASE_SWAP(Shape); class Line : public Shape { VP_DERIVED(Line); 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); } }; public: Line() : VP_DERIVED_INITIALIZE(Shape, Line) { VP_DERIVED_CONSTRUCT(LineData); } Line(Point p0, Point p1) : VP_DERIVED_INITIALIZE(Shape, Line) { VP_DERIVED_CONSTRUCT(LineData, p0, p1); } private: static void lineDraw(const Shape* line) { auto& data = VP_DERIVED_ACCESS(const Line, LineData, line); std::cout << "Drawing a line: " << data.endpoint[0] << "; " << data.endpoint[1] << std::endl; } }; VP_DERIVED_VTABLE(Line, LineData, VP_DERIVED_VFUNCTION(Line, lineDraw), ); class Rectangle : public Shape { VP_DERIVED(Rectangle); 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); } }; public: Rectangle() : VP_DERIVED_INITIALIZE(Shape, Rectangle) { VP_DERIVED_CONSTRUCT(RectangleData); } Rectangle(Point v0, Point v1, bool filled) : VP_DERIVED_INITIALIZE(Shape, Rectangle) { VP_DERIVED_CONSTRUCT(RectangleData, v0, v1, filled); } private: static void rectangleDraw(const Shape* rect) { auto& data = VP_DERIVED_ACCESS(const Rectangle, RectangleData, rect); std::cout << "Drawing a rectangle: " << data.vertex[0] << "; " << data.vertex[1] << "; " << (data.filled ? "filled" : "blank") << std::endl; } }; VP_DERIVED_VTABLE(Rectangle, RectangleData, VP_DERIVED_VFUNCTION(Rectangle, rectangleDraw), );
效果一般,并没有简化很多。不仅如此,如果不想让自己的值多态类支持operator==
的话,还得写一个新的宏,非常死板。
再次于工具,值多态是否可以成为一种设计模式呢?我认为它具有成为设计模式的潜质,因为各个值多态类都具有相似的内存布局,可以把共用代码抽离出来写成宏。但是,由于我没有在任何地方看到过这种用法,现在还不能大张旗鼓地把它作为一种设计模式来宣扬。Anyway,让值多态成为一种设计模式是我的愿景。(谁还不想搞一点发明创造呢?)
比较
值多态处于传统多态与类型擦除之间,与C++中现有的各种多态实现方式相比,在它的适用范围内,具有集大成的优势。
与传统多态相比,值多态保留了继承的工具与思维方式,但是与传统多态的指针语义不同,值多态是值语义的,多态性可以在值拷贝时被保留。值语义的多态的意义不仅在于带来方便,更有消除潜在的bug——C/C++的指针被人诟病得还不够吗?
与类型擦除相比,值多态同样使用值语义(类型擦除界也有引用语义的),但是并非duck typing而是选择了较为传统的继承。duck typing在静态类型语言C++中处处受限:类型擦除类的实例可以由duck来构造但是无法还原;类型擦除类有固定的affordance,如std::function
要求operator()
,即使用上适配器可以搞定Shape
,但对于两个多态函数的Line
和ThickLine
还是束手无策。继承作为C++原生特性不存在这些问题,更重要的是继承是C++和很多其他语言的程序员所习惯的思维方式。
与polymorphic_value
相比,值多态用普适性换取了运行时的性能和实现上的自由——毕竟除SBOData
以外的类都是自己写的。在类型转换时,polymorphic_value
会套娃,而值多态不会,并且能不能转换可以由编译器说了算。值多态的类型对客户完全开放,用不用SBO、SBO多大都可以按需控制,甚至可以人为干预向下类型转换。当然,自由的代价是更长的代码。
总结
值多态是一种介于传统多态与类型擦除之间的多态实现方式,借鉴了值语义,保留了继承,在单继承的适用范围内,程序和程序员都能从中受益。本文也是《深度探索C++对象模型》中“Function语意学”一章的最佳实践。
换个内存大一点的单片机,屁事都没有了——技术不够,成本来凑。
参考
Polymorphism (computer science) - Wikipedia
加载全部内容