2013年10月10日 星期四

Round 6: Enters matrix

目前為止,我的例子只有一個class,vector。接下來,我將讓例子裡有兩個class。我仍取材自簡單的線性代數運算。



問題P2:寫一個程式,提示使用者輸入兩個分別存放一個矩陣M與一個向量v的檔案的名稱,其中矩陣M代表一個線性轉換。請計算對向量v施以該轉換所得之向量u。

u= Mv

並將u輸出到第三個使用者指定名稱的檔案。提示使用者可重複執行或結束程式。

限制:你必須儘量使用為inner product所開發的vector類別及相關函數,並在必要時增加相關函數。

U步驟:

問題P2要求的檔案輸入輸出均有C++ library可供使用,且牽涉的計算不複雜,所以我暫時將不分解成數個更小的問題。當然,如同前5輪U-D-C-L,我們後續仍有可能提出改善的sub-problem。

你可能要問我,為什麼不在一開始(round 1)的時候就把問題P2一併提出來?你的疑問很合理,我的確可以在一開始就一併提出向量內積與線性轉換等二個問題。即便如此,我並不需要一次面對個不同的問題;因為這個問題彼此間的邊界(boundary)相當清楚,分開來解是合理的做法。同時,因為解線性轉換問題時須使用向量內積,故這個解題順序仍屬合理。

除此之外,現實世界中,客戶追加需求是相當常見的情形。追加的需求,當然需要站在既有的基礎上,正像前述問題限制我們必須儘量重複使用(reuse)為inner product所開發的vector類別及相關函數。問題P2中,線性轉換u = Mv的算法,u的分量為M相同位置的列向量(row vector)與v的內積。


D步驟:


我們需要針對matrix運算與測試、線性轉換主程式建立相關專案。

由於問題要求重複使用且我們也可能需要修改或新增inner product所開發的code,在專案的安排上,我們需要繼續讓
project inner product、 project vector test維持在開發中的狀態。雖然問題P2並未要求與 inner product有關係,由於vector可能有所變動,所以project inner product需要連帶被編譯、執行以確保其正確性。

列出工作如下:


T13 設計 vector在檔案中的儲存格式,編寫讀/寫檔案函數。
T14 測試vector讀/寫檔案函數。
T15 設計matrix在檔案中的儲存格式,編寫讀/寫檔案函數。
T16 測試matrix讀/寫檔案函數。
T17 編寫 matrix類別,含data members 與 member functions。
T18 測試matrix類別。
T19 編寫線性轉換函數。
T20 測試線性轉換函數。
T21 編寫main函數。

C步驟:
先做哪個task?基於round 5結果,我們有vector test專案,因此可以先顯選擇範圍限制在處理T13(先寫 vector讀寫檔函數)或T14(先寫 vector讀寫檔函數單元測試)。 

我採取 測試先行(test first)策略,先選擇T14。好處有二:

  • 當unit test code完成時,已初步完成vector讀寫檔函數的函數原型設計。
  • unit test執行無誤時,你知道已初步完成vector讀寫檔函數的函數的實作。

T14與T13完成後,編譯、執行inner product project,確認無誤。這個動作為inner product project進行迴歸測試(Regression testing)原有的inner product 執行已達預期狀況,但它使用的class vector與函數變動了,我們需重新加以確認。

(學習課題:ifstream, ofstream, fstream)

接著,完成matrix相關測試、類別與函數,仍採測試先行,依序執行T18、T17、T16、T15。在此之前,我們仿照先前vector的做法,建立matrix test專案。

下篇繼續。

© Y C Cheng, 2013. All rights reserved.

2013年10月3日 星期四

Round 5: Unify when you can; separate when you must

U步驟:第四輪結束時,所有class與function均存放在同一個file main.cpp,如下:

main函數是一個劇烈變動點,經常透過編輯code的方式主功能(production code)測試(test code)間切換。將此問題具體敘述如下:

SP5:分割程式碼,建立測試專案,與production專案分立。

D步驟:要解決SP5,必須要使test code與product code不共用 main function。具體的做法是自innerProduct專案拆出另一個測試專案,暫時稱為 vectorTest專案,並設法讓兩個專案共用 vector, inputVector, outputVector 與 innerProduct,示意如下:


建立待辦工作如下:
T11 分割test code、vector與相關函數
T12 建立test project, 修訂inner product project。

