Saturday, November 12, 2011

該如何學好 "寫程式" #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)
   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 是好事,不過事情都有黑暗面,我覺的不妥的地方有幾個:
  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 的程式碼
   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 執行看看。
image
在 .NET 裡 ASSERT 觸動後就是這個樣子。那 TRACE 呢? 我們進 DEBUG MODE 來看看:
image
TRACE Message 直接被收到 Visual Studio 的 Output 視窗內。不過在 .NET 環境下,這兩者的行為已經跟書上講的廿年前作法有很多不同了。這些機制仍然可以開關,不過已經不是靠 DEBUG / RELEASE MODE 來切換,而是在 .NET configuration 裡用設定檔的方式來切換。



------------------------------------
果然寫到一半寫不完 @_@,先做個小結。這些技巧都是一般人寫程式不會注意的,然而這些才是你寫的程式品質有沒有比別人好的關鍵,要讓你的程式可靠,做好預防措施是很重要的。你沒有辦法在所有地方都派警衛防守,但是你至少可以張貼警告標示,ASSERT 就是這樣的東西。下一篇會更進一步的以這例子為延申,ASSERT 還有更強大的應用。也許有人看到這裡會想說:
"怎麼跟單元測試有點像? 我們直接用 UnitTest 就好了啊"
沒錯,單元測試其實就是從最基本的 Trace / Assert 衍生出來的,一直到這幾年才成為顯學。後續幾篇也會再對這些議題做討論,敬請期待 :D

Reference:

No comments: