論程序設計方法

如果你是初學者----------------請不要閱讀;
但有志成爲中高級程序員--------請務必閱讀;
如果你是中級程序員------------請務必閱讀;
如果你高級程序員--------------請批評指正。

  本文是我在“軟件工程師班”開學第一節課的講義,和“計算機軟件設計發展”講座上的內容整理而成。寫作本文的目的是引導學生從更高的層次來看待程序設計方法,爲將來成爲高級程序員而做好理論準備。

一、計算機硬件環境對軟件設計方法的限制
  計算機的發明到現在已經60年了,計算機程序設計方法也伴隨着計算機硬件技術的提高而不斷髮展。硬件環境對軟件設計既有嚴重的制約作用,也有積極的推動作用。
  在我的大學母校(此處刪除6個字),數學系的一些老師,有幸成爲了我國第一代的計算機DIY一族。呵呵,不要以爲是組裝PC機呦,他們組裝的可是小型機。一人多高鐵皮櫃大小的主機,加上紙帶機(後期改進爲讀卡機),組裝好後,除了供學校自己的科研使用外,還在全國各地銷售了十幾臺。當時(七十年代)一臺的售價是10幾萬元人民幣,如果換算到今天,相當於價值大約爲100多萬元,非常高檔的小型計算機了。下面大家猜猜,這麼高檔的計算機,它的內存是多少那?(都把嘴閉好了,我要公佈答案了)—— 4K。
一塊50公分見方的內存板,
插入到主機箱中,好了------ 1K;
再插一塊內存板,好了------ 2K;
再插一塊內存板,好了------ 3K;
再插一塊內存板,好了------ 4K;
再......不行了,插不起了,太貴了!這就是當時的環境。這樣的環境下,用什麼寫程序那?當然只有機器碼了。先用匯編寫,然後翻閱手冊手工改寫爲機器碼,然後打卡或穿紙帶,輸入運行。可以想象,在當時的條件下,什麼叫好的程序那?什麼叫優秀的程序那?—— 技巧!
  程序設計的最初始階段,是講究技巧的年代。如何能節省一個字節,如何能提高程序運行的效率,這些都是要嚴肅考慮的問題。而所謂的程序的易讀性,程序的可維護性根本不在考慮範圍之內。
  今天,35歲以上的學習過計算機的朋友可能都使用過一種個人計算機——APPLE-II(中國也生產過這種計算機的類似產品“中華學習機”)。主頻1M,內存48K(擴展後,最多可達到64K)。我就是使用這樣的計算機長大的 :)。當年,類似的個人計算機產品,還有PC1500,Layser310等。這種計算機上已經固化了 BASIC 語言,當然只是爲學習使用。要想開發出真正的商業程序,則必須使用匯編,否則的話,程序就比蝸牛還要慢了。於是,程序設計中對於技巧的運用,是至關重要的了。

題外話1:
  比爾蓋茨是 BASIC 的忠實擁護和推動者。當年,他在沒有調式環境的狀況下,用彙編語言寫出了一款僅有 4K 大小的 BASIC 解釋器,且一次通過。確實另人佩服。(不象現在微軟出品的程序,動輒幾十兆。)這也許就是比爾對 BASIC 情有獨忠的原因,每當微軟推出(臨摹)一個新技術,則他會立刻在 BASIC 中提供支持。

題外話2:
  在 APPLE-II 上有一款遊戲軟件“警察抓小偷”,當年熬夜玩遊戲,樂趣無窮。後來這款遊戲被移植到了PC上,咳~~~根本沒有辦法玩,因爲小偷還沒跑就被警察抓到了。硬件的速度提升,另我無法再回味以前的時光了。

二、結構化程序設計
  隨着計算機的價格不斷下降,硬件環境不斷改善,運行速度不斷提升。程序越寫越大,功能越來越強,講究技巧的程序設計方法已經不能適應需求了。記得是哪本書上講過,一個軟件的開發成本是由:程序設計 30% 和程序維護 70% 構成。這是書上給出的一個理論值,但實際上,從我十幾年的工作經驗中,我得到的體會是:程序設計佔 10%,而維護要佔 90%。也許我說的還是太保守了,維護的成本還應該再提高。下面這個程序,提供了兩種設計方案,大家看看哪個更好一些那?

