該如何學好 "寫程式" #4. 你的程式夠 "可靠" 嗎?

撐了很久,續篇來了。這次要進階一點,直接從 software engineer (軟體工程師) 的階段開始。   所謂的軟體工程師,我對它的定義是在這個領域已經算是資深人員了。programmer 該作的是把程式寫好,要挑正確的方式及技術寫好你的程式 (如之前幾篇介紹的演算法及資料結構等等)。而軟體工程師呢? 之前介紹的那些已經不夠了,你該好好的安排你的 code 及工具,要能把你的 solution (如會用到的演算法及資料結構),跟你手上能運用的資源 (如程式語言、開發工具及函式庫) 作最佳化的搭配及整合。因此我認為在這階段的重點有幾個:
  1. 先成為一個好的 programmer (廢話)
  2. 程式要有足夠的可靠性 (穩定、沒有BUG、易讀、對於未知問題的防禦能力)
  3. 要有足夠的系統知識 (比如作業系統/API/系統服務/記憶體管理等等 OS 提供的環境及功能)
  4. 程式要有好的結構 (正確/優秀的類別設計、好維護、有足夠的擴充及應變能力)
  5. 要有解決未知問題或是未知 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)[copy code]
<?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)[copy code]
<?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 的程式碼:
第一版計分程式[copy code]
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)[copy code]
<?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: 放棄的話不算分[copy code]
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)[copy code]
<?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分為止[copy code]
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 的程式碼[copy code]
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 是好事,不過事情都有黑暗面,我覺的不妥的地方有幾個:
  1. 可讀性變差 太多的 check / debug code, 完全把正常流程的 code 淹沒了,一眼看去看不出什麼邏輯...
  2. 效能變差 對我來說,有些問題是輸入造成的 (如沒有給答案卷),有些是鳥程式自己沒寫好 (如前面的例子)。並不是所有的 check 都需要寫在程式裡。
  3. 花在寫 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 的程式碼[copy 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 執行看看。 image 在 .NET 裡 ASSERT 觸動後就是這個樣子。那 TRACE 呢? 我們進 DEBUG MODE 來看看: image TRACE Message 直接被收到 Visual Studio 的 Output 視窗內。不過在 .NET 環境下,這兩者的行為已經跟書上講的廿年前作法有很多不同了。這些機制仍然可以開關,不過已經不是靠 DEBUG / RELEASE MODE 來切換,而是在 .NET configuration 裡用設定檔的方式來切換。       ------------------------------------ 果然寫到一半寫不完 @_@,先做個小結。這些技巧都是一般人寫程式不會注意的,然而這些才是你寫的程式品質有沒有比別人好的關鍵,要讓你的程式可靠,做好預防措施是很重要的。你沒有辦法在所有地方都派警衛防守,但是你至少可以張貼警告標示,ASSERT 就是這樣的東西。下一篇會更進一步的以這例子為延申,ASSERT 還有更強大的應用。也許有人看到這裡會想說: "怎麼跟單元測試有點像? 我們直接用 UnitTest 就好了啊" 沒錯,單元測試其實就是從最基本的 Trace / Assert 衍生出來的,一直到這幾年才成為顯學。後續幾篇也會再對這些議題做討論,敬請期待 :D




Search

    Post Directory

    Facebook Pages