使用 c++ 模板顯示例項化解決模板函式宣告與實現分離的問題
問題背景
開始正文之前,做一些背景鋪墊,方便讀者瞭解我的工程需求。我的專案是一個客戶端訊息分發中心,在連線上訊息後臺後,後臺會不定時的給我推送一些訊息,我再將它們轉發給本機的其它桌面產品去做顯示。後臺為了保證訊息一定可以推到客戶端,它採取了一種重複推送的策略,也就是說,每次當我重新連線上後臺時,後臺會把一段時間內的訊息都推給我、而不論這些訊息之前是否已經推送過,如果我不加處理的直接推給產品,可能造成同一個訊息重複展示多次的問題。為此,我在接收到訊息後,會將它們儲存在程序中的一個容器中,當有新訊息到達時,會先在這個容器裡檢查有沒有收到這條訊息,如果有,就不再轉發。
1 namespace GCM { 2 class server_msg_t 3 { 4 public: 5 void dump(char const* prompt); 6 7 std::string appname; 8 std::string uid; 9 std::string msgid; 10 time_t recv_first = 0; 11 time_t recv_last = 0; 12 int recv_cnt = 0; 13 }; 14 15 class WorkEngine 16 { 17 public: 18 WorkEngine(); 19 ~WorkEngine(); 20 21 private: 22 // to avoid server push duplicate messages to same client. 23 // note this instance is only accessed when single connection to server arrives message, so no lock needed.. 24 std::vector<server_msg_t> m_svrmsgs; 25 }; 26 }
上面的是經過簡化以後的程式碼,m_svrmsgs 成員儲存的就是接收到的所有的後臺訊息,server_msg_t 代表的就是一個後臺訊息,appname、uid 用來定位發給哪個產品的哪個例項;msgid 用來唯一的標識一個訊息;recv_first、recv_last、recv_cnt 分別表示訊息接收的首次時間、最後時間以及重複接收次數。那麼現在一個很現實的問題就是,我需要把這些訊息序列化到永久儲存上去,以便程序重啟後這些資訊還在。這裡我使用了 sqlite 資料庫,與此相關的程式碼封裝在了 WorkEngine 的成員函式中,很容易想到的一種函式宣告方式是這樣:
1 namespace GCM { 2 class server_msg_t 3 { 4 public: 5 void dump(char const* prompt); 6 7 std::string appname; 8 std::string uid; 9 std::string msgid; 10 time_t recv_first = 0; 11 time_t recv_last = 0; 12 int recv_cnt = 0; 13 }; 14 15 class WorkEngine 16 { 17 public: 18 WorkEngine(); 19 ~WorkEngine(); 20 21 protected: 22 int db_store_server_msg (std::vector<server_msg_t> const& vec); 23 int db_fetch_server_msg (std::vector<server_msg_t> & vec); 24 25 private: 26 // to avoid server push duplicate messages to same client. 27 // note this instance is only accessed when single connection to server arrives message, so no lock needed.. 28 std::vector<server_msg_t> m_svrmsgs; 29 }; 30 } 31
像 line 22-23 展示的那樣,直接使用 std::vector<server_msg_t> 這個容器作為引數(有的人可能覺得我多此一舉,直接在函式裡訪問 m_svrmsgs 成員不就行了,為什麼要通過引數傳遞呢?可能這個例子不太明顯,但是確實存在一些情況容器是作為區域性變數而非成員變數存在的,這裡出於說明目的做了一些簡化)。但是我覺得這樣寫太死板了,萬一以後我換了容器呢,這裡是不是還要改?也許是泛型演算法看多了,總感覺這樣寫不夠“通用”。但是如果寫成下面這樣,還是換湯不換藥:
int db_store_server_msg (std::vector<server_msg_t>::iterator beg, std::vector<server_msg_t>::iterator end);
參考標準庫 std::copy 演算法,將其改造一番,結果就成了這個樣子:
template <class InputIterator> int db_store_server_msg(InputIterator beg, InputIterator end);
叫成員函式模板,還是成員模板函式,還是模板成員函式……說不清楚,反正就是成員函式+模板函式。實現的話可以這樣寫:
1 namespace GCM { 2 template <class InputIterator> 3 int WorkEngine::db_store_server_msg(InputIterator beg, InputIterator end) 4 { 5 int ret = 0, rowid = 0; 6 qtl::sqlite::database db(SQLITE_TIMEOUT); 7 8 try 9 { 10 db.open(get_db_path().c_str(), NULL); 11 writeInfoLog("open db for store server msg OK"); 12 13 db.begin_transaction(); 14 15 for (auto it = beg; it != end; ++it) 16 { 17 // 1th, insert or update user info 18 rowid = db.insert_direct("replace into server_msg (appname, uid, msgid, first_recv, last_recv, count) values (?, ?, ?, ?, ?, ?);", 19 it->appname, it->uid, it->msgid, it->recv_first, it->recv_last, it->recv_cnt); 20 21 ret++; 22 } 23 24 db.commit(); 25 db.close(); 26 writeInfoLog("replace into %d records", ret); 27 } 28 catch (qtl::sqlite::error &e) 29 { 30 writeInfoLog("manipute db for store server msg error: %s", e.what()); 31 db.rollback(); 32 db.close(); 33 return -1; 34 } 35 36 return ret; 37 } 38 }
可以看到,核心程式碼就是對迭代器區間作遍歷 (line 15)。呼叫方也是非常簡潔:
db_store_server_msg(m_svrmsgs.begin(), m_svrmsgs.end());
一行搞定,看起來已經大功告成了,毫無難度可言,那麼這篇文章想要說明什麼呢?彆著急,真正的難點在於從資料庫恢復資料。首先直接使用迭代器是不行了,因為我們現在要往容器裡插入元素,迭代器只能遍歷元素,一點幫助也沒有。但是相信讀者一定看過類似這樣的程式碼:
1 int main (void) 2 { 3 int arr[] = { 1, 3, 5, 7, 11 }; 4 std::vector vec; 5 std::copy (arr, arr + sizeof (arr) / sizeof (int), std::back_inserter(vec)); 6 for (auto it = vec.begin (); it != vec.end (); ++ it) 7 printf ("%d\n", *it); 8 9 return 0; 10 }
為了在容器尾部插入元素,標準庫演算法藉助了 back_inserter 這個東東。於是自然而然的想到,我們這裡能不能宣告 back_inserter 作為輸入引數呢? 例如像這樣:
template <class OutputIterator> int db_fetch_server_msg(OutputIterator it);
模板實現這樣寫:
1 namespace GCM { 2 template <class OutputIterator> 3 int WorkEngine::db_fetch_server_msg(OutputIterator it) 4 { 5 int ret = 0; 6 qtl::sqlite::database db(SQLITE_TIMEOUT); 7 8 try 9 { 10 db.open(get_db_path().c_str(), NULL); 11 writeInfoLog("open db for fetch server msg OK"); 12 13 db.query("select appname, uid, msgid, first_recv, last_recv, count from server_msg", 14 [&ret, &it](std::string const& appname, std::string const& uid, std::string const& msgid, time_t first_recv, time_t last_recv, int count) { 15 server_msg_t sm; 16 sm.appname = appname; 17 sm.uid = uid; 18 sm.msgid = msgid; 19 sm.recv_first = first_recv; 20 sm.recv_last = last_recv; 21 sm.recv_cnt = count; 22 *it = sm; 23 ++ret; 24 }); 25 26 db.close(); 27 writeInfoLog("query %d records", ret); 28 } 29 catch (qtl::sqlite::error &e) 30 { 31 writeInfoLog("manipute db for store server msg error: %s", e.what()); 32 db.close(); 33 return -1; 34 } 35 36 return ret; 37 } 38 }
其實核心就是一句對 back_inserter 的賦值語句 (line 22)。呼叫方同樣是一行搞定:
db_fetch_server_msg (std::back_inserter(m_svrmsgs));
模板宣告與模板實現的分離
上面的程式碼可以正常通過編譯,但前提是模板實現與模板呼叫位於同一檔案。考慮到這個類之前已經有許多邏輯,我決定將與資料庫相關的內容,轉移到一個新的檔案(engine_db.cpp),來減少單個檔案的程式碼量。調整後的檔案結構如下:
+ engine.h: WorkEngine 宣告 + engine.cpp:WorkEngine 實現 (包含 engine.h) + engine_db.cpp:WorkEngine::db_xxx 模板實現 (包含 engine.h)
重新編譯,報了一個連結錯誤:
1>workengine.obj : error LNK2001: 無法解析的外部符號 "protected: int __thiscall GCM::WorkEngine::db_fetch_server_msg<class std::back_insert_iterator<class std::vector<class GCM::server_msg_t,class std::allocator<class GCM::server_msg_t> > > >(class std::back_insert_iterator<class std::vector<class GCM::server_msg_t,class std::allocator<class GCM::server_msg_t> > >)" (??$db_fetch_server_msg@V?$back_insert_iterator@V?$vector@Vserver_msg_t@GCM@@V?$allocator@Vserver_msg_t@GCM@@@std@@@std@@@std@@@WorkEngine@GCM@@IAEHV?$back_insert_iterator@V?$vector@Vserver_msg_t@GCM@@V?$allocator@Vserver_msg_t@GCM@@@std@@@std@@@std@@@Z)
很明顯是模板呼叫時找不到對應的連結所致。此時需要使用“模板顯示例項化”在 engine_db.cpp 檔案中強制模板生成對應的程式碼實體,來和 engine.cpp 中的呼叫點進行連結。需要在該檔案開始處加入下面兩行程式碼:
using namespace GCM;
template int WorkEngine::db_fetch_server_msg<std::back_insert<std::vector<server_msg_t> > >(std::back_insert<std::vector<server_msg_t> >);
注意模板成員函式顯示例項化的語法,我專門查了下《cpp primer》,格式為:
template return_type CLASS::member_func<type1, type2, ……> (type1, type2, ……);
對應到上面的語句,就是使用 std::back_insert<std::vector<server_msg_t> > 代替原來的 OutputIterator 型別,來告訴編譯器顯示生成這樣一個函式模板例項。注意這裡相同的型別要寫兩遍,一遍是函式模板引數,一遍是函式引數。然而這個顯示例項化語法卻沒有通過編譯:
1>engine_db.cpp(15): error C2061: 語法錯誤: 識別符號“back_inserter” 1>engine_db.cpp(15): error C2974: 'GCM::WorkEngine::db_fetch_server_msg' : 模板 對於 'OutputIterator'是無效引數,應為型別 1> f:\gdpclient\src\gcm\gcmsvc\workengine.h(137) : 參見“GCM::WorkEngine::db_fetch_server_msg”的宣告 1>engine_db.cpp(15): error C3190: 具有所提供的模板引數的“int GCM::WorkEngine::db_fetch_server_msg(void)”不是“GCM::WorkEngine”的任何成員函式的顯式例項化 1>engine_db.cpp(15): error C2945: 顯式例項化不引用模板類專用化
百思不得其解。出去轉了一圈,呼吸了一點新鮮空氣,腦袋突然靈光乍現:之前不是有一長串的連結錯誤嗎,把那個裡面的型別直接拿來用,應該能通過編譯!說幹就幹,於是有了下面這一長串顯示例項化宣告:
template int GCM::WorkEngine::db_fetch_server_msg<class std::back_insert_iterator<class std::vector<class GCM::server_msg_t,class std::allocator<class GCM::server_msg_t> > > >(class std::back_insert_iterator<class std::vector<class GCM::server_msg_t,class std::allocator<class GCM::server_msg_t> > >)
過分的是 —— 居然通過編譯了!再仔細看看這一長串型別宣告,貌似只是把 vector 展開了而已,我用“濃縮版”的 vector 再宣告一次試下有什麼變化:
template int GCM::WorkEngine::db_fetch_server_msg<std::back_insert_iterator<std::vector<server_msg_t> > >(std::back_insert_iterator<std::vector<server_msg_t> >);
居然也通過了。看來只是用 back_insert_iterator 代替了 back_inserter 就好了,back_insert_iterator 又是一個什麼鬼?檢視 back_inserter 定義,有如下發現:
1 template<class _Container> inline back_insert_iterator<_Container> back_inserter(_Container& _Cont) 2 { // return a back_insert_iterator 3 return (_STD back_insert_iterator<_Container>(_Cont)); 4 }
貌似 back_inserter 就是一個返回 back_insert_iterator 型別的模板函式,與 std::make_pair(a,b) 和 std::pair <A,B> 的關係很像,因為這裡要的是一個型別,所以不能直接傳 back_inserter 這個函式給顯示例項化的宣告。好,到目前我止,我們實現了用一個 inserter 或兩個 iterator 引數代替笨拙的容器引數、並可以將宣告、呼叫、實現分割在三個不同的檔案中,已經非常完美。美中不足的是,模板顯示例項化還有一些囉嗦,這裡使用 typedef 定義要例項化的型別,將上面的語句改造的更清晰一些:
typedef std::back_insert_iterator<std::vector <server_msg_t> > inserter_t; template int WorkEngine::db_fetch_server_msg<inserter_t>(inserter_t);
同理,對 db_store_server_msg 進行同樣的改造:
typedef std::vector <std::string, server_msg_t>::iterator iterator_t; template int WorkEngine::db_store_server_msg<iterator_t>(iterator_t, iterator_t);
這樣是不是更完美了?
使用 map 代替 vector
在使用過程中,發現使用 map 可以更快更方便的查詢訊息是否已經在容器中,於是決定將訊息容器定義變更如下:
std::map<std::string, server_msg_t> m_servmsgs;
其中 map 的 value 部分與之前不變,增加的 key 部分為 msgid。這樣改了之後,遍歷時要使用 "it->second." 代替 "it->";插入元素時需要使用 “*it = std::make_pair (sm.msgid, sm)” 代替 “*it = sm”。做完上述修改,我發現程式仍然編譯不通過。經過一番排查,發現原來是 back_inserter 不能適配 map 容器。因為 back_inserter 對應的 back_insert_iterator 在 = 操作符中會呼叫容器的 push_back 介面,而這個介面僅有 vector、list、deque 幾個容器支援,map 是不支援的。怎麼辦呢,幸好已經有好心人寫好了 map 的插入器 —— map_inserter:
1 #pragma once 2 3 namespace std 4 { 5 template <class _Key, class _Value, class _Compare> 6 class map_inserter { 7 8 public: 9 typedef std::map<_Key, _Value, _Compare> map_type; 10 typedef typename map_type::value_type value_type; 11 12 private: 13 map_type &m_; 14 15 public: 16 map_inserter(map_type &_m) 17 : m_(_m) 18 {} 19 20 public: 21 template <class _K, class _V, class _Cmp> 22 class map_inserter_helper { 23 public: 24 typedef map_inserter<_K, _V, _Cmp> mi_type; 25 typedef typename mi_type::map_type map_type; 26 typedef typename mi_type::value_type value_type; 27 28 map_inserter_helper(map_type &_m) 29 :m_(_m) 30 {} 31 32 const value_type & operator= (const value_type & v) { 33 m_[v.first] = v.second; 34 return v; 35 } 36 private: 37 map_type &m_; 38 }; 39 40 typedef map_inserter_helper<_Key, _Value, _Compare> mi_helper_type; 41 mi_helper_type operator* () { 42 return mi_helper_type(m_); 43 } 44 45 map_inserter<_Key, _Value, _Compare> &operator++() { 46 return *this; 47 } 48 49 map_inserter<_Key, _Value, _Compare> &operator++(int) { 50 return *this; 51 } 52 53 }; 54 55 template <class _K, class _V, class _Cmp> 56 map_inserter<_K, _V, _Cmp> map_insert(std::map<_K, _V, _Cmp> &m) { 57 return map_inserter<_K, _V, _Cmp>(m); 58 } 59 };
這段程式碼我是從網上抄來的,具體請參考下面的連結:std::map 的 inserter 實現。然而不幸的是,這段程式碼“殘疾”了,不知道是作者盜鏈、還是沒有輸入完整的原因,這段程式碼有一些先天語法缺失,導致它甚至不能通過編譯,在我的不懈“腦補”過程下,缺失的部分已經通過高亮部位補齊了,眾位客官可以直接享用~
特別需要說明的是,最有技術含量的缺失發生在 line 37 的一個引用符,如果沒有加入這個,雖然可以通過編譯,但在執行過程中,inserter 不能向 map 中插入元素,會導致從資料庫讀取完成後得到空的 map。我一直嘗試查詢這個文章的原文,但是一無所獲,對於網際網路傳播過程中發現這樣驢頭馬嘴的訛誤事件,本人表示非常痛心疾首(雖然我不是很懂,但你也不能坑我啊)……
好了,話歸正題,有了 map_inserter 後,我們就可以這樣聲明瞭:
typedef std::map_inserter<std::string, server_msg_t, std::less<std::string> > inserter_t; template int WorkEngine::db_fetch_server_msg<inserter_t>(inserter_t);
對於這個 map_inserter 實現,我們需要傳遞 map 的三個模板引數,而不是 map 本身這個引數,我不太清楚是一種進步、還是一種退步,反正這個 map_inserter 有點兒怪,沒有封裝成 map_insert_iterator + map_inserter 的形式,和標準庫的實現水平還是有差異的,大家將就看吧。呼叫方也需要進行一些微調:
db_fetch_server_msg(std::map_inserter<std::string, server_msg_t, std::less <std::string> >(m_svrmsgs));
看看,沒有標準庫實現的簡潔吧,到底是山寨貨啊~ 幸好我們已經封裝了 inserter_t 型別,可以改寫成這樣:
db_fetch_server_msg(inserter_t(m_svrmsgs));
簡潔多了。現在我們再看下專案的檔案組成:
+ map_inserter.hpp: map_inserter 宣告+實現 + engine.h: WorkEngine 宣告 (包含 map_inserter.hpp) + engine.cpp:WorkEngine 實現 (包含 engine.h) + engine_db.cpp:WorkEngine::db_xxx 模板實現 (包含 engine.h) ……
這裡為了降低複雜度,將 map_inserter 放在標頭檔案中進行共享,類似於標準庫標頭檔案的使用方式。
使用普通模板函式代替類成員模板函式
本文的最後,我們再回頭看一下上面例子中的兩個成員模板函式,發現它們並沒有使用到類中的其它成員,其實完全可以將它們獨立成兩個普通模板函式去呼叫,例如改成這樣:
1 namespace GCM { 2 class server_msg_t 3 { 4 public: 5 void dump(char const* prompt); 6 7 std::string appname; 8 std::string uid; 9 std::string msgid; 10 time_t recv_first = 0; 11 time_t recv_last = 0; 12 int recv_cnt = 0; 13 }; 14 15 class WorkEngine 16 { 17 public: 18 WorkEngine(); 19 ~WorkEngine(); 20 21 private: 22 // to avoid server push duplicate messages to same client. 23 // note this instance is only accessed when single connection to server arrives message, so no lock needed.. 24 std::vector<server_msg_t> m_svrmsgs; 25 }; 26 27 template <class InputIterator> 28 int db_store_server_msg(InputIterator beg, InputIterator end); 29 template <class OutputIterator> 30 int db_fetch_server_msg(OutputIterator it); 31 32 typedef std::map <std::string, server_msg_t>::iterator iterator_t; 33 typedef std::map_inserter<std::string, server_msg_t, std::less<std::string> > inserter_t; 34 }
將模板函式宣告從類中移到類外(line 27-30),同時修改 engine_db.cpp 中兩個類的定義和顯示例項化語句,去掉類限制(WorkEngine::):
template int db_fetch_server_msg<inserter_t>(inserter_t); template int db_store_server_msg<iterator_t>(iterator_t, iterator_t);
呼叫處不需要修改。再次編譯報錯:
1>engine_db.cpp(16): warning C4667: “int GCM::db_fetch_server_msg(GCM::inserter_t)”: 未定義與強制例項化匹配的函式模板 1>engine_db.cpp(17): warning C4667: “int GCM::db_store_server_msg(GCM::iterator_t,GCM::iterator_t)”: 未定義與強制例項化匹配的函式模板 1> 正在建立庫 F:\gdpclient\src\gcm\Release\gcmsvc.lib 和物件 F:\gdpclient\src\gcm\Release\gcmsvc.exp 1>workengine.obj : error LNK2001: 無法解析的外部符號 "int __cdecl GCM::db_fetch_server_msg<class std::map_inserter<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> >,class GCM::server_msg_t,struct std::less<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > > > >(class std::map_inserter<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> >,class GCM::server_msg_t,struct std::less<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > > >)" (??$db_fetch_server_msg@V?$map_inserter@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@U?$less@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@2@@std@@@GCM@@YAHV?$map_inserter@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@U?$less@V?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@2@@std@@@Z) 1>workengine.obj : error LNK2001: 無法解析的外部符號 "int __cdecl GCM::db_store_server_msg<class std::_Tree_iterator<class std::_Tree_val<struct std::_Tree_simple_types<struct std::pair<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const ,class GCM::server_msg_t> > > > >(class std::_Tree_iterator<class std::_Tree_val<struct std::_Tree_simple_types<struct std::pair<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const ,class GCM::server_msg_t> > > >,class std::_Tree_iterator<class std::_Tree_val<struct std::_Tree_simple_types<struct std::pair<class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const ,class GCM::server_msg_t> > > >)" (??$db_store_server_msg@V?$_Tree_iterator@V?$_Tree_val@U?$_Tree_simple_types@U?$pair@$$CBV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@@std@@@std@@@std@@@std@@@GCM@@YAHV?$_Tree_iterator@V?$_Tree_val@U?$_Tree_simple_types@U?$pair@$$CBV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@Vserver_msg_t@GCM@@@std@@@std@@@std@@@std@@0@Z)
前兩個 warning 是因為由成員函式變為普通函式後,顯示例項化需要放在函式實現後面,我們將這兩條語句調整到檔案末尾就好了。對於後面兩個連結 error,百思不得其解,後來使用一個非常簡單的 test 模板函式做試驗,發現是名稱空間搞的鬼,需要在每個函式的定義和顯示例項化語句前加上名稱空間限定(GCM::):
template int GCM::db_fetch_server_msg<inserter_t>(inserter_t); template int GCM::db_store_server_msg<iterator_t>(iterator_t, iterator_t);
可以看到,類成員模板函式和普通模板函式差別還是蠻大的,因為類本身也是一種名稱空間,它的出現簡化了其中成員函式的定址。
結語
其實本文講解了一種通用的通過 iterator 讀取容器、通過 inserter 插入容器元素的方法,這種方式較之直接傳遞容器本身“優雅”不少,雖然不能實現 100% 無縫切換容器,但是也提供了極大的靈活性。特別是還研究瞭如何將這種方式實現的模板函式在不同檔案中分別宣告與實現,達到解除程式碼耦合的目的,具有較強的實用性。當然,這裡僅僅是使用了模板例項化的方式,如果遇到模板不同的 TYPE 需要使用不同的函式實現的話,你可能還要遭遇模板特化語法(包括全特化與偏特化),那樣複雜度還會上升,這裡沒有做進一步探索。
參考
[1]. C++ 11 Lambda表示式
[2]. std::map 的 inserter 實現
[3]. C++ 模板類的宣告與實現分離問題(模板例項化)
[4]. C++函式模板的編譯方式
[5]. c++函式模板宣告與定義相分離
[6]. C++模板之函式模板例項化和具體化
[7]. C++ 函式模板 例項化和具體化
[8]. C++模板之隱式例項化、顯示例項化、隱式呼叫、顯示呼叫和模板特化詳解
[9]. c++模板函式宣告和定義分離
[10]. C++模板程式設計:如何使非通用的模板函式實現宣告和定義