撐了很久,續篇來了。這次要進階一點,直接從 software engineer (軟體工程師) 的階段開始。
所謂的軟體工程師,我對它的定義是在這個領域已經算是資深人員了。programmer 該作的是把程式寫好,要挑正確的方式及技術寫好你的程式 (如之前幾篇介紹的演算法及資料結構等等)。而軟體工程師呢? 之前介紹的那些已經不夠了,你該好好的安排你的 code 及工具,要能把你的 solution (如會用到的演算法及資料結構),跟你手上能運用的資源 (如程式語言、開發工具及函式庫) 作最佳化的搭配及整合。
前言: 如何學好寫程式 系列文章導讀
- 該如何學好 “寫程式” #1, 2008/09/27
- 該如何學好 “寫程式” #2. 為什麼 programmer 該學資料結構 ??, 2008/10/01
- 該如何學好 “寫程式” #3. 進階應用 - 資料結構 + 問題分析, 2008/10/08
- 該如何學好 “寫程式” #4. 你的程式夠 “可靠” 嗎?, 2008/10/20
- 該如何學好 “寫程式” #5. 善用 TRACE / ASSERT, 2008/11/03
因此,我認為在這階段的重點有幾個:
- 先成為一個好的 programmer (廢話)
- 程式要有足夠的可靠性 (穩定、沒有BUG、易讀、對於未知問題的防禦能力)
- 要有足夠的系統知識 (比如作業系統/API/系統服務/記憶體管理等等 OS 提供的環境及功能)
- 程式要有好的結構 (正確/優秀的類別設計、好維護、有足夠的擴充及應變能力)
- 要有解決未知問題或是未知 BUG 的能力,有自行學習新知的能力。
這些能力,跟 programmer 需要俱備的剛好是另一個角度的要求。某種程度上是各自發展的,不會互相衝突。有心的 programmer 應該要及早作好準備。如果 programmer 是要把程式寫對,那 software engineer 就是要把程式寫好,用專業的方式來寫,而不是用業餘的方式。
什麼叫作 “專業” 的程式? 我舉幾個例子,你的程式防呆嗎? 你的程式面對未知的問題或狀況的免疫力夠不夠強? 面對問題時你的程式有沒有比其它人的程式還容易抓出 BUG ? 你有能力有系統的找出未知的問題嗎? 還是只能看著程式碼發呆? 面對上面的問題有沒有有效的預防措施? 設計階段可以怎樣預防? … etc
實在太多了。不過這些看起來又是教條,實際上這幾點會影響的到底是什麼? 後面幾篇就一項一項來看吧!
#程式要有足夠的可靠性
老實說,我很怕光是這一段,就會拖到好幾篇了 … @_@,我會盡量挑出重點來寫。開始之前先問一下,不知道有多少人看過馬奎爾 (Steve Maguire) 寫的這本書? 有的話記得留個回應 :D
“完美程式設計指南” (Writing Solid Code)
這本書真是經典。不過它真的也很 “經典”,是 1993 年就出版的書。以講程式設計來說,這個年代的書真的可以扔了,裡面的範例現在沒幾個人用的到了。不過它提到的作法真的是很實際,只是書上的範例大半都過時了,下面碰到的例子我都會用 C# 重新表達一次作者的理念。在這個主題我就舉幾個例子,各位讀者可以自己回顧一下你的程式碼,到底藏了多少地雷在裡面?
#要讓問題浮現出來: 善用 DEBUG / RELEASE 模式
專不專業就看這裡了。如果你想當個稱職的軟體工程師,除了讓程式跑的快之外,第一點就是要降低 BUG 數。如果你面對 BUG 的態度是 “找到再改就好”,或是 BUG 一堆你也沒方法去預防,也沒辦法降低 BUG 出現的頻率,那麼你跟半路出家的人差別在那?
大家都知道 Visual Studio 正上方就有個切換 Release / Debug 模式的選單吧? 你確切瞭解它是幹嘛的嗎? 先從一個簡單的範例開始吧! 我工作上常碰到線上測驗之類的應用軟體開發,因此線上考試算分是個很常用的功能。因此我把這個重責大任交給工程師來處理。先來看看我要求工程師寫什麼 CODE ? 我用 XML 定義了一份考卷 (QUIZ.xml,含正確答案),也定義了答案卷的格式 (PAPER-XXXX.xml),程式很簡單,就是拿到題目跟答案卷後,要算出正確的總分。
不難吧? 先看看 XML 檔長啥樣子:
試卷 (QUIZ.xml):
<?xml version="1.0" encoding="utf-8" ?>
<quiz>
<question score="20">
<body>那一隻熊最勵害?</body>
<item correct="false">白熊</item>
<item correct="false">黑熊</item>
<item correct="false">棕熊</item>
<item correct="true">灰熊</item>
</question>
<question score="40">
<body>誰發現萬有引力?</body>
<item correct="false">鼠頓</item>
<item correct="true">牛頓</item>
<item correct="false">虎頓</item>
<item correct="false">兔頓</item>
</question>
<question score="40">
<body>下列那些東西是可以吃的?</body>
<item correct="false">東瓜</item>
<item correct="true">西瓜</item>
<item correct="true">南瓜</item>
<item correct="false">北瓜</item>
</question>
</quiz>
再來代表答案卷的檔案 (PAPER-PERFECT.xml),這份看來是天才寫的,每一題都答對了… @_@
答案卷 (都是正確答案,PAPER-PERFECT.xml):
<?xml version="1.0" encoding="utf-8" ?>
<quiz>
<question>
<item checked="false" />
<item checked="false" />
<item checked="false" />
<item checked="true" />
</question>
<question>
<item checked="false" />
<item checked="true" />
<item checked="false" />
<item checked="false" />
</question>
<question>
<item checked="false" />
<item checked="true" />
<item checked="true" />
<item checked="false" />
</question>
</quiz>
而我交待的算分規則也很簡單,就一般考試的計算方式: 每題有自己的配分,以複選題來算,答對幾個選項就照比例給分,答錯會倒扣。新人工程師果然好用耐操,不一會就交給我這份 Library 的程式碼:
第一版計分程式:
public static int ComputeQuizScore(XmlDocument quizDoc, XmlDocument paperDoc)
{
int questionCount = quizDoc.SelectNodes("/quiz/question").Count;
int totalScore = 0;
for (int questionPos = 0; questionPos < questionCount; questionPos++)
{
XmlElement quiz_question = quizDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
XmlElement paper_question = paperDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
totalScore += ComputeQuestionScore(quiz_question, paper_question);
}
return totalScore;
}
public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
{
int totalScore = 0;
int itemCount = quiz_question.SelectNodes("item").Count;
//
// 題目的配分
//
int quiz_score = int.Parse(quiz_question.GetAttribute("score"));
//
// 答對一個選項的分數
//
int item_score = quiz_score / itemCount;
for (int itemPos = 0; itemPos < itemCount; itemPos++)
{
XmlElement quiz_item = quiz_question.SelectNodes("item")[itemPos] as XmlElement;
XmlElement paper_item = paper_question.SelectNodes("item")[itemPos] as XmlElement;
//
// 算成積
//
if (quiz_item.GetAttribute("correct") == paper_item.GetAttribute("checked"))
{
totalScore += item_score;
}
else
{
totalScore -= item_score;
}
}
return totalScore;
}
很中規中舉的程式,把天才寫的答案卷 (paper-perfect.xml) 套進去算,也真的拿到滿分,於是工程師就很高興的把程式 shelve 給我…
各位回頭想想上面的問題。這段程式以作業的標準來說勉強及格了。但是以實際系統運作的角度來說有那些缺陷?
原則上程式只要是人寫的都會有 BUG,不過我也是人,沒辦法一眼看穿所有程式的問題… 只能事事抱著懷疑的態度,試一試再說。我不是天才,所以寫不出滿分的答案,我另外準備了一份答案卷 (PAPER-NORMAL1.xml):
只答對第一題的答案卷 (PAPER-NORMAL1.xml):
<?xml version="1.0" encoding="utf-8" ?>
<quiz>
<question>
<item checked="false" />
<item checked="false" />
<item checked="false" />
<item checked="true" />
</question>
<question>
<item checked="false" />
<item checked="false" />
<item checked="false" />
<item checked="false" />
</question>
<question>
<item checked="false" />
<item checked="false" />
<item checked="false" />
<item checked="false" />
</question>
</quiz>
見鬼了,算出來是 40 分… 蠢才也是有尊嚴的,不用平白無故送我 20 分吧… @_@,我把 BUG 丟回去給工程師,最後他抓出 BUG 在那裡了,第二題第三題我完全沒作答,應該視為放棄才對,結果程式也照規則給我算分… 運氣好多賺了 20 分.. 工程師又改了一版給我,這次加上了放棄此題的判斷 (第八行):
修正後的程式 #2: 放棄的話不算分
public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
{
int totalScore = 0;
int itemCount = quiz_question.SelectNodes("item").Count;
//
// 如果都沒作答, 此題放棄
//
if (paper_question.SelectNodes("item[@checked='true']").Count == 0) return 0;
//
// 題目的配分
//
int quiz_score = int.Parse(quiz_question.GetAttribute("score"));
有了上一次經驗,直覺告訴我我還得再測一測,搞不好還有其它 BUG … 這次找了丁丁來考試,丁丁果真是個人才,交了一份全都錯的答案卷給我,前兩題放棄,第三題全選錯 (PAPER-NATIVE.xml):
丁丁的答案卷: 倒扣 (PAPER-NATIVE.xml):
<?xml version="1.0" encoding="utf-8" ?>
<quiz>
<question>
<item checked="false" />
<item checked="false" />
<item checked="false" />
<item checked="false" />
</question>
<question>
<item checked="false" />
<item checked="false" />
<item checked="false" />
<item checked="false" />
</question>
<question>
<item checked="true" />
<item checked="false" />
<item checked="false" />
<item checked="true" />
</question>
</quiz>
果然有柯南的地方就有密室殺人事件… @_@,又被我抓到一個問題。這次得到的總分是 -40,那有人扣到負的? 工程師又被我叫來唸了一頓,這次改了這版程式給我 (第十一行,最低是0分):
修正後的程式 #3: 倒扣到0分為止
public static int ComputeQuizScore(XmlDocument quizDoc, XmlDocument paperDoc)
{
int questionCount = quizDoc.SelectNodes("/quiz/question").Count;
int totalScore = 0;
for (int questionPos = 0; questionPos < questionCount; questionPos++)
{
XmlElement quiz_question = quizDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
XmlElement paper_question = paperDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
totalScore += ComputeQuestionScore(quiz_question, paper_question);
}
return Math.Max(0, totalScore);
}
金融業最重視的就是錢了,銀行的程式連一毛錢都不能算錯,而在線上考試的系統也一樣,連一分都不能算錯。只是當你的老闆這樣要求你的時後,你是謹記在心,還是照一般方式寫程式嗎? 還是你有什麼有效的措施可以預防這些問題? 這時才是顯示你專業的地方啊… 套句鄉民的慣用語:
“閃開! 讓專業的來…”
哈哈,來看看鄉民… 不,專家該怎麼解決這種問題。怕程式錯就加上一堆檢查就好了。上面舉的例子真的只是 BUG 而以,其它還有更多不可預測的問題,像是題目跟答案卷跟本搭不起來,或是沒有答案卷等等鳥問題都有可能發生。那怎辦? 可憐的工程師被我訓了一頓,只好摸摸鼻子加了一堆令人哭笑不得的 check code, 像這樣:
多了一堆 CHECK 及印出 DEBUG MESSAGE 的程式碼:
public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
{
int totalScore = 0;
int itemCount = quiz_question.SelectNodes("item").Count;
if (quiz_question == null)
{
throw new Exception("沒有題目卷");
}
if (paper_question == null)
{
throw new Exception("沒有答案卷");
}
//
// 如果都沒作答, 此題放棄
//
if (paper_question.SelectNodes("item[@checked='true']").Count == 0)
{
Console.WriteLine("偵測到沒作答的答案,此題放棄");
return 0;
}
//
// 確認題目跟答案的選項數目一致
//
if (paper_question.SelectNodes("item").Count != quiz_question.SelectNodes("item").Count)
{
throw new Exception("此題的選項跟題目定義不符合");
}
老實說這範例我也寫不下去了,加這麼多 check 是好事,不過事情都有黑暗面,我覺的不妥的地方有幾個:
- 可讀性變差
太多的 check / debug code, 完全把正常流程的 code 淹沒了,一眼看去看不出什麼邏輯… - 效能變差
對我來說,有些問題是輸入造成的 (如沒有給答案卷),有些是鳥程式自己沒寫好 (如前面的例子)。並不是所有的 check 都需要寫在程式裡。 - 花在寫 check 程式的時間太多
沒錯,寫個程式五分鐘就搞定,寫 check 要多花廿分鐘…
即使這樣,我還是贊成要這樣做。只是要做的聰明一點,要消掉上面的疑慮,還要達成一樣的效果。不需要什麼新技術,十幾年前馬奎爾這本 “Write Solid Code” 就講的很清楚了,要同時維護 RELEASE / DEBUG 兩種版本的程式!
在 C 的年代,只靠兩個巨集就解決了,分別是 TRACE 跟 ASSERT。一個就相當於 printf,可以印出 MESSAGE,另一個 ASSERT 則什麼都不做,只要你傳給它當參數是 TRUE 的話。否則就會印出錯誤訊息同時中止程式。而這兩個巨集都有個特點,就是只在 DEBUG MODE 發生作用,如果是在 RELEASE MODE,則一點用都沒有,就像你沒寫這段 CODE 一樣。
細節我就不多說了,這本書講的很清楚,我直接來用。老實說這種應用太經典了,經典到每種程式語言跟開發工具都有支援,連 Microsoft 在 JavaScript 都有實作,甚至跟 debugger 也有整合,不過不曉得有多少人知道? 在 .NET 當然也有 (System.Diagnoistics)。來看看我改版過的 code:
public static int ComputeQuizScore(XmlDocument quizDoc, XmlDocument paperDoc)
{
Trace.Assert(quizDoc != null);
Trace.Assert(paperDoc != null);
Trace.Assert(quizDoc.SelectNodes("/quiz/question").Count == paperDoc.SelectNodes("/quiz/question").Count);
int questionCount = quizDoc.SelectNodes("/quiz/question").Count;
int totalScore = 0;
for (int questionPos = 0; questionPos < questionCount; questionPos++)
{
XmlElement quiz_question = quizDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
XmlElement paper_question = paperDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
totalScore += ComputeQuestionScore(quiz_question, paper_question);
}
totalScore = Math.Max(0, totalScore);
Trace.Assert(totalScore >= 0);
return totalScore;
}
public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
{
int totalScore = 0;
int itemCount = quiz_question.SelectNodes("item").Count;
//if (quiz_question == null)
//{
// throw new Exception("沒有題目卷");
//}
//if (paper_question == null)
//{
// throw new Exception("沒有答案卷");
//}
////
//// 確認題目跟答案的選項數目一致
////
//if (paper_question.SelectNodes("item").Count != quiz_question.SelectNodes("item").Count)
//{
// throw new Exception("此題的選項跟題目定義不符合");
//}
Trace.Assert(quiz_question != null);
Trace.Assert(paper_question != null);
Trace.Assert(paper_question.SelectNodes("item").Count == quiz_question.SelectNodes("item").Count);
//
// 如果都沒作答, 此題放棄
//
if (paper_question.SelectNodes("item[@checked='true']").Count == 0)
{
//Console.WriteLine("偵測到沒作答的答案,此題放棄");
Trace.WriteLine("偵測到沒作答的答案,此題放棄");
return 0;
}
//
// 題目的配分
//
int quiz_score = int.Parse(quiz_question.GetAttribute("score"));
//
// 答對一個選項的分數
//
int item_score = quiz_score / itemCount;
for (int itemPos = 0; itemPos < itemCount; itemPos++)
{
XmlElement quiz_item = quiz_question.SelectNodes("item")[itemPos] as XmlElement;
XmlElement paper_item = paper_question.SelectNodes("item")[itemPos] as XmlElement;
//
// 算成積
//
if (quiz_item.GetAttribute("correct") == paper_item.GetAttribute("checked"))
{
totalScore += item_score;
}
else
{
totalScore -= item_score;
}
}
Trace.Assert(totalScore >= (0 - quiz_score));
Trace.Assert(totalScore <= quiz_score);
return totalScore;
}
我特地把之前加的亂七八糟的 check code 用註解留下來,各位可以看看用 TRACE / ASSERT 前後的差別有多少。ASSERT是其中的精華。你可以到處都加上 ASSERT ,來說明你對於程式執行到某個地方的 “假設”。舉例來說,你 “假設” 呼叫你 FUNC 的人一定會傳 quizDoc 跟 paperDoc 給你,你又不想為了它寫一堆 IF ….,你就可以簡單的加上這一行 ASSERT( quizDoc != null), 代表只有 quizDoc 不是 NULL 時才是 “正常” 的。
那真的不正常的話會怎樣? 我特地拿掉倒扣扣到 0 分為止的 check, 用新版的 code 執行看看。
在 .NET 裡 ASSERT 觸動後就是這個樣子。那 TRACE 呢? 我們進 DEBUG MODE 來看看:
TRACE Message 直接被收到 Visual Studio 的 Output 視窗內。不過在 .NET 環境下,這兩者的行為已經跟書上講的廿年前作法有很多不同了。這些機制仍然可以開關,不過已經不是靠 DEBUG / RELEASE MODE 來切換,而是在 .NET configuration 裡用設定檔的方式來切換。
果然寫到一半寫不完 @_@,先做個小結。這些技巧都是一般人寫程式不會注意的,然而這些才是你寫的程式品質有沒有比別人好的關鍵,要讓你的程式可靠,做好預防措施是很重要的。你沒有辦法在所有地方都派警衛防守,但是你至少可以張貼警告標示,ASSERT 就是這樣的東西。下一篇會更進一步的以這例子為延申,ASSERT 還有更強大的應用。也許有人看到這裡會想說:
“怎麼跟單元測試有點像? 我們直接用 UnitTest 就好了啊”
沒錯,單元測試其實就是從最基本的 Trace / Assert 衍生出來的,一直到這幾年才成為顯學。後續幾篇也會再對這些議題做討論,敬請期待 :D