整合營銷服務(wù)商

          電腦端+手機端+微信端=數(shù)據(jù)同步管理

          免費咨詢熱線:

          完整版:資深程序員都了解的代碼復(fù)用法則

          寫代碼最重要一條,是怎樣復(fù)用其他程序員的代碼和思路來解決問題。

          通過修改他人的代碼來解決復(fù)雜問題是種錯誤的做法,不僅成功的機率很低,就算成功也不會提供什么經(jīng)驗。按照這種方式進行編程,無法成長為一名真正的程序員,在軟件開發(fā)領(lǐng)域,前景也是非常有限。

          一旦問題達到了一定規(guī)模,期望程序員從頭開發(fā)一個解決方案不太現(xiàn)實,這會導(dǎo)致程序員大量時間浪費在低效率工作中,并且極大地依賴程序員精通各個方面的知識。另外,這種做法也容易導(dǎo)致程序充滿缺陷或難以維護。

          良好的復(fù)用和不良的復(fù)用

          良好的復(fù)用幫助我們編寫更好的程序,并且提高程序的編寫速度。不良的復(fù)用可能短時間內(nèi)幫助我們借用其他程序員的思維,但最終會導(dǎo)致不良的開發(fā)。下面表格對它們之間的區(qū)別進行了總結(jié)。

          左邊一列顯示了良好復(fù)用的屬性,右邊一列顯示了不良復(fù)用的屬性。在考慮是否對代碼進行復(fù)用時,要考慮它很可能會產(chǎn)生左邊一列的屬性還是右邊一列的屬性。

          表:良好的復(fù)用和不良的復(fù)用

          值得注意的是,良好的復(fù)用和不良的復(fù)用之間的區(qū)別,并不是我們復(fù)用了什么代碼或者我們怎樣復(fù)習(xí)它們,而是在于我們所借用的代碼和概念之間的關(guān)系

          按照編程的術(shù)語,良好的復(fù)用是指我們通過閱讀某個人對一個基本概念的描述后編寫代碼或者利用自己以前所編寫的代碼

          注意表格的最后一行,不良的復(fù)用常常會導(dǎo)致失敗,因為有可能是一位程序員在復(fù)用自己實際上并不理解的代碼。在有些情況下,被借用的代碼一開始能夠工作,但是當(dāng)程序員試圖對借用的代碼進行修改或者擴展時,由于缺乏深入的理解,很難用有組織的方式完成這樣的任務(wù)。程序員必須通過不斷的嘗試和失敗來進行試驗,這樣就違背了我們的基本問題解決規(guī)則的第一條也是最重要一條:先規(guī)劃再開發(fā)

          可復(fù)用組件的 5 種類型

          知道了我們打算采用的復(fù)用類型之后,現(xiàn)在可以對代碼的不同復(fù)用方法進行分類了。

          在本文中,組件表示由一位程序員所創(chuàng)建的、可以被其他人復(fù)用以幫助解決編程問題的任何東西

          組件可以在任何地方出現(xiàn),它可以是抽象的,也可以是具體的。它可以是一個思路,也可以是一段完整實現(xiàn)的代碼。如果我們把解決一個編程問題看成是處理一個手工項目,我們所學(xué)會的解決問題的技巧就像工具一樣,而組件就像是專用的零件。下面的每種組件都是復(fù)用程序員的以前工作的不同方式。

          1. 代碼塊 Code Block

          代碼塊就是將一塊代碼從一個程序清單復(fù)制到另一個程序清單。按照更通俗的說法,我們可以稱為復(fù)制粘貼工作。這是最低級形式的組件用法,常常代表了不良的復(fù)用,會出現(xiàn)不良復(fù)用可能導(dǎo)致的所有問題。當(dāng)然,如果被復(fù)制的代碼是自己所編寫的,那就不會有實際的危害,但最好還是把一段現(xiàn)有的代碼包裝成為一個類庫或其他結(jié)構(gòu),允許它以一種更清晰和更容易維護的方式被復(fù)用。

          2. 算法 Algorithm

          算法是一種編程配方,它是完成一個目標(biāo)的特定方法,是以日常語言或流程圖的形式表達的。算法是一種高級形式的復(fù)用,一般具有良好的復(fù)用屬性。算法在本質(zhì)上只是思路。

          3. 模式 Pattern

          在編程中,模式(或設(shè)計模式)表示具有一種特定編程技巧的模板,比如 singleton。這個概念與算法有關(guān),但又存在區(qū)別。算法就像解決特定問題的配方,而模式是在特定的編程情況下所使用的基本技巧。模式所解決的問題一般是在代碼本身的結(jié)構(gòu)內(nèi)部。


          和算法一樣,模式也是高級形式的組件復(fù)用。

          4. 抽象數(shù)據(jù)類型 Abstract Data Type

          抽象數(shù)據(jù)類型是由它的操作而不是由這些操作的實現(xiàn)方法所定義的類型。堆棧類型就是一個很好的例子。抽象數(shù)據(jù)類型與模式的相似之處在于它們定義了操作的效果,但并沒有特別地定義這些操作的實現(xiàn)方式。但是,和算法一樣,這些操作存在一些眾所周知的實現(xiàn)技巧。

          5. 庫 Library

          在編程中,庫表示一些相關(guān)代碼片段的集合。庫一般包含了已編譯形式的代碼以及所需的源代碼聲明。庫可以包含獨立的函數(shù)、類、類型聲明以及任何可以出現(xiàn)在代碼中的東西。在C++中,最顯而易見的例子就是標(biāo)準(zhǔn)庫。


          一般而言,庫的使用是良好的代碼復(fù)用。代碼被包含在庫中,因為它提供了各種程序一般需要使用的功能。庫的代碼可以幫助程序避免“重新發(fā)明輪子”。然而,作為程序開發(fā)人員,當(dāng)我們使用庫代碼時,必須從中學(xué)到些什么,而不是單純走捷徑。

          創(chuàng)建可復(fù)用組件的方法

          組件非常有用,因此程序員應(yīng)該盡可能地利用組件。

          優(yōu)秀的程序員必須養(yǎng)成習(xí)慣向他的工具箱中不斷添加組件。

          對組件的收集可以通過兩種不同的方式進行:程序員可以明確分配時間學(xué)習(xí)新組件,把它作為一項基本任務(wù),或者可以搜索一個組件來解決一個特定的問題。我們把第一種方法稱為探索式學(xué)習(xí),把第二種方法稱為根據(jù)需要學(xué)習(xí)

          1. 探索式學(xué)習(xí)組件

          我們首先從一個探索式學(xué)習(xí)的例子開始。假設(shè)我們想學(xué)習(xí)關(guān)于設(shè)計模式的更多知識,對于頻繁使用的設(shè)計模式,人們已經(jīng)達成了廣泛的共識。因此我們能接觸大量的資源。

          通過簡單地查找一些設(shè)計模式并對它們進行研究,我們就可以從中受益。如果實現(xiàn)了其中一些模式,就可以得到更多的收獲。

          我們在典型的模式列表中可以找到的一種模式叫策略。這是一種思路,允許一種算法(或算法的一部分)在運行時才被選擇。在策略模式的最基本形式中,它允許更改函數(shù)或方法的操作方式,但不允許對結(jié)果進行更改。

          例如,一個對類的數(shù)據(jù)進行排序(或者參與了排序過程)的類方法可能允許對排序的方法做出選擇(如選擇快速排序或插入排序)。不管選擇什么排序方式,其結(jié)果(排序后的數(shù)據(jù))是相同的,但是允許客戶選擇排序方法可能使代碼的執(zhí)行效率更高。

          客戶對于具有很高重復(fù)率的數(shù)據(jù)可以避免選擇快速排序。根據(jù)策略的形式,客戶的選擇會對結(jié)果產(chǎn)生影響。例如,有一個表示一手牌的類,排序策略可能會決定 A 被認為是最大(比 K 大)還是最小(比 2 小)。

          開始把學(xué)習(xí)融入到可復(fù)用實踐中

          通過上文我們知道什么是策略模式,但還沒有運用于創(chuàng)建自己的實現(xiàn)。在工具商店瀏覽工具和實際購買并使用工具之間還是存在顯著區(qū)別的。因此,我們現(xiàn)在從貨架上取出這個設(shè)計模式,并把它投入到使用中。

          嘗試新技巧的最快方法是把它融入到已經(jīng)編寫完成的代碼中。讓我們設(shè)計一個可以用這種模式解決的問題,它建立在我們已經(jīng)編寫完成的代碼基礎(chǔ)之上。

          班長


          在一所特定的學(xué)校中,每個班級具有一名指定的“班長”,如果教師離開了教室,就由這名學(xué)生負責(zé)維持課堂秩序。最初,這個稱號授予班里學(xué)習(xí)成績最好的學(xué)生。但是,現(xiàn)在有些教師覺得班長應(yīng)該是具有最深資歷的學(xué)生,也就是學(xué)生 ID 最小的那個學(xué)生,因為學(xué)生 ID 是按照進入班級的先后順序依次分配的。還有一部分教師覺得指定班長這個傳統(tǒng)是件非常愚蠢的事情。為了表示抗議,他們簡單地選擇按照字母順序排列的班級花名冊中所出現(xiàn)的第 1 個學(xué)生。我們的任務(wù)是修改學(xué)生集合類,添加一個方法從集合中提取班長,同時又能適應(yīng)不同教師的選擇標(biāo)準(zhǔn)。

          正如我們所見,這個問題將要采用策略形式的模式。我們需要讓這個方法根據(jù)不同的選擇標(biāo)準(zhǔn)返回不同的班長。

          為了在 C++ 中實現(xiàn)這一點,需要使用函數(shù)指針。我們已經(jīng)簡單地從 qsort 函數(shù)中了解過這個概念。qsort 函數(shù)接受一個函數(shù)指針,它所指向的函數(shù)對需要進行排序的數(shù)組中的兩個數(shù)據(jù)項進行比較。

          在這個例子中,我們完成類似的任務(wù)。我們將創(chuàng)建一組比較函數(shù),接受 2 個 studentRecord 對象為參數(shù)并分別根據(jù)成績、學(xué)生 ID 值或姓名確定第 1 個學(xué)生是否“好于”第 2 個學(xué)生。

          首先,我們需要為比較函數(shù)定義一個類型:

          這個聲明創(chuàng)建了一個稱為 firstStudentPolicy 的類型,它是個函數(shù)指針,它所指向的函數(shù)返回一個 bool 值并接受兩個 studentRecord 類型的參數(shù)。

          *firstStudentPolicy 號兩邊的括號 ? 是必要的,這是為了防止這個聲明被解釋為返回一個 BOOL 類型的指針的函數(shù)。有了這個聲明之后,我們就可以創(chuàng)建 3 個策略函數(shù)了:

          前兩個函數(shù)非常簡單:

          • higherGrade 在第 1 條記錄的成績值大于第 2 條記錄時返回 true;

          • lowerStudentNumber 在第 1 條記錄的學(xué)生 ID 值小于第 2 條記錄時返回 true。

          • 第 3 個函數(shù) nameComesFirst 在本質(zhì)上與前兩個函數(shù)相同,但它需要使用 strcmp 庫函數(shù)。這個函數(shù)接受 2 個“C 風(fēng)格”的字符串,即以 結(jié)尾的字符數(shù)組而不是 string 對象。因此我們必須對兩條學(xué)生記錄的姓名字符串使用 c_str 方法。strcmp 函數(shù)在第 1 個字符串按照字母順序出現(xiàn)在第 2 個字符串之前時返回一個負數(shù),因此我們檢查它的返回值以判斷它是否小于 0 ?。

          現(xiàn)在,我們就可以修改 studentCollection 類本身了:

          這個類聲明增加了一些新的成員:一個私有數(shù)據(jù)成員 _currentPolicy,它存儲了指向其中一個策略函數(shù)的指針、一個用于修改策略的 setFirstStudentPolicy 方法,以及根據(jù)當(dāng)前策略返回班長的 firstStudent 方法本身。

          setFirstStudentPolicy 的代碼非常簡單:

          我們還需要修改默認構(gòu)造函數(shù)對當(dāng)前策略進行初始化:

          現(xiàn)在,我們可以編寫 firstStudent 方法:

          這個方法首先檢查特殊情況。如果沒有需要檢查的鏈表或者不存在策略? ,就返回一條啞記錄。否則,就使用本書中廣泛使用的基本搜索技巧,對這個鏈表進行遍歷并尋找最適當(dāng)?shù)仄ヅ洚?dāng)前策略的學(xué)生。

          我們把鏈表開始位置的那條記錄賦值給 first ?,使循環(huán)變量從鏈表的第 2 條記錄開始 ?,然后執(zhí)行遍歷。

          在遍歷循環(huán)中,對當(dāng)前策略函數(shù)的調(diào)用 ? 告訴我們目前所查看的學(xué)生根據(jù)當(dāng)前標(biāo)準(zhǔn)是否“好于”到現(xiàn)在為止所找到的最佳學(xué)生。當(dāng)這個循環(huán)結(jié)束時,我們就返回“班長” ?。

          班長解決方案的分析

          使用策略模式解決了一個問題之后,我們很可能想確認這種技巧可以適用的其他場合,而不是一次了解了這個技巧之就將其束之高閣。我們還可以對示例問題進行分析,形成對這個技巧的價值的認識,明白什么時候使用它比較合適,什么時候使用它則是個錯誤,或至少它帶來的價值應(yīng)多于麻煩。對于這個特定的模式,讀者可能會看到它弱化了封裝和信息隱藏。

          例如,如果客戶代碼提供了策略函數(shù),它就需要訪問通常屬于類內(nèi)部的類型,在這個例子中也就是 studentRecord 類型。這意味著如果我們修改了這個類型,客戶代碼就有可能失敗。把這個模式應(yīng)用于其他項目之前,必須在這個顧慮與它可能帶來的好處之間進行權(quán)衡。通過對自己的代碼進行檢查,可以對這個關(guān)鍵問題獲得深入的體會。

          至于進一步的實踐,我們可以檢查已完成項目的庫,搜索可以使用這種技巧進行重構(gòu)的代碼。記住,很多“現(xiàn)實世界”的編程涉及到對現(xiàn)有的代碼進行補充或修改,因此這是進行這類修改的一種非常好的實踐,還能發(fā)展自己運用某種特定組件的技能。而且,良好的代碼復(fù)用的一個優(yōu)點是我們可以從中進行學(xué)習(xí),而實踐能夠最大限度地提升學(xué)習(xí)的效果。

          2. 根據(jù)需要尋找可復(fù)用組件

          前一節(jié)描述了“漫游式學(xué)習(xí)”的過程。雖然這種類型的學(xué)習(xí)旅程對于程序員而言是極具價值的,但有時候我們必須直接針對一個特定的目標(biāo)學(xué)習(xí)。

          如果我們正在著手處理一個特定的問題,特別是當(dāng)這項工作面臨極大的時間壓力時,我們會猜測某個組件可能會為我們提供極大的幫助。我們不想通過隨機漫游編程世界來碰到自己所需要的東西,而是想盡可能快地找到直接適用于自己所面臨問題的組件。

          但是,這聽起來似乎有些挑戰(zhàn),當(dāng)我們并不準(zhǔn)確地知道自己所尋找的是什么時,怎樣才能找到自己所需要的東西呢?思考下面這個示例問題:

          高效的遍歷

          一個編程項目將使用我們的 studentCollection 類。客戶代碼需要做到能夠遍歷集合中的所有學(xué)生。顯然,為了維護信息隱藏,客戶代碼不能直接訪問這個鏈表,但要求高效地對其進行遍歷。

          由于這段描述中的關(guān)鍵詞是高效,讓我們精確地分析它在這個例子中的含義。我們假設(shè) studentCollection 類的一個特定對象具有 100 條學(xué)生記錄。如果我們直接訪問這個鏈表,可以編寫一個迭代 100 次的循環(huán)。這是所有的鏈表遍歷中最高效的做法。任何要求我們迭代超過 100 次的循環(huán)都可以認為其結(jié)果是不夠高效的。

          如果沒有高效這個需求,我們可以在這個類中添加一個簡單的 recordAt 方法來解決這個問題。這個方法返回集合中特定位置的學(xué)生記錄,第1條記錄的位置編號為 1:

          在這個方法中,我們使用了一個循環(huán) ? 對鏈表進行遍歷,直到找到了所需的位置或者到達了鏈表的尾部。當(dāng)這個循環(huán)結(jié)束時,如果已經(jīng)到達了鏈表的尾部,我們就創(chuàng)建并返回一條啞記錄 ?。如果是在指定的位置就返回這條記錄 ?。問題在于我們執(zhí)行遍歷只是為了尋找一條學(xué)生記錄。這并不一定是完整的遍歷,因為當(dāng)我們到達所需的位置時就會終止循環(huán),但它終歸還是進行了遍歷。假設(shè)客戶代碼試圖求學(xué)生成績的平均值:

          對于這段代碼,假設(shè) sc 是個以前所聲明并生成的 studentCollection 對象,recNum 是個表示記錄數(shù)量的整數(shù)。假設(shè) recNum 變量值為 100。當(dāng)我們初步掃視這段代碼時,可能覺得計算平均成績只需要迭代這個循環(huán) 100 次,但由于每次調(diào)用 recordAt 函數(shù)本身就要執(zhí)行一次不完整遍歷,因此這段代碼總共涉及 100 次遍歷,每次遍歷平均需要進行 50 次迭代。因此,它的結(jié)果并不是非常高效的 100 個步驟,而是大約需要 5000 個步驟,這是極為低效的。

          什么時候搜索可復(fù)用組件

          現(xiàn)在,我們觸及到真正的問題。讓客戶訪問集合成員對其進行遍歷是非常容易的,但高效地提供這種訪問卻是非常困難的。當(dāng)然,我們可以嘗試只用自己的能力來解決這個問題。但是,如果我們可以使用一個組件,就能夠很快實現(xiàn)一個解決方案。

          為了尋找一個適用于我們的解決方案的未知組件,第 1 個步驟是假設(shè)這個組件實際上存在。換句話說,如果我們不開始搜索,就肯定無法找到這樣一個組件。因此,為了最大限度地獲得組件的優(yōu)點,需要使自己處于能夠讓組件發(fā)揮作用的場合。發(fā)現(xiàn)自己陷在問題的某個方面而無法自拔時,可以嘗試下面這些方法:

          1. 以通用的方式重新陳述這個問題。

          2. 向自己提問:這是否可能成為一個常見的問題?

          第 1 個步驟非常重要,因為我們把問題陳述為“允許客戶代碼高效地計算一個類所封裝的記錄鏈表中的平均學(xué)生成績”,它聽上去特定于我們所面臨的情形。但是,如果我們把這個問題陳述為“允許客戶代碼高效地遍歷一個鏈表,并且不需要提供對鏈表指針的直接訪問”,我們就開始理解這可能成為一個常見的問題。

          顯然,我們可以想象,由于程序常常需要在類中存儲鏈表和其他線性訪問的數(shù)據(jù)結(jié)構(gòu),因此其他程序員肯定已經(jīng)想出了允許高效地訪問數(shù)據(jù)結(jié)構(gòu)中的每個數(shù)據(jù)項的辦法。

          尋找可復(fù)用組件

          既然我們已經(jīng)同意進行觀察,現(xiàn)在就可以尋找組件了。為了清晰起見,我們把原來的編程問題重新陳述為一個搜索問題:“尋找一個組件,可以用它修改我們的studentCollection類,允許客戶代碼高效地遍歷內(nèi)部的鏈表。”

          那么怎樣解決這個問題呢?我們首先可以觀察任意類型的組件:模型、算法、抽象數(shù)據(jù)類型或庫。

          假設(shè)我們首先在標(biāo)準(zhǔn) C++ 庫中進行尋找。我們沒有必要尋找一個可以“插入”到自己的解決方案中的類,而是挖掘一個與自己的 studentCollection 類相似的庫類,以借鑒思路。這就用到了我們用于解決編程問題的類比策略。以前對 C++ 庫的探索已經(jīng)使我們與諸如 vector 這樣的容器類有了一定程度的接觸,因此我們應(yīng)該尋找一種與學(xué)生集合類最為相似的容器類。

          如果求助于自己所喜歡的 C++ 參考資料,例如一本相關(guān)的書籍或網(wǎng)絡(luò)上的一個站點并查看C++容器類,將會發(fā)現(xiàn)有一個稱為 list 的“線性容器”符合這個要求。

          list 類是否允許客戶代碼對它進行高效的遍歷呢?它能夠做到這一點,只要使用一個稱為迭代器的對象。我們看到 list 類提供了產(chǎn)生迭代器的 begin 和 end 方法。迭代器是一種對象,它可以引用 list 類中的一個特定數(shù)據(jù)項,并且可以增加自己的值,使它引用list類中的下一個對象。如果 integerList 是一個包含了整數(shù)的 list<int> 并且 iter 是個 list<int>::iterator,我們就可以用下面的代碼顯示這個 list 中的所有整數(shù):

          通過使用迭代器,list 類向客戶代碼提供了一種機制高效地對 list 進行遍歷,從而解決了這個問題。此時,我們可能會想到把 list 類本身吸收到我們的 studentCollection 類中,替換原先所使用的鏈表。然后,我們可以為這個類創(chuàng)建 begin 和 end 方法,對它所包含的 list 對象的方法進行包裝,這樣問題就解決了。

          但是,這種做法就涉及到良好的復(fù)用和不良的復(fù)用的問題。

          一旦我們完全理解了迭代器的概念并且可以在自己的代碼中生成它,再把標(biāo)準(zhǔn)模板庫中的一個現(xiàn)有的類插入到自己的代碼中就是非常好的選擇,甚至是最好的選擇。但是,如果我們沒有能力做到這一點,對 list 類的這種偷懶用法就不會幫助自己成長為優(yōu)秀的程序員。

          當(dāng)然有時候我們必須使用那些自己無法開發(fā)的組件,但是如果我們養(yǎng)成了讓其他程序員為自己解決問題的習(xí)慣,就很難成長為真正的問題解決專家。因此,讓我們自己實現(xiàn)迭代器

          在此之前,我們先簡單地觀察一下尋找迭代器方法的其他途徑。我們是在標(biāo)準(zhǔn)模板庫中進行搜索的,但也可以從其他地方開始搜索。

          例如,我們也可以在一組常用的設(shè)計模式中進行搜索。在“行為模式”這個標(biāo)題的下面,我們可以找到迭代器模式。在這個模式中,客戶允許對集合中的數(shù)據(jù)項進行線性訪問,而不需要直接接觸集合的底層結(jié)構(gòu)。這正是我們所需要的。我們可以通過搜索一個模式列表找到它,也可以通過以前對模式的研究想到它。

          我們還可以從抽象數(shù)據(jù)類型開始搜索,因為通用意義上的列表(以及特定意義上的鏈表)是常見的抽象數(shù)據(jù)類型。但是,對列表抽象數(shù)據(jù)類型的許多討論和實現(xiàn)并沒有考慮到把客戶對列表的遍歷作為一種基本操作,因此不會引發(fā)迭代器的概念。

          最后,如果我們是在算法領(lǐng)域開始搜索的,很可能無法找到適用的東西。算法傾向于描述技巧性的代碼,而創(chuàng)建迭代器則相當(dāng)簡單,正如我們稍后將要看到的那樣。在這個例子中,在類庫中搜索使我們以最快的速度找到了目標(biāo),其次是模式。但是,作為一個基本規(guī)則,在搜索一個有用的組件時,必須考慮所有的組件類型。

          應(yīng)用可復(fù)用組件

          現(xiàn)在,我們準(zhǔn)備為 studentCollection 類創(chuàng)建一個迭代器,但是標(biāo)準(zhǔn)模板庫的 list 類向我們所展示的只是怎樣在客戶代碼中使用迭代器的方法。

          如果我們不知道該怎樣實現(xiàn)迭代器,可以考慮對 list 類以及它的祖先類的代碼進行研究,但是閱讀大量不熟悉的代碼無疑具有很大的難度,是萬般無奈的情況下不得已采用的辦法。

          其實,我們可以用自己的方式來對它進行思考。把以前的代碼例子作為參考,我們可以認為迭代器是由 4 個核心操作所定義的:

          1.集合類提供了一個方法 ,提供了引用集合第 1 個元素的迭代器。在 list 類中,這個方法是 begin。

          2.測試迭代器是否越過了集合最后一個元素的機制。在 list 類中,這個方法是 end,它針對這個測試產(chǎn)生了一個特殊的迭代器對象。

          3.迭代器類中使迭代器向前推進一步的方法,是使它引用集合中的下一個元素。在 list 類中,這個方法是重載的 ++ 操作符。

          4.迭代器類中返回集合當(dāng)前所引用的元素的方法。在 list 類中,這個方法是重載的 *(前綴形式)操作符。

          站在編寫代碼的角度,上面這些并沒有困難之處,唯一的問題就是把所有的東西放在正確的位置。因此,我們現(xiàn)在就開始處理這個問題。

          根據(jù)上面的描述,我們的迭代器(稱為 scIterator)需要存儲一個指向 studentCollection 中的一個元素的引用,并且能夠推進到下一個元素。因此,這個迭代器應(yīng)該存儲一個指向 studentNode 的指針,這樣就允許它返回集合中的studentRecord對象并允許它推進到下一個 studentNode 對象。

          因此,這個迭代器類的私有部分將具備以下這個數(shù)據(jù)成員:

          我們馬上就遇到了一個問題。studentNode 類型是在 studentCollection 類的私有部分聲明的,因此上面這行代碼是行不通的。我們首先想到的是不應(yīng)該把 studentNode 聲明為私有部分,但這并不是正確的答案。節(jié)點類型在本質(zhì)上是私有的,因為我們并不希望任何客戶代碼依賴節(jié)點類型的某種特定性質(zhì)實現(xiàn),不想因為這個類進行了修改而導(dǎo)致客戶代碼的失敗。然而,我們還是需要讓 scIterator 類能夠訪問自己的私有類型。

          我們通過一個友元聲明來解決這個問題。在 studentCollection 類的公共部分,我們添加了下面這一行:

          現(xiàn)在,scIterator 可以訪問 studentCollection 類的私有聲明,包括 studentNode 的聲明。我們還可以聲明一些構(gòu)造函數(shù),如下所示:

          我們稍微觀察一下 studentCollection 類再編寫 begin 方法,這個方法返回一個引用集合第 1 個元素的迭代器。根據(jù)本書所使用的命名方案,這個方法應(yīng)該用名詞來表示,例如 firstItemIterator:

          正如所看到的那樣,我們需要完成的任務(wù)就是把鏈表的頭指針塞到一個 scIterator 對象中并返回它。如果讀者的做事風(fēng)格與我相似,看到指針飛臨此處可能會覺得有點緊張,但是注意 scIterator 正要保存一個指向 studentCollection 列表中的一個元素的引用。它不會為自己分配任何內(nèi)存,因此我們并不需要擔(dān)心深拷貝和重載的賦值操作符。

          現(xiàn)在我們返回到 scIterator 并編寫其他方法。我們需要一個方法推進迭代器,使它引用下一個元素,還需要編寫一個方法測試它是否越過了集合的尾部。我們應(yīng)該同時考慮這兩個操作。

          在推進迭代器之前,我們需要知道當(dāng)?shù)髟竭^了列表的最后一個節(jié)點之后應(yīng)該具有什么值。如果不想搞什么特殊,迭代器在這個時候很自然應(yīng)該是 值,這也是最容易使用的值。注意,我們已經(jīng)在默認構(gòu)造函數(shù)中把迭代器初始化為 ,因此當(dāng)我們用 提示越過集合尾部時,就會在這兩種狀態(tài)之間產(chǎn)生混淆。但是,對于當(dāng)前的問題而言,這并不會造成什么麻煩。這個方法的代碼如下:

          記住,我們只是用迭代器概念來解決原先的問題。我們并不需要復(fù)制C++標(biāo)準(zhǔn)模板庫的迭代器類的準(zhǔn)確規(guī)范,因此無需使用相同的接口。在這個例子中,我們并非對++操作符進行重載,而是選擇了一個稱為 advance ? 的方法,它判斷當(dāng)前的指針是否為 ?,然后再把它推進到下一個節(jié)點 ?。類似地,我發(fā)現(xiàn)創(chuàng)建一種特殊的“尾”迭代器并與之進行比較是種很笨拙的做法,因此決定只選擇一個稱為 pastEnd ? 的 bool 方法,用于確定是否已經(jīng)遍歷完了節(jié)點。

          最后,我們需要一種方法獲取當(dāng)前所引用的 studentRecord 對象:

          正如我們之前所做的那樣,為了安全起見,如果指針的值為,我們就創(chuàng)建并返回一條啞記錄 ?。否則,我們就返回當(dāng)前所引用的記錄 ?。這樣我們就完成了 studentCollection 類的迭代器概念的實現(xiàn)。

          為了清晰起見,以下是 scIterator 類的完整聲明:

          完成了所有的代碼之后,我們可以用一個示例遍歷對代碼進行測試。下面我們實現(xiàn)平均成績計算以進行比較。

          這段代碼使用了所有與迭代器相關(guān)的方法,因此可以對我們的代碼進行很好的測試。我們調(diào)用 firstItemIterator 函數(shù)對 scIterator 對象進行初始化 ?,調(diào)用 pastEnd 函數(shù)作為循環(huán)終止測試 ?。我們還調(diào)用迭代器對象的 student 方法獲取當(dāng)前的studentRecord以便提取成績 ?。最后,為了把迭代器移動到下一條記錄,我們調(diào)用了 advance 方法 ?。

          當(dāng)這段代碼順利運行時,我們可以合理地確信自己已經(jīng)正確地實現(xiàn)了各個方法,而且對迭代器的概念有了堅實的理解。

          高效遍歷解決方案的分析

          和以前一樣,代碼能夠工作并不意味著這個事件的學(xué)習(xí)潛力就到此為止了。我們還應(yīng)該仔細考慮完成了什么任務(wù)、它的正面效果和負面影響,并對我們所實現(xiàn)的基本思路的相應(yīng)擴展進行思考。

          在這個例子中,我們可以認為迭代器的概念確實解決了客戶代碼對集合的低效遍歷這個最初問題。一旦實現(xiàn)了迭代器之后,它的使用就變得非常優(yōu)雅并容易理解。從負面的角度考慮,基于 recordAt 方法的低效方法顯然要容易編寫得多。在決定是否為一種特定的情況實現(xiàn)迭代器時,必須考慮遍歷的發(fā)生頻率、列表中一般會出現(xiàn)多少個元素等問題。

          如果很少進行對列表的遍歷并且列表本身很短,那么低效問題很可能并不嚴(yán)重。但是,如果我們預(yù)期列表將會增長或者無法保證它不會增長,那么就應(yīng)該使用迭代器方法。

          當(dāng)然,如果我們已經(jīng)決定使用標(biāo)準(zhǔn)模板庫的一個 list 類對象,就不需要再擔(dān)心迭代器的實現(xiàn)難度這個問題,因為我們用不著自己實現(xiàn)它。下次再遇到類似的情況時,我們就可以使用 list 類,而不必感覺自己是在偷懶,也不必認為以后會在這方面遇到困難。因為我們已經(jīng)對列表和迭代器進行了研究,理解了它們幕后的工作原理,即使自己從來沒有研究過它們的實際源代碼。

          把話題再深入一步,我們可以考慮迭代器的更廣泛應(yīng)用以及它們可能存在的限制。

          例如,假設(shè)我們需要一個迭代器,不僅希望它能夠高效地推進到 studentCollection 中的下一個元素,而且能夠同樣高效地退回到前一個元素。既然我們已經(jīng)理解了迭代器的工作原理,就很容易明白在當(dāng)前的 studentCollection 實現(xiàn)上是沒有辦法完成這個任務(wù)的。如果迭代器維護一個指向列表中某個特定節(jié)點的鏈(即next字段),把它推進到下一個節(jié)點只需要訪問節(jié)點中的這個鏈。但是,撤回到前一個節(jié)點則要求反向遍歷列表。我們可以采用雙鏈表,每個節(jié)點維護兩個分別指向前一個節(jié)點和下一個節(jié)點的指針。

          我們可以對這個思路進行歸納,開始考慮不同的數(shù)據(jù)結(jié)構(gòu)以及它們可以向客戶提供的高效的遍歷類型或數(shù)據(jù)訪問。

          考慮這樣的類似問題可以幫助我們成為更優(yōu)秀的程序員。我們不僅能夠?qū)W習(xí)新的技巧,還能夠了解不同組件的優(yōu)點和缺點。了解組件的優(yōu)缺點可以幫助我們合理地使用組件。沒有考慮到一種特定方法所存在的限制可能會導(dǎo)致悲慘的結(jié)果。對自己所使用的組件了解越多,發(fā)生這種事件的概率也就越低。

          選擇可復(fù)用組件類型

          正如我們在這些例子中所看到的那樣,示例問題可以通過不同類型的組件來解決。一個模式可能表達了一種解決方案的思路,一種算法可能規(guī)劃了思路的一種實現(xiàn)或者解決同一個問題的另一種思路,一種抽象數(shù)據(jù)類型可能封裝了某個概念,類庫中的一個類可能包含了一種抽象數(shù)據(jù)類型的完整的、經(jīng)過測試的實現(xiàn)。如果它們都是對于解決我們的問題所需要的同一個概念的一種表達,那么我們怎樣才能知道哪種組件類型放進我們的工具箱是最適合的呢?

          一個主要的考慮是把組件集成到自己的解決方案需要多大的工作量。把一個類庫鏈接到自己的代碼常常是解決問題最迅速的方法,從一段偽碼描述實現(xiàn)一種算法可能需要大量的時間。

          另一個重要的考慮是組件所提供的靈活性有多大。組件常常是以一種漂亮的、預(yù)包裝的形式出現(xiàn),但是當(dāng)它集成到解決方案時,程序員發(fā)現(xiàn)雖然這個組件具有他所需要的大多數(shù)功能,但它并不能完成所有的任務(wù)。

          例如,也許一個方法的返回值格式不正確,需要額外的處理。如果堅持使用這個組件,在使用過程中可能會出現(xiàn)更多的問題,最后還是不得不放棄,只能從頭尋找新的方案。如果程序員選擇了一種位于更高概念層次的組件(如模式),最終的代碼實現(xiàn)將會完美地適合需要解決的問題,因為它就是根據(jù)這個問題而創(chuàng)建的。

          下圖對這兩個因素的相互影響進行了總結(jié)。一般而言,來自類庫的代碼馬上就能被使用,但它無法被直接修改。它只能通過間接的方式修改,或者使用C++模板,或者讓解決問題的代碼實現(xiàn)本文前面所提到的策略模式之類的東西。另一方面,模式所表示的東西可能僅僅是個思路(如“這個類只能具有1個實例”),它提供了最大的實現(xiàn)靈活性,但是對于程序員而言則需要大量的工作。

          當(dāng)然,這并不是一個基本的指導(dǎo)方針,每個人所面臨的情況可能各不相同。也許我們從類庫中所使用的類在自己的程序中位于相當(dāng)?shù)偷膶哟危撵`活性并不重要。例如,我們可能想自己設(shè)計一個集合類,包裝了類似list這樣的基本容器類。由于list類所提供的功能相當(dāng)廣泛,因此即使我們必須對這個集合類的功能進行擴展,預(yù)計作為底層容器的list類也完全能夠勝任。在使用模式之前,也許過去已經(jīng)實現(xiàn)了一個特定的模式,我們可以對以前所編寫的代碼進行適配,這樣就不需要創(chuàng)建太多的新代碼。

          (圖:組件類型的靈活性與所需要的工作量)

          在使用組件方面的經(jīng)驗越豐富,對于選擇正確的組件就會有更大的自信。在積累足夠的經(jīng)驗之前,可以把靈活性和所需工作量之間的權(quán)衡作為粗略的指導(dǎo)方針。對于每種特定的情況,可以提出下面這幾個問題:

          • 能不能直接使用這個組件?還是需要額外的代碼才能讓它應(yīng)用于自己的項目?

          • 我是否確信已經(jīng)從各個方面理解了問題,或者理解了與這個組件相關(guān)聯(lián)的問題,并且確定它在未來也不會發(fā)生變化?

          • 通過選擇這個組件,是不是能夠擴展我的編程知識?

          這些問題的答案可以幫助我們評估選擇某個組件所需要的工作量以及自己能夠從每種可能的方法中獲得多大的益處。

          可復(fù)用組件選擇的實戰(zhàn)

          現(xiàn)在我們已經(jīng)理解了基本的思路,下面可以通過一個簡單的例子來說明具體的細節(jié)了。

          對某些數(shù)據(jù)進行排序,但其他數(shù)據(jù)保持不變


          一個項目要求我們對一個 studentRecord 對象數(shù)組按成績進行排序,但是存在一個難點:這個程序的另一部分使用 ?1 這個特殊的成績值表示那些無法移動記錄的學(xué)生。因此,盡管所有其他記錄必須移動,但那些成績值為 ?1 的記錄必須保留在原先的位置。最終所產(chǎn)生的結(jié)果是一個排好序的數(shù)組,但其間散布著一些成績值為 ?1 的記錄。

          這是一個需要技巧的問題,我們可以嘗試用多種方法來解決這個問題。為了簡單起見,我們把選項減為 2 個:

          1. 選擇一種算法,例如像插入排序這樣的算法并對它進行修改,忽略那些成績值為 ?1 的 studentRecord 對象。

          2. 想出一種方法,用 qsort 庫函數(shù)來解決這個問題。

          這兩個選擇都是可行的。由于我們已經(jīng)熟悉了插入排序的代碼,在它的里面插入幾條 if 語句,顯式地檢查并跳過那些成績值為 ?1 的記錄應(yīng)該不會太困難。讓 qsort 為我們完成工作就需要一些變通。我們可以把具有真正成績值的學(xué)生記錄復(fù)制到一個單獨的數(shù)組中,用 qsort 對它們進行排序,然后再復(fù)制回原先的數(shù)組,并保證在復(fù)制時不會覆蓋原先成績值為 ?1 的記錄。

          讓我們對這兩個選項進行分析,觀察組件類型的選擇是怎樣影響最終代碼的。我們首先從算法組件開始,編寫經(jīng)過修改的插入排序算法來解決這個問題。和往常一樣,我們將分幾個階段來解決這個問題。

          首先,我們通過去掉 ?1 成績這個階段性問題來削減這個問題,對 studentRecord 對象數(shù)組進行排序時不考慮任何特殊規(guī)則。如果 sra 是包含了arraysize個studentRecord 類型的對象數(shù)組,它的代碼應(yīng)該如下所示:

          這段代碼與整數(shù)的插入排序非常相似。唯一的區(qū)別是它在執(zhí)行比較時調(diào)用了 grade 方法 ?,另外還更改了用于交換空間的臨時對象的類型 ?。這段代碼能夠順利完成任務(wù),但是對它以及本節(jié)后面的代碼段進行測試的時候,有一個需要警惕的地方:正如以前所編寫的那樣,studentRecord 類會對數(shù)據(jù)執(zhí)行驗證,它不會接受 ?1 作為成績值,因此需要進行必要的修改。現(xiàn)在我們就可以完成這個版本的解決方案了。

          我們需要讓插入排序忽略成績值為 ?1 的記錄。這個任務(wù)并不像聽上去那么簡單。在基本的插入排序算法中,我們總是在數(shù)組中交換相鄰的位置,如上面代碼中的 j 和 j – 1。但是,如果我們讓有些記錄的成績值保留為 ?1,那么需要與當(dāng)前記錄進行交換的下一條記錄的位置可能相隔甚遠。

          下圖用一個例子描述了這個問題。它顯示了最初配置下的數(shù)組,并用箭頭提示第 1 條記錄需要被交換到的位置,它們并不是相鄰的。而且,最后一條記錄(表示 Art)最終將從位置 [5] 交換到位置 [3] ,然后再從 [3] 交換到 [0],因此對這個數(shù)組排序所進行的所有交換都涉及到非相鄰的記錄(至少對于我們所排序的那些記錄是這樣的)。

          圖:修改后的排序算法中需要被交換的記錄之間的任意距離

          在考慮怎樣解決這個問題時,我設(shè)法尋找一個類比。我在處理鏈表的問題的選擇中找到了一個類比。在許多鏈表算法中,我們在鏈表遍歷時不僅需要維護一個指向當(dāng)前節(jié)點的指針,還需要維護一個指向前一個節(jié)點的指針。因此在循環(huán)體結(jié)束的時候,我們經(jīng)常把當(dāng)前節(jié)點指針賦值給前一節(jié)點指針,然后再把當(dāng)前節(jié)點指針指向下一個節(jié)點。

          這個例子也需要類似的做法。當(dāng)我們按照線性順序遍歷這個數(shù)組尋找下一條“真正的”學(xué)生記錄時,還需要追蹤前一條“真正的”的學(xué)生記錄。把這個思路投放到實踐中就產(chǎn)生了如下的代碼:

          在基本的插入排序算法中,我們反復(fù)地把未排序的元素插入到數(shù)組中一塊不斷增長的已排序區(qū)域中。外層的循環(huán)選擇下一條需要被放到排序區(qū)的未排序元素。

          在這個版本的代碼中,我們首先在外層循環(huán)體中判斷位置i的成績值是不是 ?1 ?。如果是,我們就簡單地跳到下一條記錄,保留這個位置不變。

          當(dāng)我們確定位置 i 的學(xué)生記錄可以被移動時,就把 rightswap 初始化為這個位置 ?。然后我們就進入內(nèi)層循環(huán)。在基本的插入排序算法中,內(nèi)層循環(huán)的每次迭代都把一個元素與它相鄰的元素進行交換。

          但是,在這個版本的插入排序中,由于我們讓成績值為 ?1 的學(xué)生記錄保持不動,所以只有當(dāng)位置 j 的學(xué)生記錄的成績值不是 ?1 時才執(zhí)行交換 ?。

          然后,我們在 leftswap 和 rightswap 這兩個位置之間執(zhí)行交換并把 leftswap 賦值給 rightswap ?,如果還有要執(zhí)行的交換就設(shè)置下一次交換。

          最后,我們必須修改內(nèi)層循環(huán)的終止條件。正常情況下,插入排序的內(nèi)層循環(huán)是在到達了數(shù)組的前端或者找到了小于需要被插入值的元素的時候終止。在這個例子中,我們必須用邏輯或操作符創(chuàng)建一個復(fù)合條件,使循環(huán)能夠跳過成績值為 ?1 的記錄 ?。(由于?1小于所有合法的成績值,因此會永久地停止循環(huán)。)

          這段代碼解決了我們的問題,但它很可能會散發(fā)出某種“不良的氣味”。標(biāo)準(zhǔn)的插入排序算法很容易理解,尤其是當(dāng)我們理解了它的主旨時。但是,這個經(jīng)過修改的版本就很難讀懂,如果我們想在以后還能看懂這段代碼,很可能需要添加幾條注釋。

          也許可以對它進行重構(gòu),但我們先試試用其他方法來解決這個問題并觀察其結(jié)果。

          我們所需要的第一樣?xùn)|西就是一個在 qsort 中使用的比較函數(shù)。在這個例子中,我們將比較兩個 studentRecord 對象,并且這個函數(shù)將把一個成績值減去另一個成績值:

          現(xiàn)在,我們就可以對記錄進行排序了。我們將分為 3 個階段完成這項任務(wù)。首先,我們把所有成績值不是 ?1 的記錄復(fù)制到第 2 個數(shù)組,元素之間沒有空隙。接著,我們調(diào)用 qsort 對第 2 個數(shù)組進行排序。

          最后,我們把第 2 個數(shù)組的記錄復(fù)制回原來的數(shù)組,跳過那些成績值為 ?1 的記錄。最終的代碼如下所示:

          盡管這段代碼的長度和前面那個解決方案差不多,但它更加簡明易懂。

          我們首先聲明第 2 個數(shù)組 sortArray ?,它的長度與原先的數(shù)組相同。sortArrayCount 變量被初始化為 0 ?。在第1個循環(huán)中,我們將用這個變量追蹤檢查有多少條記錄已經(jīng)被復(fù)制到第2個數(shù)組中。在這個循環(huán)的內(nèi)部,每次遇到一條成績值不是 ?1 的記錄時 ?,我們就把它賦值給 sortArray 中的下一個空位置并將 sortArrayCount 的值增加 1。

          當(dāng)這個循環(huán)結(jié)束時,我們就對第 2 個數(shù)組進行排序 ?。sortArrayCount 變量被重置為 0 ?。我們將在第 2 個循環(huán)中用它追蹤檢查有多少條記錄已經(jīng)從第 2 個數(shù)組復(fù)制回原先的數(shù)組。注意,第 2 個循環(huán)對原先的數(shù)組進行遍歷 ?,尋找需要被填充的位置 ?。

          如果我們用其他方法來完成這個任務(wù),可以嘗試對第 2 個數(shù)組進行遍歷,并把記錄推回到原來的數(shù)組。那樣,我們將需要一個雙重循環(huán),其中內(nèi)層循環(huán)搜索原先的數(shù)組中下一個具有真正成績值的位置。這是問題的難易程度取決于它的概念化層次的又一個例子。

          比較結(jié)果

          這兩個解決方案都可以完成任務(wù)并且都采用了合理的方法。對于大多數(shù)程序員而言,對插入排序進行修改并在排序時使部分記錄保持不動的第 1 個解決方案很難編寫和讀懂。但是,第 2 個解決方案似乎有些低效,因為它需要把數(shù)據(jù)復(fù)制到第 2 個數(shù)組并復(fù)制回來。

          下面對這兩種算法進行簡單的分析。假設(shè)我們對 10,000 條記錄進行排序,如果需要排序的次數(shù)極少,那就無需太關(guān)注效率問題。我們無法確切地知道 qsort 所采用的底層算法是什么,但是對于通用目的的排序,最壞的情況是要執(zhí)行 1 億次的記錄交換,最佳的情況只需要執(zhí)行 13 萬次。

          不管實際的交換次數(shù)是這個范圍內(nèi)的哪個數(shù)字,來回復(fù)制 10,000 條記錄相對于排序而言并不會對性能產(chǎn)生非常大的影響。另外,還必須考慮 qsort 所采用的排序算法可能比我們所采用的簡單排序更為高效,這樣使第 1 個解決方案不需要把數(shù)據(jù)來回復(fù)制到第 2 個數(shù)組的優(yōu)點也化為烏有。

          因此在這個場景中,使用 qsort 的第 2 種方法要更好一點。它更容易實現(xiàn)、更容易讀懂,因此也更容易維護。并且,我們可以預(yù)期它的執(zhí)行效率不遜于第1個解決方案,甚至更勝一籌。

          第 1 個解決方案的最大優(yōu)點是我們可以把學(xué)會的技巧應(yīng)用于其他問題,而第 2 個解決方案由于過于簡單而缺乏這一點。

          作為基本規(guī)則,當(dāng)我們處在需要最大限度地提高自己的編程能力的階段時,應(yīng)該優(yōu)先選擇高層次的組件,例如算法或模式。當(dāng)我們處在需要最大限度地提高編程效率(或者時間期限非常緊張)的階段時,應(yīng)該優(yōu)先考慮低層次的組件,盡可能選擇預(yù)創(chuàng)建的代碼。

          當(dāng)然,如果時間允許,可以嘗試不同的方法,就像我們剛剛所完成的那樣,這樣可以得到最大的收獲。

          思考題

          盡可能多地對可復(fù)用組件進行試驗,一旦掌握了怎樣學(xué)習(xí)新組件,將會極大地提高自己的編程能力。

          1. 對策略模式的一個反對意見是它需要暴露類的一些內(nèi)部實現(xiàn),例如類型。修改本文前半部分的“班長”程序,使策略函數(shù)都存儲在這個類中,并通過傳遞一個代碼值(例如,一種新的枚舉類型)來進行選擇,而不是傳遞策略函數(shù)本身。

          2. 考慮一個 studentRecord 對象的集合。我們想要根據(jù)學(xué)生編號尋找一條特定的記錄。把學(xué)生記錄存儲在一個數(shù)組中,根據(jù)學(xué)生編號對數(shù)組進行排序,并研究和實現(xiàn)插值搜索算法。

          3. 對于上面的問題,通過一個抽象數(shù)據(jù)類型來實現(xiàn)一個解決方案。這個抽象數(shù)據(jù)類型允許存儲任意數(shù)量的數(shù)據(jù)項,并可以根據(jù)鍵值提取單獨的記錄。對于能夠根據(jù)一個鍵值高效地存儲和提取數(shù)據(jù)項的結(jié)構(gòu),它用基本術(shù)語表示就是符號表,符號表思路的常見實現(xiàn)是散列表和二叉搜索樹。

          本文節(jié)選自人民郵電出版社《像程序員一樣思考》第 7 章,部分內(nèi)容有簡化,感興趣的讀者可以在各大書店購買。

          人郵的公眾號「人郵 IT 書坊」長期提供最新 IT 圖書資訊,歡迎關(guān)注。

          優(yōu)質(zhì)文章,及時送達

          作者:xybaby

          鏈接:cnblogs.com/xybaby/p/11372846.html

          正文

          不管是不要重復(fù)造輪子,還是站在巨人的肩膀上,對于軟件開發(fā)來說,代碼復(fù)用都是最基本的原則之一。

          代碼復(fù)用,可能是DRY(dont repeat yourself),也可能是使用別人的代碼,或者是開源項目,或者是其他團隊提供的組件、服務(wù),或者是團隊內(nèi)他人實現(xiàn)的公共模塊,這些復(fù)用大大減少了項目的開發(fā)周期和成本。

          但怎樣才算是高效、正確的第三方代碼使用姿勢呢?在實操中,也會出現(xiàn)一些使用第三方代碼導(dǎo)致失控的情況,比如使用了一些第三方代碼,但年久失修,當(dāng)線上事故貌似與第三方代碼有關(guān)時,無法快速定位、解決問題。

          本文是閱讀《clean code》的第八章邊界(Boundaries)時的一些思考。

          本文將復(fù)用的代碼分為兩類:

          • 一類是團隊外的代碼,具體指第三方庫、開源庫、公司內(nèi)其他團隊的通用組件,其特征是,這樣的代碼往往需要做的比較通用,大而全;

          項目團隊只是使用者,很難從根本上影響其設(shè)計或?qū)崿F(xiàn)。

          • 另一類則是團隊內(nèi)的代碼,即項目團隊成員自行封裝的一些通用模塊、通用組件,其特征是核心為項目服務(wù),比較方便協(xié)商修改。

          這里的復(fù)用,不局限于代碼,也包括可供遠程調(diào)用的服務(wù)。一般來說,項目會調(diào)研、選擇一些開源框架,也會使用公司內(nèi)基礎(chǔ)服務(wù)部門或者云計算上的一些服務(wù),我覺得這都算復(fù)用。

          最小化、集中化代碼復(fù)用

          第三方庫往往追求功能(服務(wù))的普適性,這樣代碼就能在多個環(huán)境中工作,吸引更多的用戶。而使用者往往只需要滿足特定需求的部分接口,對于不需要的功能(以及不建議的使用方式),對項目來說反而是負擔(dān),控制不當(dāng)反而會帶來風(fēng)險。

          比如redis,既能做內(nèi)存數(shù)據(jù)庫,也能持久化;既支持單點部署,也能通過sentinel、cluster提供高可用以及水平擴展;而且還支持pub-sub(以及比較新的stream)。但在我們的項目中,只用來內(nèi)存緩存,而且對可用性、伸縮性也沒有太大需求。

          原則上,使用第三方庫時,使用到的接口(服務(wù))越少越好,將其封裝到單獨的文件(類、模塊),在其他地方不能直接使用第三方庫。通過適配,只將需要的部分功能納入,不需要的功能(接口)不要暴露出來。

          這樣的好處在于入口統(tǒng)一,將所有對第三方庫的使用集中到最少量的代碼里面,便于維護。同時,這也是分層的思想,將業(yè)務(wù)代碼與第三方庫解耦合,便于替換實現(xiàn)。

          learning tests

          要將一個開源項目引入自己的業(yè)務(wù)代碼,需要進行科學(xué)的調(diào)研和完備的測試。調(diào)研包括但不限于:與業(yè)務(wù)需求的重合度,開源社區(qū)的成熟度、活躍度等。而測試應(yīng)包含以下幾個方面

          • 功能測試

          • 性能測試

          • 壓力測試

          • 故障測試

          前兩項是最基礎(chǔ)的測試,主要判斷是第三方庫是否符合業(yè)務(wù)的功能、性能要求,同時掌握正確的使用姿勢。而后兩者,則常常是第三方庫以單獨的服務(wù)部署運行時的測試要點。

          為了進行測試,我們會有一些測試代碼,也許會參考項目自帶的unittest、 code sample、tutorial、benchmark。但問題在于,這樣的測試代碼經(jīng)常用完就扔,這樣導(dǎo)致

          • 如果后面出現(xiàn)問題,我們就需要不斷調(diào)試,來確定是類庫本身的問題,還是我們使用姿勢的問題。

          • 當(dāng)?shù)厝綆焐壷螅瑧?yīng)用不敢跟著升級,因為沒有手段保證新版本的類庫提供了同等契約。

          第二個問題我想很多很多人都會遇到,當(dāng)依賴的第三方庫升級的時候,項目是否跟著一起升級你?兩種比較極端的策略我都遇到過,一種是始終更新到第三方庫的最新穩(wěn)定版本;另一種是基本不升級,自己維護某個特定版本。

          learning test能解決上述的第二個問題:

          我們將所有的測試整理為一整套針對所使用的功能的單元測試,這些測試覆蓋了我們對功能、性能、穩(wěn)定性都諸多方面的需求。當(dāng)?shù)谌筋悗斓陌姹靖碌臅r候,我們只要把單元測試再跑一遍,就可以判斷新代碼的代碼是否提供了同等的契約,也就可以比較安全的進行升級。

          不難看到,上一小節(jié),“集中化第三方代碼使用”是learning test的基礎(chǔ),讓我們很清楚的知道應(yīng)該對哪些接口進行測試,如果要擴展對第三方庫的使用,也能很方便的增加、維護對應(yīng)的測試。

          在團隊內(nèi),也是非常鼓勵代碼的復(fù)用,比較常見的方式是形成各種通用的組件。那么,如果程序員A使用了程序員B提供的公共模塊出了問題,那么責(zé)任該如何劃分?

          如果是開源代碼,毫無疑問只能責(zé)怪使用者,但是在團隊中,似乎并不能完全歸咎于使用者。公共組件的使用者一般并不會對使用進行完整的測試,也會認為,“都是一個團隊的,就應(yīng)該提供者保證質(zhì)量,方便快速使用”。

          我認為,使用者的責(zé)任占主要,使用者應(yīng)該就使用方式進行測試,如果提供者已經(jīng)提供了相應(yīng)的單元測試,而且能通過,那么就可以直接使用。否則應(yīng)該添加對應(yīng)的測試case,如果無法通過,則可以找提供者協(xié)商解決。對于通用模塊、通用組件的提供者,也應(yīng)該有義務(wù)提供高覆蓋率的單元測試,一來開發(fā)的時候因為本身就會測試,并不會增加額外的工作量;二來是對使用者的一份正式的保證,也能增加自己在團隊的影響力。

          本文版權(quán)歸作者xybaby(博文地址:http://www.cnblogs.com/xybaby/)

          -END-

          如果看到這里,說明你喜歡這篇文章,請 。同時 標(biāo)星(置頂)本公眾號可以第一時間接受到博文推送。

          最近整理一份面試資料《Java技術(shù)棧學(xué)習(xí)手冊》,覆蓋了Java技術(shù)、面試題精選、Spring全家桶、Nginx、SSM、微服務(wù)、數(shù)據(jù)庫、數(shù)據(jù)結(jié)構(gòu)、架構(gòu)等等。

          、Problem Description

          1. PageHelper方法使用了靜態(tài)的ThreadLocal參數(shù),在startPage()調(diào)用緊跟MyBatis查詢方法后,才會自動清除ThreadLocal存儲的對象。

          2. 當(dāng)一個線程先執(zhí)行了A方法的PageHelper.startPage(int pageNum, int pageSize)后,在未執(zhí)行到SQL語句前,因為代碼拋異常而提前結(jié)束。

          3. 這個線程被另一個請求復(fù)用,根據(jù)當(dāng)前的pageNum和pageSize參數(shù),執(zhí)行了B方法中的SQL語句。

          4. B方法的SQL是全表掃描并查詢出所有符合條件的數(shù)據(jù),所以因為A方法的分頁參數(shù)限定<<實際B方法中符合條件的數(shù)據(jù)量,導(dǎo)致了B方法查詢結(jié)果的錯誤。


          B、Problem inspection Steps

          1. Code Review







          先看一下A方法的代碼就會發(fā)現(xiàn),在使用了PageHelper.startPage之后,Mybatis查詢SQL之前,有很多判斷邏輯,并且問題就發(fā)生在中間標(biāo)紅的異常情況判斷。





          B方法在執(zhí)行到第一個SQL查詢語句的時候,就會因為復(fù)用線程中 PageMethod 所帶有A方法中ThreadLocal的(pageNum,pageSize)參數(shù)導(dǎo)致B方法的查詢也限定了分頁參數(shù)


          2. Log Check and Prove

          a. A方法提前拋異常,且沒執(zhí)行MyBatis查詢方法的日志截圖



          b. B方法執(zhí)行到MyBatis查詢方法的截圖



          C、Analysis Steps

          1. How to use PageHelper

          a. Github Official Document Link

          ?https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md


          PageHelper 方法使用了靜態(tài)的 ThreadLocal 參數(shù),分頁參數(shù)和線程是綁定的。

          只要你可以保證在 PageHelper 方法調(diào)用后緊跟 MyBatis 查詢方法,這就是安全的。因為 PageHelper 在 finally 代碼段中自動清除了 ThreadLocal 存儲的對象。


          b. Analysis Source Code of PageHelper

          i. startPage() and getLocalPage()






          通過上圖我們可以發(fā)現(xiàn),當(dāng)一個請求來的時候,會獲取持有當(dāng)前請求的線程的ThreadLocal,調(diào)用LOCAL_PAGE.get(),查看當(dāng)前線程是否有未執(zhí)行的分頁配置,再通過setLocalPage(page)方法設(shè)置線程的分頁配置。


          ii. Intercept Method in PageInterceptor

          @Override
              public Object intercept(Invocation invocation) throws Throwable {
                  try {
                      Object[] args = invocation.getArgs();
                      MappedStatement ms = (MappedStatement) args[0];
                      Object parameter = args[1];
                      RowBounds rowBounds = (RowBounds) args[2];
                      ResultHandler resultHandler = (ResultHandler) args[3];
                      Executor executor = (Executor) invocation.getTarget();
                      CacheKey cacheKey;
                      BoundSql boundSql;
                      //由于邏輯關(guān)系,只會進入一次
                      if (args.length == 4) {
                          //4 個參數(shù)時
                          boundSql = ms.getBoundSql(parameter);
                          cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
                      } else {
                          //6 個參數(shù)時
                          cacheKey = (CacheKey) args[4];
                          boundSql = (BoundSql) args[5];
                      }
                      checkDialectExists();
          
                      List resultList;
                      //調(diào)用方法判斷是否需要進行分頁,如果不需要,直接返回結(jié)果
                      if (!dialect.skip(ms, parameter, rowBounds)) {
                          //判斷是否需要進行 count 查詢
                          if (dialect.beforeCount(ms, parameter, rowBounds)) {
                              //查詢總數(shù)
                              Long count = count(executor, ms, parameter, rowBounds, resultHandler, boundSql);
                              //處理查詢總數(shù),返回 true 時繼續(xù)分頁查詢,false 時直接返回
                              if (!dialect.afterCount(count, parameter, rowBounds)) {
                                  //當(dāng)查詢總數(shù)為 0 時,直接返回空的結(jié)果
                                  return dialect.afterPage(new ArrayList(), parameter, rowBounds);
                              }
                          }
                          resultList = ExecutorUtil.pageQuery(dialect, executor,
                                  ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
                      } else {
                          //rowBounds用參數(shù)值,不使用分頁插件處理時,仍然支持默認的內(nèi)存分頁
                          resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
                      }
                      return dialect.afterPage(resultList, parameter, rowBounds);
                  } finally {
                      if(dialect != null){
                          dialect.afterAll();
                      }
                  }
              }

          我們需要關(guān)注mybatis什么時候使用的這個ThreadLocal,也就是何時將分頁參數(shù)獲取的?

          前面提到過,通過PageHelper的startPage()方法進行page緩存的設(shè)置,當(dāng)程序執(zhí)行sql接口mapper的方法時,就會被攔截器PageInterceptor攔截到。

          PageHelper其實就是mybatis的分頁插件,其實現(xiàn)原理就是通過攔截器的方式,pageHelper通PageInterceptor實現(xiàn)分頁,我們只關(guān)注intercept方法。


          iii. dialect.skip(ms, parameter, rowBounds)

          此處的skip方法進行設(shè)置分頁參數(shù),內(nèi)部調(diào)用方法:

          Page page = pageParams.getPage(parameterObject, rowBounds);

          繼續(xù)跟蹤getPage(),發(fā)現(xiàn)此方法的第一行就獲取了ThreadLocal的值:

          Page page = PageHelper.getLocalPage();


          iv. ExecutorUtil.pageQuery

          resultList = ExecutorUtil.pageQuery(dialect, executor, ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);

          這是分頁方法,此方法在執(zhí)行分頁之前,會判斷是否執(zhí)行分頁,依據(jù)就是前面我們通過ThreadLocal的獲取的page。


          v. executor.query

          resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);

          這是非分頁方法,我們可以思考一下,如果ThreadLoad在使用后沒有被清除,當(dāng)執(zhí)行非分頁的方法時,那么就會將Limit拼接到sql后面。

          為什么不分也得也會拼接?我們回頭看下前面提到的dialect.skip(ms, parameterObject, rowBounds):



          如上所示,只要page被獲取到了,那么這個sql,就會走前面提到的ExecutorUtil.pageQuery分頁邏輯,最終導(dǎo)致出現(xiàn)不可預(yù)料的情況。

          其實PageHelper對于分頁后的ThreaLocal是有清除處理的。


          vi. clearPage()

          在intercept方法的最后,會在sql方法執(zhí)行完成后,清理page緩存:



          看看這個afterAll()方法:



          只關(guān)注 clearPage()



          vii. Conclusion

          整體看下來,似乎不會存在什么問題,但是我們可以考慮集中極端情況:

          ?如果使用了startPage(),但是沒有執(zhí)行對應(yīng)的sql,那么就表明,當(dāng)前線程ThreadLocal被設(shè)置了分頁參數(shù),可是沒有被使用,當(dāng)下一個使用此線程的請求來時,就會出現(xiàn)問題。

          ?如果程序在執(zhí)行sql前,發(fā)生異常了,就沒辦法執(zhí)行finally當(dāng)中的clearPage()方法,也會造成線程的ThreadLocal被污染。

          所以,官方給我們的建議,在使用PageHelper進行分頁時,執(zhí)行sql的代碼要緊跟startPage()方法

          除此之外,我們可以手動調(diào)用clearPage()方法 ,在存在問題的方法之前。


          2. How to solve the problem

          1. 確保PageHelper 方法調(diào)用后緊跟 MyBatis 查詢方法,在查詢前不要寫任何邏輯處理,因為任何代碼都可能產(chǎn)生Exception并發(fā)生線程復(fù)用的問題。

          2. 如果原有不合理的代碼太多,沒辦法一一修改,可以考慮Controller層增加切面JSF接口增加Filter,手動調(diào)用clearPage()方法。代碼示例如下:

          // 針對JSF接口的Filter
          
          @Slf4j
          public class BscJsfAspectForPageHelper extends AbstractFilter {
          
              public BscJsfAspectForPageHelper(){}
          
              @Override
              public ResponseMessage invoke(RequestMessage requestMessage) {
                  try {
                      log.info("BscJsfAspectForPageHelper.invoke For JSF PageHelper.clearPage()");
                      PageHelper.clearPage();
                  }catch (Exception e){
                      log.error("BscJsfAspectForPageHelper.invoke發(fā)生異常,error msg:", e);
                  }
          
                  return getNext().invoke(requestMessage);
              }
          }
          
          // XML配置
              <bean id="bscJsfAspectForPageHelper" class="com.jdl.bsc.aspect.BscJsfAspectForPageHelper" scope="prototype">
              </bean>
          // 針對Controller的切面
          
          @Aspect
          @Component
          @Slf4j
          public class BscAspectForPageHelper{
          
              @Pointcut("execution(public * com.jdl.bsc.controller.*.*(..)) ")
              public void bscAspectForPageHelper(){}
          
              @Before("bscAspectForPageHelper()")
              public void doBefore(JoinPoint joinPoint) {
                  try {
                      log.info("BscAspectForPageHelper.doBefore For PageHelper.clearPage()");
                      PageHelper.clearPage();
                  }catch (Exception e){
                      log.error("BscAspectForPageHelper.doBefore發(fā)生異常,error msg:", e);
                  }
              }
          }
          
          
          

          作者:京東物流 王崧

          來源:京東云開發(fā)者社區(qū) 自猿其說 Tech 轉(zhuǎn)載請注明來源


          主站蜘蛛池模板: 国产对白精品刺激一区二区| 日韩在线不卡免费视频一区| 中文字幕VA一区二区三区| 日本一区二区三区爆乳| 国产精品无圣光一区二区| 无码人妻av一区二区三区蜜臀 | 无码喷水一区二区浪潮AV| 精品视频一区二区| 久久精品无码一区二区无码| 乱中年女人伦av一区二区| 亚洲日韩一区二区一无码| 精品一区二区无码AV| 亚洲国产精品一区二区第一页免 | 精品一区二区三区高清免费观看 | 精品少妇人妻AV一区二区| 亚洲精品无码一区二区| 丰满岳乱妇一区二区三区| 亚洲AV日韩综合一区尤物| 久久久久久免费一区二区三区 | 精品一区精品二区制服| 日韩精品成人一区二区三区| 精品福利一区二区三区| 精品一区二区三区AV天堂| 国产成人一区二区三中文| 无码人妻AⅤ一区二区三区| 好吊视频一区二区三区| 人妻体内射精一区二区| 国产精品日韩一区二区三区| 亚洲综合无码一区二区痴汉| 无码少妇一区二区性色AV| 国产精品无码一区二区三区电影 | 亚洲一区二区久久| 国产一区二区三区不卡AV| 91精品一区国产高清在线| 日亚毛片免费乱码不卡一区| 狠狠爱无码一区二区三区| 精彩视频一区二区| 亚洲AⅤ视频一区二区三区| 无码人妻精品一区二区三区不卡 | 色一情一乱一区二区三区啪啪高| 亚洲一区二区三区在线观看精品中文|