泛型演算法萃取實例
從語彙符號(Token)的擷取到 consecutive 演算法 柯向上 Josh Ko
2005.11.24 楔子
這篇文章描述的,是一個函式從目的特定(special-purpose)的設計轉化至泛型演算法(generic algorithm)的過程。這篇文章採演化觀點,對整個轉化過程中的考量、所遇問題、程式碼轉變,都做了 詳細論述。整個過程的濫觴是一個「辨識垃圾郵件」的資料結構程式作業。給予一些常見於垃圾郵件的 關鍵字和各個關鍵字的權值,以及一個郵件檔。找出信件檔中所有的關鍵字,將其權值加總後與一門檻 值做比較。若權值總和高於門檻,即回報為垃圾郵件。程式本身並不困難,尤其在不禁止使用 STL 的情 況下,寫起來不花多少時間。然而,其中「將關鍵字擷取出來」的動作,在 STL 內並無直接對應的演算 法。這個動作即所謂的 tokenization,從所得檔案內依序擷取出全部的 tokens(語彙符號)。
起頭:get_token 原始版本
郵件檔是以 file stream 的形式讀入,因此我將擷取 token 的函式介面設計為
istream& get_token(istream&, string&);
這個介面類似於用在 string 的 getline 函式,所以我們可以寫:
string s;
while(get_token(cin, s)){
cout << s << endl;
}
如此將從 cin 依序擷取各個 token、輸出至 cout、換行,並在 cin 發生錯誤或遇到 EOF 時停止。
這份作業所定義的 token 是「連續英文字母」,內部實作只需先將前導的非 token 字元捨去,再將連續出 現的英文字母持續累積,直到再次遇見非 token 字元為止。
istream& get_token(istream& is, string& s){
char c;
while(is.get(c) && !isalpha(c)); // 讀取、捨棄非字母之字元
if(is){ // 造成上面迴圈中止的條件有兩種:其一是 is 遇到錯誤或 EOF;其二是 c 為英文字母。
// 若 is 仍處於正常狀態(未遇到錯誤或 EOF),就代表 c 現在所存的字元為英文字母。
s.clear(); // 先將目的字串清空,以存放新讀進的 token
do{ // 因 c 亦為此 token 的一部分,故以 do while 直接附加於 s 之後 s += c;
}while(is.get(c) && isalpha(c)); // 讀取、附加「是英文字母」的字元 is.putback(c); // 此時的 c 並非 token 之一部分,將之歸還給 is
}
return is;
}
附帶一提,這個函式具有 commit-or-rollback 的性質:如果讀取不到任何 token,目的字串將不會被修改。
跨步:將「Token 組成條件」參數化
倘若現在 token 改以「英文字母、數字、底線」三者組成,顯然 get_token 的原始版本無法滿足需求。
我們必須將函式裡兩個出現「token 組成條件」的地方都改掉才行。那麼如果要讀入不同種類的 tokens,
我們豈不就得寫出多種版本的 get_token?「This is tedious and error-prone.」幸好,解決方法十分顯 而易見:把「token 組成條件」參數化(parameterize)就行了。我依照 STL 的形式,將此條件參數的 型 別 設 為 一 個 template parameter, 並 依 STL 分 類 學 將 這 個 template parameter 命 名 為 UnaryPredicate:
template<typename UnaryPredicate>
istream& get_token(istream& is, string& s, UnaryPredicate pred){
char c; // buffer
while(is.get(c) && !pred(c)); // 捨棄非 token 的字元 if(is){
s.clear();
do{
s += c;
}while(is.get(c) && pred(c));
is.putback(c);
}
return is;
}
使用函式時,語意也變得較為清晰,且不顯累贅:
string s;
while(get_token(cin, s, isalpha)){
cout << s << endl;
}
不過,g++(我手上的版本是 3.4.2)在試圖推導 isalpha 引數型別時會有些問題,肇因於 isalpha 除 了在<cctype>內所定義的 int isalpha(int)版本以外,尚有與 locale 相關的 templatized 版本與前 者形成多載化,而使 g++ 無法判定要使用哪個版本。在此提供一個解決方案:以帶有 explicit template arguments 的 ptr_fun 修飾之:
// ptr_fun<argument type, return type>
while(get_token(cin, s, ptr_fun<int, int>(isalpha))){
// ...
完成「條件參數化」之後,要從串流擷取任何形式的 token 即易如反掌。只需寫出描述該種 token 的 unary predicate,再以之作為 get_token 的第三參數即可。Predicate 有時還不需自己動手實作,直接從 standard library 拿就是了,還可以用 function object adaptors 配接現存的 function objects,或是以 Boost lambda library 產生 function objects…。猶如在 get_token 的介面鑿個窟窿,立時引入來自 STL 的豐 富泉源一般,我們獲得了 STL concepts 所提供的極佳彈性。
靠攏:泛化輸入與輸出
現在的 get_token 輸入來源和輸出目的分別綁死在 istream 和 string 上。若能以 get_token 從一 個 string 讀取 token,並將結果存於一個 vector<char>,豈不快哉?以 STL 的思維來看,立刻想到 iterator正是表述輸入輸出的 concept。只要將 istream 改為兩個 input iterator 所標示的輸入區間,
string改為output iterator,經此泛化動作,便可更貼近 STL 的設計理念,與 STL 接軌。以下是泛 化版本的草稿:
template<typename InputIterator, typename OutputIterator, typename UnaryPredicate>
/* ??? */ get_token(InputIterator first, InputIterator last, OutputIterator oi, UnaryPredicate pred){
typename iterator_traits<InputIterator>::value_type c;
while(first != last && !pred(c = *first++)); // 捨棄非 token 的元素 if(first != last){
do{
*oi++ = c;
}while(first != last && pred(c = *first++));
// input iterator無法執行類似 istream::putback 的動作 }
return /* ??? */;
}
泛化版本用了 iterator_traits 搾取 InputIterator 的 value_type,作為變數 c 的型別。串流 讀入、字串附加動作都被改為泛化的 iterator 讀寫形式。原先版本在累積形成一個 token 之後,將多取的 一個字元以 istream::putback 放回 stream 之中;泛化版則因為 input iterator 的先天限制,而無法 執行這個動作。我將在最後探討這個問題。
回傳值的討論
這個 get_token 版本的回傳值較難決定,候選人有 first、oi、和 pred 三者。我們將參考 STL algorithms 和實際運用時的便利性,以做出決定。
首先排除 pred。所有 STL algorithms 之中只有 for_each 回傳其獲得的 function object,而我們 知道它這麼做的原因是因為 for_each 的主要動作都是由傳入的 function object 完成,而這個 function object 可能帶有呼叫者想要的資訊,也就是說,這個 function object 可能帶有 state。
Predicate 幾乎都不具 state(甚至嚴禁「在演算法執行時變動的 state」影響其判斷結果),所以傳 回 predicate 對呼叫者並無多大幫助。
STL algorithms 之中,接收 output iterator 又有回返值的演算法均傳回所得的 output iterator,因 此傳回 oi 的呼聲甚高。但稍安勿躁,先看看下一個 ☺。
若傳回 first,我們就可知道 token 擷取的停止位置,這十分有用。例如:
string s("abc;def2ghi.");
vector<char> v;
string::iterator i(s.begin()); // 若使用 const_iterator,
// 下面 get_token 的引數推導將會失敗
while((i = get_token(i, s.end(), back_inserter(v), isalpha)) != s.end()){
copy(v.begin(), v.end(), ostreambuf_iterator<char>(cout));
cout << endl; // 或是更複雜的處理 v.clear();
}
如此就能持續擷取 token。回頭看看前述的 STL algorithms,均走訪整個輸入範圍,而不像 get_token通常僅走訪一小部份。因此,對這些 algorithms 來講,傳回 input iterator 對呼叫者沒 有幫助,不如傳回 output iterator。而對於 get_token 而言,傳回 input iterator 有顯著的好處,
何樂而不為?
由以上討論,我們拍板定案:
template<typename InputIterator, typename OutputIterator, typename UnaryPredicate>
InputIterator get_token(InputIterator first, InputIterator last, OutputIterator oi, UnaryPredicate pred){
typename iterator_traits<InputIterator>::value_type c;
while(first != last && !pred(c = *first++)); // 捨棄非 token 的元素 if(first != last){
do{
*oi++ = c;
}while(first != last && pred(c = *first++));
// input iterator無法執行類似 istream::putback 的動作 }
return first;
}
到位:這就是 consecutive_copy
觀察上面的泛化 get_token,我們赫然發現:
template<typename InputIterator, typename OutputIterator, typename UnaryPredicate>
InputIterator get_token(InputIterator first, InputIterator last, OutputIterator oi, UnaryPredicate pred);
它的介面已經是個道地的 STL algorithm 了!也因為輸入媒介被泛化為兩個 input iterator,整個演算法 可以更廣義地解釋為「在給定區間內尋找最先出現的一連串『均符合某特定條件』的元素,再將這些元 素複製到給定的 output iterator」。如此一來,get_token 這個名稱已過於狹義。再觀察此演算法的第 二部份「寫入 output iterator」的動作,我們發現這是 STL 內_copy系列演算法的共同特徵,因此這個 演算法應該稱為 xxx_copy。最後,因為此演算法尋找的是「一連串」元素,所以我將之命名為 consecutive_copy。
template<typename InputIterator, typename OutputIterator, typename UnaryPredicate>
InputIterator consecutive_copy(InputIterator first, InputIterator last, OutputIterator oi, UnaryPredicate pred);
溯源:consecutive_copy - _copy = ??
既然有_copy 版本,根據使用 STL 的經驗,必然有個consecutive演算法。consecutive 演算法沒 有 consecutive_copy 的 output iterator 參數,所以必然得傳回原範圍內的一個子區間(以pair包 裝兩個 iterator),標示其找到的 token。(雖然我已將 get_token 此一名稱捨棄,但接下來為了行文方 便,仍將沿用 token 指稱「一連串均符合某特定條件的元素」。)如此一來,輸入來源就必須從 input iterator 窄化為forward iterator,介面變成:
template<typename ForwardIterator, typename UnaryPredicate>
pair<ForwardIterator, ForwardIterator>
consecutive(ForwardIterator first, ForwardIterator last, UnaryPredicate pred);
內部實作也因應介面變動和需求,而有所修正:
template<typename ForwardIterator, typename UnaryPredicate>
pair<ForwardIterator, ForwardIterator>
consecutive(ForwardIterator first, ForwardIterator last, UnaryPredicate pred){
for( ; first != last && !pred(*first); ++first); // 捨棄非 token 的元素 if(first != last){ // first已指到 token 的第一個元素
ForwardIterator second = first; // 用以標示 token 區間的 past-the-end 位置 while(++second != last && pred(*second)); // 遞增 second;判斷是否超出範圍;
// 若還在範圍內,就判斷是否為 token 元素;
// 若是 token 元素就繼續。
return make_pair(first, second); // 跳出前面迴圈的可能條件有二:second 已抵達 last
// 或是 second 所指向的元素不為 token 元素,
// 正好符合 STL 區間的「(前閉)後開」規格。
}
return make_pair(last, last); // 未找到任何 token,傳回空區間 }
程式碼雖然長度差不多,但已經變得有點 tricky,特別是裡面的兩個迴圈。讓我們仔細觀察這兩個迴圈從 consecutive_copy到 consecutive 的演化。
忽略非 Token 元素的迴圈
首先是「忽略非 token 元素」的迴圈,在 consecutive_copy 裡是:
while(first != last && !pred(c = *first++));
首先,變數 c 可以省略。在 consecutive_copy 裡,提領 input iterator 後就不能再度提領,因此我們 必須將提領出來的值先存放於 c,以備後面輸出至 output iterator 時使用。然而在 consecutive 裡面,
我們的目的只剩下移動 first,後面也不用將提領出的值寫到 output iterator,所以已無必要另外存放 此值。如此一來,程式碼就變成:
while(first != last && !pred(*first++));
接著,我們發現跳出迴圈後,first 指向的元素是 token 的第二個元素(或 past-the-end),而非我們所 想要的第一個元素。原因是 first 提領取值判斷後,就被 postfix operator++給遞增了,因此在 first 指向第一個 token 元素、判斷、跳出迴圈後,first 將被遞增,指向 token 的第二個元素。我們要的效 果應該是「條件確定成立才遞增 first」,而這正是 while loop 主體或 for loop 表頭第三段(或主體)
所能做的。我選擇將遞增動作寫在 for 表頭的第三段,以強調 first 被 for loop 驅動的感覺:
for( ; first != last && !pred(*first); ++first);
這就是在 consecutive 裡面的第一個迴圈了。
定出 Token 區間的迴圈
至於第二個「定出 token 區間」的迴圈,我個人經歷了一些轉折,甚為有趣。在 consecutive_copy 的程式碼片段是:
do{
*oi++ = c;
}while(first != last && pred(c = *first++));
首先,關於 output iterator 的東西先刪去,do while 化簡成為 while:
while(first != last && pred(*first++));
接下來,因為 first 指向 token 的第一個元素,已是我們想求的第一個 iterator,因此在尋找 token 的 past-the-end 位置時,應該用另一個 iterator 巡訪:
ForwardIterator second = first;
while(second != last && pred(*second++));
這個迴圈和「忽略非 token 元素」的迴圈有相同的 iterator 遞增問題:跳出迴圈時,若 second 不等於 last,則 second 將指向最後一個 token 元素的再後面兩個位置。因此我們使用相同手法,把遞增動作 改置於 for 表頭的第三段:
ForwardIterator second = first;
for( ; second != last && pred(*second); ++second);
再來,我們發現第一次進入這個迴圈時的檢查動作是冗贅的:若能抵達此段程式碼,first 必定不為 last,且所指元素必然為 token 元素。因此 second 以 first 初始化後,可以直接遞增,再進入迴圈:
ForwardIterator second = first;
++second;
for( ; second != last && pred(*second); ++second);
顯然這個新增的述句可與其下的 for 結合:
ForwardIterator second = first;
for(++second; second != last && pred(*second); ++second);
最後的關鍵:觀察現在的 for loop,第一段與第三段的動作相同,而其執行次序是「遞增(第一段) → 判斷(第二段) → 遞增(第三段) → 判斷(第二段) → 遞增(第三段) → …(二、三循環)」,
也就是「遞增 → 判斷 → …(兩動作循環)」,因此根本沒必要寫成 for 的三段式,只需在判斷條件真 假以前遞增 second 即可。要達成這件事,我們可將 for 重新改寫為 while,並以 prefix operator++
遞增 second:
ForwardIterator second = first;
while(++second != last && pred(*second));
這就是 consecutive 裡面的第二個迴圈。
討論:consecutive_copy 回傳值的兩難抉擇
最後,我們探討一個懸宕未決的問題 ── consecutive_copy的回傳值。以下是曾出現於第 4 頁的測 試程式碼:
string s("abc;def2ghi.");
vector<char> v;
string::iterator i(s.begin());
while((i = consecutive_copy(i, s.end(), back_inserter(v), isalpha)) != s.end()){
copy(v.begin(), v.end(), ostreambuf_iterator<char>(cout));
cout << endl;
v.clear();
}
其執行結果是
abc def
少了最後的 ghi!觀察 consecutive_copy 目前的程式碼:
template<typename InputIterator, typename OutputIterator, typename UnaryPredicate>
InputIterator consecutive_copy(InputIterator first, InputIterator last, OutputIterator oi, UnaryPredicate pred){
typename iterator_traits<InputIterator>::value_type c;
while(first != last && !pred(c = *first++)); // 捨棄非 token 的元素 if(first != last){
do{
*oi++ = c;
}while(first != last && pred(c = *first++));
// input iterator無法執行類似 istream::putback 的動作 }
return first;
}
啊哈!從 get_token 轉化到 consecutive_copy 時,我們把 putback 給省略了,而此動作是為了還 原前面「擷取到非 token 字元」所做。現在輸入媒介是 input iterator,當第二個迴圈終止時,first 可 能指向 last,或是最後一個 token 元素的再後面兩個位置。這立刻勾起我們的回憶:我們在推演 consecutive時,不也修正過這問題嗎?標準作法是將 first 的遞增動作提到 for loop 表頭的第三 段,但現在 consecutive_copy 內的迴圈是 do while,無法直接改為 for。不過,我們仍然能搬弄 C++的算式求值規則,寫出與所求功能相同的 condition:
// 修訂版本 do{
*oi++ = c;
}while(first != last && pred(c = *first) && (++first, true));
如此 consecutive_copy 所傳回的 input iterator 即指向正確的 past-the-end 位置,而前述的測試程式 碼就能如預期地輸出最後面的 ghi。然而這就牽扯到 input iterator 的特性:同一個位置僅能提領一次。
因此如果採用修訂後的版本,最後傳回的 input iterator 將不能被提領。這自然造成使用者的困擾,更 別說使用者真的提領這個 iterator 時,會發生什麼狀況。事實上,這裡的測試程式碼就已經二度提領這個 回傳的 iterator 了:
while((i = consecutive_copy(i, s.end(), back_inserter(v), isalpha)) != s.end()){
我們用回傳的 iterator 當作下次呼叫 consecutive_copy 的引數,除非 i 剛好是 s.end(),否則必然 會再度提領 i。只因我們實際傳入的 string::iterator 是 random access iterator,才沒出問題。甚 至用 istream_iterator 這種「僅模塑 input iterator」的 iterator,可能也不會有問題!以 SGI STL 對 istream_iterator 的實作方式為例,istream_iterator::operator*可以重複施行,也會獲 得相同的值。所以用既有的 STL 組件實際測試時,恐怕測不出什麼差異。
但即使我們只管實際面、不理會 STL concepts 規格,倘若 s 的初始內容改為:
string s("abc;def2ghi"); // 少了最後的句點
那麼無論是修訂前或修訂後,輸出結果都不會有 ghi。因此,雖然修訂版本所傳回的 iterator 指示的位置 較為正確(token 區間的 past-the-end),但修訂所得的益處和「違反 STL concepts」相比,後者仍較顯 著。因此,我最後仍採用修訂前的版本,傳回 token 最後一個元素的後兩個位置。修訂前和修訂後兩種 版本都有瑕疵,這是在兩難情況之下,我認為較佳的抉擇。
參考書目
1. ISO/IEC 14882:2003, International Standard – Programming Languages – C++.
2. 泛型程式設計與 STL,侯捷、黃俊堯合譯,碁峰 2000。
原文本:Generic Programming and the STL, by Matthew H. Austern, Addison-Wesley 1998.
3. C++ Primer, 4/e, by Stanley B. Lippman, Jos´ee Lajoie, Barbara E. Moo, Addison-Wesley 2005.
4. The C++ Programming Language, Special Ed., by Bjarne Stroustrup, Addison-Wesley 2000.
5. STL 源碼剖析,侯捷著,碁峰 2002。
6. Exceptional C++ Style, by Herb Sutter, Addison-Wesley 2004.