详解C++值多态中的传统多态与类型擦除
引言
我有一个显示屏模块:
模块上有一个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)的指针来实现多态是这个小圈子里心照不宣的秘密。
假设有两个类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
这两个类的实例在内存中的布局可能是这样:
如果你把一个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的实现,当然它还有很多种实现方法,其他类型擦除类的实现也都大同小异,它们都包含两个要素:可能带约束的模板构造函数,以及函数指针,无论是可见的(直接维护)还是不可见的(使用继承)。
为了获得更真切的感受,我们来写一个最简单的类型擦除:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
|
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==是可以想办法支持的。
