“為什么面向對象的編程會在軟件開發領域造成如此震憾的影響?”
面向對象編程(OOP)具有多方面的吸引力。對管理人員,它實現了更快和更廉價的開發與維護過程。對分析與設計人員,建模處理變得更加簡單,能生成清晰、易于維護的設計方案。對程序員,對象模型顯得如此高雅和淺顯。此外,面向對象工具以及庫的巨大威力使編程成為一項更使人愉悅的任務。每個人都可從中獲益,至少表面如此。
如果說它有缺點,那就是掌握它需付出的代價。思考對象的時候,需要采用形象思維,而不是程序化的思維。與程序化設計相比,對象的設計過程更具挑戰性——特別是在嘗試創建可重復使用(可再生)的對象時。過去,那些初涉面向對象編程領域的人都必須進行一項令人痛苦的選擇:
(1) 選擇一種諸如Smalltalk的語言,“出師”前必須掌握一個巨型的庫。
(2) 選擇幾乎根本沒有庫的C++(注釋①),然后深入學習這種語言,直至能自行編寫對象庫。
①:幸運的是,這一情況已有明顯改觀。現在有第三方庫以及標準的C++庫供選用。
事實上,很難很好地設計出對象——從而很難設計好任何東西。因此,只有數量相當少的“專家”能設計出最好的對象,然后讓其他人享用。對于成功的OOP語言,它們不僅集成了這種語言的語法以及一個編譯程序(編譯器),而且還有一個成功的開發環境,其中包含設計優良、易于使用的庫。所以,大多數程序員的首要任務就是用現有的對象解決自己的應用問題。本章的目標就是向大家揭示出面向對象編程的概念,并證明它有多么簡單。
本章將向大家解釋Java的多項設計思想,并從概念上解釋面向對象的程序設計。但要注意在閱讀完本章后,并不能立即編寫出全功能的Java程序。所有詳細的說明和示例會在本書的其他章節慢慢道來。
1.1 抽象的進步
所有編程語言的最終目的都是提供一種“抽象”方法。一種較有爭議的說法是:解決問題的復雜程度直接取決于抽象的種類及質量。這兒的“種類”是指準備對什么進行“抽象”?匯編語言是對基礎機器的少量抽象。后來的許多“命令式”語言(如FORTRAN,BASIC和C)是對匯編語言的一種抽象。與匯編語言相比,這些語言已有了長足的進步,但它們的抽象原理依然要求我們著重考慮計算機的結構,而非考慮問題本身的結構。在機器模型(位于“方案空間”)與實際解決的問題模型(位于“問題空間”)之間,程序員必須建立起一種聯系。這個過程要求人們付出較大的精力,而且由于它脫離了編程語言本身的范圍,造成程序代碼很難編寫,而且要花較大的代價進行維護。由此造成的副作用便是一門完善的“編程方法”學科。
為機器建模的另一個方法是為要解決的問題制作模型。對一些早期語言來說,如LISP和APL,它們的做法是“從不同的角度觀察世界”——“所有問題都歸納為列表”或“所有問題都歸納為算法”。PROLOG則將所有問題都歸納為決策鏈。對于這些語言,我們認為它們一部分是面向基于“強制”的編程,另一部分則是專為處理圖形符號設計的。每種方法都有自己特殊的用途,適合解決某一類的問題。但只要超出了它們力所能及的范圍,就會顯得非常笨拙。
面向對象的程序設計在此基礎上則跨出了一大步,程序員可利用一些工具表達問題空間內的元素。由于這種表達非常普遍,所以不必受限于特定類型的問題。我們將問題空間中的元素以及它們在方案空間的表示物稱作“對象”(Object)。當然,還有一些在問題空間沒有對應體的其他對象。通過添加新的對象類型,程序可進行靈活的調整,以便與特定的問題配合。所以在閱讀方案的描述代碼時,會讀到對問題進行表達的話語。與我們以前見過的相比,這無疑是一種更加靈活、更加強大的語言抽象方法。總之,OOP允許我們根據問題來描述問題,而不是根據方案。然而,仍有一個聯系途徑回到計算機。每個對象都類似一臺小計算機;它們有自己的狀態,而且可要求它們進行特定的操作。與現實世界的“對象”或者“物體”相比,編程“對象”與它們也存在共通的地方:它們都有自己的特征和行為。
Alan Kay總結了Smalltalk的五大基本特征。這是第一種成功的面向對象程序設計語言,也是Java的基礎語言。通過這些特征,我們可理解“純粹”的面向對象程序設計方法是什么樣的:
(1) 所有東西都是對象。可將對象想象成一種新型變量;它保存著數據,但可要求它對自身進行操作。理論上講,可從要解決的問題身上提出所有概念性的組件,然后在程序中將其表達為一個對象。
(2) 程序是一大堆對象的組合;通過消息傳遞,各對象知道自己該做些什么。為了向對象發出請求,需向那個對象“發送一條消息”。更具體地講,可將消息想象為一個調用請求,它調用的是從屬于目標對象的一個子例程或函數。
(3) 每個對象都有自己的存儲空間,可容納其他對象。或者說,通過封裝現有對象,可制作出新型對象。所以,盡管對象的概念非常簡單,但在程序中卻可達到任意高的復雜程度。
(4) 每個對象都有一種類型。根據語法,每個對象都是某個“類”的一個“實例”。其中,“類”(Class)是“類型”(Type)的同義詞。一個類最重要的特征就是“能將什么消息發給它?”。
(5) 同一類所有對象都能接收相同的消息。這實際是別有含義的一種說法,大家不久便能理解。由于類型為“圓”(Circle)的一個對象也屬于類型為“形狀”(Shape)的一個對象,所以一個圓完全能接收形狀消息。這意味著可讓程序代碼統一指揮“形狀”,令其自動控制所有符合“形狀”描述的對象,其中自然包括“圓”。這一特性稱為對象的“可替換性”,是OOP最重要的概念之一。
一些語言設計者認為面向對象的程序設計本身并不足以方便解決所有形式的程序問題,提倡將不同的方法組合成“多形程序設計語言”(注釋②)。
②:參見Timothy Budd編著的《Multiparadigm Programming in Leda》,Addison-Wesley 1995年出版。
1.2 對象的接口
亞里士多德或許是認真研究“類型”概念的第一人,他曾談及“魚類和鳥類”的問題。在世界首例面向對象語言Simula-67中,第一次用到了這樣的一個概念:
所有對象——盡管各有特色——都屬于某一系列對象的一部分,這些對象具有通用的特征和行為。在Simula-67中,首次用到了class這個關鍵字,它為程序引入了一個全新的類型(clas和type通常可互換使用;注釋③)。
③:有些人進行了進一步的區分,他們強調“類型”決定了接口,而“類”是那個接口的一種特殊實現方式。
Simula是一個很好的例子。正如這個名字所暗示的,它的作用是“模擬”(Simulate)象“銀行出納員”這樣的經典問題。在這個例子里,我們有一系列出納員、客戶、帳號以及交易等。每類成員(元素)都具有一些通用的特征:每個帳號都有一定的余額;每名出納都能接收客戶的存款;等等。與此同時,每個成員都有自己的狀態;每個帳號都有不同的余額;每名出納都有一個名字。所以在計算機程序中,能用獨一無二的實體分別表示出納員、客戶、帳號以及交易。這個實體便是“對象”,而且每個對象都隸屬一個特定的“類”,那個類具有自己的通用特征與行為。
因此,在面向對象的程序設計中,盡管我們真正要做的是新建各種各樣的數據“類型”(Type),但幾乎所有面向對象的程序設計語言都采用了“class”關鍵字。當您看到“type”這個字的時候,請同時想到“class”;反之亦然。
建好一個類后,可根據情況生成許多對象。隨后,可將那些對象作為要解決問題中存在的元素進行處理。事實上,當我們進行面向對象的程序設計時,面臨的最大一項挑戰性就是:如何在“問題空間”(問題實際存在的地方)的元素與“方案空間”(對實際問題進行建模的地方,如計算機)的元素之間建立理想的“一對一”對應或映射關系。
如何利用對象完成真正有用的工作呢?必須有一種辦法能向對象發出請求,令其做一些實際的事情,比如完成一次交易、在屏幕上畫一些東西或者打開一個開關等等。每個對象僅能接受特定的請求。我們向對象發出的請求是通過它的“接口”(Interface)定義的,對象的“類型”或“類”則規定了它的接口形式。“類型”與“接口”的等價或對應關系是面向對象程序設計的基礎。
下面讓我們以電燈泡為例:
?
Light lt = new Light();
lt.on();
在這個例子中,類型/類的名稱是Light,可向Light對象發出的請求包括包括打開(on)、關閉(off)、變得更明亮(brighten)或者變得更暗淡(dim)。通過簡單地聲明一個名字(lt),我們為Light對象創建了一個“句柄”。然后用new關鍵字新建類型為Light的一個對象。再用等號將其賦給句柄。為了向對象發送一條消息,我們列出句柄名(lt),再用一個句點符號(.)把它同消息名稱(on)連接起來。從中可以看出,使用一些預先定義好的類時,我們在程序里采用的代碼是非常簡單和直觀的。
1.3 實現方案的隱藏
為方便后面的討論,讓我們先對這一領域的從業人員作一下分類。從根本上說,大致有兩方面的人員涉足面向對象的編程:“類創建者”(創建新數據類型的人)以及“客戶程序員”(在自己的應用程序中采用現成數據類型的人;注釋④)。對客戶程序員來講,最主要的目標就是收集一個充斥著各種類的編程“工具箱”,以便快速開發符合自己要求的應用。而對類創建者來說,他們的目標則是從頭構建一個類,只向客戶程序員開放有必要開放的東西(接口),其他所有細節都隱藏起來。為什么要這樣做?隱藏之后,客戶程序員就不能接觸和改變那些細節,所以原創者不用擔心自己的作品會受到非法修改,可確保它們不會對其他人造成影響。
④:感謝我的朋友Scott Meyers,是他幫我起了這個名字。
“接口”(Interface)規定了可對一個特定的對象發出哪些請求。然而,必須在某個地方存在著一些代碼,以便滿足這些請求。這些代碼與那些隱藏起來的數據便叫作“隱藏的實現”。站在程式化程序編寫(Procedural Programming)的角度,整個問題并不顯得復雜。一種類型含有與每種可能的請求關聯起來的函數。一旦向對象發出一個特定的請求,就會調用那個函數。我們通常將這個過程總結為向對象“發送一條消息”(提出一個請求)。對象的職責就是決定如何對這條消息作出反應(執行相應的代碼)。
對于任何關系,重要一點是讓牽連到的所有成員都遵守相同的規則。創建一個庫時,相當于同客戶程序員建立了一種關系。對方也是程序員,但他們的目標是組合出一個特定的應用(程序),或者用您的庫構建一個更大的庫。
若任何人都能使用一個類的所有成員,那么客戶程序員可對那個類做任何事情,沒有辦法強制他們遵守任何約束。即便非常不愿客戶程序員直接操作類內包含的一些成員,但倘若未進行訪問控制,就沒有辦法阻止這一情況的發生——所有東西都會暴露無遺。
有兩方面的原因促使我們控制對成員的訪問。第一個原因是防止程序員接觸他們不該接觸的東西——通常是內部數據類型的設計思想。若只是為了解決特定的問題,用戶只需操作接口即可,毋需明白這些信息。我們向用戶提供的實際是一種服務,因為他們很容易就可看出哪些對自己非常重要,以及哪些可忽略不計。
進行訪問控制的第二個原因是允許庫設計人員修改內部結構,不用擔心它會對客戶程序員造成什么影響。例如,我們最開始可能設計了一個形式簡單的類,以便簡化開發。以后又決定進行改寫,使其更快地運行。若接口與實現方法早已隔離開,并分別受到保護,就可放心做到這一點,只要求用戶重新鏈接一下即可。
Java采用三個顯式(明確)關鍵字以及一個隱式(暗示)關鍵字來設置類邊界:public,private,protected以及暗示性的friendly。若未明確指定其他關鍵字,則默認為后者。這些關鍵字的使用和含義都是相當直觀的,它們決定了誰能使用后續的定義內容。“public”(公共)意味著后續的定義任何人均可使用。而在另一方面,“private”(私有)意味著除您自己、類型的創建者以及那個類型的內部函數成員,其他任何人都不能訪問后續的定義信息。private在您與客戶程序員之間豎起了一堵墻。若有人試圖訪問私有成員,就會得到一個編譯期錯誤。“friendly”(友好的)涉及“包裝”或“封裝”(Package)的概念——即Java用來構建庫的方法。若某樣東西是“友好的”,意味著它只能在這個包裝的范圍內使用(所以這一訪問級別有時也叫作“包裝訪問”)。“protected”(受保護的)與“private”相似,只是一個繼承的類可訪問受保護的成員,但不能訪問私有成員。繼承的問題不久就要談到。
1.4 方案的重復使用
創建并測試好一個類后,它應(從理想的角度)代表一個有用的代碼單位。但并不象許多人希望的那樣,這種重復使用的能力并不容易實現;它要求較多的經驗以及洞察力,這樣才能設計出一個好的方案,才有可能重復使用。
許多人認為代碼或設計方案的重復使用是面向對象的程序設計提供的最偉大的一種杠桿。
為重復使用一個類,最簡單的辦法是僅直接使用那個類的對象。但同時也能將那個類的一個對象置入一個新類。我們把這叫作“創建一個成員對象”。新類可由任意數量和類型的其他對象構成。無論如何,只要新類達到了設計要求即可。這個概念叫作“組織”——在現有類的基礎上組織一個新類。有時,我們也將組織稱作“包含”關系,比如“一輛車包含了一個變速箱”。
對象的組織具有極大的靈活性。新類的“成員對象”通常設為“私有”(Private),使用這個類的客戶程序員不能訪問它們。這樣一來,我們可在不干擾客戶代碼的前提下,從容地修改那些成員。也可以在“運行期”更改成員,這進一步增大了靈活性。后面要講到的“繼承”并不具備這種靈活性,因為編譯器必須對通過繼承創建的類加以限制。
由于繼承的重要性,所以在面向對象的程序設計中,它經常被重點強調。作為新加入這一領域的程序員,或許早已先入為主地認為“繼承應當隨處可見”。沿這種思路產生的設計將是非常笨拙的,會大大增加程序的復雜程度。相反,新建類的時候,首先應考慮“組織”對象;這樣做顯得更加簡單和靈活。利用對象的組織,我們的設計可保持清爽。一旦需要用到繼承,就會明顯意識到這一點。
1.5 繼承:重新使用接口
就其本身來說,對象的概念可為我們帶來極大的便利。它在概念上允許我們將各式各樣數據和功能封裝到一起。這樣便可恰當表達“問題空間”的概念,不用刻意遵照基礎機器的表達方式。在程序設計語言中,這些概念則反映為具體的數據類型(使用class關鍵字)。
我們費盡心思做出一種數據類型后,假如不得不又新建一種類型,令其實現大致相同的功能,那會是一件非常令人灰心的事情。但若能利用現成的數據類型,對其進行“克隆”,再根據情況進行添加和修改,情況就顯得理想多了。“繼承”正是針對這個目標而設計的。但繼承并不完全等價于克隆。在繼承過程中,若原始類(正式名稱叫作基礎類、超類或父類)發生了變化,修改過的“克隆”類(正式名稱叫作繼承類或者子類)也會反映出這種變化。在Java語言中,繼承是通過extends關鍵字實現的
使用繼承時,相當于創建了一個新類。這個新類不僅包含了現有類型的所有成員(盡管private成員被隱藏起來,且不能訪問),但更重要的是,它復制了基礎類的接口。也就是說,可向基礎類的對象發送的所有消息亦可原樣發給衍生類的對象。根據可以發送的消息,我們能知道類的類型。這意味著衍生類具有與基礎類相同的類型!為真正理解面向對象程序設計的含義,首先必須認識到這種類型的等價關系。
由于基礎類和衍生類具有相同的接口,所以那個接口必須進行特殊的設計。也就是說,對象接收到一條特定的消息后,必須有一個“方法”能夠執行。若只是簡單地繼承一個類,并不做其他任何事情,來自基礎類接口的方法就會直接照搬到衍生類。這意味著衍生類的對象不僅有相同的類型,也有同樣的行為,這一后果通常是我們不愿見到的。
有兩種做法可將新得的衍生類與原來的基礎類區分開。第一種做法十分簡單:為衍生類添加新函數(功能)。這些新函數并非基礎類接口的一部分。進行這種處理時,一般都是意識到基礎類不能滿足我們的要求,所以需要添加更多的函數。這是一種最簡單、最基本的繼承用法,大多數時候都可完美地解決我們的問題。然而,事先還是要仔細調查自己的基礎類是否真的需要這些額外的函數。
1.5.1 改善基礎類
盡管extends關鍵字暗示著我們要為接口“擴展”新功能,但實情并非肯定如此。為區分我們的新類,第二個辦法是改變基礎類一個現有函數的行為。我們將其稱作“改善”那個函數。
為改善一個函數,只需為衍生類的函數建立一個新定義即可。我們的目標是:“盡管使用的函數接口未變,但它的新版本具有不同的表現”。
1.5.2 等價與類似關系
針對繼承可能會產生這樣的一個爭論:繼承只能改善原基礎類的函數嗎?若答案是肯定的,則衍生類型就是與基礎類完全相同的類型,因為都擁有完全相同的接口。這樣造成的結果就是:我們完全能夠將衍生類的一個對象換成基礎類的一個對象!可將其想象成一種“純替換”。在某種意義上,這是進行繼承的一種理想方式。此時,我們通常認為基礎類和衍生類之間存在一種“等價”關系——因為我們可以理直氣壯地說:“圓就是一種幾何形狀”。為了對繼承進行測試,一個辦法就是看看自己是否能把它們套入這種“等價”關系中,看看是否有意義。
但在許多時候,我們必須為衍生類型加入新的接口元素。所以不僅擴展了接口,也創建了一種新類型。這種新類型仍可替換成基礎類型,但這種替換并不是完美的,因為不可在基礎類里訪問新函數。我們將其稱作“類似”關系;新類型擁有舊類型的接口,但也包含了其他函數,所以不能說它們是完全等價的。舉個例子來說,讓我們考慮一下制冷機的情況。假定我們的房間連好了用于制冷的各種控制器;也就是說,我們已擁有必要的“接口”來控制制冷。現在假設機器出了故障,我們把它換成一臺新型的冷、熱兩用空調,冬天和夏天均可使用。冷、熱空調“類似”制冷機,但能做更多的事情。由于我們的房間只安裝了控制制冷的設備,所以它們只限于同新機器的制冷部分打交道。新機器的接口已得到了擴展,但現有的系統并不知道除原始接口以外的任何東西。
認識了等價與類似的區別后,再進行替換時就會有把握得多。盡管大多數時候“純替換”已經足夠,但您會發現在某些情況下,仍然有明顯的理由需要在衍生類的基礎上增添新功能。通過前面對這兩種情況的討論,相信大家已心中有數該如何做。
1.6 多形對象的互換使用
通常,繼承最終會以創建一系列類收場,所有類都建立在統一的接口基礎上。我們用一幅顛倒的樹形圖來闡明這一點(注釋⑤):
⑤:這兒采用了“統一記號法”,本書將主要采用這種方法。
對這樣的一系列類,我們要進行的一項重要處理就是將衍生類的對象當作基礎類的一個對象對待。這一點是非常重要的,因為它意味著我們只需編寫單一的代碼,令其忽略類型的特定細節,只與基礎類打交道。這樣一來,那些代碼就可與類型信息分開。所以更易編寫,也更易理解。此外,若通過繼承增添了一種新類型,如“三角形”,那么我們為“幾何形狀”新類型編寫的代碼會象在舊類型里一樣良好地工作。所以說程序具備了“擴展能力”,具有“擴展性”。
以上面的例子為基礎,假設我們用Java寫了這樣一個函數:
?
void doStuff(Shape s) {
s.erase();
// ...
s.draw();
}
這個函數可與任何“幾何形狀”(Shape)通信,所以完全獨立于它要描繪(draw)和刪除(erase)的任何特定類型的對象。如果我們在其他一些程序里使用doStuff()函數:
Circle c = new Circle();
Triangle t = new Triangle();
Line l = new Line();
doStuff(c);
doStuff(t);
doStuff(l);
那么對doStuff()的調用會自動良好地工作,無論對象的具體類型是什么。
這實際是一個非常有用的編程技巧。請考慮下面這行代碼:
doStuff(c);
此時,一個Circle(圓)句柄傳遞給一個本來期待Shape(形狀)句柄的函數。由于圓是一種幾何形狀,所以doStuff()能正確地進行處理。也就是說,凡是doStuff()能發給一個Shape的消息,Circle也能接收。所以這樣做是安全的,不會造成錯誤。
我們將這種把衍生類型當作它的基本類型處理的過程叫作“Upcasting”(上溯造型)。其中,“cast”(造型)是指根據一個現成的模型創建;而“Up”(向上)表明繼承的方向是從“上面”來的——即基礎類位于頂部,而衍生類在下方展開。所以,根據基礎類進行造型就是一個從上面繼承的過程,即“Upcasting”。
在面向對象的程序里,通常都要用到上溯造型技術。這是避免去調查準確類型的一個好辦法。請看看doStuff()里的代碼:
s.erase();
// ...
s.draw();
注意它并未這樣表達:“如果你是一個Circle,就這樣做;如果你是一個Square,就那樣做;等等”。若那樣編寫代碼,就需檢查一個Shape所有可能的類型,如圓、矩形等等。這顯然是非常麻煩的,而且每次添加了一種新的Shape類型后,都要相應地進行修改。在這兒,我們只需說:“你是一種幾何形狀,我知道你能將自己刪掉,即erase();請自己采取那個行動,并自己去控制所有的細節吧。”
1.6.1 動態綁定
在doStuff()的代碼里,最讓人吃驚的是盡管我們沒作出任何特殊指示,采取的操作也是完全正確和恰當的。我們知道,為Circle調用draw()時執行的代碼與為一個Square或Line調用draw()時執行的代碼是不同的。但在將draw()消息發給一個匿名Shape時,根據Shape句柄當時連接的實際類型,會相應地采取正確的操作。這當然令人驚訝,因為當Java編譯器為doStuff()編譯代碼時,它并不知道自己要操作的準確類型是什么。盡管我們確實可以保證最終會為Shape調用erase(),為Shape調用draw(),但并不能保證為特定的Circle,Square或者Line調用什么。然而最后采取的操作同樣是正確的,這是怎么做到的呢?
將一條消息發給對象時,如果并不知道對方的具體類型是什么,但采取的行動同樣是正確的,這種情況就叫作“多形性”(Polymorphism)。對面向對象的程序設計語言來說,它們用以實現多形性的方法叫作“動態綁定”。編譯器和運行期系統會負責對所有細節的控制;我們只需知道會發生什么事情,而且更重要的是,如何利用它幫助自己設計程序。
有些語言要求我們用一個特殊的關鍵字來允許動態綁定。在C++中,這個關鍵字是virtual。在Java中,我們則完全不必記住添加一個關鍵字,因為函數的動態綁定是自動進行的。所以在將一條消息發給對象時,我們完全可以肯定對象會采取正確的行動,即使其中涉及上溯造型之類的處理。
1.6.2 抽象的基礎類和接口
設計程序時,我們經常都希望基礎類只為自己的衍生類提供一個接口。也就是說,我們不想其他任何人實際創建基礎類的一個對象,只對上溯造型成它,以便使用它們的接口。為達到這個目的,需要把那個類變成“抽象”的——使用abstract關鍵字。若有人試圖創建抽象類的一個對象,編譯器就會阻止他們。這種工具可有效強制實行一種特殊的設計。
亦可用abstract關鍵字描述一個尚未實現的方法——作為一個“根”使用,指出:“這是適用于從這個類繼承的所有類型的一個接口函數,但目前尚沒有對它進行任何形式的實現。”抽象方法也許只能在一個抽象類里創建。繼承了一個類后,那個方法就必須實現,否則繼承的類也會變成“抽象”類。通過創建一個抽象方法,我們可以將一個方法置入接口中,不必再為那個方法提供可能毫無意義的主體代碼。
interface(接口)關鍵字將抽象類的概念更延伸了一步,它完全禁止了所有的函數定義。“接口”是一種相當有效和常用的工具。另外如果自己愿意,亦可將多個接口都合并到一起(不能從多個普通class或abstract class中繼承)。
1.7 對象的創建和存在時間
從技術角度說,OOP(面向對象程序設計)只是涉及抽象的數據類型、繼承以及多形性,但另一些問題也可能顯得非常重要。本節將就這些問題進行探討。
最重要的問題之一是對象的創建及破壞方式。對象需要的數據位于哪兒,如何控制對象的“存在時間”呢?針對這個問題,解決的方案是各異其趣的。C++認為程序的執行效率是最重要的一個問題,所以它允許程序員作出選擇。為獲得最快的運行速度,存儲以及存在時間可在編寫程序時決定,只需將對象放置在堆棧(有時也叫作自動或定域變量)或者靜態存儲區域即可。這樣便為存儲空間的分配和釋放提供了一個優先級。某些情況下,這種優先級的控制是非常有價值的。然而,我們同時也犧牲了靈活性,因為在編寫程序時,必須知道對象的準確的數量、存在時間、以及類型。如果要解決的是一個較常規的問題,如計算機輔助設計、倉儲管理或者空中交通控制,這一方法就顯得太局限了。
第二個方法是在一個內存池中動態創建對象,該內存池亦叫“堆”或者“內存堆”。若采用這種方式,除非進入運行期,否則根本不知道到底需要多少個對象,也不知道它們的存在時間有多長,以及準確的類型是什么。這些參數都在程序正式運行時才決定的。若需一個新對象,只需在需要它的時候在內存堆里簡單地創建它即可。由于存儲空間的管理是運行期間動態進行的,所以在內存堆里分配存儲空間的時間比在堆棧里創建的時間長得多(在堆棧里創建存儲空間一般只需要一個簡單的指令,將堆棧指針向下或向下移動即可)。由于動態創建方法使對象本來就傾向于復雜,所以查找存儲空間以及釋放它所需的額外開銷不會為對象的創建造成明顯的影響。除此以外,更大的靈活性對于常規編程問題的解決是至關重要的。
C++允許我們決定是在寫程序時創建對象,還是在運行期間創建,這種控制方法更加靈活。大家或許認為既然它如此靈活,那么無論如何都應在內存堆里創建對象,而不是在堆棧中創建。但還要考慮另外一個問題,亦即對象的“存在時間”或者“生存時間”(Lifetime)。若在堆棧或者靜態存儲空間里創建一個對象,編譯器會判斷對象的持續時間有多長,到時會自動“破壞”或者“清除”它。程序員可用兩種方法來破壞一個對象:用程序化的方式決定何時破壞對象,或者利用由運行環境提供的一種“垃圾收集器”特性,自動尋找那些不再使用的對象,并將其清除。當然,垃圾收集器顯得方便得多,但要求所有應用程序都必須容忍垃圾收集器的存在,并能默許隨垃圾收集帶來的額外開銷。但這并不符合C++語言的設計宗旨,所以未能包括到C++里。但Java確實提供了一個垃圾收集器(Smalltalk也有這樣的設計;盡管Delphi默認為沒有垃圾收集器,但可選擇安裝;而C++亦可使用一些由其他公司開發的垃圾收集產品)。
本節剩下的部分將討論操縱對象時要考慮的另一些因素。
1.7.1 集合與繼承器
針對一個特定問題的解決,如果事先不知道需要多少個對象,或者它們的持續時間有多長,那么也不知道如何保存那些對象。既然如此,怎樣才能知道那些對象要求多少空間呢?事先上根本無法提前知道,除非進入運行期。
在面向對象的設計中,大多數問題的解決辦法似乎都有些輕率——只是簡單地創建另一種類型的對象。用于解決特定問題的新型對象容納了指向其他對象的句柄。當然,也可以用數組來做同樣的事情,那是大多數語言都具有的一種功能。但不能只看到這一點。這種新對象通常叫作“集合”(亦叫作一個“容器”,但AWT在不同的場合應用了這個術語,所以本書將一直沿用“集合”的稱呼。在需要的時候,集合會自動擴充自己,以便適應我們在其中置入的任何東西。所以我們事先不必知道要在一個集合里容下多少東西。只需創建一個集合,以后的工作讓它自己負責好了。
幸運的是,設計優良的OOP語言都配套提供了一系列集合。在C++中,它們是以“標準模板庫”(STL)的形式提供的。Object Pascal用自己的“可視組件庫”(VCL)提供集合。Smalltalk提供了一套非常完整的集合。而Java也用自己的標準庫提供了集合。在某些庫中,一個常規集合便可滿足人們的大多數要求;而在另一些庫中(特別是C++的庫),則面向不同的需求提供了不同類型的集合。例如,可以用一個矢量統一對所有元素的訪問方式;一個鏈接列表則用于保證所有元素的插入統一。所以我們能根據自己的需要選擇適當的類型。其中包括集、隊列、散列表、樹、堆棧等等。
所有集合都提供了相應的讀寫功能。將某樣東西置入集合時,采用的方式是十分明顯的。有一個叫作“推”(Push)、“添加”(Add)或其他類似名字的函數用于做這件事情。但將數據從集合中取出的時候,方式卻并不總是那么明顯。如果是一個數組形式的實體,比如一個矢量(Vector),那么也許能用索引運算符或函數。但在許多情況下,這樣做往往會無功而返。此外,單選定函數的功能是非常有限的。如果想對集合中的一系列元素進行操縱或比較,而不是僅僅面向一個,這時又該怎么辦呢?
辦法就是使用一個“繼續器”(Iterator),它屬于一種對象,負責選擇集合內的元素,并把它們提供給繼承器的用戶。作為一個類,它也提供了一級抽象。利用這一級抽象,可將集合細節與用于訪問那個集合的代碼隔離開。通過繼承器的作用,集合被抽象成一個簡單的序列。繼承器允許我們遍歷那個序列,同時毋需關心基礎結構是什么——換言之,不管它是一個矢量、一個鏈接列表、一個堆棧,還是其他什么東西。這樣一來,我們就可以靈活地改變基礎數據,不會對程序里的代碼造成干擾。Java最開始(在1.0和1.1版中)提供的是一個標準繼承器,名為Enumeration(枚舉),為它的所有集合類提供服務。Java 1.2新增一個更復雜的集合庫,其中包含了一個名為Iterator的繼承器,可以做比老式的Enumeration更多的事情。
從設計角度出發,我們需要的是一個全功能的序列。通過對它的操縱,應該能解決自己的問題。如果一種類型的序列即可滿足我們的所有要求,那么完全沒有必要再換用不同的類型。有兩方面的原因促使我們需要對集合作出選擇。首先,集合提供了不同的接口類型以及外部行為。堆棧的接口與行為與隊列的不同,而隊列的接口與行為又與一個集(Set)或列表的不同。利用這個特征,我們解決問題時便有更大的靈活性。
其次,不同的集合在進行特定操作時往往有不同的效率。最好的例子便是矢量(Vector)和列表(List)的區別。它們都屬于簡單的序列,擁有完全一致的接口和外部行為。但在執行一些特定的任務時,需要的開銷卻是完全不同的。對矢量內的元素進行的隨機訪問(存取)是一種常時操作;無論我們選擇的選擇是什么,需要的時間量都是相同的。但在一個鏈接列表中,若想到處移動,并隨機挑選一個元素,就需付出“慘重”的代價。而且假設某個元素位于列表較遠的地方,找到它所需的時間也會長許多。但在另一方面,如果想在序列中部插入一個元素,用列表就比用矢量劃算得多。這些以及其他操作都有不同的執行效率,具體取決于序列的基礎結構是什么。在設計階段,我們可以先從一個列表開始。最后調整性能的時候,再根據情況把它換成矢量。由于抽象是通過繼承器進行的,所以能在兩者方便地切換,對代碼的影響則顯得微不足道。
最后,記住集合只是一個用來放置對象的儲藏所。如果那個儲藏所能滿足我們的所有需要,就完全沒必要關心它具體是如何實現的(這是大多數類型對象的一個基本概念)。如果在一個編程環境中工作,它由于其他因素(比如在Windows下運行,或者由垃圾收集器帶來了開銷)產生了內在的開銷,那么矢量和鏈接列表之間在系統開銷上的差異就或許不是一個大問題。我們可能只需要一種類型的序列。甚至可以想象有一個“完美”的集合抽象,它能根據自己的使用方式自動改變基層的實現方式。
1.7.2 單根結構
在面向對象的程序設計中,由于C++的引入而顯得尤為突出的一個問題是:所有類最終是否都應從單獨一個基礎類繼承。在Java中(與其他幾乎所有OOP語言一樣),對這個問題的答案都是肯定的,而且這個終級基礎類的名字很簡單,就是一個“Object”。這種“單根結構”具有許多方面的優點。
單根結構中的所有對象都有一個通用接口,所以它們最終都屬于相同的類型。另一種方案(就象C++那樣)是我們不能保證所有東西都屬于相同的基本類型。從向后兼容的角度看,這一方案可與C模型更好地配合,而且可以認為它的限制更少一些。但假期我們想進行純粹的面向對象編程,那么必須構建自己的結構,以期獲得與內建到其他OOP語言里的同樣的便利。需添加我們要用到的各種新類庫,還要使用另一些不兼容的接口。理所當然地,這也需要付出額外的精力使新接口與自己的設計方案配合(可能還需要多重繼承)。為得到C++額外的“靈活性”,付出這樣的代價值得嗎?當然,如果真的需要——如果早已是C專家,如果對C有難舍的情結——那么就真的很值得。但假如你是一名新手,首次接觸這類設計,象Java那樣的替換方案也許會更省事一些。
單根結構中的所有對象(比如所有Java對象)都可以保證擁有一些特定的功能。在自己的系統中,我們知道對每個對象都能進行一些基本操作。一個單根結構,加上所有對象都在內存堆中創建,可以極大簡化參數的傳遞(這在C++里是一個復雜的概念)。
利用單根結構,我們可以更方便地實現一個垃圾收集器。與此有關的必要支持可安裝于基礎類中,而垃圾收集器可將適當的消息發給系統內的任何對象。如果沒有這種單根結構,而且系統通過一個句柄來操縱對象,那么實現垃圾收集器的途徑會有很大的不同,而且會面臨許多障礙。
由于運行期的類型信息肯定存在于所有對象中,所以永遠不會遇到判斷不出一個對象的類型的情況。這對系統級的操作來說顯得特別重要,比如違例控制;而且也能在程序設計時獲得更大的靈活性。
但大家也可能產生疑問,既然你把好處說得這么天花亂墜,為什么C++沒有采用單根結構呢?事實上,這是早期在效率與控制上權衡的一種結果。單根結構會帶來程序設計上的一些限制。而且更重要的是,它加大了新程序與原有C代碼兼容的難度。盡管這些限制僅在特定的場合會真的造成問題,但為了獲得最大的靈活程度,C++最終決定放棄采用單根結構這一做法。而Java不存在上述的問題,它是全新設計的一種語言,不必與現有的語言保持所謂的“向后兼容”。所以很自然地,與其他大多數面向對象的程序設計語言一樣,單根結構在Java的設計方案中很快就落實下來。
1.7.3 集合庫與方便使用集合
由于集合是我們經常都要用到的一種工具,所以一個集合庫是十分必要的,它應該可以方便地重復使用。這樣一來,我們就可以方便地取用各種集合,將其插入自己的程序。Java提供了這樣的一個庫,盡管它在Java 1.0和1.1中都顯得非常有限(Java 1.2的集合庫則無疑是一個杰作)。
1. 下溯造型與模板/通用性
為了使這些集合能夠重復使用,或者“再生”,Java提供了一種通用類型,以前曾把它叫作“Object”。單根結構意味著、所有東西歸根結底都是一個對象”!所以容納了Object的一個集合實際可以容納任何東西。這使我們對它的重復使用變得非常簡便。
為使用這樣的一個集合,只需添加指向它的對象句柄即可,以后可以通過句柄重新使用對象。但由于集合只能容納Object,所以在我們向集合里添加對象句柄時,它會上溯造型成Object,這樣便丟失了它的身份或者標識信息。再次使用它的時候,會得到一個Object句柄,而非指向我們早先置入的那個類型的句柄。所以怎樣才能歸還它的本來面貌,調用早先置入集合的那個對象的有用接口呢?
在這里,我們再次用到了造型(Cast)。但這一次不是在分級結構中上溯造型成一種更“通用”的類型。而是下溯造型成一種更“特殊”的類型。這種造型方法叫作“下溯造型”(Downcasting)。舉個例子來說,我們知道在上溯造型的時候,Circle(圓)屬于Shape(幾何形狀)的一種類型,所以上溯造型是安全的。但我們不知道一個Object到底是Circle還是Shape,所以很難保證下溯造型的安全進行,除非確切地知道自己要操作的是什么。
但這也不是絕對危險的,因為假如下溯造型成錯誤的東西,會得到我們稱為“違例”(Exception)的一種運行期錯誤。我們稍后即會對此進行解釋。但在從一個集合提取對象句柄時,必須用某種方式準確地記住它們是什么,以保證下溯造型的正確進行。
下溯造型和運行期檢查都要求花額外的時間來運行程序,而且程序員必須付出額外的精力。既然如此,我們能不能創建一個“智能”集合,令其知道自己容納的類型呢?這樣做可消除下溯造型的必要以及潛在的錯誤。答案是肯定的,我們可以采用“參數化類型”,它們是編譯器能自動定制的類,可與特定的類型配合。例如,通過使用一個參數化集合,編譯器可對那個集合進行定制,使其只接受Shape,而且只提取Shape。
參數化類型是C++一個重要的組成部分,這部分是C++沒有單根結構的緣故。在C++中,用于實現參數化類型的關鍵字是template(模板)。Java目前尚未提供參數化類型,因為由于使用的是單根結構,所以使用它顯得有些笨拙。但這并不能保證以后的版本不會實現,因為“generic”這個詞已被Java“保留到將來實現”(在Ada語言中,“generic”被用來實現它的模板)。Java采取的這種關鍵字保留機制其實經常讓人摸不著頭腦,很難斷定以后會發生什么事情。
1.7.4 清除時的困境:由誰負責清除?
每個對象都要求資源才能“生存”,其中最令人注目的資源是內存。如果不再需要使用一個對象,就必須將其清除,以便釋放這些資源,以便其他對象使用。如果要解決的是非常簡單的問題,如何清除對象這個問題并不顯得很突出:我們創建對象,在需要的時候調用它,然后將其清除或者“破壞”。但在另一方面,我們平時遇到的問題往往要比這復雜得多。
舉個例子來說,假設我們要設計一套系統,用它管理一個機場的空中交通(同樣的模型也可能適于管理一個倉庫的貨柜、或者一套影帶出租系統、或者寵物店的寵物房。這初看似乎十分簡單:構造一個集合用來容納飛機,然后創建一架新飛機,將其置入集合。對進入空中交通管制區的所有飛機都如此處理。至于清除,在一架飛機離開這個區域的時候把它簡單地刪去即可。
但事情并沒有這么簡單,可能還需要另一套系統來記錄與飛機有關的數據。當然,和控制器的主要功能不同,這些數據的重要性可能一開始并不顯露出來。例如,這條記錄反映的可能是離開機場的所有小飛機的飛行計劃。所以我們得到了由小飛機組成的另一個集合。一旦創建了一個飛機對象,如果它是一架小飛機,那么也必須把它置入這個集合。然后在系統空閑時期,需對這個集合中的對象進行一些后臺處理。
問題現在顯得更復雜了:如何才能知道什么時間刪除對象呢?用完對象后,系統的其他某些部分可能仍然要發揮作用。同樣的問題也會在其他大量場合出現,而且在程序設計系統中(如C++),在用完一個對象之后必須明確地將其刪除,所以問題會變得異常復雜(注釋⑥)。
⑥:注意這一點只對內存堆里創建的對象成立(用new命令創建的)。但在另一方面,對這兒描述的問題以及其他所有常見的編程問題來說,都要求對象在內存堆里創建。
在Java中,垃圾收集器在設計時已考慮到了內存的釋放問題(盡管這并不包括清除一個對象涉及到的其他方面)。垃圾收集器“知道”一個對象在什么時候不再使用,然后會自動釋放那個對象占據的內存空間。采用這種方式,另外加上所有對象都從單個根類Object繼承的事實,而且由于我們只能在內存堆中以一種方式創建對象,所以Java的編程要比C++的編程簡單得多。我們只需要作出少量的抉擇,即可克服原先存在的大量障礙。
1. 垃圾收集器對效率及靈活性的影響
既然這是如此好的一種手段,為什么在C++里沒有得到充分的發揮呢?我們當然要為這種編程的方便性付出一定的代價,代價就是運行期的開銷。正如早先提到的那樣,在C++中,我們可在堆棧中創建對象。在這種情況下,對象會得以自動清除(但不具有在運行期間隨心所欲創建對象的靈活性)。在堆棧中創建對象是為對象分配存儲空間最有效的一種方式,也是釋放那些空間最有效的一種方式。在內存堆(Heap)中創建對象可能要付出昂貴得多的代價。如果總是從同一個基礎類繼承,并使所有函數調用都具有“同質多形”特征,那么也不可避免地需要付出一定的代價。但垃圾收集器是一種特殊的問題,因為我們永遠不能確定它什么時候啟動或者要花多長的時間。這意味著在Java程序執行期間,存在著一種不連貫的因素。所以在某些特殊的場合,我們必須避免用它——比如在一個程序的執行必須保持穩定、連貫的時候(通常把它們叫作“實時程序”,盡管并不是所有實時編程問題都要這方面的要求——注釋⑦)。
⑦:根據本書一些技術性讀者的反饋,有一個現成的實時Java系統(www.newmonics.com)確實能夠保證垃圾收集器的效能。
C++語言的設計者曾經向C程序員發出請求(而且做得非常成功),不要希望在可以使用C的任何地方,向語言里加入可能對C++的速度或使用造成影響的任何特性。這個目的達到了,但代價就是C++的編程不可避免地復雜起來。Java比C++簡單,但付出的代價是效率以及一定程度的靈活性。但對大多數程序設計問題來說,Java無疑都應是我們的首選。
1.8 違例控制:解決錯誤
從最古老的程序設計語言開始,錯誤控制一直都是設計者們需要解決的一個大問題。由于很難設計出一套完美的錯誤控制方案,許多語言干脆將問題簡單地忽略掉,將其轉嫁給庫設計人員。對大多數錯誤控制方案來說,最主要的一個問題是它們嚴重依賴程序員的警覺性,而不是依賴語言本身的強制標準。如果程序員不夠警惕——若比較匆忙,這幾乎是肯定會發生的——程序所依賴的錯誤控制方案便會失效。
“違例控制”將錯誤控制方案內置到程序設計語言中,有時甚至內建到操作系統內。這里的“違例”(Exception)屬于一個特殊的對象,它會從產生錯誤的地方“扔”或“擲”出來。隨后,這個違例會被設計用于控制特定類型錯誤的“違例控制器”捕獲。在情況變得不對勁的時候,可能有幾個違例控制器并行捕獲對應的違例對象。由于采用的是獨立的執行路徑,所以不會干擾我們的常規執行代碼。這樣便使代碼的編寫變得更加簡單,因為不必經常性強制檢查代碼。除此以外,“擲”出的一個違例不同于從函數返回的錯誤值,也不同于由函數設置的一個標志。那些錯誤值或標志的作用是指示一個錯誤狀態,是可以忽略的。但違例不能被忽略,所以肯定能在某個地方得到處置。最后,利用違例能夠可靠地從一個糟糕的環境中恢復。此時一般不需要退出,我們可以采取某些處理,恢復程序的正常執行。顯然,這樣編制出來的程序顯得更加可靠。
Java的違例控制機制與大多數程序設計語言都有所不同。因為在Java中,違例控制模塊是從一開始就封裝好的,所以必須使用它!如果沒有自己寫一些代碼來正確地控制違例,就會得到一條編譯期出錯提示。這樣可保證程序的連貫性,使錯誤控制變得更加容易。
注意違例控制并不屬于一種面向對象的特性,盡管在面向對象的程序設計語言中,違例通常是用一個對象表示的。早在面向對象語言問世以前,違例控制就已經存在了。
1.9 多線程
在計算機編程中,一個基本的概念就是同時對多個任務加以控制。許多程序設計問題都要求程序能夠停下手頭的工作,改為處理其他一些問題,再返回主進程。可以通過多種途徑達到這個目的。最開始的時候,那些擁有機器低級知識的程序員編寫一些“中斷服務例程”,主進程的暫停是通過硬件級的中斷實現的。盡管這是一種有用的方法,但編出的程序很難移植,由此造成了另一類的代價高昂問題。
有些時候,中斷對那些實時性很強的任務來說是很有必要的。但還存在其他許多問題,它們只要求將問題劃分進入獨立運行的程序片斷中,使整個程序能更迅速地響應用戶的請求。在一個程序中,這些獨立運行的片斷叫作“線程”(Thread),利用它編程的概念就叫作“多線程處理”。多線程處理一個常見的例子就是用戶界面。利用線程,用戶可按下一個按鈕,然后程序會立即作出響應,而不是讓用戶等待程序完成了當前任務以后才開始響應。
最開始,線程只是用于分配單個處理器的處理時間的一種工具。但假如操作系統本身支持多個處理器,那么每個線程都可分配給一個不同的處理器,真正進入“并行運算”狀態。從程序設計語言的角度看,多線程操作最有價值的特性之一就是程序員不必關心到底使用了多少個處理器。程序在邏輯意義上被分割為數個線程;假如機器本身安裝了多個處理器,那么程序會運行得更快,毋需作出任何特殊的調校。
根據前面的論述,大家可能感覺線程處理非常簡單。但必須注意一個問題:共享資源!如果有多個線程同時運行,而且它們試圖訪問相同的資源,就會遇到一個問題。舉個例子來說,兩個進程不能將信息同時發送給一臺打印機。為解決這個問題,對那些可共享的資源來說(比如打印機),它們在使用期間必須進入鎖定狀態。所以一個線程可將資源鎖定,在完成了它的任務后,再解開(釋放)這個鎖,使其他線程可以接著使用同樣的資源。
Java的多線程機制已內建到語言中,這使一個可能較復雜的問題變得簡單起來。對多線程處理的支持是在對象這一級支持的,所以一個執行線程可表達為一個對象。Java也提供了有限的資源鎖定方案。它能鎖定任何對象占用的內存(內存實際是多種共享資源的一種),所以同一時間只能有一個線程使用特定的內存空間。為達到這個目的,需要使用synchronized關鍵字。其他類型的資源必須由程序員明確鎖定,這通常要求程序員創建一個對象,用它代表一把鎖,所有線程在訪問那個資源時都必須檢查這把鎖。
1.10 永久性
創建一個對象后,只要我們需要,它就會一直存在下去。但在程序結束運行時,對象的“生存期”也會宣告結束。盡管這一現象表面上非常合理,但深入追究就會發現,假如在程序停止運行以后,對象也能繼續存在,并能保留它的全部信息,那么在某些情況下將是一件非常有價值的事情。下次啟動程序時,對象仍然在那里,里面保留的信息仍然是程序上一次運行時的那些信息。當然,可以將信息寫入一個文件或者數據庫,從而達到相同的效果。但盡管可將所有東西都看作一個對象,如果能將對象聲明成“永久性”,并令其為我們照看其他所有細節,無疑也是一件相當方便的事情。
Java 1.1提供了對“有限永久性”的支持,這意味著我們可將對象簡單地保存到磁盤上,以后任何時間都可取回。之所以稱它為“有限”的,是由于我們仍然需要明確發出調用,進行對象的保存和取回工作。這些工作不能自動進行。在Java未來的版本中,對“永久性”的支持有望更加全面。
1.11 Java和因特網
既然Java不過另一種類型的程序設計語言,大家可能會奇怪它為什么值得如此重視,為什么還有這么多的人認為它是計算機程序設計的一個里程碑呢?如果您來自一個傳統的程序設計背景,那么答案在剛開始的時候并不是很明顯。Java除了可解決傳統的程序設計問題以外,還能解決World Wide Web(萬維網)上的編程問題。
1.11.1 什么是Web?
Web這個詞剛開始顯得有些泛泛,似乎“沖浪”、“網上存在”以及“主頁”等等都和它拉上了一些關系。甚至還有一種“Internet綜合癥”的說法,對許多人狂熱的上網行為提出了質疑。我們在這里有必要作一些深入的探討,但在這之前,必須理解客戶機/服務器系統的概念,這是充斥著許多令人迷惑的問題的又一個計算領域。
1. 客戶機/服務器計算
客戶機/服務器系統的基本思想是我們能在一個統一的地方集中存放信息資源。一般將數據集中保存在某個數據庫中,根據其他人或者機器的請求將信息投遞給對方。客戶機/服務器概述的一個關鍵在于信息是“集中存放”的。所以我們能方便地更改信息,然后將修改過的信息發放給信息的消費者。將各種元素集中到一起,信息倉庫、用于投遞信息的軟件以及信息及軟件所在的那臺機器,它們聯合起來便叫作“服務器”(Server)。而對那些駐留在遠程機器上的軟件,它們需要與服務器通信,取回信息,進行適當的處理,然后在遠程機器上顯示出來,這些就叫作“客戶”(Client)。
這樣看來,客戶機/服務器的基本概念并不復雜。這里要注意的一個主要問題是單個服務器需要同時向多個客戶提供服務。在這一機制中,通常少不了一套數據庫管理系統,使設計人員能將數據布局封裝到表格中,以獲得最優的使用。除此以外,系統經常允許客戶將新信息插入一個服務器。這意味著必須確保客戶的新數據不會與其他客戶的新數據沖突,或者說需要保證那些數據在加入數據庫的時候不會丟失(用數據庫的術語來說,這叫作“事務處理”)。客戶軟件發生了改變之后,它們必須在客戶機器上構建、調試以及安裝。所有這些會使問題變得比我們一般想象的復雜得多。另外,對多種類型的計算機和操作系統的支持也是一個大問題。最后,性能的問題顯得尤為重要:可能會有數百個客戶同時向服務器發出請求。所以任何微小的延誤都是不能忽視的。為盡可能緩解潛伏的問題,程序員需要謹慎地分散任務的處理負擔。一般可以考慮讓客戶機負擔部分處理任務,但有時亦可分派給服務器所在地的其他機器,那些機器亦叫作“中間件”(中間件也用于改進對系統的維護)。
所以在具體實現的時候,其他人發布信息這樣一個簡單的概念可能變得異常復雜。有時甚至會使人產生完全無從著手的感覺。客戶機/服務器的概念在這時就可以大顯身手了。事實上,大約有一半的程序設計活動都可以采用客戶機/服務器的結構。這種系統可負責從處理訂單及信用卡交易,一直到發布各類數據的方方面面的任務——股票市場、科學研究、政府運作等等。在過去,我們一般為單獨的問題采取單獨的解決方案;每次都要設計一套新方案。這些方案無論創建還是使用都比較困難,用戶每次都要學習和適應新界面。客戶機/服務器問題需要從根本上加以變革!
2. Web是一個巨大的服務器
Web實際就是一套規模巨大的客戶機/服務器系統。但它的情況要復雜一些,因為所有服務器和客戶都同時存在于單個網絡上面。但我們沒必要了解更進一步的細節,因為唯一要關心的就是一次建立同一個服務器的連接,并同它打交道(即使可能要在全世界的范圍內搜索正確的服務器)。
最開始的時候,這是一個簡單的單向操作過程。我們向一個服務器發出請求,它向我們回傳一個文件,由于本機的瀏覽器軟件(亦即“客戶”或“客戶程序”)負責解釋和格式化,并在我們面前的屏幕上正確地顯示出來。但人們不久就不滿足于只從一個服務器傳遞網頁。他們希望獲得完全的客戶機/服務器能力,使客戶(程序)也能反饋一些信息到服務器。比如希望對服務器上的數據庫進行檢索,向服務器添加新信息,或者下一份訂單等等(這也提供了比以前的系統更高的安全要求)。在Web的發展過程中,我們可以很清晰地看出這些令人心喜的變化。
Web瀏覽器的發展終于邁出了重要的一步:某個信息可在任何類型的計算機上顯示出來,毋需任何改動。然而,瀏覽器仍然顯得很原始,在用戶迅速增多的要求面前顯得有些力不從心。它們的交互能力不夠強,而且對服務器和因特網都造成了一定程度的干擾。這是由于每次采取一些要求編程的操作時,必須將信息反饋回服務器,在服務器那一端進行處理。所以完全可能需要等待數秒乃至數分鐘的時間才會發現自己剛才拼錯了一個單詞。由于瀏覽器只是一個純粹的查看程序,所以連最簡單的計算任務都不能進行(當然在另一方面,它也顯得非常安全,因為不能在本機上面執行任何程序,避開了程序錯誤或者病毒的騷擾)。
為解決這個問題,人們采取了許多不同的方法。最開始的時候,人們對圖形標準進行了改進,使瀏覽器能顯示更好的動畫和視頻。為解決剩下的問題,唯一的辦法就是在客戶端(瀏覽器)內運行程序。這就叫作“客戶端編程”,它是對傳統的“服務器端編程”的一個非常重要的拓展。
1.11.2 客戶端編程(注釋⑧)
Web最初采用的“服務器-瀏覽器”方案可提供交互式內容,但這種交互能力完全由服務器提供,為服務器和因特網帶來了不小的負擔。服務器一般為客戶瀏覽器產生靜態網頁,由后者簡單地解釋并顯示出來。基本HTML語言提供了簡單的數據收集機制:文字輸入框、復選框、單選鈕、列表以及下拉列表等,另外還有一個按鈕,只能由程序規定重新設置表單中的數據,以便回傳給服務器。用戶提交的信息通過所有Web服務器均能支持的“通用網關接口”(CGI)回傳到服務器。包含在提交數據中的文字指示CGI該如何操作。最常見的行動是運行位于服務器的一個程序。那個程序一般保存在一個名為“cgi-bin”的目錄中(按下Web頁內的一個按鈕時,請注意一下瀏覽器頂部的地址窗,經常都能發現“cgi-bin”的字樣)。大多數語言都可用來編制這些程序,但其中最常見的是Perl。這是由于Perl是專為文字的處理及解釋而設計的,所以能在任何服務器上安裝和使用,無論采用的處理器或操作系統是什么。
⑧:本節內容改編自某位作者的一篇文章。那篇文章最早出現在位于www.mainspring.com的Mainspring上。本節的采用已征得了對方的同意。
今天的許多Web站點都嚴格地建立在CGI的基礎上,事實上幾乎所有事情都可用CGI做到。唯一的問題就是響應時間。CGI程序的響應取決于需要傳送多少數據,以及服務器和因特網兩方面的負擔有多重(而且CGI程序的啟動比較慢)。Web的早期設計者并未預料到當初綽綽有余的帶寬很快就變得不夠用,這正是大量應用充斥網上造成的結果。例如,此時任何形式的動態圖形顯示都幾乎不能連貫地顯示,因為此時必須創建一個GIF文件,再將圖形的每種變化從服務器傳遞給客戶。而且大家應該對輸入表單上的數據校驗有著深刻的體會。原來的方法是我們按下網頁上的提交按鈕(Submit);數據回傳給服務器;服務器啟動一個CGI程序,檢查用戶輸入是否有錯;格式化一個HTML頁,通知可能遇到的錯誤,并將這個頁回傳給我們;隨后必須回到原先那個表單頁,再輸入一遍。這種方法不僅速度非常慢,也顯得非常繁瑣。
解決的辦法就是客戶端的程序設計。運行Web瀏覽器的大多數機器都擁有足夠強的能力,可進行其他大量工作。與此同時,原始的靜態HTML方法仍然可以采用,它會一直等到服務器送回下一個頁。客戶端編程意味著Web瀏覽器可獲得更充分的利用,并可有效改善Web服務器的交互(互動)能力。
對客戶端編程的討論與常規編程問題的討論并沒有太大的區別。采用的參數肯定是相同的,只是運行的平臺不同:Web瀏覽器就象一個有限的操作系統。無論如何,我們仍然需要編程,仍然會在客戶端編程中遇到大量問題,同時也有很多解決的方案。在本節剩下的部分里,我們將對這些問題進行一番概括,并介紹在客戶端編程中采取的對策。
1. 插件
朝客戶端編程邁進的時候,最重要的一個問題就是插件的設計。利用插件,程序員可以方便地為瀏覽器添加新功能,用戶只需下載一些代碼,把它們“插入”瀏覽器的適當位置即可。這些代碼的作用是告訴瀏覽器“從現在開始,你可以進行這些新活動了”(僅需下載這些插入一次)。有些快速和功能強大的行為是通過插件添加到瀏覽器的。但插件的編寫并不是一件簡單的任務。在我們構建一個特定的站點時,可能并不希望涉及這方面的工作。對客戶端程序設計來說,插件的價值在于它允許專業程序員設計出一種新的語言,并將那種語言添加到瀏覽器,同時不必經過瀏覽器原創者的許可。由此可以看出,插件實際是瀏覽器的一個“后門”,允許創建新的客戶端程序設計語言(盡管并非所有語言都是作為插件實現的)。
2. 腳本編制語言
插件造成了腳本編制語言的爆炸性增長。通過這種腳本語言,可將用于自己客戶端程序的源碼直接插入HTML頁,而對那種語言進行解釋的插件會在HTML頁顯示的時候自動激活。腳本語言一般都傾向于盡量簡化,易于理解。而且由于它們是從屬于HTML頁的一些簡單正文,所以只需向服務器發出對那個頁的一次請求,即可非常快地載入。缺點是我們的代碼全部暴露在人們面前。另一方面,由于通常不用腳本編制語言做過份復雜的事情,所以這個問題暫且可以放在一邊。
腳本語言真正面向的是特定類型問題的解決,其中主要涉及如何創建更豐富、更具有互動能力的圖形用戶界面(GUI)。然而,腳本語言也許能解決客戶端編程中80%的問題。你碰到的問題可能完全就在那80%里面。而且由于腳本編制語言的宗旨是盡可能地簡化與快速,所以在考慮其他更復雜的方案之前(如Java及ActiveX),首先應想一下腳本語言是否可行。
目前討論得最多的腳本編制語言包括JavaScript(它與Java沒有任何關系;之所以叫那個名字,完全是一種市場策略)、VBScript(同Visual Basic很相似)以及Tcl/Tk(來源于流行的跨平臺GUI構造語言)。當然還有其他許多語言,也有許多正在開發中。
JavaScript也許是目常用的,它得到的支持也最全面。無論NetscapeNavigator,Microsoft Internet Explorer,還是Opera,目前都提供了對JavaScript的支持。除此以外,市面上講述JavaScript的書籍也要比講述其他語言的書多得多。有些工具還能利用JavaScript自動產生網頁。當然,如果你已經有Visual Basic或者Tcl/Tk的深厚功底,當然用它們要簡單得多,起碼可以避免學習新語言的煩惱(解決Web方面的問題就已經夠讓人頭痛了)。
3. Java
如果說一種腳本編制語言能解決80%的客戶端程序設計問題,那么剩下的20%又該怎么辦呢?它們屬于一些高難度的問題嗎?目前最流行的方案就是Java。它不僅是一種功能強大、高度安全、可以跨平臺使用以及國際通用的程序設計語言,也是一種具有旺盛生命力的語言。對Java的擴展是不斷進行的,提供的語言特性和庫能夠很好地解決傳統語言不能解決的問題,比如多線程操作、數據庫訪問、連網程序設計以及分布式計算等等。Java通過“程序片”(Applet)巧妙地解決了客戶端編程的問題。
程序片(或“小應用程序”)是一種非常小的程序,只能在Web瀏覽器中運行。作為Web頁的一部分,程序片代碼會自動下載回來(這和網頁中的圖片差不多)。激活程序片后,它會執行一個程序。程序片的一個優點體現在:通過程序片,一旦用戶需要客戶軟件,軟件就可從服務器自動下載回來。它們能自動取得客戶軟件的最新版本,不會出錯,也沒有重新安裝的麻煩。由于Java的設計原理,程序員只需要創建程序的一個版本,那個程序能在幾乎所有計算機以及安裝了Java解釋器的瀏覽器中運行。由于Java是一種全功能的編程語言,所以在向服務器發出一個請求之前,我們能先在客戶端做完盡可能多的工作。例如,再也不必通過因特網傳送一個請求表單,再由服務器確定其中是否存在一個拼寫或者其他參數錯誤。大多數數據校驗工作均可在客戶端完成,沒有必要坐在計算機前面焦急地等待服務器的響應。這樣一來,不僅速度和響應的靈敏度得到了極大的提高,對網絡和服務器造成的負擔也可以明顯減輕,這對保障因特網的暢通是至關重要的。
與腳本程序相比,Java程序片的另一個優點是它采用編譯好的形式,所以客戶端看不到源碼。當然在另一方面,反編譯Java程序片也并不是件難事,而且代碼的隱藏一般并不是個重要的問題。大家要注意另外兩個重要的問題。正如本書以前會講到的那樣,編譯好的Java程序片可能包含了許多模塊,所以要多次“命中”(訪問)服務器以便下載(在Java 1.1中,這個問題得到了有效的改善——利用Java壓縮檔,即JAR文件——它允許設計者將所有必要的模塊都封裝到一起,供用戶統一下載)。在另一方面,腳本程序是作為Web頁正文的一部分集成到Web頁內的。這種程序一般都非常小,可有效減少對服務器的點擊數。另一個因素是學習方面的問題。不管你平時聽別人怎么說,Java都不是一種十分容易便可學會的語言。如果你以前是一名Visual Basic程序員,那么轉向VBScript會是一種最快捷的方案。由于VBScript可以解決大多數典型的客戶機/服務器問題,所以一旦上手,就很難下定決心再去學習Java。如果對腳本編制語言比較熟,那么在轉向Java之前,建議先熟悉一下JavaScript或者VBScript,因為它們可能已經能夠滿足你的需要,不必經歷學習Java的艱苦過程。
4. ActiveX
在某種程度上,Java的一個有力競爭對手應該是微軟的ActiveX,盡管它采用的是完全不同的一套實現機制。ActiveX最早是一種純Windows的方案。經過一家獨立的專業協會的努力,ActiveX現在已具備了跨平臺使用的能力。實際上,ActiveX的意思是“假如你的程序同它的工作環境正常連接,它就能進入Web頁,并在支持ActiveX的瀏覽器中運行”(IE固化了對ActiveX的支持,而Netscape需要一個插件)。所以,ActiveX并沒有限制我們使用一種特定的語言。比如,假設我們已經是一名有經驗的Windows程序員,能熟練地使用象C++、Visual Basic或者BorlandDelphi那樣的語言,就能幾乎不加任何學習地創建出ActiveX組件。事實上,ActiveX是在我們的Web頁中使用“歷史遺留”代碼的最佳途徑。
5. 安全
自動下載和通過因特網運行程序聽起來就象是一個病毒制造者的夢想。在客戶端的編程中,ActiveX帶來了最讓人頭痛的安全問題。點擊一個Web站點的時候,可能會隨同HTML網頁傳回任何數量的東西:GIF文件、腳本代碼、編譯好的Java代碼以及ActiveX組件。有些是無害的;GIF文件不會對我們造成任何危害,而腳本編制語言通常在自己可做的事情上有著很大的限制。Java也設計成在一個安全“沙箱”里在它的程序片中運行,這樣可防止操作位于沙箱以外的磁盤或者內存區域。
ActiveX是所有這些里面最讓人擔心的。用ActiveX編寫程序就象編制Windows應用程序——可以做自己想做的任何事情。下載回一個ActiveX組件后,它完全可能對我們磁盤上的文件造成破壞。當然,對那些下載回來并不限于在Web瀏覽器內部運行的程序,它們同樣也可能破壞我們的系統。從BBS下載回來的病毒一直是個大問題,但因特網的速度使得這個問題變得更加復雜。
目前解決的辦法是“數字簽名”,代碼會得到權威機構的驗證,顯示出它的作者是誰。這一機制的基礎是認為病毒之所以會傳播,是由于它的編制者匿名的緣故。所以假如去掉了匿名的因素,所有設計者都不得不為它們的行為負責。這似乎是一個很好的主意,因為它使程序顯得更加正規。但我對它能消除惡意因素持懷疑態度,因為假如一個程序便含有Bug,那么同樣會造成問題。
Java通過“沙箱”來防止這些問題的發生。Java解釋器內嵌于我們本地的Web瀏覽器中,在程序片裝載時會檢查所有有嫌疑的指令。特別地,程序片根本沒有權力將文件寫進磁盤,或者刪除文件(這是病毒最喜歡做的事情之一)。我們通常認為程序片是安全的。而且由于安全對于營建一套可靠的客戶機/服務器系統至關重要,所以會給病毒留下漏洞的所有錯誤都能很快得到修復(瀏覽器軟件實際需要強行遵守這些安全規則;而有些瀏覽器則允許我們選擇不同的安全級別,防止對系統不同程度的訪問)。
大家或許會懷疑這種限制是否會妨礙我們將文件寫到本地磁盤。比如,我們有時需要構建一個本地數據庫,或將數據保存下來,以便日后離線使用。最早的版本似乎每個人都能在線做任何敏感的事情,但這很快就變得非常不現實(盡管低價“互聯網工具”有一天可能會滿足大多數用戶的需要)。解決的方案是“簽了名的程序片”,它用公共密鑰加密算法驗證程序片確實來自它所聲稱的地方。當然在通過驗證后,簽了名的一個程序片仍然可以開始清除你的磁盤。但從理論上說,既然現在能夠找到創建人“算帳”,他們一般不會干這種蠢事。Java 1.1為數字簽名提供了一個框架,在必要時,可讓一個程序片“走”到沙箱的外面來。
數字簽名遺漏了一個重要的問題,那就是人們在因特網上移動的速度。如下載回一個錯誤百出的程序,而它很不幸地真的干了某些蠢事,需要多久的時間才能發覺這一點呢?這也許是幾天,也可能幾周之后。發現了之后,又如何追蹤當初肇事的程序呢(以及它當時的責任有多大)?
6. 因特網和內聯網
Web是解決客戶機/服務器問題的一種常用方案,所以最好能用相同的技術解決此類問題的一些“子集”,特別是公司內部的傳統客戶機/服務器問題。對于傳統的客戶機/服務器模式,我們面臨的問題是擁有多種不同類型的客戶計算機,而且很難安裝新的客戶軟件。但通過Web瀏覽器和客戶端編程,這兩類問題都可得到很好的解決。若一個信息網絡局限于一家特定的公司,那么在將Web技術應用于它之后,即可稱其為“內聯網”(Intranet),以示與國際性的“因特網”(Internet)有別。內聯網提供了比因特網更大的安全級別,因為可以物理性地控制對公司內部服務器的使用。說到培訓,一般只要人們理解了瀏覽器的常規概念,就可以非常輕松地掌握網頁和程序片之間的差異,所以學習新型系統的開銷會大幅度減少。
安全問題將我們引入客戶端編程領域一個似乎是自動形成的分支。若程序是在因特網上運行,由于無從知曉它會在什么平臺上運行,所以編程時要特別留意,防范可能出現的編程錯誤。需作一些跨平臺處理,以及適當的安全防范,比如采用某種腳本語言或者Java。
但假如在內聯網中運行,面臨的一些制約因素就會發生變化。全部機器均為Intel/Windows平臺是件很平常的事情。在內聯網中,需要對自己代碼的質量負責。而且一旦發現錯誤,就可以馬上改正。除此以外,可能已經有了一些“歷史遺留”的代碼,并用較傳統的客戶機/服務器方式使用那些代碼。但在進行升級時,每次都要物理性地安裝一道客戶程序。浪費在升級安裝上的時間是轉移到瀏覽器的一項重要原因。使用了瀏覽器后,升級就變得易如反掌,而且整個過程是透明和自動進行的。如果真的是牽涉到這樣的一個內聯網中,最明智的方法是采用ActiveX,而非試圖采用一種新的語言來改寫程序代碼。
面臨客戶端編程問題令人困惑的一系列解決方案時,最好的方案是先做一次投資/回報分析。請總結出問題的全部制約因素,以及什么才是最快的方案。由于客戶端程序設計仍然要編程,所以無論如何都該針對自己的特定情況采取最好的開發途徑。這是準備面對程序開發中一些不可避免的問題時,我們可以作出的最佳姿態。
1.11.3 服務器端編程
我們的整個討論都忽略了服務器端編程的問題。如果向服務器發出一個請求,會發生什么事情?大多數時候的請求都是很簡單的一個“把這個文件發給我”。瀏覽器隨后會按適當的形式解釋這個文件:作為HTML頁、一幅圖、一個Java程序片、一個腳本程序等等。向服務器發出的較復雜的請求通常涉及到對一個數據庫進行操作(事務處理)。其中最常見的就是發出一個數據庫檢索命令,得到結果后,服務器會把它格式化成HTML頁,并作為結果傳回來(當然,假如客戶通過Java或者某種腳本語言具有了更高的智能,那么原始數據就能在客戶端發送和格式化;這樣做速度可以更快,也能減輕服務器的負擔)。另外,有時需要在數據庫中注冊自己的名字(比如加入一個組時),或者向服務器發出一份訂單,這就涉及到對那個數據庫的修改。這類服務器請求必須通過服務器端的一些代碼進行,我們稱其為“服務器端的編程”。在傳統意義上,服務器端編程是用Perl和CGI腳本進行的,但更復雜的系統已經出現。其中包括基于Java的Web服務器,它允許我們用Java進行所有服務器端編程,寫出的程序就叫作“小服務程序”(Servlet)。
1.11.4 一個獨立的領域:應用程序
與Java有關的大多數爭論都是與程序片有關的。Java實際是一種常規用途的程序設計語言,可解決任何類型的問題,至少理論上如此。而且正如前面指出的,可以用更有效的方式來解決大多數客戶機/服務器問題。如果將視線從程序片身上轉開(同時放寬一些限制,比如禁止寫盤等),就進入了常規用途的應用程序的廣闊領域。這種應用程序可獨立運行,毋需瀏覽器,就象普通的執行程序那樣。在這兒,Java的特色并不僅僅反應在它的移植能力,也反映在編程本身上。就象貫穿全書都會講到的那樣,Java提供了許多有用的特性,使我們能在較短的時間里創建出比用從前的程序設計語言更健壯的程序。
但要注意任何東西都不是十全十美的,我們為此也要付出一些代價。其中最明顯的是執行速度放慢了(盡管可對此進行多方面的調整)。和任何語言一樣,Java本身也存在一些限制,使得它不十分適合解決某些特殊的編程問題。但不管怎樣,Java都是一種正在快速發展的語言。隨著每個新版本的發布,它變得越來越可愛,能充分解決的問題也變得越來越多。
1.12 分析和設計
面向對象的范式是思考程序設計時一種新的、而且全然不同的方式,許多人最開始都會在如何構造一個項目上皺起了眉頭。事實上,我們可以作出一個“好”的設計,它能充分利用OOP提供的所有優點。
有關OOP分析與設計的書籍大多數都不盡如人意。其中的大多數書都充斥著莫名其妙的話語、笨拙的筆調以及許多聽起來似乎很重要的聲明(注釋⑨)。我認為這種書最好壓縮到一章左右的空間,至多寫成一本非常薄的書。具有諷剌意味的是,那些特別專注于復雜事物管理的人往往在寫一些淺顯、明白的書上面大費周章!如果不能說得簡單和直接,一定沒多少人喜歡看這方面的內容。畢竟,OOP的全部宗旨就是讓軟件開發的過程變得更加容易。盡管這可能影響了那些喜歡解決復雜問題的人的生計,但為什么不從一開始就把事情弄得簡單些呢?因此,希望我能從開始就為大家打下一個良好的基礎,盡可能用幾個段落來說清楚分析與設計的問題。
⑨:最好的入門書仍然是Grady Booch的《Object-Oriented Design withApplications,第2版本》,Wiely & Sons于1996年出版。這本書講得很有深度,而且通俗易懂,盡管他的記號方法對大多數設計來說都顯得不必要地復雜。
1.12.1 不要迷失
在整個開發過程中,最重要的事情就是:不要將自己迷失!但事實上這種事情很容易發生。大多數方法都設計用來解決最大范圍內的問題。當然,也存在一些特別困難的項目,需要作者付出更為艱辛的努力,或者付出更大的代價。但是,大多數項目都是比較“常規”的,所以一般都能作出成功的分析與設計,而且只需用到推薦的一小部分方法。但無論多么有限,某些形式的處理總是有益的,這可使整個項目的開發更加容易,總比直接了當開始編碼好!
也就是說,假如你正在考察一種特殊的方法,其中包含了大量細節,并推薦了許多步驟和文檔,那么仍然很難正確判斷自己該在何時停止。時刻提醒自己注意以下幾個問題:
(1) 對象是什么?(怎樣將自己的項目分割成一系列單獨的組件?)
(2) 它們的接口是什么?(需要將什么消息發給每一個對象?)
在確定了對象和它們的接口后,便可著手編寫一個程序。出于對多方面原因的考慮,可能還需要比這更多的說明及文檔,但要求掌握的資料絕對不能比這還少。
整個過程可劃分為四個階段,階段0剛剛開始采用某些形式的結構。
1.12.2 階段0:擬出一個計劃
第一步是決定在后面的過程中采取哪些步驟。這聽起來似乎很簡單(事實上,我們這兒說的一切都似乎很簡單),但很常見的一種情況是:有些人甚至沒有進入階段1,便忙忙慌慌地開始編寫代碼。如果你的計劃本來就是“直接開始開始編碼”,那樣做當然也無可非議(若對自己要解決的問題已有很透徹的理解,便可考慮那樣做)。但最低程度也應同意自己該有個計劃。
在這個階段,可能要決定一些必要的附加處理結構。但非常不幸,有些程序員寫程序時喜歡隨心所欲,他們認為“該完成的時候自然會完成”。這樣做剛開始可能不會有什么問題,但我覺得假如能在整個過程中設置幾個標志,或者“路標”,將更有益于你集中注意力。這恐怕比單純地為了“完成工作”而工作好得多。至少,在達到了一個又一個的目標,經過了一個接一個的路標以后,可對自己的進度有清晰的把握,干勁也會相應地提高,不會產生“路遙漫漫無期”的感覺。
座我剛開始學習故事結構起(我想有一天能寫本小說出來),就一直堅持這種做法,感覺就象簡單地讓文字“流”到紙上。在我寫與計算機有關的東西時,發現結構要比小說簡單得多,所以不需要考慮太多這方面的問題。但我仍然制訂了整個寫作的結構,使自己對要寫什么做到心中有數。因此,即使你的計劃就是直接開始寫程序,仍然需要經歷以下的階段,同時向自己提出一些特定的問題。
1.12.3 階段1:要制作什么?
在上一代程序設計中(即“過程化或程序化設計”),這個階段稱為“建立需求分析和系統規格”。當然,那些操作今天已經不再需要了,或者至少改換了形式。大量令人頭痛的文檔資料已成為歷史。但當時的初衷是好的。需求分析的意思是“建立一系列規則,根據它判斷任務什么時候完成,以及客戶怎樣才能滿意”。系統規格則表示“這里是一些具體的說明,讓你知道程序需要做什么(而不是怎樣做)才能滿足要求”。需求分析實際就是你和客戶之間的一份合約(即使客戶就在本公司內部工作,或者是其他對象及系統)。系統規格是對所面臨問題的最高級別的一種揭示,我們依據它判斷任務是否完成,以及需要花多長的時間。由于這些都需要取得參與者的一致同意,所以我建議盡可能地簡化它們——最好采用列表和基本圖表的形式——以節省時間。可能還會面臨另一些限制,需要把它們擴充成為更大的文檔。
我們特別要注意將重點放在這一階段的核心問題上,不要糾纏于細枝末節。這個核心問題就是:決定采用什么系統。對這個問題,最有價值的工具就是一個名為“使用條件”的集合。對那些采用“假如……,系統該怎樣做?”形式的問題,這便是最有說服力的回答。例如,“假如客戶需要提取一張現金支票,但當時又沒有這么多的現金儲備,那么自動取款機該怎樣反應?”對這個問題,“使用條件”可以指示自動取款機在那種“條件”下的正確操作。
應盡可能總結出自己系統的一套完整的“使用條件”或者“應用場合”。一旦完成這個工作,就相當于摸清了想讓系統完成的核心任務。由于將重點放在“使用條件”上,一個很好的效果就是它們總能讓你放精力放在最關鍵的東西上,并防止自己分心于對完成任務關系不大的其他事情上面。也就是說,只要掌握了一套完整的“使用條件”,就可以對自己的系統作出清晰的描述,并轉移到下一個階段。在這一階段,也有可能無法完全掌握系統日后的各種應用場合,但這也沒有關系。只要肯花時間,所有問題都會自然而然暴露出來。不要過份在意系統規格的“完美”,否則也容易產生挫敗感和焦燥情緒。
在這一階段,最好用幾個簡單的段落對自己的系統作出描述,然后圍繞它們再進行擴充,添加一些“名詞”和“動詞”。“名詞”自然成為對象,而“動詞”自然成為要整合到對象接口中的“方法”。只要親自試著做一做,就會發現這是多么有用的一個工具;有些時候,它能幫助你完成絕大多數的工作。
盡管仍處在初級階段,但這時的一些日程安排也可能會非常管用。我們現在對自己要構建的東西應該有了一個較全面的認識,所以可能已經感覺到了它大概會花多長的時間來完成。此時要考慮多方面的因素:如果估計出一個較長的日程,那么公司也許決定不再繼續下去;或者一名主管已經估算出了這個項目要花多長的時間,并會試著影響你的估計。但無論如何,最好從一開始就草擬出一份“誠實”的時間表,以后再進行一些暫時難以作出的決策。目前有許多技術可幫助我們計算出準確的日程安排(就象那些預測股票市場起落的技術),但通常最好的方法還是依賴自己的經驗和直覺(不要忘記,直覺也要建立在經驗上)。感覺一下大概需要花多長的時間,然后將這個時間加倍,再加上10%。你的感覺可能是正確的;“也許”能在那個時間里完成。但“加倍”使那個時間更加充裕,“10%”的時間則用于進行最后的推敲和深化。但同時也要對此向上級主管作出適當的解釋,無論對方有什么抱怨和修改,只要明確地告訴他們:這樣的一個日程安排,只是我的一個估計!
1.12.4 階段2:如何構建?
在這一階段,必須拿出一套設計方案,并解釋其中包含的各類對象在外觀上是什么樣子,以及相互間是如何溝通的。此時可考慮采用一種特殊的圖表工具:“統一建模語言”(UML)。請到http://www.rational.com去下載一份UML規格書。作為第1階段中的描述工具,UML也是很有幫助的。此外,還可用它在第2階段中處理一些圖表(如流程圖)。當然并非一定要使用UML,但它對你會很有幫助,特別是在希望描繪一張詳盡的圖表,讓許多人在一起研究的時候。除UML外,還可選擇對對象以及它們的接口進行文字化描述(就象我在《Thinking in C++》里說的那樣,但這種方法非常原始,發揮的作用亦較有限。
我曾有一次非常成功的咨詢經歷,那時涉及到一小組人的初始設計。他們以前還沒有構建過OOP(面向對象程序設計)項目,將對象畫在白板上面。我們談到各對象相互間該如何溝通(通信),并刪除了其中的一部分,以及替換了另一部分對象。這個小組(他們知道這個項目的目的是什么)實際上已經制訂出了設計方案;他們自己“擁有”了設計,而不是讓設計自然而然地顯露出來。我在那里做的事情就是對設計進行指導,提出一些適當的問題,嘗試作出一些假設,并從小組中得到反饋,以便修改那些假設。這個過程中最美妙的事情就是整個小組并不是通過學習一些抽象的例子來進行面向對象的設計,而是通過實踐一個真正的設計來掌握OOP的竅門,而那個設計正是他們當時手上的工作!
作出了對對象以及它們的接口的說明后,就完成了第2階段的工作。當然,這些工作可能并不完全。有些工作可能要等到進入階段3才能得知。但這已經足夠了。我們真正需要關心的是最終找出所有的對象。能早些發現當然好,但OOP提供了足夠完美的結構,以后再找出它們也不遲。
1.12.5 階段3:開始創建
讀這本書的可能是程序員,現在進入的正是你可能最感興趣的階段。由于手頭上有一個計劃——無論它有多么簡要,而且在正式編碼前掌握了正確的設計結構,所以會發現接下去的工作比一開始就埋頭寫程序要簡單得多。而這正是我們想達到的目的。讓代碼做到我們想做的事情,這是所有程序項目最終的目標。但切不要急功冒進,否則只有得不償失。根據我的經驗,最后先拿出一套較為全面的方案,使其盡可能設想周全,能滿足盡可能多的要求。給我的感覺,編程更象一門藝術,不能只是作為技術活來看待。所有付出最終都會得到回報。作為真正的程序員,這并非可有可無的一種素質。全面的思考、周密的準備、良好的構造不僅使程序更易構建與調試,也使其更易理解和維護,而那正是一套軟件贏利的必要條件。
構建好系統,并令其運行起來后,必須進行實際檢驗,以前做的那些需求分析和系統規格便可派上用場了。全面地考察自己的程序,確定提出的所有要求均已滿足。現在一切似乎都該結束了?是嗎?
1.12.6 階段4:校訂
事實上,整個開發周期還沒有結束,現在進入的是傳統意義上稱為“維護”的一個階段。“維護”是一個比較曖昧的稱呼,可用它表示從“保持它按設想的軌道運行”、“加入客戶從前忘了聲明的功能”或者更傳統的“除掉暴露出來的一切臭蟲”等等意思。所以大家對“維護”這個詞產生了許多誤解,有的人認為:凡是需要“維護”的東西,必定不是好的,或者是有缺陷的!因為這個詞說明你實際構建的是一個非常“原始”的程序,以后需要頻繁地作出改動、添加新的代碼或者防止它的落后、退化等。因此,我們需要用一個更合理的詞語來稱呼以后需要繼續的工作。
這個詞便是“校訂”。換言之,“你第一次做的東西并不完善,所以需為自己留下一個深入學習、認知的空間,再回過頭去作一些改變”。對于要解決的問題,隨著對它的學習和了解愈加深入,可能需要作出大量改動。進行這些工作的一個動力是隨著不斷的改革優化,終于能夠從自己的努力中得到回報,無論這需要經歷一個較短還是較長的時期。
什么時候才叫“達到理想的狀態”呢?這并不僅僅意味著程序必須按要求的那樣工作,并能適應各種指定的“使用條件”,它也意味著代碼的內部結構應當盡善盡美。至少,我們應能感覺出整個結構都能良好地協調運作。沒有笨拙的語法,沒有臃腫的對象,也沒有一些華而不實的東西。除此以外,必須保證程序結構有很強的生命力。由于多方面的原因,以后對程序的改動是必不可少。但必須確定改動能夠方便和清楚地進行。這里沒有花巧可言。不僅需要理解自己構建的是什么,也要理解程序如何不斷地進化。幸運的是,面向對象的程序設計語言特別適合進行這類連續作出的修改——由對象建立起來的邊界可有效保證結構的整體性,并能防范對無關對象進行的無謂干擾、破壞。也可以對自己的程序作一些看似激烈的大變動,同時不會破壞程序的整體性,不會波及到其他代碼。事實上,對“校訂”的支持是OOP非常重要的一個特點。
通過校訂,可創建出至少接近自己設想的東西。然后從整體上觀察自己的作品,把它與自己的要求比較,看看還短缺什么。然后就可以從容地回過頭去,對程序中不恰當的部分進行重新設計和重新實現(注釋⑩)。在最終得到一套恰當的方案之前,可能需要解決一些不能回避的問題,或者至少解決問題的一個方面。而且一般要多“校訂”幾次才行(“設計范式”在這里可起到很大的幫助作用。有關它的討論,請參考本書第16章)。
構建一套系統時,“校訂”幾乎是不可避免的。我們需要不斷地對比自己的需求,了解系統是否自己實際所需要的。有時只有實際看到系統,才能意識到自己需要解決一個不同的問題。若認為這種形式的校訂必然會發生,那么最好盡快拿出自己的第一個版本,檢查它是否自己希望的,使自己的思想不斷趨向成熟。
反復的“校訂”同“遞增開發”有關密不可分的關系。遞增開發意味著先從系統的核心入手,將其作為一個框架實現,以后要在這個框架的基礎上逐漸建立起系統剩余的部分。隨后,將準備提供的各種功能(特性)一個接一個地加入其中。這里最考驗技巧的是架設起一個能方便擴充所有目標特性的一個框架(對這個問題,大家可參考第16章的論述)。這樣做的好處在于一旦令核心框架運作起來,要加入的每一項特性就象它自身內的一個小項目,而非大項目的一部分。此外,開發或維護階段合成的新特性可以更方便地加入。OOP之所以提供了對遞增開發的支持,是由于假如程序設計得好,每一次遞增都可以成為完善的對象或者對象組。
⑩:這有點類似“快速造型”。此時應著眼于建立一個簡單、明了的版本,使自己能對系統有個清楚的把握。再把這個原型扔掉,并正式地構建一個。快速造型最麻煩的一種情況就是人們不將原型扔掉,而是直接在它的基礎上建造。如果再加上程序化設計中“結構”的缺乏,就會導致一個混亂的系統,致使維護成本增加。
1.12.7 計劃的回報
如果沒有仔細擬定的設計圖,當然不可能建起一所房子。如建立的是一所狗舍,盡管設計圖可以不必那么詳盡,但仍然需要一些草圖,以做到心中有數。軟件開發則完全不同,它的“設計圖”(計劃)必須詳盡而完備。在很長的一段時間里,人們在他們的開發過程中并沒有太多的結構,但那些大型項目很容易就會遭致失敗。通過不斷的摸索,人們掌握了數量眾多的結構和詳細資料。但它們的使用卻?
?