從 C+98到C+17,元編程是如何演進的?
作者 | 祁宇
責編 | 郭芮
出品 | CSDN(ID:CSDNnews)
不斷出現的C 新的標準,正在改變元編程的編程思想,新的idea和方法不斷湧現,讓元編程變得越來越簡單,讓C 變得簡單也是C 未來的一個趨勢。
很多人對元編程有一些誤解,認為代碼晦澀難懂,編譯錯誤提示很糟糕,還會讓編譯時間變長,對元編程有一種厭惡感。不可否認,元編程確實有這樣或那樣的缺點,但是它同時也有非常鮮明的優點:
zero-overhead的編譯期計算;
簡潔而優雅地解決問題;
終極抽象。
在我看來元編程最大的魅力是它常常能化腐朽為神奇,幫我們寫出dream code!
C 98模版元編程思想
C 98中的模版元編程通常和這些特性和方法有關:
元函數;
SFINAE;
模版遞歸;
遞歸繼承;
Tag Dispatch;
模版特化/偏特化。
元函數
元函數就是編譯期函數調用的類或模版類。比如下面這個例子:
addpointer就是一個元函數(模版類),元函數的調用是通過訪問其sub-type實現的,比如addpointer::type就是調用add_pointer元函數了。
這裡面類型T作為元函數的value,類型是元編程中的一等公民。模版元編程概念上是函數式編程,對應於一個普通函數,值作為參數傳給函數,在模版元里,類型作為元函數的參數被傳來傳去。
SFINAE
替換失敗不是錯誤。
在上面的例子中,調用foo("a")模版函數的時候,有一個模版實例化的過程,這個過程中會替換模版參數,如果模版參數替換失敗,比如不符合編譯期的某個條件,那麼這個模版實例化會失敗,但是這時候編譯器不認為這是一個錯誤,還會繼續尋找其他的替換方案,直到所有的都失敗時才會產生編譯錯誤,這就是SFINAE。SFINAE實際上是一種編譯期的選擇,不斷去選擇直到選擇到一個合適的版本位置,其實它也可以認為是基於模板實例化的tag dispatch。
模版遞歸,模版特化
這是模版元編程的hello world例子,通過模版特化和模版遞歸實現編譯期計算。
在C 98中模版元編程的集大成者的庫是boost.mpl和boost.fusion,boost.mpl主要提供了編譯期類型容器和演算法,boost.fusion通過異構的編譯期容器融合編譯期和運行期計算。
上面這個例子遍歷boost::fusion::vector異構容器,列印其中的string類型。
關於C 98模版元的書可以看《modern c design》和《c templates》。
Modern C metaprogramming編程思想
C 11中的元編程思想
Modern C 新標準對於元編程有著深刻的影響,一些新的編程思想和方法湧現,但總體趨勢是元編程變得更簡單了。比如C 98中的add_pointer元函數,我們需要寫一個模版類:
而在C 11中我們只需要使用C 11的新特性模版別名就可以定義一個add_pointer元函數了,代碼變得更簡潔了。
在C 11中,元函數由模版類變為模版別名了。C 11中提供了大量元函數在type_traits庫中,這樣我們不用再自己寫了,直接拿過來使用就行了。
C 11中另外的一個新特性variadic template可以作為一個類型容器,我們可以通過variadic templates pack訪問模版參數,不需要通過模版遞歸和特化來訪問模版參數。
通過variadic template pack讓編譯器幫助我們訪問類型,比C 98中通過模版遞歸和特化來訪問類型效率更高。
C 11中另外一個新特性constexpr也讓我們編寫元函數變得更簡單了。
在C 98中:
在C 11中:
我們不再需要通過模版特化和遞歸來做編譯期計算了,我們直接通過新的關鍵字constexpr來實現編譯期計算,它修飾一個函數,表明這個函數是在編譯期計算的,這個函數和一個普通函數看起來幾乎沒有分別,唯一的差別就是多了一個constexpr,比C 98的寫法簡單多了。
不過在C 11中constexpr的限制比較多,比如說constexpr函數中只能是個表達式,無法使用變數,循環等語句,在C 14中就去掉這個限制了,讓我們可以更方便地寫編譯期計算的函數了。
C 14中的元編程思想
可以看到在C 14中我們寫constexpr編譯期計算的函數時,不必受限於表達式語句了,可以定義變數和寫循環語句了,這樣也不用通過遞歸去計算了,直接通過循環語句就可以得到編譯期計算結果了,使用起來更方便了。
在C 14中除了constexpr增強之外,更重要的幾個影響元編程思想的特性是constexpr, generic lambda, variable template。新標準、新特性會產生新的編程思想,在C 14里元編程的編程思想發生了重大的變化!
在2014年Louis Dionne用C 14寫的一個叫Hana的元編程庫橫空出世,它的出現在C 社區引起震動,因為它所採用的方法不再是經典的模版元的那一套方法了,是真正意義上的函數式編程實現的。模版元在概念上是函數式編程,而Hana是第一次在寫法上也變成函數式編程了,這是C 元編程思想的一個重大改變。
Boost.Hana的編程思想
通過一個例子來看Boost.Hana的編程思想:
這裡我們定義了一個類型的wraper,裡面只有一個子類型,接著定義這個wraper的變數模版,有了這個變數模版,我們就可以很方便的實現type-to-value和value-to-type了。
某個具體類型的變數模版就代表一個值,通過decltype這個值就能得到變數模版的類型了,有了這個變數模版,我們就可以通過Lambda寫元函數了,這裡的Lambda是C 14中的generic lambda,這個Lambda的參數就是一個變數模版值,在Lambda表達式中,我們可以對獲取值的sub type並做轉換,然後再返回變換之後的變數模版值。
這裡的add_pointer元函數不再是一個模版類或者模版別名了,而是一個Lambda表達式。這裡面關鍵的兩個地方是如何把類型變為值和把值變為類型,通過C 14的變數模版就可以實現這個目標了。
Boost.Hana的目標是通過類型容器融合編譯期和運行期計算,替代boost.mpl和boost.fusion!比如下面的例子:
我們既可以操作類型容器中的類型,又可以操作類型容器中的運行期的值,Hana可以幫我們很方便地融合編譯期與運行期的計算。
Boost.Hana的特點:
元函數不再是類或類模版,而是lambda;
不再基於類型,而是基於值;
沒有SFINAE,沒有模版遞歸;
函數式編程;
代碼更容易理解;
元編程變得更簡單;
融合編譯期與運行期。
以Boost.Hana為代表的元編程實現不再是經典的type level的思想了,而是以C 14新特性實現的lambda level的函數式編程思想了。
C 17元編程思想
在C 17中,元編程得到了進一步地簡化,比如我們之前需要藉助模版特化,SFINAE才能實現的編譯期選擇,現在通過if constexpr就可以很輕鬆的實現了。
在C 98中:
在C 17中:
這裡不再需要模版特化了,也不需要拆分成多個函數了,就像普通的if-else語句一樣寫編譯期選擇的代碼,簡潔易懂!
在C 14中:
在C 17中:
這裡不再需要SFINAE了,同樣可以實現編譯期選擇,代碼更加簡潔。
C 元編程的庫以這些庫為代表,這些庫代表了C 元編程思想不斷演進的一個趨勢:
C 98:boost.mpl,boost.fusion
C 11:boost.mp11,meta,brigand
C 14:boost.hana
從C 98到Modern C ,C 新標準新特性產生新的idea,讓元編程變得更簡單更強大,Newer is Better!
Modern C 元編程應用
編譯期檢查
元編程的一個典型應用就是編譯期檢查,這也是元編程最簡單的一個應用,簡單到用一行代碼就可以實現編譯期檢查。比如我們需要檢查程序運行的系統是32位的還是64位的,通過一個簡單的assert就可以實現了。
當系統為32位時就會產生一個編譯期錯誤並且編譯器會告訴你錯誤的原因。
這種編譯期檢查比通過#if define宏定義來檢查系統是32位還是64位好得多,因為宏定義可能存在忘記寫的問題,並不能在編譯期就檢查到錯誤,要到運行期才能發現問題,這時候就太晚了。
再看一個例子:
在這個例子中,這個Matrix是非常安全的,完全不用擔心定義Matrix時行和列的值寫錯了,因為編譯器會在編譯期提醒你哪裡寫錯了,而不是等到運行期才發現錯誤。
除了經常用staticassert做編譯期檢查之外,我們還可以使用enableif來做編譯期檢查。
比如這個代碼,我們通過std::enableift來限定輸入參數的類型必須為非成員函數,如果傳入了成員函數則會出現一個編譯期錯誤。
元編程可以讓我們的代碼更安全,幫助我們儘可能早地、在程序運行之前的編譯期就發現bug,讓編譯器而不是人來幫助我們發現bug。
編譯期探測
元編程可以幫助我們在編譯期探測一個成員函數或者成員變數是否存在。
我們藉助C 17的void_t,就可以輕鬆實現編譯期探測功能了,這裡實際上是利用了SFINAE特性,當decltype(std::declval().foo())成功了就表明存在foo成員函數,否則就不存在。
通過編譯期探測我們可以很容易實現一個AOP(Aspect Oriented Programming)功能,AOP可以通過一系列的切面幫我們把核心邏輯和非核心邏輯分離。
上面這段代碼的核心邏輯就是返回一個hello world,非核心邏輯就是檢查輸入參數和記錄日誌,把非核心邏輯分離出來放到兩個切面中,不僅僅可以讓我們的核心邏輯保持簡潔,還可以讓我們可以更專註於核心邏輯。
實現AOP的思路很簡單,通過編譯期探測,探測切面中是否存在before或者after成員函數,存在就調用。
為了讓編譯期探測的代碼能復用,並且支持可變模版參數,我們可以寫一個通用的編譯期探測的代碼:
具體代碼可以參考這裡:https://github.com/qicosmos/feather。
註:這段宏代碼可以用c 20的std::is_detected替代,也可以寫一個C 14/17的代碼來替代這個宏:
編譯期計算
編譯期計算包含了較多內容,限於篇幅,我們重點說一下類型萃取的應用:
類型計算;
類型推導;
類型萃取;
類型轉換;
數值計算:表達式模版,Xtensor,Eigen,Mshadow。
我們可以通過一個function_traits來萃取可調用對象的類型、參數類型、參數個數等類型信息。
完整代碼可以參考這裡:https://github.com/qicosmos/cinatra。
有了這個function_traits之後就方便實現一個RPC路由了,以rest_rpc為例(https://github.com/qicosmos/rest_rpc):
RPCServer註冊了兩個服務函數add和translate,客戶端發起RPC調用,會傳RPC函數的實際參數,這裡需要把網路傳過來的位元組映射到一個函數並調用,這裡就需要一個RPC路由來做這個事情。下面是RestRPC路由的實現:
RPCServer註冊RPC服務函數的時候,函數類型會保存在invoker中,後面收到網路位元組的時候,我們通過functiontraits萃取出函數參數對應的tuple類型,反序列化得到一個實例化的tuple之後就可以藉助C 17的std::apply實現函數調用了。詳細代碼可以參考rest_rpc。
編譯期反射
通過編譯期反射,我們可以得到類型的元數據,有了這個元數據之後我們就可以用它做很多有趣的事情了。可以用編譯期反射實現:
序列化引擎;
ORM;
協議適配器。
以序列化引擎iguana(https://github.com/qicosmos/iguana)來舉例,通過編譯期反射可以很容易的將元數據映射為json、xml、msgpack或其他格式的數據。
以ORM引擎(https://github.com/qicosmos/ormpp)舉例,通過編譯期反射得到的元數據可以用來自動生成目標資料庫的SQL語句:
反射將進入C 23標準,未來的C 標準中的反射將更強大和易用。
融合編譯期和運行期
運行期和編譯期存在一個巨大的鴻溝,而在實際應用中我需要融合編譯期與運行期,這時候就需要一個橋樑來連接編譯期與運行期。編譯期和運行期從概念上可以簡單地認為分別代表了type和value,融合的關鍵就是如何實現type to value以及value to type。
Modern C 已經給我們提供了便利,比如下面這個例子:
我們可以很方便地將一個值變為一個類型,然後由通過類型獲得一個值。接下來我們來看一個具體的例子:如何根據一個運行時的值調用一個編譯期模版函數?
這個代碼似乎很好地解決了這個問題,可以實現從運行期數值到編譯期模版函數調用。但是如果這個運行期數值越來越大的時候,我們這個switch就會越來越長,還存在寫錯的可能,比如調用了foo(100),那這時候真的需要寫100個switch-case嗎?所以這個寫法並不完美。
我們可以藉助tuple來比較完美地解決這個問題:
通過一個tuple_switch就可以通過運行期的值調用編譯期模版函數了,不用switch-case了。關於之前需要寫很長的switch-case語句的問題,也可以藉助元編程來解決:
這裡的decltype(maketuplefrom_sequence())會自動生成一個有100個int的tuple輔助類型,有了這個輔助類型,我們完全不必要去寫長長的switch-case語句了。
有人也許會擔心,這裡這麼長的tuple會不會生成100個Lambda實例化代碼?這裡其實不用擔心,因為編譯器可以做優化,優化的情況下只會生成一次Lambda實例化的代碼,而且實際場景中不可能存在100個分支的代碼。
介面的泛化與統一
元編程可以幫助我們融合底層異構的子系統、屏蔽介面或系統的差異、提供統一的介面。
以ORM為例:
MySQL connect
PostgreSQL connect
Sqlite connect
ORM unified connect interface
不同的資料庫的C connector相同功能的介面是完全不同的,ormpp庫(https://github.com/qicosmos/ormpp)要做的一件事就是要屏蔽這些介面的差異,讓用戶可以試用統一的介面來操作資料庫,完全感受不到底層資料庫的差異。
元編程可以幫助我們實現這個目標,具體思路是通過可變參數模版來統一介面,通過policy-base設計和variadic templates來屏蔽資料庫介面差異。
這裡通過connect(Args... args)統一連接資料庫的介面,然後再connect內部通過if constexpr和變參來選擇不同的分支。if constexpr加variadic templates等於靜態多態,這是C 17給我們提供的一種新的實現靜態多態方法。
這樣的好處是可以通過增加參數或修改參數類型方式來擴展介面,沒有繼承,沒有SFINAE,沒有模版特化,簡單直接。
消除重複(宏)
很多人喜歡用宏來減少手寫重複的代碼,比如下面這個例子,如果對每個枚舉類型都寫一個寫到輸出流里的代碼段,是重複而繁瑣的,於是就通過一個宏來消除這些重複代碼(事實上,這些重複代碼仍然會生成,只不過由編譯器幫助生成了)。
這看似是使用宏的合適場景,但是宏最大的問題是代碼無法調試,代碼的易讀性差,但是用元編程,我們不用寫這個宏了,也不用去寫宏定義了。
元編程比宏更好地解決了問題。
再看一個宏的例子:
這也是宏使用的一個典型場景——復用代碼段。當很多代碼段都是類似的時候,只有一點點代碼不同,那麼就可以通過宏來避免手寫這些重複代碼。上面這個宏把不同的代碼段func1(rootpath),func2(temppath)作為參數傳進來,從而復用這個代碼段。
我們可以通過一個泛型函數來替換這個宏:
事實上大部分宏能做的,元編程能做得更好、更完美!
介面易用和靈活性
還是以rest_rpc為例,我們可以註冊任意類型的RPC函數,不管參數個數和類型是否相同、返回類型是否相同,這讓我們的註冊介面非常易用和靈活。
這裡我們使用元編程幫我們擦除了函數類型:
typename Function做了類型擦除,typename functiontraits::argstuple幫我們還原了類型。
再來看另外一個例子,cinatra(https://github.com/qicosmos/cinatra)註冊路由函數的例子:
這個例子中,用戶可以增加任意切面,還可以增加緩存參數,切面和緩存參數的順序可以是任意的,這樣完全消除了用戶使用介面時需需要注意參數順序的負擔,完全是自由靈活的。這裡並沒有使用多個重載函數做這個事情,而是藉助元編程,把緩存參數過濾出來,這樣就可以無視外面傳入參數的順序了。
過濾參數的代碼如下:
這裡通過C 17的std::disjunction來判斷是否存在某個類型,通過if constexpr實現編譯期選擇。
總結
C 新標準給元編程帶來了巨大的改變,不僅僅讓元編程變得簡單好寫了,還讓它變得更加強大了,幫助我們優雅地解決了很多實際的問題。文中列舉到的元編程應用僅僅是冰山一角,還有很多其他方面的應用。
本文內容為作者自 2018 中國 C 大會演講內容整理而來。
作者:祁宇,Modern C 開源社區purecpp.org創始人,《深入應用 C 11》作者,開源庫cinatra、feather作者,熱愛開源,熱愛Modern C 。樂於研究和分享技術,多次在國際C 大會(cppcon)做演講。
熱 文推 薦


※ofo 遭千萬人退押金
※大話雲上「分散式實踐」,理解 B、A、C 並不難!
TAG:CSDN |