HIT 軟件構造 第三章總結

前言

大致複習完這一章的所有知識內容,做了些概括,結合實驗中的體會,這章主要講到了可變和不可變數據類型,講到了spec(規約),還有ADT(抽象數據類型),面向對象編程(OOP),還有多態,寫的內容主要是我自己的一些理解和感悟,可能有不盡完善不夠深入的地方,望多多指教。

可變數據類型和不可變數據類型

介紹可變數據類型和不可變數據類型之前,先介紹一下java不像c語言有指針,但其實很多實現都是和指針類似的。一個別名a指向了一個地址,如果這個地址中的數據不能被改變,那就是不可變的(immutable)。比如一些基礎類,String,int等都是不可變的數據類型。那麼像集合類List,Set,Map這種就屬於可變的數據類型,也就是其中內部的數據是可以改變的,就叫可變數據類型。
使用可變數據類型的危害
最大的危害就是因爲它可變,所以容易在不改變引用的前提在改變它的值。特別是如果我們返回內部的數據給用戶時,如果這個數據是可變的數據類型,那麼客戶端就有可能惡意修改這個數據。
所以我們要儘量避免使用可變的數據類型
但其實並不是完全不使用,而是在沒有必要的場合不使用

如果必須使用,那麼首先要注意防禦式拷貝,就是返回一個新的對象給客戶端,也就是給一個克隆的對象而不是真正的對象。

這一節中還介紹了Snapshot
Snapshot用於描述程序運行時的內部狀態。就是各個數據類型的狀態,指向的地址內容。
比如一個String a = “abc”,就可以表示爲下圖:

在這裏插入圖片描述
由於String是不可變的數據類型,所以用雙線圈表示其地址內容。
若令 a = “abcd”,就變成如下形式
在這裏插入圖片描述
如果a是個final關鍵字的對象呢?比如final String a = “abcde”,那就將單線箭頭改成雙線箭頭即可。
如果是一個可變的數據類型,Point a = new Point(1,2)
在這裏插入圖片描述
比較容易將引用不變性和不可變弄混淆
引用不變性指的是一個對象a,如果它已經指向一個地址,那麼它將不能被改變指向其他地址。這和它可不可變沒有關係,相當於加上了final關鍵字。

Designing specification

首先我們要了解方法之間如何判斷等價。
我們使用行爲等價性來判斷兩個方法是否是等價的。從客戶端的角度來看,是根據規約判斷行爲等價性的。
specification也簡稱spec,我們叫它規約,它是客戶和開發者之間的一種約定,它即約束了客戶端,也約束了開發者。

  1. 前置條件(precondition):對客戶端的約束,在使用方法時必須滿足的條件
  2. 後置條件(postcondition):對開發者的約束,方法結束時必須滿足的條件
    如果前置條件被滿足,那麼後置條件就一定要滿足,若前置條件被違反,從理論上講程序員可以返回任何結果。但是根據程序員的職業道德,開發者有義務儘早提醒用戶
    就想合同協議一樣的,用戶在使用方法時必須要遵循前置條件,開發者也必須前置條件下滿足後置條件,實現相應的功能。

spec中有三種關鍵字

  1. @param:參數關鍵字,用來描述參數的信息,約束條件等
  2. @return:返回值關鍵字,用來描述返回值的情況
  3. @throws:異常關鍵字 ,用來描述拋出異常的情況

根據比較spec,我們可以用不同方法實現相同功能從而進行方法的替換
有着更強spec的方法,可以替換更弱spec的方法。
spec的強度比較:S1>S2
一:S1的前置條件應當弱於S2的前置條件
二:在二者前置條件相同的基礎之上,S1的後置應該強於S2的後置
換言之:對客戶端的約束條件更少,對開發者的約束條件更多的spec更強
我們在寫方法前,應該先構想好spec,然後再根據spec來編寫代碼,最後還應該檢查寫出來的代碼有沒有違法spec。
設計spec的時候還需要注意以下幾個條件

  1. 內聚的,spec描述的功能儘量單一,易理解
  2. 信息豐富的
  3. 不易太強也不易太弱
  4. 儘量使用抽象數據類型
    5.有必要時檢查前置條件
    6.不能暴露方法內部的變量或實現細節

ADT(抽象數據類型)

ADT分兩種:1.可變的數據類型 2.不可變的數據類型
可變的數據類型:一定有方法可以改變其內部數據的值
不可變的數據類型:內部數據不可變,無方法提供修改
建立一個ADT需要的幾種方法:

  1. creator 構造器:構造一個數據類型
  2. producers 生產器:返回一個新的數據類型
  3. observers 觀察器:觀察ADT內部的數據
  4. mutator 變值器:改變ADT內部的數據,只有可變的數據類型纔有