C步驟:

T11 分割test code、vector與相關函數

1. 將main.cc中vector與相關的函數搬到二個檔案,vector.h與vector.cpp,在main.cc中引用 vector.h。編譯,修正錯誤。

2. main.cc中test code搬移至另一個檔案,vectorTest.cpp
vectorTest.cpp中引用 vector.h,編譯,修正錯誤。

(學習課題:為class與函數分別建立 header file(宣告)與 implementation file(定義),亦即 interface in .h file, implementation in .cpp file)
(學習課題:#ifndef, #define, #endif的使用)

T12 建立test project。

1. 建立 test project:vectorTest.cpp code併入 main function所在的mainTest.cpp;設定CppUnitLite 路徑;在test project中加入 vector.h、 vector.cpp;編譯,修正錯誤。

2. 移除原 innerProduct專案之CppUnitLite 路徑設定,移除並刪除vectorTest.cpp。

L步驟:現在,你已擁有 a production project and a test project各一個!接下來將能更容易的為vector及相關函數做更多測試。再度檢視待辦工作,我們發現只剩SP2了。解或不解?請你自己決定。inner product問題,將暫告一個段落。





© Y C Cheng, 2013. All rights reserved.

2013年9月26日 星期四

Round 4: Forget main; unit testing is better

第三輪結束時,我們找到一個議題:讓test code脫離 main函數存在。按照round 3的做法,test code寄生在main之下,則innerProduct函數的test code可如下:




第四輪將分離這些test code。

U步驟:檢視test code,可了解做的是針對innerProduct函數的局部測試,分別為可計算內積與不可計算內積的兩種情形。我們採取的方式是將結果輸出到console,然後自行判讀執行結果是否符合期望。用同樣的方法做更多局部測試,將出現下面的現象:
  • 當我們做的局部測試更多時,console輸出將會越來越多,不易判讀結果是否符合期望。
  • 為了省寫些test code,你可能會重複使用某些變數,例如前列中的向量u出現在兩個測試裡。你必須很確定u第一次被使用後,仍然有原來的值,否則第二次測試的test code本身就很難說是對的了。
根據測試分類,我們所做的測試屬於單元測試(unit testing),每一個目的不同的測試則稱為一個test case,所以前述test code含兩個 test case。

執行一個單元測試的test case,具備下列四步驟:
  1. 建立測試用資料
  2. 呼叫待測函數,得到結果值
  3. 比對預期值與結果值
  4. 撤掉測試用資料
C++程式單元測試框架如CppUnit、CppUnitLite等提供對test case依上述四步驟自動執行機制必要的library(例如第三步驟的比對),並提供組織test case的具體方法。使用後,將能一舉解決這些問題。 我將使用簡單易用的CppUnitLite

SP4:以CppUnitLite重作test case。

SP2與SP4二選一,我們將先解SP4。


D步驟:將SP4分割成兩個task:

T9:安裝CppUnitLite。
T10:搬移(與新增)test cases。

C步驟
依序進行T9(參考資料)與T10。完成T10後,原先在main的test code改變如下:

...
#include "C:\Program Files (x86)\Dev-Cpp\MinGW64\include\cppunitlite\TestHarness.h"
...

int main(int argc, char** argv) {
TestResult tr;
TestRegistry::runAllTests(tr);
/* 
... what main should do here
*/
return 0;
}

TEST (computable, innerProduct){
  double a[2]={0,1};
  double b[2]={1,0};
  vector u(2,a);
  vector v(2,b);
  double prod;
  CHECK_EQUAL(true,innerProduct(prod,u,v));
  DOUBLES_EQUAL(0,prod,0.00001);
}

TEST (dimension_error, innerProduct){
  double a[2]={0,1};
  double c[3]={1,2,3};
  vector u(2,a);
  vector w(3,c);
  double prod;
  CHECK_EQUAL (true,innerProduct(prod,u,w))
}

main 中兩行紅色的code使用CppUnitLite中的API,執行所有test case(這裡只有兩個)。

蓄意讓第二個test case驗證失敗: 向量u的 dimension為2,向量w的 dimension為3,所以innerProduct還傳的實際值(return value)為false,但我將期望值設為true執行後,console畫面如下:



看畫面第一列,我們的得到驗證失敗的原因(Failure: "expected true but was false")、發生的地方(line 123 in main.cpp)。值得注意的是第一個test case驗證通過,故無任何訊息。最後總計失敗的次數,共計一次

比較原test code執行後console畫面,好壞立見:



console上每一列輸出,都需要靠你自己判讀是否正確。如果判定為有錯,你得自己尋找錯誤發生在哪裡。想像你跑了100個測試,這個判讀/尋找的任務看起來還挺辛苦且易錯的!

L步驟:OK,test code與main函數已分開再次以programmer的觀點進行回顧。現在,這個功能簡單的程式達到120 Lines的規模。在裡面進行修改、新增code,經常需滾動IDE編輯視窗,programmer犯錯的機率升高。此外,啟動unit testing的API仍然與佔據main函數,需與main該做的事互相切換。

© Y C Cheng, 2013. All rights reserved.

2013年9月21日 星期六

Round 3: All bundled -- vector as object

Round 2回顧中找到的議題向量及其維度應能確保一致。我們將以C++的class加以解決。進入第三輪。

U步驟:我們需要一個策略由於我們的目的是減少programmer犯錯的機會,因此採取策略:檢視program code,找出容易導致programmer犯錯的code。

先看一個程式片段:



上面main函數讀起來,可以說是以u這個變數表示向量;和向量u的定義、使用相關的敘述有 
  • line 51: int dim_u; (向量的維度) 
  • line 52: double * u; (double 指標,指到向量記憶體中的位置)
  • line 55: u = new double[dim_u];(配置記憶體,佔用連續的dim_u個double,u為這塊記憶體的beginning address) 
  • line 71: delete [] u; (釋放u佔用的記憶體)
  • line 36 (and other lines): p += u[i]*v[i];(取用第i個分量)

簡單以下圖表示它們之間的關係:



我們發現programmer需自行關聯double vector[]、int dim;兩者也與 double * 宣告、new 運算子、delete運算子、與index 運算子 [] 等有關係,這些關係都得靠你自己記住;C幫不上忙! 「C是個相對低階的語言」這種說法,果然不是隨便說說的。

好消息:C++可以幫上忙!C++的class可以讓我們把這些構成向量的變數、變數間的關係變數與運算子間的關係做一個統合

壞消息:你得學會如何將散落各處的資料、函數打包(好吧,這算不上是一個壞消息 :) 

列出新的子問題如下:

SP3: 統合向量相關變數、運算、函數,以物件表達,修改相關函數,使其接受並使用向量物件。

當此問題解決後,programmer使用物件時不需自行關聯獨立的變數。

現在未解的問題有SP3 及 SP2,讓我們先解 SP3。
D步驟:如何列出工作?

T7 宣告新的向量型態,決定它打包的資料函數
T8 修改相關函數以使用新的向量型態

更新待辦工作如下:



C步驟先做T7。將新的類別型態(class type)稱為vector,經過U步驟地分析得知,
  • vector類別型態打包兩項資料double []與int (學習課題:class, data member, member functions, private, public)。
  • 定義vector類別型態變數的建構元(constructor) (學習課題:constructor, initialization list, dynamic memory allocation, zeroing)
  • 回收被vector類別型態變數佔用的記憶體用的解構元(destructor) (學習課題:scope and lifetime of a variable, destructor, memory deallocation)
  • 向量分量取用函數(accessor function)(學習課題:operator overloading, return by reference) 
  • 向量維度的讀取函數(getter function) (學習課題:const member function)
括弧中為C++ 相關的學習課題,你可以找任何一本C++書或任何C++ web site,直接閱讀與學習課題相關的頁數/內容,直到你可了解下面的code即可:

class vector {
private:
double * _v;
int _dim;
public:
vector(int dim):_dim(dim){
_v = new double[_dim];
for (int i=0; i<_dim; ++i)
_v[i]=0;
}
~vector() {
delete [] _v;
}
double & operator [](int i) const {
return _v[i];
}
int dim() const {
return _dim;
}
};

畫張類別圖(class diagram)來表示vector打包的資料與函數:





有了 vector class 型態,接著處理T8。

T8的學習課題:model of a variable in memory, runtime memory model of a program, call by reference vs call by value, copy constructor, deep copy vs shallow copy。

原 main函數片段



可修改如下:

新程式line 70

    vector u(dim_u);

呼叫建構元,定義vector型態的物件u,它的維度為dim_u;line 70 取代原程式line 51, line 52及line 55。

新程式line 71

    inputVector(u);

取代舊程式line 56,

    inputVector(u, dim_u);

呼叫inputVector時只需傳入vector型態的物件u,而不傳入它的維度dim_u。

完成的程式請點連結下載。

L步驟:請檢查round 3的程式碼是否優於前一版,讓programmer犯錯的機會減少。

除此之外,讓我們再次以programmer的觀點進行回顧。這次,回想自round 1起,我們為求方便,每當完成一個工作時,(例如round 1中的innerProduct函數)經常借用main函數來測試這個工作完成的code。(例如,準備好兩個向量,將作為參數他們傳給innerProduct函數做計算,然後將結果輸出到console:見round 3程式)

/*
double a[2]={0,1};
double b[2]={1,0};
double c[3]={1,2,3};
vector u(2,a);
vector v(2,b);
vector w(3,c);

double prod;
if (innerProduct(prod,u,v))
cout << "inner product is " << prod << endl;
else
cout << "Dimension error!" << endl;

if (innerProduct(prod,u,w))
cout << "inner product is " << prod << endl;
else

cout << "Dimension error!" << endl;
*/

注意到這些code被註解掉了嗎?因為後來我們需要 main 函數做它該做的事!在HTSI的U-D-C-L迴授圈中的C步驟,這些測試碼 (test code)擔負著驗證工作是否完成的責任,但是它們卻「寄生」在main函數之下。

我們找到一個議題:讓test code脫離 main函數存在

© Y C Cheng, 2013. All rights reserved.

2013年9月16日 星期一

Round 2: Look back as a programmer

L步驟:SP1已順利解決,程式處於可執行、可驗證的狀態。儘管SP2尚未解決,我們仍可(1)決定是否程式已達要求?如是,則表示程式將被釋出且暫時不解SP2。(2)探索有無其他議題。如有,則表示進入下一輪時,我們除SP2外,有選擇其他議題的空間。

此次,讓我們以programmer的觀點來回顧程式:看程式碼。

身為programmer,你在乎的事情必然與程式有關。手邊的程式的寫法是否容易讓自己或其他programmer寫出錯誤的程式碼?程式是否容易閱讀、編輯?程式是否容易維護(新增功能、修改或拿掉既有功能)?程式是否容易測試,會不會很耗時間、且經常造成遺漏一些該測而未測的項目?

山頭很多,你得決定要攻哪一座。由於現在程式規模仍小,測試也不太難〈雖然測使用者輸入時將會費一番功夫,但我們尚未深入考慮SP2,此時尚無具體可回顧的測試項目〉。所以讓我們選擇找出容易導致自己或其他programmer犯錯的程式碼。

檢視程式,可發現向量及其維度分別以double []型態與 int型態的兩個值表達。例如,

void outputVector(double v[], int dim);

呼叫時,你必須自行關聯這兩個值,確保它們的正確性:

outputVector(u,dim_u);

如果u=[1,1]而dim_u = 3,那麼ouputVector將會應出錯誤的向量(例如[1,1,-347292697])。

同樣的問題也發生在

bool inputVector(double v[], int dim);

及它的呼叫:

cin >> dim_u;
u = new double[dim_u];
inputVector(u,dim_u);

事實上,上面程式片段的代表維度的變數 dim_u 的值可被任意(有意或無意)修改;一旦被修改,則向量及其維度一致性即遭到破壞。我們因此找到一個議題:向量及其維度應能確保一致。 

讓我們進入下一輪。

© Y C Cheng, 2013. All rights reserved.

2013年9月10日 星期二

Round 2: Improvements

回顧(L)讓我們得知程式有使用者相關的議題仍待解決。這讓我們有理由進入第二輪U-D-C-L。

U步驟這兩個議題都和使用者有關,但它們在第一輪進行時並不明顯。事實上,我們可以說透過操作第一輪得到的working software,我們發現了與原問題息息相關的新問題 -- 在正常操作下,程式必須強健;使用者犯錯時,程式必須能容忍並引導使用者

我們將增加兩個子問題(sub-problem):

SP1. 身為使用者,當我輸入的兩個向量維度不同時,程式在示警後,仍能正常執行。

SP2. 身為使用者,當我輸入的向量格式不對時,程式在示警,仍能正常執行。

SP1與SP2屬於改善程式品質的需求。我們以使用者觀點寫下這些需求,前半段敘述使用者的動作

        身為使用者,當我輸入向量格式不對時,...

後半段敘述程式採取的措施

        .... 程式示警,仍能正常執行。

這種寫法稱為user stories,廣泛地使用於敏捷開發(agile development),例如Scrum。你可以調整出自己的寫法,但以使用者觀點列出的需求,都應該包含這兩個元素。

先做哪個?SP1發生於原問題期待的正常操作下:
...
    [1,0] 與 [1,1,0] 提示無法計算內積
...
而SP2則進一步涉及使用者犯的錯誤,需要能解析使用者的輸入,問題較為複雜。

在此第二輪,讓我們先處理SP1;SP2以後再說。

D步驟為SP1增加兩個工作:

T5. 修改內積計算函數,使其在傳入的兩個向量維度不同時,避免結束程式的執行。
T6. 修改主程式,改用新的內積函數

D步驟完成。我們的問題與工作列表如下:




C步驟:先做T5。

完成程式如下:

/* T2 inner product */
double innerProduct(double u[], int dim_u, double v[], int dim_v){
double p=0;
if (dim_u != dim_v){
cout << "Dimension error!" << endl;
exit(EXIT_FAILURE);
}
else {
for (int i=0; i < dim_u; ++i){
p += u[i]*v[i];
}
return p;  
}
}

/* T2(R1), T5(R2) inner product */
bool innerProduct(double *product, double u[], int dim_u, double v[], int dim_v){
double p=0;
if (dim_u != dim_v){
return false;
}
else {
for (int i=0; i < dim_u; ++i){
p += u[i]*v[i];
}
*product = p;
return true;  
}
}

原來的innerProduct回傳double,不能涵蓋兩向量維度不同時之回傳,選擇以exit(EXIT_FAILURE)結束程式。
innerProduct則回傳bool - 內積可計算時回傳true,否則回傳false - 並增加一個輸出參數 prod,在可計算時回傳內積值。

接著做T6,修改主程式如下:

/* T3(R1), T5(R2) main */
int main(int argc, char** argv) {
        ...
char ch = 'c';
while (ch == 'c'){
                            ...
double prod;
if (innerProduct(&prod,u,dim_u,v,dim_v))
cout << "inner product is " << prod << endl;
else
cout << "Dimension error!" << endl;
...
  }
cout << "bye..." << endl;
return 0;
}

新舊二版innerProduct可並存,因新版多了一個參數,C++認定二者是不同的函示。此稱為function overloading:一個function名稱,二(或以上)個意義。留下舊版?當然不,因為它有錯;有錯的程式不該被留下來,以免日後混淆!


編譯、執行程式以驗證達到預期改善:



L步驟下篇繼續。

© Y C Cheng, 2013. All rights reserved.

2013年9月8日 星期日

Look back to keep getting better

第一輪C步驟結束時,我們得到一個可執行、可驗證的程式。接著進行L步驟:回顧,其的目的是為了讓程式變得更好,找出值得改善的議題,為進入第二輪做好準備。如果程式已經無懈可擊,找不到改善議題,則可宣告做完。當然也有可能時間已經用盡,只好宣告結束。這時候,你會慶幸至少程式是可執行、可驗證。你不想只把程式的「屍體」交出去吧!

一個程式至少有兩種回顧的方式:其一是把自己當user,看看這個程式跑起來如何?其二是自己programmer,看看這個程式是否乾淨、易讀、好修改?這一篇我將以user觀點回顧目前的程式


*   *   *

User拿到程式的自然反應:把它跑起來看看!前文中程式執行的畫面,當使用者一切均依據提示完美輸入,程式看起來相當好。眼尖的讀者會注意到最後一次計算內積,兩個向量維度不同,在印出"Dimension error!"後,程式就結束了。很顯然的程式強健度不夠
  

再回到使用者的角色,使用者還會在哪裡遭遇困難?我們的程式要求輸入向量,使用者須打相當多個字。即使使用者極為小心,他可能犯各種錯誤,例如:

  • 使用者說向量維度是m,卻提供一個n向量,n  != m。
  • 向量格式錯誤。

實驗一下即可知道,程式行為變得怪異,再次顯示其強健度不夠




這樣的回顧可繼續下去,但我們先暫時打住。下一篇進行第二輪U-D-C-L。