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