比較容易弄混淆的就是producer和mutator,他兩之間最大的區別就在於producer並沒有改變原數據結構內部的值,而是造了一個新的數據結構,mutator是改變了原數據結構的值。

ADT中有方法,元素屬性(rep),之前已經講了怎麼寫spec就是針對方法的,針對rep也有相應的註釋,就是AF(Abstraction Function)和RI(Rep Invariant)。
AF:R->A 通過字面意思,就是抽象函數的意思,放在映射的情景中,就是ADT中的Rep在表示空間®向抽象空間(E)的映射。即如何去解釋R中的每一個值爲A中的每一個值。比如Point中的x映射到抽象空間,在客戶端看來是橫座標的意思。、
RI:R->boolean ,表示不變性,用來衡量某個具體的表示(R)是否是合法的,也可以看做是對Rep的約束,比如Person類中sex只能是male或者female,這就是對Rep的約束。

不管是AF,RI還是spec,都可以看成一種約束,它規範了我們設計的ADT。
在設計ADT的時候,我們一般都是使用面向對象的編程思想,對一個事物進行剖析,常用的一種思考方法就是名詞分析法,這個東西有什麼特徵,屬性,這些名詞都能作爲我們設計的Rep,方法。我們在設計的時候一定要保證RI和AF的正確性,不能違反。所以我們在確定設計一個ADT的時候,應先把其AF和RI寫出來,再根據AF和RI來確定方法的spec,最容易犯的錯誤就是寫出來的方法可能違反了RI和AF。爲什麼一定要嚴格遵守RI和AF呢,在後續的學習中還有學習到LSP原則,RI和AF在LSP原則中至關重要。

既然要保證RI和AF的準確性,那我們就在對Rep變化的地方都進行檢查,也就是CheckRep()。
CheckRep():一種用來檢查RI的方法,裏面就是對Rep的檢查,比如上面說的性別的檢查

checkRep(){
	assert sex.equals("male") || sex.equals("famale") : "性別不對";
}

我們有必要在creator,producer,mutator方法中都添加checkRep方法進行檢查,只有這樣才能真正保證我們寫的方法是滿足RI和AF規定的。

面向對象編程(OOP)

在學c++的時候就有過面向對象的方法進行編程,其實java裏也有相同的概念。和c++中的類定義類似,在java中ADT就是一個類,所以在設計ADT的時候,我們也按面向對象的思想編程。
那麼java中的OOP有什麼特點呢?

  1. 封裝與信息隱藏
  2. 繼承和重寫
  3. 多態,子類型,重載
  4. 靜態和動態分派

interface(接口)
在進行ADT設計的時候,我們可以提前定義ADT中要實現的方法,在寫具體的實現,這就是接口(interface)和類(class)。
接口裏就是對類的實現先進行定義,類就是對接口的方法進行實現,所以class和interface之間的關鍵字是implement(實現)。
接口和類類似,類可以繼承,接口自然也能,在java中接口與接口之間的繼承關係叫做擴展extends。
同樣的,一個類可以實現多個接口(從而就可以實現多個接口中的方法),一個接口也可以有多種實現類。這一特性就能讓我們在接口和實現類中間進行多樣的組合,編程思想也變得豐富。

interface具體是什麼呢?
可以將interface和抽象類來想,interface就是定義了一堆方法,但沒有具體的實現,它起到一種封裝的效果,即正是因爲它沒有具體的實現,所以客戶端在使用這個接口的時候就不知道具體的實現過程,只能通過各個spec來了解各個方法實現了什麼功能,這就是信息隱藏,當然,要想使得接口能夠使用,自然要有實現類。
比如List是一個接口,它的實現類就有ArrayList,LinkedList等等。當時不管是ArrayList還是LinkedList,在使用接口對象對其聲明的時候它們都能實現相同的功能,所以接口使得我們實現的手段變得多樣。

那麼接口裏的方法都沒有實現體嗎?
並不是的。使用static和default關鍵字的方法是可以有實現體的。前者叫靜態方法,後者是默認方法。靜態方法不需要申請一個具體的實現類就可以使用,只不過要在方法前加上接口名,default也是一樣的,一旦使用了這兩關鍵字,那麼這個方法在實現類裏面就不能再重寫了。

實現類
實現類就是extends(擴展)了接口的類,那麼這個類就有了接口裏所有定義的方法,它要對這些方法進行重寫(@Override)。實現類中可以添加新的方法,但這些在接口中沒有的方法,在接口使用的時候是用不了的,可以說是類的個性。

