用Optional取代null
如果你作為Java程式設計師曾經遭遇過NullPointerException,請舉起手。如果這是你最常遭遇的異常,請繼續舉手。非常可惜,這個時刻,我們無法看到對方,但是我相信很多人的手這個時刻是舉著的。我們還猜想你可能也有這樣的想法:“毫無疑問,我承認,對任何一位Java程式設計師來說,無論是初出茅廬的新人,還是久經江湖的專家,NullPointerException都是他心中的痛,可是我們又無能為力,因為這就是我們為了使用方便甚至不可避免的像null引用這樣的構造所付出的代價。”這就是程式設計世界裡大家都持有的觀點,然而,這可能並非事實的全部真相,只是我們根深蒂固的一種偏見。
1965年,英國一位名為Tony Hoare的電腦科學家在設計ALGOL W語言時提出了null引用的想法。ALGOL W是第一批在堆上分配記錄的型別語言之一。Hoare選擇null引用這種方式,“只是因為這種方法實現起來非常容易”。雖然他的設計初衷就是要“通過編譯器的自動檢測機制,確保所有使用引用的地方都是絕對安全的”,他還是決定為null引用開個綠燈,因為他認為這是為“不存在的值”建模最容易的方式。很多年後,他開始為自己曾經做過這樣的決定而後悔不迭,把它稱為“我價值百萬的重大失誤”。我們已經看到它帶來的後果——程式設計師對物件的欄位進行檢查,判斷它的值是否為期望的格式,最終卻發現我們檢視的並不是一個物件,而是一個空指標,它會立即丟擲一個讓人厭煩的NullPointerException異常。
實際上,Hoare的這段話低估了過去五十年來數百萬程式設計師為修復空引用所耗費的代價。近十年出現的大多數現代程式設計語言,包括Java,都採用了同樣的設計方式,其原因是為了與更老的語言保持相容,或者就像Hoare曾經陳述的那樣,“僅僅是因為這樣實現起來更加容易”。讓我們從一個簡單的例子入手,看看使用null都有什麼樣的問題。
如何為缺失的值建模
假設你需要處理下面這樣的巢狀物件,這是一個擁有汽車及汽車保險的客戶。
public class Person {
private Car car;
public Car getCar() { return car; }
}
public class Car {
private Insurance insurance;
public Insurance getInsurance() { return insurance; }
}
public class Insurance {
private String name;
public String getName() { return name; }
}
複製程式碼
那麼,下面這段程式碼存在怎樣的問題呢?
public String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
複製程式碼
這段程式碼看起來相當正常,但是現實生活中很多人沒有車。所以呼叫getCar方法的結果會怎樣呢?在實踐中,一種比較常見的做法是返回一個null引用,表示該值的缺失,即使用者沒有車。而接下來,對getInsurance的呼叫會返回null引用的insurance,這會導致執行時出現一個NullPointerException,終止程式的執行。但這還不是全部。如果返回的person值為null會怎樣?如果getInsurance的返回值也是null,結果又會怎樣?
採用防禦式檢查減少NullPointerException
怎樣做才能避免這種不期而至的NullPointerException呢?通常,你可以在需要的地方新增null的檢查(過於激進的防禦式檢查甚至會在不太需要的地方新增檢測程式碼),並且新增的方式往往各有不同。下面這個例子是我們試圖在方法中避免NullPointerException的第一次嘗試。
public String getCarInsuranceName(Person person) {
if (person != null) {
Car car = person.getCar();
if (car != null) {
Insurance insurance = car.getInsurance();
if (insurance != null) {
return insurance.getName();
}
}
}
return "Unknown";
}
複製程式碼
這個方法每次引用一個變數都會做一次null檢查,如果引用鏈上的任何一個遍歷的解變數值為null,它就返回一個值為“Unknown”的字串。唯一的例外是保險公司的名字,你不需要對它進行檢查,原因很簡單,因為任何一家公司必定有個名字。注意到了嗎,由於你掌握業務領域的知識,避免了最後這個檢查,但這並不會直接反映在你建模資料的Java類之中。
我們將上面的程式碼標記為“深層質疑”,原因是它不斷重複著一種模式:每次你不確定一個變數是否為null時,都需要新增一個進一步巢狀的if塊,也增加了程式碼縮排的層數。很明顯,這種方式不具備擴充套件性,同時還犧牲了程式碼的可讀性。面對這種窘境,你也許願意嘗試另一種方案。下面的程式碼清單中,我們試圖通過一種不同的方式避免這種問題。
public String getCarInsuranceName(Person person) {
if (person == null) {
return "Unknown";
}
Car car = person.getCar();
if (car == null) {
return "Unknown";
}
Insurance insurance = car.getInsurance();
if (insurance == null) {
return "Unknown";
}
return insurance.getName();
}
複製程式碼
第二種嘗試中,你試圖避免深層遞迴的if語句塊,採用了一種不同的策略:每次你遭遇null變數,都返回一個字串常量“Unknown”。然而,這種方案遠非理想,現在這個方法有了四個截然不同的退出點,使得程式碼的維護異常艱難。更糟的是,發生null時返回的預設值,即字串“Unknown”在三個不同的地方重複出現——出現拼寫錯誤的概率不小!當然,你可能會說,我們可以用把它們抽取到一個常量中的方式避免這種問題。
進一步而言,這種流程是極易出錯的;如果你忘記檢查了那個可能為null的屬性會怎樣?通過這一章的學習,你會了解使用null來表示變數值的缺失是大錯特錯的。你需要更優雅的方式來對缺失的變數值建模。
null 帶來的種種問題
讓我們一起回顧一下到目前為止進行的討論,在Java程式開發中使用null會帶來理論和實際操作上的種種問題。
- 它是錯誤之源。NullPointerException是目前Java程式開發中最典型的異常。
- 它會使你的程式碼膨脹。它讓你的程式碼充斥著深度巢狀的null檢查,程式碼的可讀性糟糕透頂。
- 它自身是毫無意義的。null自身沒有任何的語義,尤其是,它代表的是在靜態型別語言中以一種錯誤的方式對缺失變數值的建模。
- 它破壞了Java的哲學。Java一直試圖避免讓程式設計師意識到指標的存在,唯一的例外是:null指標。
- 它在Java的型別系統上開了個口子。null並不屬於任何型別,這意味著它可以被賦值給任意引用型別的變數。這會導致問題,原因是當這個變數被傳遞到系統中的另一個部分後,你將無法獲知這個null變數最初的賦值到底是什麼型別。
Optional 類入門
為了更好的解決和避免NPE異常,Java 8中引入了一個新的類java.util.Optional。這是一個封裝Optional值的類。舉例來說,使用新的類意味著,如果你知道一個人可能有也可能沒有車,那麼Person類內部的car變數就不應該宣告為Car,遭遇某人沒有車時把null引用賦值給它,而是將其宣告為Optional型別。
變數存在時,Optional類只是對類簡單封裝。變數不存在時,缺失的值會被建模成一個“空”的Optional物件,由方法Optional.empty()返回。Optional.empty()方法是一個靜態工廠方法,它返回Optional類的特定單一例項。你可能還有疑惑,null引用和Optional.empty()有什麼本質的區別嗎?從語義上,你可以把它們當作一回事兒,但是實際中它們之間的差別非常大: 如果你嘗試解引用一個null , 一定會觸發NullPointerException , 不過使用Optional.empty()就完全沒事兒,它是Optional類的一個有效物件,多種場景都能呼叫,非常有用。關於這一點,接下來的部分會詳細介紹。
使用Optional而不是null的一個非常重要而又實際的語義區別是,第一個例子中,我們在宣告變數時使用的是Optional型別,而不是Car型別,這句宣告非常清楚地表明瞭這裡發生變數缺失是允許的。與此相反,使用Car這樣的型別,可能將變數賦值為null,這意味著你需要獨立面對這些,你只能依賴你對業務模型的理解,判斷一個null是否屬於該變數的有效範疇。
牢記上面這些原則,你現在可以使用Optional類對最初的程式碼進行重構,結果如下。
public class Person {
private Optional<Car> car;
public Optional<Car> getCar() {
return car;
}
}
public class Insurance {
private String name;
public String getName() {
return name;
}
}
public class Car {
private Optional<Insurance> insurance;
public Optional<Insurance> getInsurance() {
return insurance;
}
}
複製程式碼
發現Optional是如何豐富你模型的語義了吧。程式碼中person引用的是Optional,而car引用的是Optional,這種方式非常清晰地表達了你的模型中一個person可能擁有也可能沒有car的情形,同樣,car可能進行了保險,也可能沒有保險。
與此同時,我們看到insurance公司的名稱被宣告成String型別,而不是Optional,這非常清楚地表明宣告為insurance公司的型別必須提供公司名稱。使用這種方式,一旦解引用insurance公司名稱時發生NullPointerException,你就能非常確定地知道出錯的原因,不再需要為其新增null的檢查,因為null的檢查只會掩蓋問題,並未真正地修復問題。insurance公司必須有個名字,所以,如果你遇到一個公司沒有名稱,你需要調查你的資料出了什麼問題,而不應該再新增一段程式碼,將這個問題隱藏。
在你的程式碼中始終如一地使用Optional,能非常清晰地界定出變數值的缺失是結構上的問題,還是你演算法上的缺陷,抑或是你資料中的問題。另外,我們還想特別強調,引入Optional類的意圖並非要消除每一個null引用。與此相反,它的目標是幫助你更好地設計出普適的API,讓程式設計師看到方法簽名,就能瞭解它是否接受一個Optional的值。這種強制會讓你更積極地將變數從Optional中解包出來,直面缺失的變數值。
應用Optional 的幾種模式
到目前為止,一切都很順利;你已經知道了如何使用Optional型別來宣告你的域模型,也瞭解了這種方式與直接使用null引用表示變數值的缺失的優劣。但是,我們該如何使用呢?用這種方式能做什麼,或者怎樣使用Optional封裝的值呢?
建立Optional 物件
使用Optional之前,你首先需要學習的是如何建立Optional物件。完成這一任務有多種方法。
- 宣告一個空的Optional
正如前文已經提到,你可以通過靜態工廠方法Optional.empty,建立一個空的Optional物件:
Optional<Car> optCar = Optional.empty();
複製程式碼
- 依據一個非空值建立Optional
你還可以使用靜態工廠方法Optional.of,依據一個非空值建立一個Optional物件:
Optional<Car> optCar = Optional.of(car);
複製程式碼
如果car是一個null,這段程式碼會立即丟擲一個NullPointerException,而不是等到你試圖訪問car的屬性值時才返回一個錯誤。
- 可接受null的Optional
最後,使用靜態工廠方法Optional.ofNullable,你可以建立一個允許null值的Optional物件:
Optional<Car> optCar = Optional.ofNullable(car);
複製程式碼
如果car是null,那麼得到的Optional物件就是個空物件。
你可能已經猜到,我們還需要繼續研究“如何獲取Optional變數中的值”。尤其是,Optional提供了一個get方法,它能非常精準地完成這項工作,我們在後面會詳細介紹這部分內容。不過get方法在遭遇到空的Optional物件時也會丟擲異常,所以不按照約定的方式使用它,又會讓我們再度陷入由null引起的程式碼維護的夢魘。因此,我們首先從無需顯式檢查的Optional值的使用入手,這些方法與Stream中的某些操作極其相似。
使用map 從Optional 物件中提取和轉換值
從物件中提取資訊是一種比較常見的模式。比如,你可能想要從insurance公司物件中提取公司的名稱。提取名稱之前,你需要檢查insurance物件是否為null,程式碼如下所示:
String name = null;
if(insurance != null){
name = insurance.getName();
}
複製程式碼
為了支援這種模式,Optional提供了一個map方法。它的工作方式如下:
Optional<Insurance> optionalInsurance = Optional.ofNullable(insurance);
Optional<String> name = optionalInsurance.map(Insurance::getName);
複製程式碼
從概念上,這與我們在第4章和第5章中看到的流的map方法相差無幾。map操作會將提供的函式應用於流的每個元素。你可以把Optional物件看成一種特殊的集合資料,它至多包含一個元素。如果Optional包含一個值,那函式就將該值作為引數傳遞給map,對該值進行轉換。如果Optional為空,就什麼也不做。
這看起來挺有用,但是你怎樣才能應用起來,重構之前的程式碼呢?前文的程式碼裡用安全的方式連結了多個方法。
public String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
複製程式碼
為了達到這個目的,我們需要求助Optional提供的另一個方法flatMap。
使用flatMap 連結Optional 物件
由於我們剛剛學習瞭如何使用map,你的第一反應可能是我們可以利用map重寫之前的程式碼,如下所示:
Optional<Person> optPerson = Optional.of(person);
Optional<String> name = optPerson.map(Person::getCar)
.map(Car::getInsurance)
.map(Insurance::getName);
複製程式碼
不幸的是,這段程式碼無法通過編譯。為什麼呢?optPerson是Optional型別的變數, 呼叫map方法應該沒有問題。但getCar返回的是一個Optional型別的物件,這意味著map操作的結果是一個Optional<Optional>型別的物件。因此,它對getInsurance的呼叫是非法的,因為最外層的optional物件包含了另一個optional物件的值,而它當然不會支援getInsurance方法。
所以,我們該如何解決這個問題呢?讓我們再回顧一下你剛剛在流上使用過的模式:flatMap方法。使用流時,flatMap方法接受一個函式作為引數,這個函式的返回值是另一個流。這個方法會應用到流中的每一個元素,最終形成一個新的流的流。但是flagMap會用流的內容替換每個新生成的流。換句話說,由方法生成的各個流會被合併或者扁平化為一個單一的流。這裡你希望的結果其實也是類似的,但是你想要的是將兩層的optional合併為一個。
這個例子中,傳遞給流的flatMap方法會將每個正方形轉換為另一個流中的兩個三角形。那麼,map操作的結果就包含有三個新的流,每一個流包含兩個三角形,但flatMap方法會將這種兩層的流合併為一個包含六個三角形的單一流。類似地,傳遞給optional的flatMap方法的函式會將原始包含正方形的optional物件轉換為包含三角形的optional物件。如果將該方法傳遞給map方法,結果會是一個Optional物件,而這個Optional物件中包含了三角形;但flatMap方法會將這種兩層的Optional物件轉換為包含三角形的單一Optional物件。
- 使用Optional獲取car的保險公司名稱
相信現在你已經對Optional的map和flatMap方法有了一定的瞭解,讓我們看看如何應用。
public String getCarInsuranceName(Optional<Person> person) {
return person.flatMap(Person::getCar)
.flatMap(Car::getInsurance)
.map(Insurance::getName)
// 如果Optional的j結果為空值,設定預設值
.orElse("Unknown");
}
複製程式碼
我們可以看到,處理潛在可能缺失的值時,使用Optional具有明顯的優勢。這一次,你可以用非常容易卻又普適的方法實現之前你期望的效果——不再需要使用那麼多的條件分支,也不會增加程式碼的複雜性。
- 使用Optional解引用串接的Person/Car/Insurance物件
由Optional物件,我們可以結合使用之前介紹的map和flatMap方法,從Person中解引用出Car,從Car中解引用出Insurance,從Insurance物件中解引用出包含insurance公司名稱的字串。
這裡,我們從以Optional封裝的Person入手,對其呼叫flatMap(Person::getCar)。如前所述,這種呼叫邏輯上可以劃分為兩步。第一步,某個Function作為引數,被傳遞給由Optional封裝的Person物件,對其進行轉換。這個場景中,Function的具體表現是一個方法引用,即對Person物件的getCar方法進行呼叫。由於該方法返回一個Optional型別的物件,Optional內的Person也被轉換成了這種物件的例項,結果就是一個兩層的Optional物件,最終它們會被flagMap操作合併。從純理論的角度而言,你可以將這種合併操作簡單地看成把兩個Optional物件結合在一起,如果其中有一個物件為空,就構成一個空的Optional物件。如果你對一個空的Optional物件呼叫flatMap,實際情況又會如何呢?結果不會發生任何改變,返回值也是個空的Optional物件。與此相反,如果Optional封裝了一個Person物件,傳遞給flapMap的Function,就會應用到Person上對其進行處理。這個例子中,由於Function的返回值已經是一個Optional物件,flapMap方法就直接將其返回。
第二步與第一步大同小異,它會將Optional轉換為Optional。第三步則會將Optional轉化為Optional物件,由於Insurance.getName()方法的返回型別為String,這裡就不再需要進行flapMap操作了。
截至目前為止,返回的Optional可能是兩種情況:如果呼叫鏈上的任何一個方法返回一個空的Optional,那麼結果就為空,否則返回的值就是你期望的保險公司的名稱。那麼,你如何讀出這個值呢?畢竟你最後得到的這個物件還是個Optional,它可能包含保險公司的名稱,也可能為空。我們使用了一個名為orElse的方法,當Optional的值為空時,它會為其設定一個預設值。除此之外,還有很多其他的方法可以為Optional設定預設值,或者解析出Optional代表的值。接下來我們會對此做進一步的探討。
預設行為及解引用Optional 物件
我們決定採用orElse方法讀取這個變數的值,使用這種方式你還可以定義一個預設值,遭遇空的Optional變數時,預設值會作為該方法的呼叫返回值。Optional類提供了多種方法讀取Optional例項中的變數值。
- get()是這些方法中最簡單但又最不安全的方法。如果變數存在,它直接返回封裝的變數值,否則就丟擲一個NoSuchElementException異常。所以,除非你非常確定Optional變數一定包含值,否則使用這個方法是個相當糟糕的主意。此外,這種方式即便相對於巢狀式的null檢查,也並未體現出多大的改進。
- orElse(T other)是我們在程式碼使用的方法,正如之前提到的,它允許你在Optional物件不包含值時提供一個預設值。
- orElseGet(Supplier<? extends T> other)是orElse方法的延遲呼叫版,Supplier方法只有在Optional物件不含值時才執行呼叫。如果建立預設值是件耗時費力的工作,你應該考慮採用這種方式(藉此提升程式的效能),或者你需要非常確定某個方法僅在Optional為空時才進行呼叫,也可以考慮該方式(這種情況有嚴格的限制條件)。
- orElseThrow(Supplier<? extends X> exceptionSupplier)和get方法非常類似,它們遭遇Optional物件為空時都會丟擲一個異常,但是使用orElseThrow你可以定製希望丟擲的異常型別。
- ifPresent(Consumer<? super T>)讓你能在變數值存在時執行一個作為引數傳入的方法,否則就不進行任何操作。
Optional類和Stream介面的相似之處,遠不止map和flatMap這兩個方法。還有第三個方法filter,它的行為在兩種型別之間也極其相似。
兩個Optional 物件的組合
現在,我們假設你有這樣一個方法,它接受一個Person和一個Car物件,並以此為條件對外部提供的服務進行查詢,通過一些複雜的業務邏輯,試圖找到滿足該組合的最便宜的保險公司:
public Insurance findCheapestInsurance(Person person, Car car) {
// 不同的保險公司提供的查詢服務
// 對比所有資料
return cheapestCompany;
}
複製程式碼
我們還假設你想要該方法的一個null-安全的版本,它接受兩個Optional物件作為引數,返回值是一個Optional物件,如果傳入的任何一個引數值為空,它的返回值亦為空。Optional類還提供了一個isPresent方法,如果Optional物件包含值,該方法就返回true,所以你的第一想法可能是通過下面這種方式實現該方法:
public Optional<Insurance> nullSafeFindCheapestInsurance(Optional<Person> person, Optional<Car> car) {
if (person.isPresent() && car.isPresent()) {
return Optional.of(findCheapestInsurance(person.get(), car.get()));
} else {
return Optional.empty();
}
}
複製程式碼
這個方法具有明顯的優勢,我們從它的簽名就能非常清楚地知道無論是person還是car,它的值都有可能為空,出現這種情況時,方法的返回值也不會包含任何值。不幸的是,該方法的具體實現和你之前曾經實現的null檢查太相似了:方法接受一個Person和一個Car物件作為引數,而二者都有可能為null。利用Optional類提供的特性,有沒有更好或更地道的方式來實現這個方法呢?
Optional類和Stream介面的相似之處遠不止map和flatMap這兩個方法。還有第三個方法filter,它的行為在兩種型別之間也極其相似,我們在接下來的一節會進行介紹。
使用filter 剔除特定的值
你經常需要呼叫某個物件的方法,檢視它的某些屬性。比如,你可能需要檢查保險公司的名稱是否為“Cambridge-Insurance”。為了以一種安全的方式進行這些操作,你首先需要確定引用指向的Insurance物件是否為null,之後再呼叫它的getName方法,如下所示:
Insurance insurance = ...;
if(insurance != null && "CambridgeInsurance".equals(insurance.getName())){
System.out.println("ok");
}
複製程式碼
使用Optional物件的filter方法,這段程式碼可以重構如下:
Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance -> "CambridgeInsurance".equals(insurance.getName()))
.ifPresent(x -> System.out.println("ok"));
複製程式碼
filter方法接受一個謂詞作為引數。如果Optional物件的值存在,並且它符合謂詞的條件,filter方法就返回其值;否則它就返回一個空的Optional物件。如果你還記得我們可以將Optional看成最多包含一個元素的Stream物件,這個方法的行為就非常清晰了。如果Optional物件為空,它不做任何操作,反之,它就對Optional物件中包含的值施加謂詞操作。如果該操作的結果為true,它不做任何改變,直接返回該Optional物件,否則就將該值過濾掉,將Optional的值置空。
下一節中,我們會探討Optional類剩下的一些特性,並提供更實際的例子,展示多種你能夠應用於程式碼中更好地管理缺失值的技巧。
使用Optional 的實戰示例
相信你已經瞭解,有效地使用Optional類意味著你需要對如何處理潛在缺失值進行全面的反思。這種反思不僅僅限於你曾經寫過的程式碼,更重要的可能是,你如何與原生Java API實現共存共贏。
實際上,我們相信如果Optional類能夠在這些API建立之初就存在的話,很多API的設計編寫可能會大有不同。為了保持後向相容性,我們很難對老的Java API進行改動,讓它們也使用Optional,但這並不表示我們什麼也做不了。你可以在自己的程式碼中新增一些工具方法,修復或者繞過這些問題,讓你的程式碼能享受Optional帶來的威力。我們會通過幾個實際的例子講解如何達到這樣的目的。
用Optional 封裝可能為null 的值
現存Java API幾乎都是通過返回一個null的方式來表示需要值的缺失,或者由於某些原因計算無法得到該值。比如,如果Map中不含指定的鍵對應的值,它的get方法會返回一個null。但是,正如我們之前介紹的,大多數情況下,你可能希望這些方法能返回一個Optional物件。你無法修改這些方法的簽名,但是你很容易用Optional對這些方法的返回值進行封裝。我們接著用Map做例子,假設你有一個Map<String, Object>方法,訪問由key索引的值時,如果map中沒有與key關聯的值,該次呼叫就會返回一個null。
Object value = map.get("key");
複製程式碼
使用Optional封裝map的返回值,你可以對這段程式碼進行優化。要達到這個目的有兩種方式:你可以使用笨拙的if-then-else判斷語句,毫無疑問這種方式會增加程式碼的複雜度;或者你可以採用我們前文介紹的Optional.ofNullable方法:
Optional<Object> value = Optional.ofNullable(map.get("key"));
複製程式碼
每次你希望安全地對潛在為null的物件進行轉換,將其替換為Optional物件時,都可以考慮使用這種方法。
異常與Optional 的對比
由於某種原因,函式無法返回某個值,這時除了返回null,Java API比較常見的替代做法是丟擲一個異常。這種情況比較典型的例子是使用靜態方法Integer.parseInt(String),將String轉換為int。在這個例子中,如果String無法解析到對應的整型,該方法就丟擲一個NumberFormatException。最後的效果是,發生String無法轉換為int時,程式碼發出一個遭遇非法引數的訊號,唯一的不同是,這次你需要使用try/catch 語句,而不是使用if條件判斷來控制一個變數的值是否非空。
你也可以用空的Optional物件,對遭遇無法轉換的String時返回的非法值進行建模,這時你期望parseInt的返回值是一個optional。我們無法修改最初的Java方法,但是這無礙我們進行需要的改進,你可以實現一個工具方法,將這部分邏輯封裝於其中,最終返回一個我們希望的Optional物件,程式碼如下所示。
public static Optional<Integer> stringToInt(String s) {
try {
return Optional.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
複製程式碼
我們的建議是,你可以將多個類似的方法封裝到一個工具類中,讓我們稱之為OptionalUtility。通過這種OptionalUtility.stringToInt方法,將String轉換為一個Optional物件,而不再需要記得你在其中封裝了笨拙的try/catch的邏輯了。
程式碼
Github: chap10
Gitee: chap10