撐了很久,續篇來了。這次要進階一點,直接從 software engineer (軟體工程師) 的階段開始。
所謂的軟體工程師,我對它的定義是在這個領域已經算是資深人員了。programmer 該作的是把程式寫好,要挑正確的方式及技術寫好你的程式 (如之前幾篇介紹的演算法及資料結構等等)。而軟體工程師呢? 之前介紹的那些已經不夠了,你該好好的安排你的 code 及工具,要能把你的 solution (如會用到的演算法及資料結構),跟你手上能運用的資源 (如程式語言、開發工具及函式庫) 作最佳化的搭配及整合。因此我認為在這階段的重點有幾個:
- 先成為一個好的 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)
1: xml version="1.0" encoding="utf-8" ?>
2: <quiz>
3: <question score="20">
4: <body>那一隻熊最勵害?body>
5: <item correct="false">白熊item>
6: <item correct="false">黑熊item>
7: <item correct="false">棕熊item>
8: <item correct="true">灰熊item>
9: question>
10:
11: <question score="40">
12: <body>誰發現萬有引力?body>
13: <item correct="false">鼠頓item>
14: <item correct="true">牛頓item>
15: <item correct="false">虎頓item>
16: <item correct="false">兔頓item>
17: question>
18:
19: <question score="40">
20: <body>下列那些東西是可以吃的?body>
21: <item correct="false">東瓜item>
22: <item correct="true">西瓜item>
23: <item correct="true">南瓜item>
24: <item correct="false">北瓜item>
25: question>
26: quiz>
再來代表答案卷的檔案 (PAPER-PERFECT.xml),這份看來是天才寫的,每一題都答對了... @_@
答案卷 (都是正確答案,PAPER-PERFECT.xml)
1: xml version="1.0" encoding="utf-8" ?>
2: <quiz>
3: <question>
4: <item checked="false" />
5: <item checked="false" />
6: <item checked="false" />
7: <item checked="true" />
8: question>
9: <question>
10: <item checked="false" />
11: <item checked="true" />
12: <item checked="false" />
13: <item checked="false" />
14: question>
15: <question>
16: <item checked="false" />
17: <item checked="true" />
18: <item checked="true" />
19: <item checked="false" />
20: question>
21: quiz>
而我交待的算分規則也很簡單,就一般考試的計算方式: 每題有自己的配分,以複選題來算,答對幾個選項就照比例給分,答錯會倒扣。新人工程師果然好用耐操,不一會就交給我這份 Library 的程式碼:
第一版計分程式
1: public static int ComputeQuizScore(XmlDocument quizDoc, XmlDocument paperDoc)
2: {
3: int questionCount = quizDoc.SelectNodes("/quiz/question").Count;
4: int totalScore = 0;
5: for (int questionPos = 0; questionPos < questionCount; questionPos++)
6: {
7: XmlElement quiz_question = quizDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
8: XmlElement paper_question = paperDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
9: totalScore += ComputeQuestionScore(quiz_question, paper_question);
10: }
11: return totalScore;
12: }
13: public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
14: {
15: int totalScore = 0;
16: int itemCount = quiz_question.SelectNodes("item").Count;
17: //
18: // 題目的配分
19: //
20: int quiz_score = int.Parse(quiz_question.GetAttribute("score"));
21: //
22: // 答對一個選項的分數
23: //
24: int item_score = quiz_score / itemCount;
25: for (int itemPos = 0; itemPos < itemCount; itemPos++)
26: {
27: XmlElement quiz_item = quiz_question.SelectNodes("item")[itemPos] as XmlElement;
28: XmlElement paper_item = paper_question.SelectNodes("item")[itemPos] as XmlElement;
29: //
30: // 算成積
31: //
32: if (quiz_item.GetAttribute("correct") == paper_item.GetAttribute("checked"))
33: {
34: totalScore += item_score;
35: }
36: else
37: {
38: totalScore -= item_score;
39: }
40: }
41: return totalScore;
42: }
很中規中舉的程式,把天才寫的答案卷 (paper-perfect.xml) 套進去算,也真的拿到滿分,於是工程師就很高興的把程式 shelve 給我...
各位回頭想想上面的問題。這段程式以作業的標準來說勉強及格了。但是以實際系統運作的角度來說有那些缺陷?
原則上程式只要是人寫的都會有 BUG,不過我也是人,沒辦法一眼看穿所有程式的問題... 只能事事抱著懷疑的態度,試一試再說。我不是天才,所以寫不出滿分的答案,我另外準備了一份答案卷 (PAPER-NORMAL1.xml):
只答對第一題的答案卷 (PAPER-NORMAL1.xml)
1: xml version="1.0" encoding="utf-8" ?>
2: <quiz>
3: <question>
4: <item checked="false" />
5: <item checked="false" />
6: <item checked="false" />
7: <item checked="true" />
8: question>
9: <question>
10: <item checked="false" />
11: <item checked="false" />
12: <item checked="false" />
13: <item checked="false" />
14: question>
15: <question>
16: <item checked="false" />
17: <item checked="false" />
18: <item checked="false" />
19: <item checked="false" />
20: question>
21: quiz>
見鬼了,算出來是 40 分... 蠢才也是有尊嚴的,不用平白無故送我 20 分吧... @_@,我把 BUG 丟回去給工程師,最後他抓出 BUG 在那裡了,第二題第三題我完全沒作答,應該視為放棄才對,結果程式也照規則給我算分... 運氣好多賺了 20 分.. 工程師又改了一版給我,這次加上了放棄此題的判斷 (第八行):
修正後的程式 #2: 放棄的話不算分
1: public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
2: {
3: int totalScore = 0;
4: int itemCount = quiz_question.SelectNodes("item").Count;
5: //
6: // 如果都沒作答, 此題放棄
7: //
8: if (paper_question.SelectNodes("item[@checked='true']").Count == 0) return 0;
9: //
10: // 題目的配分
11: //
12: int quiz_score = int.Parse(quiz_question.GetAttribute("score"));
有了上一次經驗,直覺告訴我我還得再測一測,搞不好還有其它 BUG ... 這次找了丁丁來考試,丁丁果真是個人才,交了一份全都錯的答案卷給我,前兩題放棄,第三題全選錯 (PAPER-NATIVE.xml):
丁丁的答案卷: 倒扣 (PAPER-NATIVE.xml)
1: xml version="1.0" encoding="utf-8" ?>
2: <quiz>
3: <question>
4: <item checked="false" />
5: <item checked="false" />
6: <item checked="false" />
7: <item checked="false" />
8: question>
9: <question>
10: <item checked="false" />
11: <item checked="false" />
12: <item checked="false" />
13: <item checked="false" />
14: question>
15: <question>
16: <item checked="true" />
17: <item checked="false" />
18: <item checked="false" />
19: <item checked="true" />
20: question>
21: quiz>
果然有柯南的地方就有密室殺人事件... @_@,又被我抓到一個問題。這次得到的總分是 -40,那有人扣到負的? 工程師又被我叫來唸了一頓,這次改了這版程式給我 (第十一行,最低是0分):
修正後的程式 #3: 倒扣到0分為止
1: public static int ComputeQuizScore(XmlDocument quizDoc, XmlDocument paperDoc)
2: {
3: int questionCount = quizDoc.SelectNodes("/quiz/question").Count;
4: int totalScore = 0;
5: for (int questionPos = 0; questionPos < questionCount; questionPos++)
6: {
7: XmlElement quiz_question = quizDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
8: XmlElement paper_question = paperDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
9: totalScore += ComputeQuestionScore(quiz_question, paper_question);
10: }
11: return Math.Max(0, totalScore);
12: }
金融業最重視的就是錢了,銀行的程式連一毛錢都不能算錯,而在線上考試的系統也一樣,連一分都不能算錯。只是當你的老闆這樣要求你的時後,你是謹記在心,還是照一般方式寫程式嗎? 還是你有什麼有效的措施可以預防這些問題? 這時才是顯示你專業的地方啊... 套句鄉民的慣用語:
"閃開! 讓專業的來..."
哈哈,來看看鄉民... 不,專家該怎麼解決這種問題。怕程式錯就加上一堆檢查就好了。上面舉的例子真的只是 BUG 而以,其它還有更多不可預測的問題,像是題目跟答案卷跟本搭不起來,或是沒有答案卷等等鳥問題都有可能發生。那怎辦? 可憐的工程師被我訓了一頓,只好摸摸鼻子加了一堆令人哭笑不得的 check code, 像這樣:
多了一堆 CHECK 及印出 DEBUG MESSAGE 的程式碼
1: public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
2: {
3: int totalScore = 0;
4: int itemCount = quiz_question.SelectNodes("item").Count;
5: if (quiz_question == null)
6: {
7: throw new Exception("沒有題目卷");
8: }
9: if (paper_question == null)
10: {
11: throw new Exception("沒有答案卷");
12: }
13: //
14: // 如果都沒作答, 此題放棄
15: //
16: if (paper_question.SelectNodes("item[@checked='true']").Count == 0)
17: {
18: Console.WriteLine("偵測到沒作答的答案,此題放棄");
19: return 0;
20: }
21: //
22: // 確認題目跟答案的選項數目一致
23: //
24: if (paper_question.SelectNodes("item").Count != quiz_question.SelectNodes("item").Count)
25: {
26: throw new Exception("此題的選項跟題目定義不符合");
27: }
老實說這範例我也寫不下去了,加這麼多 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:
套用 TRACE / ASSERT 的程式碼
1: public static int ComputeQuizScore(XmlDocument quizDoc, XmlDocument paperDoc)
2: {
3: Trace.Assert(quizDoc != null);
4: Trace.Assert(paperDoc != null);
5: Trace.Assert(quizDoc.SelectNodes("/quiz/question").Count == paperDoc.SelectNodes("/quiz/question").Count);
6: int questionCount = quizDoc.SelectNodes("/quiz/question").Count;
7: int totalScore = 0;
8: for (int questionPos = 0; questionPos < questionCount; questionPos++)
9: {
10: XmlElement quiz_question = quizDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
11: XmlElement paper_question = paperDoc.SelectNodes("/quiz/question")[questionPos] as XmlElement;
12: totalScore += ComputeQuestionScore(quiz_question, paper_question);
13: }
14: totalScore = Math.Max(0, totalScore);
15: Trace.Assert(totalScore >= 0);
16: return totalScore;
17: }
18: public static int ComputeQuestionScore(XmlElement quiz_question, XmlElement paper_question)
19: {
20: int totalScore = 0;
21: int itemCount = quiz_question.SelectNodes("item").Count;
22: //if (quiz_question == null)
23: //{
24: // throw new Exception("沒有題目卷");
25: //}
26: //if (paper_question == null)
27: //{
28: // throw new Exception("沒有答案卷");
29: //}
30: ////
31: //// 確認題目跟答案的選項數目一致
32: ////
33: //if (paper_question.SelectNodes("item").Count != quiz_question.SelectNodes("item").Count)
34: //{
35: // throw new Exception("此題的選項跟題目定義不符合");
36: //}
37: Trace.Assert(quiz_question != null);
38: Trace.Assert(paper_question != null);
39: Trace.Assert(paper_question.SelectNodes("item").Count == quiz_question.SelectNodes("item").Count);
40: //
41: // 如果都沒作答, 此題放棄
42: //
43: if (paper_question.SelectNodes("item[@checked='true']").Count == 0)
44: {
45: //Console.WriteLine("偵測到沒作答的答案,此題放棄");
46: Trace.WriteLine("偵測到沒作答的答案,此題放棄");
47: return 0;
48: }
49: //
50: // 題目的配分
51: //
52: int quiz_score = int.Parse(quiz_question.GetAttribute("score"));
53: //
54: // 答對一個選項的分數
55: //
56: int item_score = quiz_score / itemCount;
57: for (int itemPos = 0; itemPos < itemCount; itemPos++)
58: {
59: XmlElement quiz_item = quiz_question.SelectNodes("item")[itemPos] as XmlElement;
60: XmlElement paper_item = paper_question.SelectNodes("item")[itemPos] as XmlElement;
61: //
62: // 算成積
63: //
64: if (quiz_item.GetAttribute("correct") == paper_item.GetAttribute("checked"))
65: {
66: totalScore += item_score;
67: }
68: else
69: {
70: totalScore -= item_score;
71: }
72: }
73: Trace.Assert(totalScore >= (0 - quiz_score));
74: Trace.Assert(totalScore <= quiz_score);
75: return totalScore;
76: }
我特地把之前加的亂七八糟的 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
Reference:
No comments:
Post a Comment