重寫的概念在十分重要,無論在c++還是在java中,重寫都是多態的一種重要手段。
重寫在繼承關係中十分常見,子類型可以重寫父類型中的方法,和父類型有不同的實現,但是如果嚴重要求spec原則的話,子類型重寫的方法應當和父類型中的方法一樣滿足同一個spec,不然就不能用子類型來替換父類型,在接口中也是一樣的道理。
重寫的要求:重寫的方法和父類型中的方法應該滿足參數和返回值類型一致,並且遵循同一個spec。
重寫是在編程中十分常見的手段,因爲繼承的出現,子類型有着與父類型不盡相同的特性,所以有些方法實現起來就不一樣了,特別是equals()和hashcode()函數,我們需要通過重寫,完成對spec的遵守,無論是改進,還是多態,重寫發揮着十分重要的作用。

多態(Polymorphism)
什麼是多態?同一個方法不同的實現手段?同一個方法名不同的參數功能?還是不同的參數都能用同一個方法?這些都是多態。
多態分以下幾種:

  • 特殊多態:overload(重載)
  • 參數化多態:泛型
  • 子類型多態:不同類型的對象可以同一處理而無需區分

Overload
重載,前面有一個重寫,重載和重寫都是實現多態的重要手段,但用起來卻不一樣。
重載是在相同方法名的基礎上,使用不同的參數或返回值類型。就行人一樣的,完全不相同的兩人名字一樣,比如一個類有不同的構造函數,這就是一種重載。
重載一定有是參數列表不同,它的價值就在於用戶使用不同的參數,也可以調用相同的方法。
下圖詳細的比較了重載和重寫的區別:
在這裏插入圖片描述
最後一行也可以看到,重載在靜態類型檢查的時候就已經確認了,而重寫則是在動態檢查的時候才確認的,之所以能在靜態階段就能確定就是因爲編譯器是通過參數列表來判斷調動那個方法的。

參數化多態
在java中,參數化多態就是泛型(generics)。
泛型的重要意義在於它可以代表任何的數據類型,我們使用泛型編程的時候固然可能會受到一定的約束,比如個性的方法不能調用,但是使用泛型可以使得我們編寫的代碼適用於任何的數據類型,大大提高了適用範圍,比如List中的E就是泛型,所以我們無論是用String,int還是自定義的類型都可以使用List。
還有一個和泛型相似的東西,通配符(?),就是一個問號,他們有什麼區別和具體使用的場景還是有很多講的,這裏就不贅述了,有興趣的小夥伴可以去其他大佬的博客轉轉。

子類型多態
這是一個非常重要的概念,一個接口有不同的實現類,是一種子類型多態,一個類有多個子類,是一種子類型多態,接口與接口的擴展,類與類的擴展,不同對象,可以統一的處理而無需區分,子類型多態千變萬化,在LSP原則下,我們可以肆意的對接口和類進行各種各樣的組裝,以達到我們想要的效果。這裏會在第四章重點提到。

靜態和動態分派
綁定:將調用的名字與實際的方法名字聯繫起來(可能有多個,比如重載和重寫)
分派:具體執行哪個方法
靜態分派:編譯階段即可確定要執行哪個具體操作
動態分派:編譯階段可能綁定到多態操作,運行階段決定具體執行哪個(overload和override均是如此)
推遲綁定:編譯階段不知道類型,一定是動態分派(override是推遲綁定,override是early binding)

equality in ADT and OOP

判斷等價性:

  • 從用戶的角度,AF相同
  • 從方法的角度,任何方法都得到相同的結果

代碼中判斷兩個數據類型是否相等:

  • equals:對象等價性(自反,傳遞,對稱)
  • ==:引用等價性(內存地址相同)

Object缺省equals:使用==判斷,其實還是根據內存地址判斷。
但在我們具體使用兩個數據類型的時候,不可能都根據兩個類型內存地址來判斷相等的,肯定有它的判斷依據,所以一般而言都需要儘可能的重寫equals。
一旦重寫了equals,那就必須得重寫hashcode,如果不重寫,可能可以判斷正確,但是也可能無法判斷,比如在集合類中,如果用了HashSet或HashMap,沒有重寫hashcode的話,對於可變的數據類型,那麼其hash值發生變化,contains方法就用不了了。

說到可變和不可變,他兩的等價性判斷也是有區別的。
對可變的數據類型來說,等價性分兩種:

  • 觀察等價性:在不改變的情況下,調用observer方法結果一樣
  • 行爲等價性:調用對等的任何方法都展示出一致結果
    可變數據類型往往實現嚴格的觀察等價性,但有時候,觀察等價性可能導致bug,甚至破壞RI。
    所以,對於ADT,要根據實際需求來確定使用哪種等價性。
    在基本數據類型中,使用==這種引用等價性就可以了,但要注意常量池。
    比如String a = new String(“a”) 和 String b = “a”;使用引用等價性肯定判斷不了相等,因爲一個在堆中分配空間,一個則是在內存中。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章