• 沒有找到結果。

泛型演算法萃取實例

N/A
N/A
Protected

Academic year: 2022

Share "泛型演算法萃取實例"

Copied!
9
0
0

加載中.... (立即查看全文)

全文

(1)

泛型演算法萃取實例

從語彙符號(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

(2)

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 修飾之:

(3)

// 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 和實際運用時的便利性,以做出決定。

(4)

首先排除 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;

}

(5)

到位:這就是 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

(6)

// 或是 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:

(7)

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;

(8)

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 了:

(9)

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.

參考文獻

相關文件

2.17 Information provided to new board m embers should include the organisation’s aims and objectives, control environment, organisational risks and risk management

Numerical results are reported for some convex second-order cone programs (SOCPs) by solving the unconstrained minimization reformulation of the KKT optimality conditions,

Based on a class of smoothing approximations to projection function onto second-order cone, an approximate lower order penalty approach for solving second-order cone

It is well known that second-order cone programming can be regarded as a special case of positive semidefinite programming by using the arrow matrix.. This paper further studies

The second algorithm is based on the Fischer-Burmeister merit function for the second-order cone complementarity problem and transforms the KKT system of the second-order

Chen, Saddle points of nonlinear second-order cone pro- gramming and second order regularity of second-order cone, submitted

Abstract We investigate some properties related to the generalized Newton method for the Fischer-Burmeister (FB) function over second-order cones, which allows us to reformulate

Fukushima, On the local convergence of semismooth Newton methods for linear and nonlinear second-order cone programs without strict complementarity, SIAM Journal on Optimization,