題目:對一個數組中的100個元素,從小到大排序並顯示輸出。(BASIC)

方法1:冒泡法排序,同時輸出。

  FOR I=1 TO 100
       FOR J=I+1 TO 100
            IF A[I] > A[J] THEN T=A[J]: A[J]=A[I]: A[I]=T
       NEXT J
       ? A[I]
  NEXT I
      
方法2:冒泡法排序,然後再輸出。
  FOR I=1 TO 100
       FOR J=I+1 TO 100
            IF A[I] > A[J] THEN T=A[J]: A[J]=A[I]: A[I]=T
       NEXT
  NEXT
  
  FOR I=1 TO 100
      ? A[I]
  NEXT      
  顯然,“方法1”比“方法2”的效率要高,運行的更快。但是,從現在的程序設計角度來看,“方法2”更高級。原因很簡單:(1)功能模塊分割清晰——易讀;(2)也是最重要的——易維護。程序在設計階段的時候,就要考慮以後的維護問題。比如現在是實現了在屏幕上的輸出,也許將來某一天,你要修改程序,輸出到打印機上、輸出到繪圖儀上;也許將來某一天,你學習了一個新的高級的排序方法,由“冒泡法”改進爲“快速排序”、“堆排序”。那麼在“方法2”的基礎上進行修改,是不是就更簡單了,更容易了?!這種把功能模塊分離的程序設計方法,就叫“結構化程序設計”。

三、對程序設計中技巧使用的思考
  我可以肯定,大家在開始學習程序設計的時候,一定都做過這樣一個題目:求100以內的素數。老師在黑板上,眉飛色舞地寫出了第一個程序:(C程序)
方法1:
for(i=1; i<100; i++)
{
   for(j=2; j< i; j++)
       if(i%j == 0) break;
   if(j >= i) printf("%d,", i);
}      
  然後,老師開始批判這個程序“這個叫什麼呀?太慢了!因爲我們都知道大偶數不可能是素數了,因此,要排除掉!” 於是,意尤未盡地寫出了第二個程序:

方法2:
printf("2,");
for(i=3; i<100; i+=2)
{
   for(j=2; j< i; j++)
       if(i%j == 0) break;
   if(j >= i) printf("%d,", i);
}      
  老師說:“看!我們只改動了一點點,程序運行的速度就提高了一倍多”。然後運用誘導式教學法繼續提問“程序的效率,還能再提高嗎?能!”,得意地寫出第三個程序:

方法3:
printf("2,");
for(i=3; i<100; i+=2)		''不考慮大偶數
{
    for(j=3; j< i/2; j+=2)	''不考慮用偶數去測試,而且只驗算到一半就足夠了
        if(i%j == 0) break;
    if(j >= i) printf("%d,", i);
}      
  “大家看,我們又只改動了一點點,運行速度又提高了一倍多。可以了嗎?不可以!我們還能再提高”。於是又高傲地寫出了第四個程序:

方法4:
printf("2,");
for(i=3; i<100; i+=2)
{
    int k = sqrt(i);
    for(j=3; j<= k; j+=2)
        if(i%j == 0) break;
    if(j >= k ) printf("%d", i);
}      
然後,開始證明爲什麼我們判斷素數的時候,只需要驗算到平方根就足夠了:

  假設p是合數,那麼令:p=a*b。反正法:由於我們已經判斷了p的平方根以內的整數都不能被p整除,於是 a>SQRT(p)。基於同樣的理由 b>SQRT(p)。於是 p = a * b > SQRT(p) * SQRT(p) = p 得出矛盾, 命題得正。
  的確,“方法4”的確比“方法1”的運行速度要提高了好幾倍,甚至好幾十倍。但我們仔細分析測試看看。
