詳解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)的指標來實現多型是這個小圈子裡心照不宣的祕密。
假設有兩個類:
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," << 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,bool filled) : vertex{ v0,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,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 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* 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,&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,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,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>(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,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,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,VP_DERIVED_VFUNCTION(Line,lineDraw),); class Rectangle : public Shape { VP_DERIVED(Rectangle); private: struct RectangleData { Point vertex[2]; bool filled; RectangleData() { } RectangleData(Point v0,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,bool filled) : VP_DERIVED_INITIALIZE(Shape,Rectangle) { VP_DERIVED_CONSTRUCT(RectangleData,v0,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,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
function/bind的救贖(上)
What is Type Erasure?
A polymorphic value-type for C++
N3337: Working Draft,Standard for Programming Language C++
到此這篇關於C++值多型中的傳統多型與型別擦除的文章就介紹到這了,更多相關c++ 值多型型別擦除內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!