從一個問題開始
以下代碼存在結(jié)構(gòu)性重復(fù),如何消除熄赡?
// EventId.h
enum EventId
{
setupEventId = 0x4001,
cfgEventId,
recfgEventId,
releaseEventId
// ...
};
// EventCounter.h
struct EventCounter
{
U32 setupEventCounter;
U32 cfgEventCounter;
U32 recfgEventCounter;
U32 releaseEventCounter;
// ...
};
// EventCounter.c
EventCounter g_counter = {0};
// CountEvent.c
void countEvent(U32 eventId)
{
switch(eventId)
{
case setupEventId:
g_counter.setupEventCounter++;
break;
case cfgEventId:
g_counter.cfgEventCounter++;
break;
case recfgEventId:
g_counter.recfgEventCounter++;
break;
case releaseEventId:
g_counter.releaseEventCounter++;
break;
// ...
}
}
// PrintCounter.c
void printCounter()
{
printf("setupEventCounter = %d \n", g_Counter.setupEventCounter);
printf("cfgEventCounter = %d \n", g_Counter.cfgEventCounter);
printf("recfgEventCounter = %d \n", g_Counter.recfgEventCounter);
printf("releaseEventCounter = %d \n", g_Counter.releaseEventCounter);
// ...
}
上面的例子中除了每個文件內(nèi)部有結(jié)構(gòu)性重復(fù)详炬,文件之間也有結(jié)構(gòu)性重復(fù)!當(dāng)我們每增加一個消息的定義晰绎,都需要依次在四個文件中增加對應(yīng)的消息ID定義寓落,計數(shù)器定義,計數(shù)器累加以及計數(shù)器打印的代碼荞下,在整個過程中還要保證所有變量名伶选、字符串等的命名一致性問題史飞。
那么如何解決上述問題呢?最容易想到的方式就是定義一個元數(shù)據(jù)文件仰税,然后寫個腳本自動掃描元數(shù)據(jù)文件,自動生成上述四個文件构资。
例如可以定義一個xml格式的元數(shù)據(jù)文件event.xml:
<?xml version = "1.0 ecoding = utf-8>
<event>
<item> setup </item>
<item> cfg </item>
<item> recfg </item>
<item> release </item>
<!-- more event-->
</event>
然后再寫一個python腳本,按照規(guī)則從這個xml自動生成EventId.h陨簇、EventCounter.h吐绵, CountEvent.c、PrintEvent.c塞帐,如下圖所示:
在大的項目中頻繁使用上述方式拦赠,往往導(dǎo)致純業(yè)務(wù)代碼的技術(shù)棧不一致!例如元數(shù)據(jù)定義可以用xml葵姥、yaml荷鼠、json..., 腳本語言可以用python、ruby榔幸、perl..., 將會引起如下問題:
- 需要項目中所有構(gòu)建代碼的機器上安裝對應(yīng)腳本語言的解釋器;
- 版本的構(gòu)建過程管理變得復(fù)雜;
- 受限于業(yè)務(wù)軟件人員能力允乐,對于腳本的修改可能會集中在熟練掌握腳本語言語法的人身上;
- 連貫的代碼開發(fā)過程,卻要在不同IDE和工具鏈之間切換;
那么有沒有辦法利用C/C++語言自身完成上述工作呢削咆? 有牍疏!那就是利用預(yù)處理元編程技巧!
預(yù)處理元編程
對于上述問題鳞陨,我們回顧利用腳本的解決方法: 先定義一份元數(shù)據(jù),然后利用腳本將其解釋成四種不同的展現(xiàn)方式瞻惋! 一份描述厦滤,想要在不同場合下不同含義,如果利用宿主語言解決的手段就是多態(tài)歼狼!
大多數(shù)程序員都知道對于C++語言掏导,可以實施多態(tài)的階段分為靜態(tài)期和動態(tài)期。靜態(tài)期指的是編譯器在編譯階段確定多態(tài)結(jié)果羽峰,而動態(tài)期是在程序運行期確定趟咆!靜態(tài)多態(tài)的常用手段有函數(shù)/符號重載、模板等梅屉,動態(tài)多態(tài)的手段往往就是虛函數(shù)值纱。
事實上很少有人關(guān)注,C/C++的預(yù)處理階段也是實施多態(tài)的一個重要階段履植,而這時可以采用的手段就是宏计雌!
宏是一個很強大的工具!簡單來說宏就是文本替換玫霎,正是如此宏可以用來做代碼生成凿滤,我們把利用宏來做代碼生成的技巧叫做預(yù)處理元編程!
往往越是強大的東西庶近,越容易被誤用翁脆,所以很多教科書都在勸大家謹(jǐn)慎使用宏,語言層面很多原來靠宏來做的事情逐漸都有替代手段出現(xiàn)鼻种,但是唯獨代碼生成這一點卻沒有能夠完全替代宏的方式反番。恰當(dāng)?shù)氖褂煤陙碜龃a生成,可以解決別的技巧很難完成的事情叉钥!相信如果有一天C/C++把宏從語言中剔除掉罢缸,整個語言將會變得無趣很多:)
下面我們看看如何用預(yù)處理元編程來解決上述例子中的問題!
第一種做法
和腳本解決方案類似投队,首先要定義元數(shù)據(jù)文件枫疆,只不過這次元數(shù)據(jù)文件是一個C/C++頭文件,對元數(shù)據(jù)的定義使用宏函數(shù)敷鸦!
//EventMeta.h
EVENTS_BEGIN(Event, 0x4000)
EVENT(setup)
EVENT(cfg)
EVENT(recfg)
EVENT(release)
EVENTS_END()
這份元數(shù)據(jù)如何解釋息楔,完全看其中的EVENTS_BEGIN
、EVENT
扒披、EVENTS_END
宏函數(shù)如何被解釋了值依!
接下來我們定義四個解釋器文件,分別對上述三個宏做不同的解釋碟案,最終做到將元數(shù)據(jù)可以翻譯到消息ID定義愿险,消息計數(shù)器定義,計數(shù)函數(shù)和打印函數(shù)价说。
// StructInterpreter.h
#define EVENTS_BEGIN(name, id_offset) struct name##Counter {
#define EVENT(event) U32 event##EventCounter;
#define EVENTS_END() };
// EventIdInterpreter.h
#define EVENTS_BEGIN(name, id_offset) enum name##Id { name##BaseId = id_offset
#define EVENT(event) , event##EventId
#define EVENTS_END() };
// CountInterpreter.h
#define EVENTS_BEGIN(name, id_offset) \
void count##name(U32 eventId) \
{ \
switch(eventId){
#define EVENT(event) \
case event##EventId: \
g_counter.event##EventCounter++; \
break;
#define EVENTS_END() }};
// PrintInterpreter.h
#define EVENTS_BEGIN(name, id_offset) \
void printCounter() {
#define EVENT(event) \
printf(#event"EventCounter = %d \n", g_counter.event##EventCounter);
#define EVENTS_END() };
由于我們給了同一組宏多份重復(fù)的定義辆亏,所以需要定義一個宏擦除文件,以免編譯器告警熔任!
// UndefInterpreter.h
#ifdef EVENTS_BEGIN
#undef EVENTS_BEGIN
#endif
#ifdef EVENT
#undef EVENT
#endif
#ifdef EVENTS_END
#undef EVENTS_END
#endif
這樣我們就完成了類似腳本工具所作的工作褒链! 注意上面的元數(shù)據(jù)文件、四個解釋器文件以及最后的宏擦除文件都是頭文件疑苔,但是都不要加頭文件include guard甫匹!
最后我們用上述定義好的文件來生成最終的消息ID定義、計數(shù)器定義惦费、計數(shù)函數(shù)以及打印函數(shù)兵迅!
// EventId.h
#ifndef H529C3CEC_F5B5_4E3D_9185_D82AF679C1D4
#define H529C3CEC_F5B5_4E3D_9185_D82AF679C1D4
#include "interpreter/EventIdInterpreter.h"
#include "EventMeta.h"
#include "interpreter/UndefInterpreter.h"
#endif
//EventCounter.h
#ifndef HD8D5D593_CCA2_4FE9_9456_4AD69EF8FA54
#define HD8D5D593_CCA2_4FE9_9456_4AD69EF8FA54
#include "BaseTypes.h"
#include "interpreter/StructInterpreter.h"
#include "EventMeta.h"
#include "interpreter/UndefInterpreter.h"
#endif
// CountEvent.c
#include "EventId.h"
#include "EventCounter.h"
#include "interpreter/CountInterpreter.h"
#include "EventMeta.h"
#include "interpreter/UndefInterpreter.h"
// PrintCounter.c
#include "EventCounter.h"
#include <stdio.h>
#include "interpreter/PrintInterpreter.h"
#include "EventMeta.h"
#include "interpreter/UndefInterpreter.h"
可以看到,代碼生成的寫法很簡單薪贫,就是依次包含解釋器文件恍箭、元數(shù)據(jù)文件和宏擦除文件。 生成文件就是最終我們代碼要使用的文件瞧省,這時頭文件則需要加include guard扯夭,每個文件還要包含自身依賴的頭文件鳍贾,做到自滿足。
和使用腳本的解決方案效果一樣交洗,我們以后每次增加一個消息定義只用更改元數(shù)據(jù)文件即可骑科,其它所有地方會自動生成,避免了很多重復(fù)性勞動构拳!重要的是咆爽,預(yù)處理元編程仍然是使用C/C++技術(shù)棧,不會復(fù)雜化開發(fā)和構(gòu)建過程置森!
另一種做法
除了上述做法外斗埂,還有另一種做法,就是把元數(shù)據(jù)文件定義成一個宏函數(shù)凫海,然后將解釋器定義成不同名的宏函數(shù)呛凶,傳給元數(shù)據(jù)對應(yīng)的宏函數(shù)。這種做法可以避免定義宏擦除文件盐碱。具體如下:
// EventMeta.h
#define EVENT_DEF( __EVENTS_BEGIN \
, __EVENT \
, __EVENTS_END) \
__EVENTS_BEGIN(name, id_offset) \
__EVENT(setup) \
__EVENT(cfg) \
__EVENT(recfg) \
__EVENT(release) \
__EVENTS_END()
依然需要寫四個解釋器文件把兔,每個里面各自實現(xiàn)一份__EVENTS_BEGIN
、__EVENT
和__EVENTS_END
的宏函數(shù)定義瓮顽。不同的是每個解釋器文件中的宏函數(shù)可以起更合適的名字县好,定義只要滿足宏函數(shù)接口特征要求即可! 例如”StructInterpreter.h”和“EventIdInterpreter.h"的定義如下:
// StructInterpreter.h
#define STRUCT_BEGIN(name, id_offset) struct name##Counter {
#define FIELD(event) U32 event##EventCounter;
#define STRUCT_END() };
// EventIdInterpreter.h
#define EVENT_ID_BEGIN(name, id_offset) enum name##Id { name##BaseId = id_offset
#define EVENT_ID(event) , event##EventId
#define EVENT_ID_END() };
最后做代碼生成暖混,只要把解釋器里面定義的宏函數(shù)注入給元數(shù)據(jù)文件定義的宏函數(shù)即可:
// EventId.h
#ifndef H529C3CEC_F5B5_4E3D_9185_D82AF679C1D4
#define H529C3CEC_F5B5_4E3D_9185_D82AF679C1D4
#include "interpreter/EventIdInterpreter.h"
#include "EventMeta.h"
EVENT_DEF(EVENT_ID_BEGIN, EVENT_ID, EVENT_ID_END)
#endif
//EventCounter.h
#ifndef HD8D5D593_CCA2_4FE9_9456_4AD69EF8FA54
#define HD8D5D593_CCA2_4FE9_9456_4AD69EF8FA54
#include "BaseTypes.h"
#include "interpreter/StructInterpreter.h"
#include "EventMeta.h"
EVENT_DEF(STRUCT_BEGIN, FIELD, STRUCT_END)
#endif
該方法中由于沒有重名宏缕贡,所以也就不再需要宏擦除文件了。計數(shù)函數(shù)和打印函數(shù)的生成拣播,大家可以自行練習(xí)晾咪!
上述兩種方法,各自適合不同場合:
第一種方法適合于需要定義大量元數(shù)據(jù)的場合贮配! 優(yōu)點是元數(shù)據(jù)的描述比較簡潔谍倦,如同在使用內(nèi)部DSL。 但是這種方法由于解釋器文件之間存在同名宏泪勒,所以你的IDE在自動符號解析時可能會發(fā)出抱怨昼蛀;
第二種方法由于避免了重名宏,所以元數(shù)據(jù)和解釋器的定義不受文件約束圆存。這對于IDE比較友好叼旋! 但是定義元數(shù)據(jù)的方式會受到宏的語法限制(例如以’ \’換行的噪音)。另外當(dāng)元數(shù)據(jù)定義用到大量不同的宏函數(shù)時沦辙,每次代碼生成做宏函數(shù)注入也很累夫植。
構(gòu)建內(nèi)部DSL
在C++語言中,模板元編程是構(gòu)建內(nèi)部DSL的常用武器油讯。模板元編程本質(zhì)上是一種函數(shù)式編程详民,該技術(shù)可以讓C++在編譯期做代碼生成延欠。在實際使用中結(jié)合預(yù)處理元編程和模板元編程,可以簡化彼此的復(fù)雜度阐斜,讓代碼生成更加靈活衫冻,是C++構(gòu)建內(nèi)部DSL的強大武器!
以下是一個在真實項目中應(yīng)用的例子!
在重構(gòu)某一遺留系統(tǒng)代碼時诀紊,發(fā)現(xiàn)該系統(tǒng)包含一個模塊谒出,用來接收另一個控制器子系統(tǒng)傳來的配置消息,然后根據(jù)配置消息中攜帶的參數(shù)值進(jìn)行領(lǐng)域?qū)ο蠼⒘诘臁⑿薷捏栽h除等操作。該模塊可以接收的配置消息有幾十種碌宴,消息均采用結(jié)構(gòu)體定義杀狡,每個消息里面可以嵌套包含其它子結(jié)構(gòu)體,對于消息中的每個子結(jié)構(gòu)體可以有一個對應(yīng)的present字段指示該子結(jié)構(gòu)體內(nèi)的所有參數(shù)值在這次配置中是否有效贰镣。消息中的每個參數(shù)字段都有一個合法范圍呜象,以及一個預(yù)先定義好的錯誤碼。對一個消息的合法性校驗就是逐個檢查消息里面每一個字段以及對應(yīng)present為true的子結(jié)構(gòu)體內(nèi)的每個字段是否在其預(yù)定的合法范圍內(nèi)碑隆,如果某一個字段不在合法范圍內(nèi)恭陡,就做錯誤log記錄,然后函數(shù)結(jié)束并返回對應(yīng)的錯誤碼上煤!如下是一條配置消息的校驗函數(shù)的代碼原型:
Status XxxMsgCheck(const XxxMsg& msg)
{
// ...
if((MIN_VLAUE1 > msg.field1) || (msg.field1 > MAX_VALUE1))
{
ERR_LOG("XxxMsg : field1 is error, expect range[%d, %d], actual value(%d)", MIN_VALUE1, MAX_VALUE1, msg.field1);
return XXX_MSG_FIELD1_ERRCODE;
}
// ...
if(msg.subMsg1Present)
{
if(msg.subMsg1.field1 > MAX_VALUE2)
{
ERR_LOG("XxxMsg->subMsg1 : field1 is error, expect range[0, %d], actual value(%d)", MAX_VALUE2, msg.subMsg1.field1);
return XXX_MSG_FIELD2_ERRCODE;
}
// ...
}
if(msg.subMsg2Present)
{
//...
}
// ...
return SUCCESS;
}
可以看到消息校驗函數(shù)內(nèi)的代碼存在大量的結(jié)構(gòu)性重復(fù)休玩,而這樣的函數(shù)在該模塊中一共存在幾十個。模塊中最大的一個消息包含四十多個子結(jié)構(gòu)體劫狠,展開后一共有800多個參數(shù)字段拴疤,僅對這一個消息的校驗函數(shù)就達(dá)三千多行。該模塊一共不足三萬行独泞,而類似這樣的消息校驗代碼就占了一萬多行呐矾,還不算為每個字段定義錯誤碼、合法范圍邊界值等宏帶來的頭文件開銷懦砂。對這樣一個模塊蜒犯,消息校驗并不是其領(lǐng)域核心,但是結(jié)構(gòu)性重復(fù)導(dǎo)致其占用了相當(dāng)大的代碼比例孕惜,核心的領(lǐng)域邏輯代碼反而被淹沒在其中愧薛。
另一個由此引入的問題在于測試,在對該模塊進(jìn)行包圍測試的時候發(fā)現(xiàn)需要構(gòu)造一條合法的消息很累衫画。大多數(shù)測試僅需關(guān)注消息中的幾個參數(shù)字段毫炉,但是為了讓消息通過校驗,需要把消息中所有的字段都賦上合法的值削罩,否則就不能通過校驗瞄勾。于是有的開發(fā)人員在測試的時候费奸,干脆采用一種侵入式的做法,通過預(yù)處理宏或者全局變量的方式把消息校驗函數(shù)關(guān)閉进陡。
上述問題可能是類似系統(tǒng)中的一個通用問題愿阐,根據(jù)不同的場景可以在不同的層面上去解決。例如我們可以追問這種參數(shù)校驗是否有價值趾疚,以引起防御式編程風(fēng)格的爭辯缨历;或者在不考慮性能的時候引入一種數(shù)據(jù)字典的解決方案;或者為了保持兼容來做代碼生成...
在這里我們給出利用預(yù)處理元編程構(gòu)造內(nèi)部DSL做代碼生成的解決方式糙麦!
通過分析辛孵,上述代碼中一共存在四種明顯的結(jié)構(gòu)性重復(fù)。試想每當(dāng)你為某一個消息增加一個字段赡磅,需要做的事情有:1)在消息結(jié)構(gòu)體中增加字段定義魄缚;2)為該字段定義錯誤碼;3)在校驗函數(shù)中增加該字段的合法性校驗代碼焚廊;4)修改所有使用該消息的測試冶匹,將該字段設(shè)置成合法值,以便讓原測試中的消息能夠通過校驗咆瘟。
那么采用預(yù)編譯元編程的解決思路就是:定義一份元數(shù)據(jù)描述規(guī)則嚼隘,然后寫四個解釋器文件;通過解釋器文件對元數(shù)據(jù)進(jìn)行解釋自動生成上述四種代碼搞疗。用戶后續(xù)就只用按照規(guī)則定義元數(shù)據(jù)文件嗓蘑,在里面描述消息結(jié)構(gòu)特征、以及每個字段的合法范圍特征即可匿乃。
考慮到消息的結(jié)構(gòu)體定義往往是接口文件桩皿,一般修改受限;而且別的子系統(tǒng)也要使用幢炸,需要考慮兼容別人的使用習(xí)慣泄隔,所以對于消息結(jié)構(gòu)體的定義暫不修改,下面只用代碼生成來解決其它三種重復(fù)宛徊。
在本場景中佛嬉,由于可預(yù)期元數(shù)據(jù)數(shù)量很多,而且會經(jīng)常發(fā)生變更闸天,所以我們采用前面介紹的第一種預(yù)處理元編程的方式來做暖呕。在這里元數(shù)據(jù)的描述規(guī)則設(shè)計很重要,它決定了用戶將來使用是否方便苞氮,是否易于理解湾揽。事實上其本質(zhì)就是在定義一種DSL,需要斟酌其中每一個關(guān)鍵字的含義和用法。
例如對于下面的消息:
// XxxMsg.h
struct XxxMsg
{
U8 field1;
U32 field2;
U16 field3;
U16 field4;
U16 field5;
U16 field6;
};
按照我們設(shè)計的DSL库物,對其元數(shù)據(jù)描述文件如下:
// XxxMsgMeta.h
__def_msg_begin(XxxMsg)
__field(field1, LT(3))
__field(field2, NE(3))
__field(field3, GE(1))
__field(field4, BT(2, 128))
__field(field5, __())
__field(field6, OR(LE(2), EQ(255)))
__def_msg_end()
可以看到通過__def_msg_begin
和__def_msg_end
來進(jìn)行消息的描述霸旗。其中需要描述每一個消息字段的名稱和合法范圍。合法范圍的定義通過下面幾種關(guān)鍵字:
- EQ : ==
- NE : !=
- LE : =<
- LT : <
- GE : >=
- GT : >
- BT : between[min戚揭, max]
- OR : || 诱告, 即用來組合兩個條件式,滿足其一即可民晒。
- __ : omit精居, 即對該字段不校驗
- OP : user-defined special operation, 即用戶自定義的字段校驗方式
所有的靜態(tài)范圍描述,使用上面的關(guān)鍵字組合就夠了镀虐;對于動態(tài)規(guī)則箱蟆,用戶需要通過關(guān)鍵字OP
來擴展自定義的校驗方式。
例如對于下面這個消息SpecialOpMsg刮便,其中的field2字段的校驗是動態(tài)的,它必須大于field1字段的值才是合法的:
// SpecialOpMsg.h
struct SpecialOpMsg
{
U8 field1;
U8 field2;
};
這時對于field2字段需要按照如下方式自定義一個Operation
類绽慈,其中使用DECL_CHECK
來定義一個方法恨旱,描述field2字段的校驗規(guī)則;如果該消息要被測試用例使用的話則還需要用DECL_CONSTRUCT
來定義一個field2字段的創(chuàng)建函數(shù)坝疼。
在定義方法的時候搜贤,消息的名字msg
,field2字段的錯誤碼error
都是預(yù)定義好的钝凶,直接使用即可仪芒。
// Field2Op.h
#include "FieldOpCommon.h"
struct Field2Op
{
DECL_CHECK()
{
return (field2 > msg.field1) ? 0 : error;
}
DECL_CONSTRUCT()
{
field2 = msg.field1 + 1;
}
};
// SpecialOpMsgMeta.h
__def_msg_begin(SpecialOpMsg)
__field(field1, GE(10))
__field(field2, OP(Field2Op))
__def_msg_end()
當(dāng)有消息結(jié)構(gòu)嵌套的時候,需要逐個描述每個子結(jié)構(gòu)耕陷,最后用子結(jié)構(gòu)拼裝最終的消息描述掂名。
例如對于如下消息結(jié)構(gòu):
// SimpleMsg.h
struct SubMsg1
{
U8 field1;
U32 field2;
};
struct SubMsg2
{
U16 field1;
};
struct SimpleMsg
{
U32 field1;
SubMsg1 subMsg1;
U16 subMsg2Present;
SubMsg2 subMsg2;
};
定義的元數(shù)據(jù)描述如下:
// SimpleMsgMeta.h
/////////////////////////////////////////////
__def_msg_begin(SubMsg1)
__field(field1, LT(3))
__field(field2, NE(3))
__def_msg_end()
/////////////////////////////////////////////
__def_msg_begin(SubMsg2)
__field(field1, GE(3))
__def_msg_end()
/////////////////////////////////////////////
__def_msg_begin(SimpleMsg)
__field(field1, BT(3,5))
__sub_msg(SubMsg1, subMsg1)
__opt_sub_msg(SubMsg2, subMsg2, subMsg2Present)
__def_msg_end()
可以看到,可以用__sub_msg
來指定包含的子結(jié)構(gòu)哟沫;如果某個子結(jié)構(gòu)是由present字段指明是否進(jìn)行校驗的話饺蔑,那么就使用__opt_sub_msg
,指明子結(jié)構(gòu)體類型嗜诀,字段名以及present對應(yīng)的字段名稱猾警。
對消息描述方式的介紹就到這里!事實上還有很多實現(xiàn)上的細(xì)節(jié)隆敢,例如:如果字段或者子結(jié)構(gòu)是數(shù)組的情況发皿;如果是數(shù)組,數(shù)組大小可以是靜態(tài)的或者由某一個字段指明大小的拂蝎;整個消息中可以包含一個開關(guān)字段穴墅,如果開關(guān)關(guān)閉的話則本消息整體都不用校驗,等等。以下是目前支持的所有描述方式:
- __field : 描述一個字段封救,需要指明字段的合法范圍拇涤;
- __opt_field:描述一個可選字段,除了給出可選范圍誉结,還要給出對應(yīng)的present字段鹅士;
- __switch_field : 開關(guān)字段,當(dāng)該字段關(guān)閉態(tài)的話惩坑,整個消息不做校驗掉盅;
- __fix_arr_field : 靜態(tài)數(shù)組字段,需要指明字段合法范圍以舒,還需要指明數(shù)組靜態(tài)大兄憾弧;
- __dyn_arr_field : 動態(tài)數(shù)組字段蔓钟,需要指明字段的合法范圍永票,還需要給出指示數(shù)組大小的字段;
- __fix_arr_opt_field: 可選的靜態(tài)數(shù)組字段滥沫,在__fix_arr_field的基礎(chǔ)上給出對應(yīng)的present字段侣集;
- __dyn_arr_opt_field: 可選的動態(tài)數(shù)組字段,在__dyn_arr_field的基礎(chǔ)上給出對應(yīng)的present字段兰绣;
- __sub_msg: 描述一個包含的子結(jié)構(gòu)世分;
- __opt_sub_msg:描述一個包含的可選子結(jié)構(gòu)體;在__sub_msg的基礎(chǔ)上還需給出對應(yīng)的present字段缀辩;
- __fix_arr_sub_msg:靜態(tài)數(shù)組子結(jié)構(gòu)臭埋;在__sub_msg的基礎(chǔ)上還需給出靜態(tài)數(shù)組的大小臀玄;
- __dyn_arr_sub_msg:動態(tài)數(shù)組子結(jié)構(gòu)瓢阴;在__sub_msg的基礎(chǔ)上還需給給出指示該數(shù)組大小的字段;
- __fix_arr_opt_sub_msg:可選的靜態(tài)數(shù)組子結(jié)構(gòu)镐牺;在__fix_arr_sub_msg的基礎(chǔ)上還需給出對應(yīng)的present字段炫掐;
- __dyn_arr_opt_sub_msg:可選的動態(tài)數(shù)組子結(jié)構(gòu);在__dyn_arr_sub_msg的基礎(chǔ)上還需給出對應(yīng)的present字段睬涧;
當(dāng)利用上述規(guī)則描述好一個消息后募胃,我們就可以用寫好的預(yù)處理解釋器來生成最終我們想要的代碼了。例如對上面的SimpleMsg畦浓, 我們定義如下文件:
#include "SimpleMsg.h"
#include "ErrorCodeInterpret.h"
#include "SimpleMsgMeta.h"
#include "ConstrantInterpret.h"
#include "SimpleMsgMeta.h"
#include "ConstructInterpret.h"
#include "SimpleMsgMeta.h"
const U32 SIMPLE_MSG_ERROR_OFFSET = 0x4001;
__def_default_msg(SimpleMsg, SIMPLE_MSG_ERROR_OFFSET);
上面分別用ErrorCodeInterpret.h痹束、ConstrantInterpret.h和ConstructInterpret.h把SimpleMsg消息的元數(shù)據(jù)描述生成了對應(yīng)的錯誤碼、供消息校驗用的verify方法以及供測試用例使用的construct方法讶请。在實際中祷嘶,我們往往會把上面幾個代碼生成放在不同文件中屎媳,對于construct的生成只放在測試中。注意最后需要用__def_default_msg
描述论巍,給出消息的錯誤碼起始偏移值烛谊。另外,可以將__def_default_msg
替換成__def_msg
嘉汰,這樣還可以在消息中增加其它自定義方法丹禀。在自定義方法中可以直接使用消息的所有字段。例如:
__def_msg(SimpleMsg, ERROR_OFFSET)
{
bool isXXX() const
{
return (field1 + subMsg1.field1) == 10;
}
};
經(jīng)過上述代碼生成后鞋怀,就可以把原來的plain msg轉(zhuǎn)變成一個method-ful msg双泪。它的每個字段都自動定義了一個從起始值遞增的錯誤碼。它包含一個verify方法密似,這個方法會根據(jù)規(guī)則對每個字段做校驗焙矛,在錯誤的時候記錄log并且返回對應(yīng)的錯誤碼。它還可以包含用戶自定義的其它成員方法残腌。
例如對于SimpleMsg我們可以這樣使用:
TEST(MagCc, should_return_the_error_code_correctly)
{
SimpleMsg msg;
msg.field1 = 3; // OK : __field(field1, BT(3,5))
msg.subMsg1.field1 = 2; // OK : __field(field1, LT(3))
msg.subMsg1.field2 = 1; // OK : __field(field2, NE(3))
msg.subMsg2Present = 1;
msg.subMsg2.field1 = 2; // ERROR:__field(field1, GE(3))
ASSERT_EQ(0x4004, MSG_WRAPPER(SimpleMsg)::by(msg).verify());
}
如果生成了construct方法的話村斟,那么測試用例就可以直接調(diào)用其生成一個所有字段都在合法范圍內(nèi)的消息碼流:
TEST(MagCc, should_construct_msg_according_the_range_description_correctly)
{
SimpleMsg msg;
MSG_CTOR(SimpleMsg)::construct(msg);
ASSERT_EQ(3, msg.field1);
ASSERT_EQ(2, msg.subMsg1.field1);
ASSERT_NE(3, msg.subMsg1.field2);
ASSERT_EQ(1, msg.subMsg2Present);
ASSERT_EQ(3, msg.subMsg2.field1);
}
對于錯誤碼、verify和construct的具體生成實現(xiàn)废累,主要定義在幾個解釋器文件里面邓梅。對verify和construct的實現(xiàn)使用了一些模板的技巧。利用預(yù)處理元編程邑滨,可以將對應(yīng)的宏翻譯到不同的模板實現(xiàn)上去,所以每組模板可以只用關(guān)注一個方面钱反,簡化了模板的使用復(fù)雜度掖看。對于預(yù)處理元編程在構(gòu)造內(nèi)部DSL上的使用就介紹到這里,本例中的其它細(xì)節(jié)不再展開面哥,具體的源代碼放在https://github.com/MagicBowen/msgcc哎壳,可自行下載閱讀。
工程實踐
由于預(yù)處理元編程主要在使用宏的技巧尚卫,所以在工程實踐中归榕,使用可以自動宏展開提示的IDE,會使這一技巧的使用變得容易很多吱涉! 例如eclipse-cdt中使用快捷鍵“ctr
+=
”刹泄,可以直接在IDE中看到宏展開后的效果。
另外怎爵,也可以給makefile中增加預(yù)處理文件的構(gòu)建目標(biāo)特石,在出問題的時候可以構(gòu)建出預(yù)處理后的源代碼文件,以方便問題定位鳖链。
# makefile example
$(TARGET_PATH)%.i : $(SOURCE_PATH)%.cpp
$(CXX) -E -o $@ -c $<
總結(jié)
預(yù)處理元編程利用了宏的文本替換原理姆蘸,給一組宏不同的解釋,做到可以將一份元數(shù)據(jù)解釋成不同的形式。預(yù)處理元編程相比用腳本做代碼生成的方案逞敷,和內(nèi)部DSL相比較外部DSL的優(yōu)缺點基本一致狂秦,優(yōu)點是可以保持技術(shù)棧一致,缺點是代碼生成會受限于宿主語言的語法約束推捐。
預(yù)處理元編程是一項很罕用的技術(shù)裂问,但是使用在恰當(dāng)?shù)膱龊希瑢且豁椊鉀Q結(jié)構(gòu)性重復(fù)的有效技巧玖姑! 在C++語言中預(yù)處理元編程和模板元編程的結(jié)合使用愕秫,是構(gòu)造內(nèi)部DSL的強大武器! 由于受限于宏本身的種種限制(難以調(diào)試、難以重構(gòu))弦叶,該技巧最好用在結(jié)構(gòu)模式大量重復(fù)燕鸽,而每個變化方向都相對穩(wěn)定的情況下! 預(yù)處理元編程千萬不要濫用甜孤,使用前需要先評估其帶來的復(fù)雜度和收益!
作者:MagicBowen, Email: e.bowen.wang@icloud.com