(1)“程序4”到底比“程序1”快了多少那?我在某臺計算機上進行測試(P4,1.5G)得到的速度對比表:

計算範圍

100 1000 10000 100000
速度差 0.00 0.01秒 0.18 15

(2) 在10萬以上,纔會看出一些差別。而這種差別根本就不夠底償程序設計階段的付出。如果計算的範圍再大,那麼不管是“方法1”,還是“方法4”都不是好的算法。(計算素數的另外一個比較優秀的算法叫“漏篩法”)

(3)寫出“方法1”,只要具有小學四年級的數學水平就夠了,而“方法4”則需要初中三年級的水平並且還要具備一些“數論”的知識。

(4)從維護性看,如果你寫的程序需要另外一個程序員來維護,或者若干時間以後,你重新來閱讀這段程序,那麼就會對這個程序產生很多疑問:這個求平方根是幹什麼用的?其實,就這個題目來說,使用到“方法3”就已經足夠了。

總結髮言:
  • I. 計算機的價格每年下降一半,而運算速度每年提高一倍”,因此我們應該把速度提高的任務交給硬件實現。
  • II. 從易讀性、維護性出發,程序員只負責按定義給出軟件實現。算法的問題是數學家解決的。

題外話:
  多年以來,人們一直在尋找動態圖象(影視)的存儲和回放的算法,但效果都不理想。直到有人發現,原來在200多年前的數學家早就幫我們解決了這個問題——傅立葉(Fourier)級數展開。因此我要說,優秀的算法不是我們程序員要考慮的問題,我們的任務只要按照數學家給出的算法翻譯爲計算機程序語言而已。(這句話恐怕要遭到大多數程序員拋出的板磚襲擊)再比如,計算一元多次方程解的問題。我們使用的就是牛頓的迭代算法。不要怪我瞧不起你,你能發明這個方法的話,那就是當代的牛頓了。

四、程序的易讀性與書寫方法
  程序是否容易閱讀和維護,與怎麼書寫有很大的關係。說實在的,C語言中爲了方便程序員書寫,允許使用++,--,<<,&&,?......這些運算符號。但很多人經常亂用,以爲自己寫的程序多麼簡潔,效率多高。其實,當你分行書寫的話則更加容易閱讀和維護,效率也不會降低,因爲編譯程序早就幫你優化爲最快捷的代碼了。先看一個簡單的例子:

計算一個整數乘 255(C語言)

方法1:a *= 255;

方法2:因爲移位運算比乘法運算要快很多倍,因此a*255的運算書寫爲:

a =(a<<8)-a; //a*255 = a*256 - a = (a<<8) - a

  方法1的書寫非常簡單,直截了當,顯然更容易維護。而方法2的書寫運用了移位的技巧,不容易閱讀,但效率最高。是不是真的是這樣那?把這兩個程序編譯爲彙編代碼看看。原來無論是方法1還是方法2,它們的彙編代碼都是一樣的:

