技術(shù)交流
周立功教授數年之(zhī)心(xīn)血之作《程(chéng)序設計與數據結構》,電子版已(yǐ)無償性分享到(dào)電子工程師與高校群體。書本内容公開後,在(zài)電子行業掀起(qǐ)一片學習熱潮。經周立功教授授權,特對本書(shū)内容進(jìn)行連載,願共勉之。
第一(yī)章爲程序設計基礎,本文爲1.5.2/1.5.3共(gòng)性與(yǔ)可變性分析(xī):建立抽象和建立(lì)接口(kǒu)。
>>>> 1.5.2 建立抽象
抽象化的目的是使調用者無(wú)需知道模塊的内部細節,隻需要知道模(mó)塊或函數的名字,因此将其稱爲黑盒化。調用者隻需要(yào)知道黑盒子的輸入(rù)和輸出,而過程的細節(jiē)是隐藏的。由于建立了(le)一個(gè)由黑盒子組成的(de)系統,因此複雜的(de)結構就被黑盒子隐藏起來(lái)了,則理解系統的整體(tǐ)結構就變得(dé)更容易了(le)。
從概念的視角來看,建立抽象關注的不是如(rú)何實現,而是函數要做(zuò)什麽(me),過早地關注實現細節,将實現(xiàn)細節隐藏起來,進而幫助(zhù)我們構建更易于修改的軟件(jiàn)。因此,我們(men)首先(xiān)應該選(xuǎn)擇(zé)一個具有描述性的(de)符合需求的名字,雖然可(kě)以選擇的名字有swapByte、swapWord和swap,但swap更簡潔更貼切。其次,可以用(yòng)一句話概念性地描述swap的數據抽象——swap是(shì)實現兩個數據交換的函數。
顯然(rán),調用者僅(jǐn)需一般性地在概念層次(cì)上與實現者交流,因爲調(diào)用者的意圖是如何使用swap()實現兩個數據的交換,所以無需準确地知道實現(xiàn)的細節。而具體如何完成(chéng)數據的交換,這是在實現(xiàn)層次進行的。由此可見,将(jiāng)模塊(kuài)的目(mù)的與實現分離的抽象揭示了問題的本(běn)質(zhì),并沒有提供解決(jué)方案(àn)。隻說明需要做什(shí)麽,并不會指出如(rú)何實現某個模塊。隻要概念不變,調用者(zhě)與實現細節的變化(huà)就徹(chè)底隔離了。當某個模塊完成編碼後,隻要說明該模塊的目的和參數就(jiù)可以使用它,無需知道具體的(de)實現(xiàn)。
函數抽象對團隊項目非常重要,因爲在團隊中必(bì)須使用其他成員編(biān)寫的模塊。比如,編程語言本身自帶的庫函數,由于已經被預編譯,因此無法訪問它的源(yuán)代碼。同時庫(kù)函數(shù)不一定是用C編寫的,因此隻要知道(dào)其調用規範,就可以在程序中毫無(wú)顧忌地使用這個函數。實際上,在使(shǐ)用scanf()函數的過程中(zhōng),我們考慮過(guò)scanf()是如何實現的嗎?無關緊要。盡管不(bú)同系統實現scanf()的方(fāng)法可能(néng)不一(yī)樣,但其中的不同對于程(chéng)序員來說是透明(míng)的。
>>>> 1.5.3 建立接口
接口是由公開(kāi)訪問的方法和數據組(zǔ)成的,接口描述了與模(mó)塊交互的唯一(yī)途徑(jìng)。最小化的接口隻包含對(duì)于接口(kǒu)的任務(wù)非常重要的參數,最小化的接口便于學習如何與之交互,且隻需要理解少量的(de)參數,同時易于擴展和維護,因此設計良好的接口是一項重要的技能。
>>> 1. 函數調用
(1)傳值調用
如何調用swap()函(hán)數呢(ne)?實參将值從主調函數傳遞給被(bèi)調函數,也許其調用形式是下面(miàn)這(zhè)樣的(de):
swap(a, b);
從黑盒視角來看,形參和其它局部變量(liàng)都是函數私有的,聲明(míng)在不同函數(shù)中的同名變量是完全不同的變量,而且函數無法直接訪問其它函數中的變量,這種限制訪問保護了數據(jù)的完整性,黑盒發生了什麽對主調函數是不可見的。
一個(gè)變量的(de)有效範圍稱作它的(de)作用域,變量的作(zuò)用域指可以通過變量名(míng)稱(chēng)引用變量的區域,在函數内部聲明的(de)變量隻在該函數内(nèi)部有效(xiào)。當(dāng)主調函數調用子函(hán)數時,主函數内聲明的變量在(zài)子函數内無效,子函數内聲明(míng)的變量也隻在該子函數内部有效。
由于傳遞給(gěi)函數的是變(biàn)量的替身,因此(cǐ)改(gǎi)變函數參數對原始變量沒有影(yǐng)響。當變量傳遞給函數時,變量的值被複制給函數參數(shù)。由此可見,通過“傳值調用”方式交換(huàn)a、b的值,無法改變主調函數相應變量的值。
(2)傳址調用
如果希望通過被調函數将(jiāng)更(gèng)多的值傳回主調函數而改變主調函數中的變量,則使用“傳址調(diào)用”——将&a、&b作爲實參傳遞給形參。其調用形式如下:
swap(&a, &b);
利用指針作爲函數(shù)參(cān)數傳遞數據的本質,就是在(zài)主調(diào)函數和被調函數(shù)中,通過不同的指(zhǐ)針指向同一内存地址(zhǐ)訪問相同的内存區域(yù),即它們背後共享相同的(de)内存,從而實現數據的傳遞和交換(huàn)。
>>> 2. 函數原型
函數原型是C語言的一個(gè)強有力的工具,它讓編譯器捕獲在使用函數時可能出現的許多錯誤(wù)或疏漏。如(rú)果編譯器沒有發現(xiàn)這些問題,就很難察(chá)覺(jiào)出來。函數原型包括函數返回值的(de)類型、函數名和形(xíng)參列表(參數的數量和每個參數的(de)類型),有了(le)這些信息(xī),編譯器就可(kě)以(yǐ)檢查函數調用(yòng)與函(hán)數原型是否匹(pǐ)配?比如,參數的數量是否正确?參數的類(lèi)型是否匹配?如果(guǒ)類型不匹配(pèi),編譯器會将實參的(de)類型轉換成形參的類型。
(1)函數(shù)形參
通過程序清單 1.15可以看出(chū),其相同的處理部分是2個(gè)int類值(zhí)的交換代碼,因此可(kě)以将數據(jù)交換代碼移到swap()函(hán)數的實現中,其可(kě)變的數據(jù)由外部(bù)傳進來的參數應對。由于&a是指向int類型(xíng)變量a的指(zhǐ)針,&b是指向int類型變量b的指針,因此必須将p1、p2形參聲明(míng)爲指向int *類型的指針變量(liàng),即必須将存儲int類型值變量的地址作爲實參賦給(gěi)指針形參,實參與形參才能匹配。其函數原型進化如下:
swap(int *p1, int *p2);
(2)返回值的類型
聲明函數時必(bì)須聲明函數的類型,帶返回值的函數類型應該與(yǔ)其返回值類型相同,而沒有返(fǎn)回值的函數(shù)應該聲明爲void。類型(xíng)聲明是函數定義的一部分,函數類型指的是返回值的類型(xíng),不是函數(shù)參數的類型。
雖然可(kě)以使用return返回值,但return隻能返回一個值給主調函數。比(bǐ)如,如果返回值爲整數,則函數返回值的類型爲int。當返回值爲int類型時,如(rú)果返回值爲負數,則(zé)表示失敗;如果返回值爲非負數,則表示成功。當返回值爲bool類型時,如果(guǒ)返回值爲false,則表示失敗,如果返回值爲true,則表示成功(gōng)。當返回值爲指針(zhēn)類(lèi)型時,如果返回值爲NULL,則表示失敗(bài),否則返回一個有(yǒu)效的指針。
如果利用指針作爲參數(shù)傳遞給函數,不僅可以向函數傳入數據,而且還可以從函數返回多個(gè)值。因爲函數的調用者和函數都可(kě)以使用指(zhǐ)向同一内存地址(zhǐ)的指針,即使用同一塊内存,所以使用指針(zhēn)作爲函數參數時就是對同一數據進行讀寫操作。這樣不僅可以傳入(rù)數據(jù),還可以通過在函數内部修改(gǎi)這些數據,将函數的結果(guǒ)傳出給調用者。
當函(hán)數的實參是指(zhǐ)針變量時,有時希望函數能通過指針指向别(bié)處的方式改變此變量,則需要使用指向指針的指針作爲形參。
由于swap()無(wú)返回值,因此swap()返回值的類型爲void,其函數原(yuán)型如下:
void swap(int *p1, int *p2);
其被解釋爲swap是返回void的(de)函數(參數是int *p1,int *p2)。
這是一個不斷叠代優化的過程,用戶隻需要知道“函數名、傳入函數的參數和函數返回值的類(lèi)型(xíng)”,就知道如何有效地調用相應的函數。
>>> 3. 依賴倒置(zhì)原則
在面向過程編程中,通常的做法是高層模塊調(diào)用低層模(mó)塊,其目的之一(yī)就是要定義子程序層次結(jié)構。當(dāng)高層模塊依(yī)賴于低層模塊時(shí),對低層模塊(kuài)的改動會直接影響高(gāo)層模塊,從而迫使它們依次做出(chū)修改。如果高層模塊獨立(lì)于低層模塊,則高層模塊更容易重用,這(zhè)就是分層架構設計的核心原則,即依賴倒置原則(Dependence Inversion Principle,DIP):
● 高層模塊(kuài)不應該依賴低層(céng)模(mó)塊,兩者都應該依賴于抽象接口;
● 抽象接口不應該(gāi)依賴(lài)于細節,細節應該依賴抽象接(jiē)口。
當在分層架構中使用依賴(lài)倒置(zhì)原則時,将(jiāng)會發現“不再存在分層”的概念(niàn)了。無論是高層還是低(dī)層,它們都依賴于(yú)抽象接口,好像将整個分層架構推平一樣(yàng)。
其實從“Hello World”程序開始,我們就已經在使用(yòng)stdio.h包含的“抽象接(jiē)口”了,即以(yǐ)後凡是(shì)用#include文件的擴展名叫.h(頭文件)。如果源代碼中要用到stdio标(biāo)準輸入輸出函數時,那麽就要包含(hán)這個頭文件,比如(rú),“scanf("%d",&i);”函數,其目的是告訴編譯器(qì)要使用stdio庫。庫是一種工具(jù)的集合,這些工具是由其它程序員編寫的,用于實現特定的功能。盡管(guǎn)實現者無(wú)需關心(xīn)用戶将如何使用庫,且不會直接開放源代碼給用戶(hù)使用,但必須給用(yòng)戶提供調用函數所需要的信息(xī)。顯然隻要将頭文件(jiàn)開放給用戶,即可讓用戶了解接口的所有細節,詳見程序清單 1.16。
程序清單 1.16 swap數據交換接口(kǒu)(swap.h)
1 #ifndef _SWAP_H
2 #define _SWAP_H
3 // 前置條件:實參必須是(shì)int類型變量的地址
4 // 後置(zhì)條件:p1、p2作爲輸出參數,改(gǎi)變主(zhǔ)調函數中相應(yīng)的變量
5 void swap(int *p1, int *p2);
6 // 調用形式:swap(&a, &b)
7 #endif
其中,每個頭文件(jiàn)都指(zhǐ)出了一個用戶可見的外部函(hán)數接(jiē)口,主要包(bāo)括(kuò)函數名、所需的參(cān)數、參數的類型和(hé)返回結果的類型。其中,swap是庫的名字,程序清單 1.16(1~2)與(8)是幫(bāng)助編譯器記錄它所(suǒ)讀取(qǔ)的接口,當寫一個接口時(shí),必(bì)須包含#ifndef、#define和#ednif。#include行部分僅當接口本身需要(yào)其它庫時才使(shǐ)用,它由标準的#include行組(zǔ)成。程序清單 1.16(6)接口項表示(shì)庫輸出的函數的原型、常量和類型等。不(bú)管你是否理解,這些(xiē)行(háng)是接口的模闆文件,這就是(shì)信息隐藏。
>>> 4. 前/後置條件
處理信(xìn)息隐藏還涉及到另一個技術(shù),那就是使(shǐ)用前置條件和後置條件描述函數的行爲。在編寫一個完(wán)整的函數(shù)定義時,需要(yào)描述該函數是如何執行計算(suàn)的。但在使用函數時,隻需考慮(lǜ)該函數能做什麽,無需知道是如何完成(chéng)的。當不知(zhī)道函數是(shì)如何實現時,就是在使用一種名(míng)爲過(guò)程抽象的信息隐藏形(xíng)式,它抽象掉的是函數如何工作的細節。計算機科學家(jiā)使用“過程”表(biǎo)示任意指令(lìng)集,因此使(shǐ)用術語過程抽象。過程抽象是一種強大(dà)的工具,使得我們一次隻考慮一個而不是所有的(de)函數,從而使問題求(qiú)解簡單化。
爲了使描(miáo)述更(gèng)準确,則需要遵循固定的格式,它包含兩部分(fèn)信息(xī):函數的前置條件(jiàn)和後置條件(jiàn)。前置條件就是調用該函數必須成立的條件,當函數被(bèi)調用時,該語句給出要求爲真(zhēn)的條件。除(chú)非前置條件爲真,否則無法保證(zhèng)函數能正确執行。在調用swap()函數時,實參必須是int類型變量的地址,這是調用者的(de)職責。通(tōng)常在函數開始處檢查是否滿足?如果(guǒ)不滿足,說明調用代碼有問題(tí),抛出一個異常。
後置條件就是該操作完成後必須成立的條件,當函(hán)數調用時,如果函數是(shì)正确(què)的,而且前置條件爲(wèi)真,那麽該函數調用将可(kě)以執行完成。當函數調用完成後,後(hòu)置條(tiáo)件爲真。如果不(bú)滿足後置條件,則說明業務邏(luó)輯(jí)有問題。
當滿足調用swap()函數的前置條件時,必須同時确保其結束時滿足它的後置條件,其(qí)後置條件是被調函數将返回值傳(chuán)回主調函數,改變主(zhǔ)調函(hán)數(shù)中變量的值。
前後置條件(jiàn)不(bú)隻是概括地描述函數的行(háng)爲,聲明這些條件應該是設計任何函數的第一步。在開始考慮某個函數的算法和代碼之前(qián),應該(gāi)寫出該函數的原型,其中包括函(hán)數的返回類型、名稱和(hé)參數列表,最後緊跟一個分号。直接來自(zì)于用戶的輸入不能作爲前置條件,通常前/後(hòu)置條(tiáo)件都可以轉化爲assert語(yǔ)句。編(biān)寫函(hán)數原型時,應該(gāi)以注釋的形式(shì)描述該函數的前置條件和後(hòu)置條件。
事實上,前置條(tiáo)件和後置條件在(zài)使用函數(shù)的程序員和編寫函數的(de)程序員之間形(xíng)成了一個契約,也就(jiù)是爲什麽需要這個(gè)函數(shù)?接口通過前置條件和後置(zhì)條件以契約的形式表達需(xū)求,承諾在滿(mǎn)足前置條件時開始,按照程(chéng)序的流程運行,系統就能到達後置條件。
雖然注釋是一(yī)種很好的溝通形式,但在代碼(mǎ)可以傳遞意圖(tú)的地方(fāng)不要寫注釋。因爲(wèi)代碼解釋做了什(shí)麽,再注釋也沒有什麽用處,相反(fǎn)注釋要說(shuō)明爲什麽會(huì)這(zhè)樣寫代碼?
>>> 5. 開閉原則
接口(kǒu)僅需指明(míng)用戶調用程序(xù)可能調用的标(biāo)識符,應盡可能地将算法以及一些(xiē)與具體的實現細(xì)節無關(guān)的信息隐藏起來,這樣用戶在調用程序時也(yě)就不必依賴特定的實現細節了。當接口一(yī)旦發布後,也(yě)就不能改變了,因爲改變接口勢(shì)必引起用戶程序的改變。如果此前定義的接口滿足不了需求,怎麽辦?隻能擴展新的接口,但(dàn)不能修改或(huò)廢除原有的接口,這就是“對修改關閉(bì),對擴展開放”的開閉(bì)原則(zé)(Open-Closed Princple,OCP)。顯然,依賴倒置(zhì)原則更(gèng)加精确的定(dìng)義就是面(miàn)向接口的編程,它是實現開閉原(yuán)則的重要途徑。如(rú)果DIP依賴倒(dǎo)置原則(zé)沒有實現,就别想實現(xiàn)對擴展開放,對修改關閉(bì)。