第三章 初步設計/拆解 (Preliminary Design/Decomposition)─────────────────────────────────────── 你若對你的程式該完成些什麼已有一些概念,就到了開始設計的時候。第一階段, 初步設計,其重點是要將山一般的大問題拆解為容易管理的小丘。 本章將討論拆解符式應用程式的二種方式。 依元件以行拆解(Decomposition by Component) 不知你發生過這種事沒有? 你想上山渡個週末,計劃了幾個月,該攜帶的物品清單 都已經列出,在大白天也夢到那山坡草地。 同一時間,你也在考慮該穿哪套衣服去參加下週六的堂兄婚禮。他們是那種很隨和 的人,你也不想太打扮。但婚禮總是婚禮,也許還是該去租一套禮服才是。 像這樣的一直計劃著,直到星期四你才看出這兩件事在時間上的衝突。在那時,你 一直罵著自己糊塗。 這種心智上的失誤,怎麼可能發生在像你這般靈巧的人身上?很明顯的,人類的心 智是應該會在記憶之間產生連繫才對。新的概念是以某種方式連接到與它有關連的思想 的路徑上。 圖3-1 尚未連繫上的思想之池 像上面這件失誤,直到星期四以前,這兩件事的思想之池並沒有產生任何關連。這 個衝突很可能是因為某種新的輸入(譬如週六的天氣預報這種芝麻小事)導入了這兩片 思想之池而發生。一個領悟的閃電熔接了它們,無情地跟著那隆隆雷聲的狼狽。 有一件簡單的工具已經被發明出來,以防止類似的不幸事件。那就是日曆。如果你將 兩件事記在同一個日曆上,就能看出另一件事已經排入行程。人腦對這類難以理解的事情 ,總是處理不好。
─────────────────────────────────────
這種老生常談也適用於軟體設計,尤其是初步的設計階段。這個階段在傳統上就是 設計者將一個大的應用程式,解剖為較小的、適於程式設計師處理的模組的階段。 在第一章我們發現,應用程式可以輕而易舉地拆解為元件。
───────────────────────────────────── 舉例來說,你或許要寫一個應用程式,其中的各個事件將會按某一種預定的時程 (schedule)依序發生。為了管理這個時程,你也許需要先設計一些詞來組成一個"時 程安排"(schedule-building)的詞集。藉著它們,你就能在程式中將事件發生的次 序"描述"出來。 在這樣的一個單一元件內,你不僅可以共享資訊,又能防止潛在的衝突。錯誤的設 計方式是:讓各功能性模組"知道"與它的時程有關的事情,而那些事卻可能跟另 一個模組的時程相衝突。 當你設計一個元件時,怎麼知道將來要使用到它的其它元件們,需要的是哪些命 令呢?老實說,這就像是"雞和蛋"同樣的問題。符式程式師處理它的方式也像雞生蛋、 蛋生雞一樣:反覆地進行。 如果一個元件被設計得很好,是否完整並不重要。其實,一個元件只要對目前的反 覆設計週期是夠用的就行了。沒有任何元件應該被視為是一本"闔起來的書",除非這個 應用程式己經完成 --在這種看法下,對於一個在維護中的應用程式,元件就永遠沒有 完成的時候。 現舉一例。假設你的某項產品要經由系統內的一顆萬用I/O晶片與外界"溝通"。這晶 片各有一個"控制暫存器"及一個"資料暫存器"。在一個拙劣的設計中,程式內到處都有著 使用OUT指令來直接存取這些暫存器以操作這顆晶片的程式碼。這會使得整個應用程式 極度地仰賴著這顆特定的晶片 --是非常危險的。 相反地,符式程式師會撰寫一個元件去控制那顆晶片。其中的每個命令都有一個 邏輯上的名稱和易於呼叫的介面(通常是符式的堆疊),供程式的其他部份使用。 在任何一個設計上的反覆週期中,你只需完成那些眼前需要的命令 --而非所有可 能被用來存取"控制暫存器"的碼。稍後,如果發現需要增加一個命令來改變傳輸速率, 這個命令就應該被加到這個I/O晶片的詞集中,而不是加到需要速率設定的程式碼中。 做這樣的改變,除了需要稍費幾分鐘的時間(最多)來編輯與再編譯,並不會遭到其它 的懲罰。
───────────────────────────────────── 至於元件內部在做些什麼,那完全是它自己的事。讓一個元件內部不同的定義分享 重複的(redundent)資訊,倒並不一定就是惡形(bad style)。 舉例來說,在某一資料結構中,一個記錄(record)的長度為14個字元。元件中的 某一個定義會使指標增加14,以指向下一個記錄;另一個定義則將指標減去14。 只要14這個數值在元件中被保持為一個"秘密" ,而且不會被其它地方用到,你就 可以在元件中直接使用這個數值,而不必將它定義為一個常數: : +記錄 14 指標 加存 ;
: -記錄 -14 指標 加存 ;
反過來說,若此一數值會在這個元件之外被使用,或是在元件內用到很多次而且 可能會改變,那麼最好還是用一個名字將數值"隱藏"起來。 14 常數 /記錄
: +記錄 /記錄 指標 加存 ;
: -記錄 /記錄 變號 指標 加存 ;
( /記錄 --這個名字的意思是:每個記錄所佔有的字元數) 範例: 小小編輯器 讓我們將"依元件以行拆解"的觀念用在真正的問題上。就在此處設計一件大型的應用 程式固然是好,只可惜沒有那麼大的篇幅可供使用,更何況我們還要邊做邊去了解這個 應用程式。 所以,我從一個已被拆解的大型應用程式中取出一個元件。以它做為例子,進一步 將它拆解為次元件,並從拆解中去設計它。 想像我們要建造一個小小編輯器,它能夠讓使用者修改螢幕上的輸入欄位的內容。 例如,螢幕上的顯示如下: ┌─────────┐
Name of Member │Justine Time ▁ │
└─────────┘
這個編輯器提供使用者三種模式來修改其內容: 【覆寫】 鍵入一般的字母、數字或符號時,會蓋掉原在該處的任何字母。 【刪除】 同時按下Ctrl鍵及D鍵,可將游標所在位置的字元刪除,並且 使游標右邊的所有字元都向左移一格。 【插入】 同時按下Ctrl鍵及I鍵,使編輯器進入"插入模式"。此後所鍵入 之字元即插入到游標所在位置,並且使游標右邊的字元都向右移一格。 由於這是概念性模型的一部份,我們也該考慮錯誤或例外的處理方式;例如, 輸入欄位的限制範圍有多大?在插入模式下,字元若越出右邊界時會發生什麼?等等。 這是到目前為止我們所有的規格,其餘的可由我們自定。 讓我們先試著決定哪些元件是我們所需要的。首先,編輯器必須對鍵盤的按鍵 有所反應。因此我們需要一個按鍵解譯器 --那是某種會等待按鍵輸入,並依照一份 鍵的列表所指定的動作來反應的常式。按鍵解譯器是一個元件,而它的詞集只有單一 個字。由於這個詞能使螢幕上某個欄位得以被編輯,我們就稱它為 EDIT(編輯)。 被按鍵解譯器所呼叫的那些動作則構成第二個詞集。這個詞集中的各個定 義會去完成所需的各種功能。某個詞可能被命名為"刪除",另一詞叫做"插入", 等等。因為每個命令都將由這個解譯器來呼叫,它們每一個也都將處理單一個按鍵 的反應。 在這些命令之下應該還有第三個元件,那是用來實現被編輯的資料結構的那組字。 最後,我們還需要一個元件來顯示螢幕上的欄位。為求簡化,讓我們計劃只建一個 詞,即『重新顯示』,在每個按鍵被處理之後,用來重新顯示整個欄位。 : 編輯器 開始 讀鍵 修改 重新顯示 ... 直到 ; 這種做法使"修改緩衝區的內容"與"更新畫面"這二件事分開來。現在,讓我們先 集中注意力在修改緩衝區這件事上。 圖 3-2 小小編輯器在被概略的拆解之後 讓我們分別觀察各個元件,並試著決定各元件所需的詞。我們可以從考慮在這最重要的三項功能中:覆寫、刪除、及插入,一定會發生的事情開始進行。 我們也許會在披薩的價目表背後塗鴉,畫出像下面的樣子(在目前,我們還不要太去注意例外情況的處理): 覆寫: 存入新字元到被指標 F U N K T I O N A L I T Y 所指著的地址內,並 ^ 將指標前進一位(除 F U N C T I O N A L I T Y 非已到達欄位邊緣) ^ F U N C T I O N A L I T Y ^ 刪除: 將指標右邊一格起的 F U N C T I O N S A L I T Y 字串向左拷貝一格。 ^ 將這行的最後一個位置 F U N C T I O N A L I T Y Y 存入 "空格"。 ^ F U N C T I O N A L I T Y □ ^ 插入: 將自指標位置起右邊 F U N T I O N A L I T Y 的字串向右拷貝一格。 ^ 將新字元存入指標所指 F U N T T I O N A L I T Y 的位置,並將指標前進 ^ 一位(除非已到達欄位 F U N C T I O N A L I T Y 邊緣) ^ F U N C T I O N A L I T Y ^ 我們就這樣地發展出了這個問題的演算法。 下一個步驟是檢視這三個重要的程序,以找出有用的"名字" --那是一種程序或 元素,它們: 1. 可能被重複的使用到,或者 2. 可能會被改變 我們發現所有三個程序都使用到一種叫做"指標"的東西。 我們也需要二個程序: 1. 得到指標的值(若指標是相對的(relative),則此一程序將會進行某些運算)。 2. 使指標前進 等一下,是三個程序: 3. 使指標後退 因為我們需要能夠使用"游標鍵"來移動游標位置,而不會更改正在編輯中的資料。 這三個動作都會參考到一個被存放在記憶體中某處的實體指標(physical pointer) 。它被放在何處以及如何的存放著(相對地或絕對地),都應該在這個元件中被隱藏 起來。 且讓我們試著用程式碼去寫這些演算法: : 鍵碼 ( 傳回最後被按下的鍵碼) ... ;
: 指標位置 ( 傳回被指標所指著的字元的地址) ... ;
: 指標前進 ( 指標加一,或停留在最終的位置上) ... ;
: 指標後退 ( 指標減一,或停留在最初的位置上) ... ;
: 覆寫 鍵碼 指標位置 存入字元 指標前進 ;
: 插入 右滑 覆寫 ;
: 刪除 左滑 底位填空 ;
為了能向左及向右拷貝文句,我們必須建出兩個名字,即『右滑』及『左滑』。 兩者當然都一定會使用到『指標位置』,但它們也必須仰賴一個我們以後才會考慮的一 項元素:"知道"欄位長度的方法。我們會在撰寫第三個元件時推敲此一問題。 但看看我們已經發現了什麼:我們可以簡單地將『插入』描述為『右滑 覆寫』。 換句話說,『插入』(INSERT)實際上是在使用『覆寫』(OVERWRITE),即使兩者 看來像是存在於同一階層上(至少對一個結構化的程式設計師是如此)。 與其再向下更深入的探索到第三個元件,不如讓我們先對第一個元件 --按鍵解譯器 -- 所已經了解的事項予以解決。首先,我們必須解決"插入模式"的問題。"插入"並不僅 僅是當你按某一個鍵時,某些事就發生了 --就像"刪除"那樣。反而,它用另一種不同 的方式來解譯某些可能的按鍵。 例如在"覆寫模式"中,一般的字元會被存到目前的游標位置上;但在"插入模式"中, 該行的其餘字元必須先被右移才行。而退位鍵(backspace )在插入模式中也有不同的 反應動作。 由於有兩種模式,"插入"及"非插入",按鍵解譯器就必須對按鍵有著兩組不同名稱 的程序來處理。 我們可以將按鍵解譯器以"判斷表"的方式寫出來(如何實現等以後再操心): --按鍵-- --非插入模式-- --插入模式--
Cntrl-D 刪除(DELETE) 離開插入模式(INSERT-OFF)
Cntrl-I 進入插入模式(INSERT-ON)離開插入模式
退位鍵(backspace) 指標後退(BACKWARD) 向左刪除(INSERT<)
左箭號 指標後退 離開插入模式
右箭號 指標前進(FORWARD) 離開插入模式
返回鍵(return) 跳離(ESC) 離開插入模式
可被印出的字元 覆寫(OVERWRITE) 插入(INSERT)
左欄為按鍵的種類,中央欄是這些鍵正常會做的事,右欄是"插入模式"下它們會 做的事。 為完成退位鍵在插入模式中被按下後所應發生之事,我們增加了一個新的程序: : 向左刪除 指標後退 左滑 ;
(將游標後移一格到左邊的字元上,然後將該字元右方之所有字元向左滑動,
以掩蓋這個字元。) 這個表似乎是目前這個階段,對此一問題最合乎邏輯的表示。我們要將它的 實現留到以後再做(第八章)。
現在,我們將要示範這種做法在維護能力上所具有的驚人價值。我們將給自己一個 難題 --在規格上有一個很大的變動! 我們的設計在面對變動時會表現的多好?先假想下面這個劇本: 我們原先認為在每次有按鍵輸入時,將整個欄位的內容從新輸出一次以更新視頻畫面 是件很客易的事。我們甚至已經在個人電腦上實現了它的程式碼,藉著其中記憶體映對 (memory-mapped)的視頻電路,在一個畫面的遮沒週期中更新整行的內容。但是現在我 們的客戶卻要將這個程式使用在一個電話網路上,其中所有的I/O都是以很低的傳輸速率 進行著。因為有些輸入欄位的寬度接近於螢冪寬度,也許有65個字元,若要對每個按鍵的 輸入都更新整行的顯示,那就會變得太慢而不能接受。 我們必須修改這個應用程式,讓它只去更新欄位中需要改變的部份即可。在"插入" 及"刪除"中,這指的是游標右方的那些文句。在"覆寫"中,它指的是被改變的那個字元 而已。 這是一種相當程度的改變。視頻更新,這種原先被很豪爽的丟給按鍵解譯器來 處理的工作,如今卻要依據所發生的是哪一種編輯功能而定。正如我們已經發現的, 那些用來完成按鍵解譯器的最重要的詞是: 指標前進(FORWARD)
指標後退(BACKWARD)
覆寫 (OVERWRITE)
插入 (INSERT)
刪除 (DELETE)
向左刪除(INSERT)
對於它們所做的描述,沒有一處曾參考到視頻更新的程序,因為那原本假定是在 以後才會發生的事。 不過事情並不如看起來那麼糟。至少,"覆寫"過程就可以輕易地加入一個命令,去 將終端機的游標所在位置的那個字元印出。而且『左滑』及『右滑』也可以加上一些命 令,將游標右方(含游標所在)的字元印出,然後再重新設定終端機的游標到它目前的 位置上。 下面是我們修改之後的程序名稱。新加入的命令是以特殊字體顯示的: : 覆寫 鍵碼 指標位置 字元存入 ~FRBO2;鍵碼 送出 指標前進 ;
: ~FRBO2;重新印出 ( 重新送出自游標位置起到欄位結束止的字元 ) ... ;
: 插入 右滑 ~FRBO2;重新印出 覆寫 ;
: 刪除 左滑 底位填空 ~FRBO2;重新印出 ;
由於只有這三個功能會改變記憶體的內容,因此也就只有它們需要更新螢幕。這個 概念非常重要。我們必須能夠確定這點才能保証程式的正確性。這種確定直指問題的本
質。 注意,這個新增的視頻更新問題,會添加一個新的"指標":目前螢冪上的游標位置。
"依元件以行拆解"的設計方式鼓勵我們將『覆寫』程序看成是同時改變了資料欄位 和它的視頻顯示,就如同『左滑』及『右滑』一樣。因此,很自然地似乎只需
要維持一個真正的相對指標 --經由它來計算出記憶體中的資料地址,和螢幕上的位置。
由於指標的本性是完全地隱藏在『指標位置』、『指標前進』、『指標後退』三個 程序之中,我們因此能夠充分地適應這種方法,即使它原來並不是我們的第一個方法。
這個改變在此處看來似乎是簡單的 --甚至是顯而易見的。若真有這樣的結果,那是 因為這種設計技術確保了彈性。如果我們採用傳統的途徑
--如果我們是依據結構來設計 ,或者依據經由順序式的程序(sequencial process)所產生的資料轉變(data
transformation)來設計-- 我們脆弱的設計就會因為這個改變而肢離破碎。 為了要證明這個論點,我們不得不再重新做起。 假設我們尚未研究過小小編輯器,而且回到最初的最少規格的情況。也同樣地 做出最初的那個假定 --在每個按鍵之後,就重新印出整個欄位以更新顯示。 根據由上向下設計的格言,讓我們儘可能的用最寬廣的角度來檢視這個問題。 圖3-3是以最簡單的項目來描述這個程式。從圖中可以了解,編輯器實際上是個迴路, 它不時地取得按鍵並執行一些編輯的功能,直到使用者按下返回鍵為止。 圖3-3 傳統的方法:從最外層看來 │
♁───────┐
│ ┌──┴──┐
│ │ 取得按鍵 │
│ └──┬──┘
│ ╱╲
│ ╱ ╲
│ ╱ ┌──┴──┐
│"返回" ╱ │ 處理按鍵 │
│ 鍵 ╱ └──┬──┘
│ ╱ ┌──┴──┐
│ ╱ │ 更新顯示 │
│ └──┬──┘
│ ●
│
迴路中有三個模組:從鍵盤取得一個字元,編輯這個資料,更新顯示以符合資料。很明顯的,大部份工作都在"處理按鍵"的模組內。
經過了運用"持續淨化"的觀念後,圖3-4顯示此編輯器在"處理按鍵"這部份擴展之後的情形。這是經過幾次的嘗試才得到的圖形。設計此一階層時,也迫使我們同時考慮許多件事,而這在前一個設計中都是可以被延後考慮的。 圖3-4 "處理按鍵"的結構 例如,我們必須決定所有可能會被按下的鍵。尢其重要的是,我們必須考慮"插入模式"的問題。這層認識迫使我們製作一個名為"插入模式"的旗標,它會被Cntrl-I鍵所轉態。在圖中的結構線上,有多處用到這個旗標來決定該如何處理某一個按鍵。 第二個旗標,叫做"跳離(ESC)"。當使用者不在插入模式時若按下了返回鍵,它提供了一種很不錯的結構化方法來跳出這個編輯器的迴路。
完成這張圖後,我們又被那出現多次的"測試是否為插入模式"所困擾。我們能夠在一開始時祗做一次插入模式的檢查嗎?這個想法使我們畫出了圖3-5。 圖3-5 另一個"處理按鍵"的結構
就如你所見的,這張圖甚至比第一張還要糟。現在,我們對每一個按鍵都測試了兩次。有趣的是,在結構上截然不同的兩張圖,功能上卻是相同的。這已經足以令人起疑,不知這個控制結構是否真的和這個問題吻合?。 圖3-6(a)是一個可列印字元的原始的結構化路徑。 圖3-6 同樣的一段,但經過"精製"以及"最佳化"之後的結構
我們一旦將覆寫及插入字元的演算法想出,也許就會將它精製成圖3-6(b)。但看看那令人困窘的重複的碼(圈起之部份)。大多數幹練的結構化程式設計師都會認為這種重複是不必要的,而將它改為圖3-6(c)。
計劃有變 現在,每個人都大吃一驚。我們剛被告知,這個應用程式將不在具有記憶體映對裝置的 顯示器上執行。這樣的改變對我們的設計結構會有什麼影響? 首先,它破壞了"更新顯示"做為一個單獨模組的結構。"更新顯示"的功能被散 佈到"處理按鍵"內的各個角落。我們整個應用程式的結構都改變了。很明顯的可以 看出,我們花費了幾個禮拜所做的由上向下設計的努力,只是發現我們在一棵錯誤的 樹(tree)下狂吠罷了。 如果我們要改寫程式,會發生什麼事?讓我們再看看那條"可列印字元"的路徑。 圖3-7 增加更新顯示的動作
圖3-7(a)說明當我們加上更新顯示的功能後,在那第一次的設計上會發生的事。
圖的(b)部份是經過"最佳化"之後模組的展開圖。請注意,僅僅在這外層迴路的單一分 支內,我們就做了兩次插入模式旗標的測試。
但更糟的是,在這個設計中還有錯誤。你能找得出來嗎? 不論在覆寫或插入的情況下,指標都在更新顯示之前就被往前移了。在覆寫模式
下,我們會將新字元顯示在錯誤的位置上。在插入模式下,我們印出了該行的剩餘部份, 但卻未印出那個新字元。
當然,這是個很容易修正的問題。我們只要將更新顯示的模組移到"指標加一" 之前就行了。重點在於:我們怎麼會弄錯的?那是因為我們讓控制流程的結構 --程式設
計中的表象元素-- 先入為主的結果。 相反的,在我們依元件而行的設計中,正確的解決方案很自然地出現,因為我們在
編輯元件中"使用"更新顯示的元件。我們也在"插入"中使用"覆寫"。 藉著將應用程式拆解為相互使喚的元件,我們不但得到了雅緻的品質,而且也得到一
條更正確的康莊大道。 介面元件(The Interface Component ) 在電腦科學的術語中,所謂模組間的介面指的有二方面。其一是關於其他模組叫用 這個模組的方法;這是控制介面。其二是其它模組對這個模組傳送及接收資料的方法; 這是資料介面。 由於符式的字典結構,控制不是個問題。定義是經由名字來叫用的。在本節中,當 我們提到"介面"時,指的是資料的介面。 談到模組間的資料介面時,傳統的智者只是說:「介面應該小心地設計,儘可能避免 複雜」。這層顧慮的起因在於,因為各個模組必須各自去完成它自己的終端介面。(圖3- 8) 這意味著重複程式碼的存在。就如我們已知的,重複的程式碼至少帶來兩個問題: 龐大的程式碼和貧乏的維修性。某一模組的介面一旦有變,必然會影響到對應的那個模 組的介面。 圖3-8 傳統的看法是將介面認為是一種"接合面" 比上圖更好的介面設計其實很多。且容我介紹一種我稱作"介面元件"的設計元素。 介面元件之目的是要實現並隱藏兩個或多個模組間的資料介面。(圖 3-9) 圖3-9 介面元件的使用
─────────────────────────────────────
我要舉一個最近親身經歷的例子。我的嗜好之一是撰寫關於文書的編排/編輯( formatter/editors)的應用程式。(我已經完成了兩件,其中之一就是用來完成這本書 的。) 在我最近的那個設計中,格式編排器(formatter )的部份含有兩個元件。第一個 元件負責讀取原始文件(source document),並決定何處該做分行及分頁的設定等等。 但它並不將這些處理過的文句直接送往終端機或列表機,而是一次一行的儲存到一個 "行緩衝器"之中。 同樣的,它也不將列表機的控制命令 --粗體字、劃底線等等-- 在文句正被處理的 時候送出,而是延遲至真正的送到輸出裝置時。為了要延後這些控制命令,我另 設有一個稱為"屬性緩衝器"的第二個緩衝器。它與"行緩衝器"之間是一種字元對字元的 對應關係,其內的每個字元都是一組位元旗標,用來指示其對應的"行緩衝器"字元是否 應劃底線或設為粗體字等等。 第二個元件負責顯示或印出"行緩衝器"的內容。這個元件知道該送往終端機還是列 表機,而且會根據屬性緩衝器所指定的屬性將文句輸出。 在此,我們有兩個定義良好的元件 --行規劃器及輸出元件-- 它們各自肩負著部份的 格式編排器功能,並結為一體。 這兩個元件之間的資料介面相當複雜。這個介面含有兩個緩衝器,一個變數 --用來 指出目前有效的字元數目,最後是一些有關屬性樣式(pattern)含意的"知識"。 在符式中,我只用一冪就將所有這些元素的定義完成。(譯註:在早期的符式系統, 編輯器所用的編輯單位是一個64字x16行大小的空間,恰可容納1仟個字元,稱為一冪。) 緩衝器是以『建造』這個定義詞所所定義出的,計數器只是個平常的變數,而屬性樣式 則被定義為常數,譬如: 1 常數 底線 ( 底線位元的遮罩(mask)
2 常數 粗體 ( 粗體位元的遮罩 )
做編排的元件使用像是『底線
設定』一類的片語來設定屬性緩衝器內的位元。做輸 出元件則使用『底線 AND』一類的片語來讀取屬性緩衝器。 在設計一個介面元件時,你該問你自己:「有哪些資料結構及命令是必須由這些相互溝 通中的元件所共享的?」重要的是,要決定哪些元素屬於介面元件,哪些元素應保留在 單個元件內。 在撰寫這個文書格式編排器的程式時,我因為沒有充分地回答這個問題,而產生 了一個錯誤。問題是在: 我允許使用不同的字寬:濃縮的(condensed ),二倍寬度的(double width) 等等。這表示我不但要送出不同的控制訊號給列表機,也要改變每行所容許的字元數。 我保有一個叫做『牆』的變數給"行規劃器"。『牆』指出右邊的界限:超過這點就不 能再傳送文句。如果字型的寬度改變,意味著『牆』的值也要跟著依比例改變。(其實 這種做法是錯誤的。我應該使用一些更細的量度單位,讓每一行佔有固定數量的這種單位 。改變字寬也就是改變每一字元所佔有這種單位的數目。但是,先讓我們回到目前的這 個錯誤上...) 要命的是,在輸出元件中我也使用到『牆』來決定該顯示(或印出)多少個字元。 理由是,這個數值會依照目前我所使用的字體寬度而變。 我是對的 --99%是對的。但是,有一天我發現,在某種狀況下,有一行濃縮的本文 不知怎麼被切短了。最後的一些字元不見了。最後我終於找出其中的道理:在輸出元件 有機會用到『牆』這個變數之前,它已經被改變了。 起初,我並沒看出,讓輸出元件去使用"行規劃器"的『牆』這個變數有何不妥。現 在我了解到,行規劃器必須留下一個"個別"的變數給輸出元件使用,以指示緩衝器中的有 效字元數是多少。這樣才能使隨後的字型設定命令自由地去改變『牆』的值。(譯註: 作者所使用的是一個多工的系統,編排與輸出這二件事是"同時"在進行著的。) 重要的是,兩個緩衝器、屬性設定命令、以及這個新的變數,才是唯一能被這二 個模組所共享的元素。要從一個模組中去碰觸另一個模組的內部都意味著會有麻煩。 這個故事的教訓是,我們必須能夠分辨出那些只能在某一單個元件中被使用的資 料結構,和那些可供多數模組分享的資料結構。 一個相關的觀點是:
─────────────────────────────────────
例如: 模組A測量爐溫。 模組B控制燃燒器。 模組C確保在爐溫過高時,爐門是密閉的。
我們所關心的是爐子的溫度,它以實際的度數來表示。雖然模組A也許會從某一個 熱感應器接收到代表溫度的電壓值,但它必需在將此一資訊傳給應用程式的其它部份
之前,先將伏特數轉換為度數。 我們已經討論過一種拆解的方式:依元件以行拆解。第二種方式是依過程上的複雜 情節以行拆解。 符式的規則之一是,一個詞必須先被定義之後,才能被呼叫或參考到。一般而言, 詞被定義出來的順序,是和它們所具有的能力的增加是並行的。這種程序導致在原始程 式的列表上出現很自然的組織。強而有力的命令能被很容易地加到基本的應用部份之上。 (圖3-10a) 正如教科書的編排一樣,基礎的事情先說。計劃案的新手要先能看懂基礎部份的程 式碼,才能進入到更深的部位。 圖3-10 往程式中添加高級功能之兩種方式 然而,在許多大型的應用程式中,某些特殊功能最好的實現方式還是將它們視為是 應用程式基礎部份中的一些根部功能(root function)的增強形(圖 3-10b)。藉著 這種改變根部功能的能力,使用者就能夠改變所有使用到那些根部功能的命令的威力。 再以文字處理器為例,其中用到一個相當基礎的(primitive)常式來執行換頁 的任務。它會被一個啟動新行的詞所呼叫;當我們已無新行可供使用時,就必須 開啟新的一頁。這個啟動新行的詞又由負責規劃一行中的字元的常式所呼叫;當下一個 字元不能塞進目前這一行時,我們就呼叫『新行』。這種"使喚"的階層體制促使我們在 應用程式的早期就要定義出『新頁』這個詞。 問題來了?某一高層元件中含有某一個常式,它必須被『新頁』呼叫。特別是,如果 一張圖或一個表格出現在文句中,而在安排位置時卻發現這張圖無法被排入這一頁的剩餘 空間中,因此規劃器就將它延後處理並繼續編排後面的文句。這個特別的處理需要"進 入"到『新頁』的內部,以便在『新頁』被執行時,它會將那張延後的圖片放置於新一頁 的起始處。 : 新頁 ... ... ... ( 以頁尾footer結束這一頁 )
( 以頁頭header啟始新一頁) ... ?上頁殘留 ... ;
若『?上頁殘留』要在很後面才被定義出,『新頁』又怎能呼叫它? 雖然在理論上來說,經由從新安排程式碼的順序,讓高級功能被定義在根部功能之 前,是可能做得到的。但這種方法卻會產生兩個毛病: 第一,自然的組織(依功能上的程度來安排)會被破壞無遺。第二,高級的常式經常 使用到那些在基礎功能中定義的字。如果你要將高級常式挪到應用程式的開頭,你也要 將它們所用到的任何常式一起帶過去,要不然就得複製一份。這是一種非常雜亂的方式, 行不得也。 你可以利用一種叫做"向量"(vectoring)的技術來達到這種依功能程度來組 織程式列表的目的。你大可先讓根部功能去呼叫(指向)任何在以後才會定義的常式。 在我們剛才舉出的例子中,只有『?上頁殘留』這個詞的"名稱"部份需要先被提前定出 ;其內容則可以待稍後再定不遲。 本書的第七章專門討論符式的向量處理。
我們之中大部份的人都犯了過份強調"高階"與"低階"差異的過失。這種看法是 很草率的。它侷限了我們能清楚地思考軟體問題的能力。 "階層"的想法,按傳統的意義來說,從三方面扭曲了我們的努力: 1. 它暗示程式發展的次序應追隨一個階級性的架構。 2. 它暗示階層之間應相互隔離,禁絕了重複使用的利益。 3. 它助長了階層間在語法上的差異(譬如組合語言對高階語言),且誤導 我們相信,程式設計的本質會跟著遠離機器碼而改變。
且讓我們逐個檢驗這些錯誤的觀念。
───────────────────────────────────────
我不會從上向下做。就這個問題來說,我先會寫出只畫一個箱子的詞。我從下面 開始,直到一個用來監督鍵盤輸入的、叫做GO的字為止。 這其中有多少直覺? 或許有一些吧。我知道我要向何處去,所以我不必從那兒開始。更何況畫箱子要比 處理鍵盤的事來得有趣的多。我要從那些最有趣的事開始,以便進入問題。如果後來我 必需收拾那些細節,那也是我該付出的代價。 你同意這種"逐趣"的途徑嗎? 如果是在一種隨興的態度下做這件事,是的,我同意。如果我們在兩天內要向客戶 做展示,那我的做法就不同了。我會從那些最看得見的事下手,而不是那最有趣的事。 但仍然不是那種階層式由上向下的程序。我把方法的重點放在那些比較需要立即考慮的 事項上,譬如能使客戶加深印象,或能使某樣東西動起來,或讓別人看出這件設計將 會是怎樣完成的,以引起他們的興趣。
如果你把每一個階層看做是一次"做窠"(nesting),好吧,那倒是個拆解問題的好
辦法。但是,我從未發現"階層"的觀念是有用的。"階層"的另一面相是語言、介變語言 (metalanguage)、介變-介變語言(meta-metalanguage)。想要強行去分出類別,
認為你是在某個階層上 --組合語言階層,第一階積分階層,最後積分階層-- 真教人厭煩 透頂而毫無幫助。對我而言,我的那些階層們都完全不能分辨地混在一起。 「依元件以行設計」的方法使"從何處著手"的問題不再是那麼重要。譬如你可以從設 計按鍵解譯器下手。它的目標是接受按鍵,將它們轉變為數值,再將這些數值傳送到一 個內部的字。如果你能替換掉符式中用來印出堆疊數值的那個字『.』,我們就能做出 按鍵解譯器,測試它,並且除錯,而不需要用到任何和畫方塊有關的常式。 另一方面,如果這個應用程式需要硬體的支援(諸如繪圖程式),而我們手上沒有 或者買不到,我們可能會找個什麼來代替一下,譬如去顯示一個星號。反正只要能先進 入問題就行了。以詞集(lexicon )這種單元來思考,就像是要繪製一幅由好幾張畫布 組成的特大號壁畫。你從每一塊畫布開始,首先畫上主要的設計元素,然後這邊刷幾下, 那邊噴上一些顏料 .... 直到整片牆通通完成為止。
─────────────────────────────────────
•最需要創造力之處(多半是那會變動之處)
•能使你得到最滿意的回饋之處(那最有趣的地方) •在做法上會對其它地方最有影響之處,或是那足以決定問題是否能夠解決之處。
•該展示給客戶看的東西,以增進彼此的了解。 •能展示給投資者(老闆)看的東面。 沒有階層就不會有隔離 (No Segregation Without Representation) 階層觀念之所以會妨礙最佳的解決方案出現,第二個原因是在於,它鼓勵階層之間 的互相隔離。那個所謂"物件"(object)的時麾設計架構就是這種危險哲學的典型。 一個物件其實就祗是一堆程式碼,它可以被單一的名字呼叫出來,但卻能執行多種 不同的功能。要執行它的某一特定功能,你必須呼叫它,並且要傳給它一個參數或一 組參數。你可以想像那些參數就像是一組按扭,只要按個鈕就能讓那個物件去幫你 做到你要做的事。 經由物件來設計應用程式的好處在於,就如同元件一樣,物件能將資訊對應用程式 中的其它部份隱藏起來,使程式的修改變得容易。 但它仍然有不少問題。首先,物件必須含有一個相當複雜的判斷結構,以便決定它 必須完成的是哪一項功能。這使物件的體形增大而性能降低。對照之下,詞集卻是以 名字的形式提供了所有可用的功能,直接讓你叫用。 其次,物件通常被設計成是一種能獨立存在的形態。因此它就無法得到能夠使用支 援性元件所提供的一些工具的好處。結果是,它傾向於在它自身內部去複製應用程式中 隨處可見的那些程式碼。某些物件甚至為了解譯傳遞過來的參數,還需要自行去做語法 分析。甚至各自使用它自己的語法。這真是糟塌時間及精力! 最後,由於物件是被建造來只能認知一組有限的可能性,當有新的功能需要加入時, 就很難在那一組按扭上添加東西。而且,物件內部的那些工具也不是被設計來能被重複 使用的。 階層的觀念甚至滲透到我所使用的個人電腦的設計中,也就是IBM個人電腦。除了微 處理機的本身(當然,它有自己的機器指令集)外,還有下列的軟體階層: • 以組合語言撰寫,並且已經燒錄到系統ROM記憶體內的公用程式(BIOS) • 呼叫這些公用程式的磁碟作業系統(DOS) • 所選用的高階語言。它呼叫作業系統及公用程式 • 最後,任何使用這個語言之應用程式 ROM記憶體提供硬體相關的常式:也就是處理視頻螢幕、磁碟機及鍵盤。你藉著在 某一CPU暫存器中放上一個控制碼並產生一個適當的軟體中斷去呼叫它們。 例如,軟體中斷10H會進入視頻服務常式。其中共有16種功能。你按所希望的功能 將一個數值載入暫存器AH中。 不幸的是,在所有16個功能中,沒有一個是可以用來顯示一行字串的。要做到這點, 你就必須重複地在暫存器中放上某一個控制碼,並產生一個適當的軟體中斷,由它去判 定哪一個功能是你所希望的,同時還做一些跟這不相干的事 --每一個字元的顯示都要重 複這一套。 不信就試試看,你自己寫一個編輯器程式,讓螢幕畫面在每一次按鍵就做一次更新。 慢得就像是寄封信一樣!你無法讓它加快,因為,除了那些提供給外部使用的常式之外, 你無法使用視頻常式內部的任何資訊。它對這點的解釋是,為了要能將程式設計師與硬體 裝置的地址及其它細節"隔離"。畢竟,這些是將來更新硬體時,可能會改變的東西。 要想在這個機器上,有效率地實現視頻裝置的輸出輸入動作,唯一的辦法就是將字 串直接送到視頻記憶體上。你很容易做到這一點,因為參考手冊會告訴你視頻記憶體 的起始地址。可惜這樣會與系統設計者的意圖相違背。你的程式碼可能活不過硬體改版 這一關。 藉口要"提防"程式設計師涉入細節,"隔離"終於打敗了資訊隱藏的目標。而元件, 相反地,它們並不隔離模組,而是對詞典作累積性的增加。一個視頻詞集,最保守地說, 至少也達到了賦予視頻記憶體起始地址一個名字的功用。 並不是說"以位元開關功能(bit-switch function)作為元件之間的介面"這種觀 念就是錯的,有時候也會有這種需要。問題在於,這個視頻元件的設計並不完全。 反過來說,如果這個系統是被充分地整合著 --作業系統和磁碟驅動程式都是以符式所撰 寫的 --那麼視頻元件就不必為了應付所有可能的需要而設計(譯註:不需要在意目前 是否設計的完全)。一個應用程式設計師可以重寫視頻驅動常式,也可以使用目前的視頻 詞集所提供的工具,進行驅動常式的功能性擴充。
───────────────────────────────────── 語言之塔(The Tower of Babble)
"階層"的想法最後犯下的欺詐罪是,它認為程式設計語言越向"高處"走就越在質的方面
有所不同。我們總愛說什麼「高階程式碼是精純的,低階程式碼是骯髒而俗氣的」。 這種區別也有某種程度的道理,但這只是大家習以為常但卻不切實際的一些架構上
的束縛(architectural constraints)所造成的結果。我們在成長中已經習慣,對於
帶有簡單助憶符號及不自然語法的組合語言,反正視它為"低級的"就對了。 元件化的概念卻反對這種高級對低級的兩極論法。凡是程式碼都應給予相同的看待
和感覺。一個元件只不過是一套命令而已,它們一起去將資料結構及演算法轉化為有用 的功能,而這些功能卻可以在不了解內部的資料結構及演算法的情形下被使用罷了。
這些結構與實際機器碼之間的距離應該是無關緊要的。用來將輸出入埠內的一個位 元轉態的程式碼,在理論上,並不會比用來產生一個報告的程式碼,讓人感到畏懼。
甚至機器碼也應該是可讀的。一個真正奠基於符式的引擎(a Forth-based engine)
享受著一種語法和詞典,也繼續地享受著我們今天所知的"高階"詞典(譯註:指人類所 使用的詞典)。
我們在本章中已看到應用程式可按兩種方式予以拆解:拆解為元件,或依程序上的 複雜度予以拆解。 特別要注意那些作為元件之間介面的元件。
現在,如果你在初步設計上做的不錯,你的問題已經變成一堆容易處理的小件,躺在 你的腳邊。每一件都是一個待解決的問題。何不隨手抓一件,帶到下一章去。 (答案見附錄D) 1. 下面是兩種不同的方法來定義編輯程式中的按鍵解譯器。你喜歡哪一種? 並請說明理由。 A) ( 定義編輯用鍵 )
16進制
72 常數 上箭號 80 常數 下箭號 77 常數 右箭號
75 常數 左箭號 82 常數 插入鍵 83 常數 刪除鍵
10進制
( 按鍵解譯器 )
: 編輯器
開始 未結束 若是
讀鍵
條件分枝
上箭號 分枝 游標上移 分枝結束
下箭號 分枝 游標下移 分枝結束
右箭號 分枝 游標右移 分枝結束
左箭號 分枝 游標左移 分枝結束
插入鍵 分枝 插入模式 分枝結束
刪除鍵 分枝 刪除 分枝結束
條件結束
反覆 ;
B) (按鍵解譯器)
: 編輯器
開始 未結束 若是
讀鍵
條件分枝
72 分枝 游標上移 分枝結束
80 分枝 游標下移 分枝結束
77 分枝 游標右移 分枝結束
75 分枝 游標左移 分枝結束
82 分枝 插入模式 分枝結束
83 分枝 刪除 分枝結束
條件結束
反覆 ;
2. 這個問題是資料隱藏的練習
假設我們在符式字典之外有一塊記憶體,那是我們要用來安置資料結構之處。這塊記憶體起始於HEX位址C000。現在,我們想要為即將駐留該記憶體內的一串陣列 16進制
0C000 常數 陣列一 ( 8 bytes)
0C008 常數 陣列二 ( 6 bytes)
0C00C 常數 陣列三 ( 100 bytes)
每一陣列的名字會傳回適當的陣列的起始地址。但請注意,我們必須根據已經用去了多少位元組來為每一陣列計算出正確的起始地址。我們不妨藉著保有一個叫做『定位 變數 定位指標
0C000 定位指標 存入
然後我們就可以為各個陣列定義如下: 定位指標 讀取 常數 陣列一 8 定位指標 加存
定位指標 讀取 常數 陣列二 6 定位指標 加存
定位指標 讀取 常數 陣列三 100 定位指標 加存
注意,每當定義了一個陣列之後,我們就按新陣列的大小去增加指標的值,以表示我們保留了這麼多的記憶體空間。 : 可用地址 ( -- RAM空間的下一個可供使用的地址) 定位指標 讀取 ;
: 保留RAM空間 ( 要保留的數量 -- ) 定位指標 加存 ;
加上這兩個定義後,我們就可以重寫上面三式: 可用地址 常數 陣列一 8 保留RAM空間
可用地址 常數 陣列二 6 保留RAM空間
可用地址 常數 陣列三 100 保留RAM空間
(程度高一點的符式程式設計師很可能將這些作業寫成一個單一的"定義字",但這和我現在所要討論的東西無關。)最後,姑且假定在我們的應用程式中已列出了二十個類似的陣列定義。 0F000 定位指標 存入 ( 最後一個位址0EFFF + 1)
: 可用地址 ( -- RAM空間的下一個可供使用的地址) 定位指標 讀取 ;
: 保留RAM空間 ( 要保留的數量 -- ) 變號 定位指標 加存 ;
8 保留RAM空間 可用地址 常數 陣列一
6 保留RAM空間 可用地址 常數 陣列二
100 保留RAM空間 可用地址 常數 陣列三
現在,『保留RAM空間』會減小指標值。這不要緊,因為可以很容易地將『變號』加到『保留RAM空間』的定義中,以解決這個問題。我們現在最關心的是,每當我們為一個陣列下定義時,都必須在定義它之前,先行保留RAM空間,而似先前所做的 --在定義之後。在我們的程式碼中有二十幾個地方要找出來並加以修改。
|