mov ecx, eax
shl eax, 8
sub eax, ecx

  也就是說,你認爲非常技巧的書寫方法,其實編譯器的優化功能早就幫你想到了。那麼方法2的方式就很值得批判了。下面是幾個有關C語言書寫方面的重要原則:
 

  1. 儘量表達願義,多加註釋;
  2. 變量名稱和函數名稱,要使用有意義的符號,並且遵守“匈牙利命名法”;
  3. 不要爲儉省內存,使一個變量在一個模塊中表達多個含義。
    在某個模塊中,前半部分用i表示計數器,由於後半部分不再使用計數器了,於是又用i來保存某個中間的結果。等你維護這段程序的時候,保證你肯定會犯傻的。
  4. 在使用條件表達式的時候,不要混合書寫運算表達式;
    經常有人在書寫for循環的時候,使用這樣的方式:
            for(int a=1,s=0; a<=100 && (s+=a); a++);      
    天呀,這樣寫是不會提高程序運行效率的,尤其是當運算表達式複雜的時候,就更不容易閱讀了,還是把運算寫到for的循環體中吧。
            int s = 0;
            for(int a=1; a<=100; a++)
            s += a; //計算1+2+...+100 這不很好嗎?!        
    再比如,if(a=b)這個寫法在語法上是允許的,但不要使用。要使用也要if(0!=(a=b))這樣的方式。 還有值得一提的是慎用“,”(逗號運算符)。
  5. 不要連續使用++,--,<<,*,& .....這樣的運算符號。
            a = b++-(--c<<1+e&0x0f>>1); //這個人有病。出這個題目考試的老師,也有病。        
  6. 常量要寫在條件表達式的左邊;
    if(5 == a) 這是正確的寫法,這樣書寫可以避免勿輸入而導致的 if(a=5)這樣的錯誤。
  7. 避免程序中{ }的嵌套層次太深;
    最多4層。如果必須大於4層,那麼寫成調用子函數或宏的方式。
  8. 儘量多地使用斷言;
    當你在書寫程序的過程中,憑你的智慧,你一定是知道:程序運行到我正書寫的這行代碼的時候某個變量一定是某個值。好啦,那麼不要憂鬱,馬上加上一句代碼:ASSERT(nnn == xxx);。將來在調式維護這段代碼的時候,你會得到無限美妙的回報。
  9. 書寫需要“成對匹配”使用的代碼的時候,在寫使用代碼之前,就先把結束寫出來;
            file.Open(...); //當要打開文件的時候 char *lp=new char [100]; //當要申請內存的時候
            ...... //先不要寫這段代碼 ...... //先不要寫這段代碼
            file.Close(); //馬上寫關閉 delete [] lp; //馬上寫釋放
            
            xxx.Loack(); //當某個對象需要鎖定的時候 for(....)
            ...... //先不要寫這段代碼 { //寫大括號的時候
            xxx.Unlock(); //馬上寫解鎖 } //馬上寫大括號結束       
    和這個道理相同,在C++的類中,如果需要申請內存,那麼先在構造函數中給出 lp=NULL;然後馬上在析構函數中書寫 if(lp) delete []lp;
  10. 可以適當地使用goto;
    在結構化程序設計中,goto 是被排斥的。但是,如果適當地使用 goto 不但不影響斜率,而且還能提高程序的可讀性。

題目:合併2個文件到一個新文件中。(不要挑我的毛病呀~~~~~,我使用的是類C的方式書寫的。)
方法1:

FILE *f1,*f2,*f3;
if(Open(f1)成功)
{
    if(Open(f2)成功)
    {
        if(Open(f3)成功)
        {
            ......	//這裏是真正幹活的地方
            Close(f1);
            Close(f2);
            Close(f3);
        }
        else //f3不成功
        {
            Close(f1);
            Close(f2);
            ......
        }
    }
    else //f2不成功
    {
        Close(f1);
        ......
    }
}
else //f1不成功
{
......
}      
==========================================================
方法2:
FILE *f1=NULL,*f2=NULL,*f3=NULL;
if(Open(f1)不成功) goto err;
if(Open(f2)不成功) goto err;
if(Open(f3)不成功) goto err;
...... //這裏是真正幹活的地方
err:
if(f3) Close(f3);
if(f2) Close(f2);
if(f1) Close(f1);

  方法1是最最標準的結構化設計,好嗎?不好!尤其是當{ }的層次比較深的時候,估計你尋找真正幹活的代碼的地方都找不到。而使用方法2的程序,不但程序容易讀,而且沒有{ } 的深度。在C++中,又提供了異常try/catch的設計結構,而異常的結構則比 goto 的結構更好、更完善了。

五、面向對象的程序設計
  隨着程序的設計的複雜性增加,結構化程序設計方法又不夠用了。不夠用的根本原因是“代碼重用”的時候不方便。面向對象的方法誕生了,它通過繼承來實現比較完善的代碼重用功能。很多學生在應聘工作,面試的時候,常被問及一個問題“你來談談什麼是面向對象的程序設計”,學生無言,回來問我,這個問題應該怎麼回答。我告訴他,你只要說一句話就夠了“面向對象程序設計是對數據的封裝;範式(模板)的程序設計是對算法的封裝。”後來再有學生遇到了這個問題,只簡單的一句對答,對方就對這個學生就刮目相看了(學生後來自豪地告訴我的)。爲什麼那?因爲只有經過徹底的體會和實踐才能提煉出這個精華。
  面向對象的設計方法和思想,其實早在70年代初就已經被提出來了。其目的就是:強制程序必須通過函數的方式來操縱數據。這樣實現了數據的封裝,就避免了以前設計方法中的,任何代碼都可以隨便操作數據而因起的BUG,而查找修改這個BUG是非常困難的。那麼你可以說,即使我不使用面向對象,當我想訪問某個數據的時候,我就通過調用函數訪問不就可以了嗎?是的,的確可以,但並不是強制的。人都有惰性,當我想對 i 加1的時候,幹嗎非要調用函數呀?算了,直接i++多省事呀。呵呵,正式由於這個懶惰,當程序出BUG的時候,可就不好捉啦。而面向對象是強制性的,從編譯階段就解決了你懶惰的問題。
  巧合的是,面向對象的思想,其實和我們的日常生活中處理問題是吻合的。舉例來說,我打算丟掉一個茶杯,怎麼扔那?太簡單了,拿起茶杯,走到垃圾桶,扔!注意分析這個過程,我們是先選一個“對象”------茶杯,然後向這個對象施加一個動作——扔。每個對象所能施加在它上面的動作是有一定限制的:茶杯,可以被扔,可以被砸,可以用來喝水,可以敲它發出聲音......;一張紙,可以被寫字,可以撕,可以燒......。也就是說,一旦確定了一個對象,則方法也就跟着確定了。我們的日常生活就是如此。但是,大家回想一下我們程序設計和對計算機的操作,卻不是這樣的。拿DOS的操作來說,我要刪除一個文件,方法是在DOS提示符下:c:> del 文件名<回車>。注意看這個過程,動作在前(del),對象在後(文件名),和麪向對象的方法正好順序相反。那麼只是一個順序的問題,會帶來什麼影響那?呵呵,大家一定看到過這個現象:File not found. “啊~~~,我錯了,我錯了,文件名敲錯了一個字母”,於是重新輸入:c:> del 文件名2<回車>。不幸又發生了,計算機報告:File read only. 哈哈,痛苦吧:)。所以DOS的操作其實是違反我們日常生活中的習慣的(當然,以前誰也沒有提出過異議),而現在由於使用了面向對象的設計,那麼這些問題,就在編譯的時候解決了,而不是在運行的時候。obj.fun(),對於這條語句,無論是對象,還是函數,如果你輸入有問題,那麼都會在編譯的時候報告出來,方便你修改,而不是在執行的時候出錯,害的你到處去捉蟲子。
  同時,面向對象又能解決代碼重用的問題——繼承。我以前寫了一個“狗”的類,屬性有(變量):有毛、4條腿、有翹着的尾巴(耷拉着尾巴的那是狼)、鼻子很靈敏、喜歡吃肉骨頭......方法有(函數):能跑、能聞、汪汪叫......如果它去抓耗子,人家叫它“多管閒事”。好了,狗這個類寫好了。但在我實際的生活中,我家養的這條狗和我以前寫的這個“狗類”非常相似,只有一點點的不同,就是我的這條狗,它是:捲毛而且長長的,鼻子小,嘴小......。於是,我派生一個新的類型,叫“哈巴狗類”在“狗類”的基礎上,加上新的特性。好了,程序寫完了,並且是重用了以前的正確的代碼——這就是面向對象程序設計的好處。我的成功只是站在了巨人的肩膀上。當然,如果你使用VC的話,重用最多的代碼就是MFC的類庫。

六、組件(COM)程序設計
  有了面向對象程序設計方法,就徹底解決了代碼重用的問題了嗎?答案是:否!硬件越來越快,越來越小了,軟件的規模卻也越來越大了,集體合作越來越重要,代碼重用又出現的新的問題。

  1. 我用C++寫的類,不能被BASIC重用——不能誇語言;
  2. 你要幹什麼,想重用我的代碼?不行,這樣你就看見了我的設計思想——只能在源程序級別重用,不能在二進制級別(可執行代碼及)重用;
  3. 我耗盡畢生的精力,寫了一個包羅萬象的類庫,但沒有人用。因爲他們說:你這個太大了,我的程序只有1K,你卻給我一個 10000MB 的庫——MFC 的尷尬;
  4. 太好了,我終於找到了程序中的一個BUG,已經修改完成,而且是隻改動了一個字節。接下來我要重新向我的用戶分發新的版本,我的用戶有......10萬個——升級的非魯棒性,不是我分發累死了,就是用戶重新安裝累死了。(魯棒:robust。意爲強壯性的,平順的,順滑的.....鬼知道是哪個不懂計算機的人翻譯的這個詞彙。)
  5. 我想寫一個集大成的軟件,這個軟件的功能是我中有你,你中有我。既能實現文字編輯,又能實現電子表格計算,又能實現自動翻譯,還能畫圖,還能實現數據庫檢索,還可以看電影.....只要用了我的這個軟件,想要什麼就有什麼,我要強佔整個軟件的市場------OLE實現的重用功能,只要學會了COM,這些都不是問題了;
  6. 用戶甲要求我的軟件窗口上下分割,用戶乙要求我的軟件窗口左右分割......我需要在我的軟件基礎上,派生出100個類型,可怎麼辦呀?將來怎麼維護呀?------在腳本的支持下,實現同一程序的的靈活配置而重用,問題迎刃而解了;
  7. 我是個老闆,你知道我有多痛苦嗎?我手下的員工向我提出加工資的要求,我不得不答應呀。因爲如果這個員工跳槽了,他的代碼要維護起來有多難!!!——現在好啦,我要求員工統統用組件寫模塊,想加工資?門都沒有,威脅我要走?那你走吧,這個月的工資也不發了。反正用組件寫的代碼,我可以很容易地進行包容和聚合實現維護。(老闆的福音,程序員的悲哀);
  8. 還有好多那,現在想不起來了......

  COM程序設計方法,就是解決以上問題的一個方式。有很多朋友覺得COM非常複雜難懂,不想學習了。你一定學習過程序設計的最基本的方法(非結構化設計:彙編、gwBasic......),然後,你又學習了結構化程序設計(C、Pascal......),然後,你又努力學習並熟練掌握了面向對象的程序設計方法(C++、Delphi、Java......),那麼不要怕,要有信心去學習組件程序設計,它只是一個設計方法和思想,並且是目前較高級的方法,如果不掌握,就太可惜了。

學習了結構化程序設計,你就會“藐視”那些不遵守結構化設計思想而寫出的代碼;
學習了面向對象設計,你就會“嘲笑”那些爲找BUG而暈頭轉向的程序員;
同樣,學習了組件程序設計,你就會站在更高的層次看待程序設計。

七、結束語
  寫程序的目的是什麼?養家餬口、興趣使然、我的事業......這些都對。但我要強調的是:寫程序的目的是爲了修改程序。在這個觀點上,那麼寫註釋、寫文檔、選擇語言、選擇結構......都是爲這個服務的。本文從軟件設計方法的進化角度來反覆闡述這個觀點,希望愛好者能有所體會和思考。
文中所討論的技術和觀點,適合於大多數情況下的程序設計,而對於特殊的應用的(驅動開發,嵌入式開發,網絡通訊,實時視頻......),這些領域中,由於硬件環境的限制和極限效率的要求,有些觀點就不合適了,需要具體情況具體分析。另外就是對於程序設計的初學者,可以先不考慮這麼多問題,以掌握基本技巧方法和思想爲要。 

發佈了8 篇原創文章 · 獲贊 2 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章