補救 null 的策略


iThome 網站首載:補救 null 的策略

快速排序發明者、圖靈獎得主(Turing Award Winner)Tony Hoare,在QCon London 2009主講《Null References: The Billion Dollar Mistake》場次,演講摘要中即指出null的使用,已經造成無數的錯誤、弱點與系統當機,在過去四十年來,或許造就了價值數十億美元的苦難與損失。長久以來,不少語言亦採用了null的觀念與類似特性,多數程式庫亦常見null的蹤跡,開發者也常不假思索地加以使用;然而在檢視過去犯下的錯誤同時,著實也累積了不少補救措施與設計,可用以避免null的繼續使用,或者是銜接既存的程式。

含糊的null與引發的問題

不少程式語言中都有null觀念的存在,像是C++的NULL、Java的null、Python的None或Ruby的nil等,JavaScript甚至有兩種令人混淆的undefined與null。null的最根本問題在於語意含糊不清,雖然就字面來說,null可以是「不存在」、「沒有」、「無」或「空」的概念,因此在應用時,總是令人感到模稜兩可,也就讓開發者有了各自解釋的空間,當開發者想到「嘿!這邊可以沒有東西...」就直接放個null,或者是想到「嗯!沒什麼東西可以傳回...」,就不假思索地傳回個null,然後使用者就總是忘了檢查null,引發各種可能的錯誤。

舉例來說,Java的Map在鍵/值部份都允許是null,而get()方法在指定的鍵不存在時,也是傳回null,這就形成一個很詭異的情況:如果使用Map的get()指定鍵為"SomeKey"時傳回null,那會是指"SomeKey"對應值為null呢?還是指Map中不存在一個鍵為"SomeKey"呢?同樣是不存在,類似結構也常會有不同表現,Servlet的ServletRequest在取得請求參數值時,會使用getParameter()方法並傳入請求參數名稱,這感覺像是Map結構;如果請求參數「不存在」呢?如果不去查詢規格書的話,不少開發者會各自解釋,有人會以為傳回null,有人會認為傳回空字串。實際上請求參數不存在這個問題很模糊,到底是指根本沒有指定的請求參數名稱,還是實際上使用者沒有在欄位上填值呢?

如果方法可以傳回null,或者是方法的參數可以是null,都會引發的問題就是忘了檢查值是否為null。例如,當某資料庫查詢方法會傳回字串,如果指定的查詢對象不存在時會傳回null,方法呼叫者必須記得檢查傳回值是否為null,並提供null的替代方案
,如果忘了,那麼Java開發者最熟悉的NullPointException就會出現,不過這是在Java的情況,null的觀念應該是什麼都不存在,所以不能作任何操作嗎?有的語言不這麼認為,Ruby中的nil也可以作一些操作,nil.to_a會傳回[],nil.to_s會傳回空字串,如果程式剛好是呼叫這類操作,而又忘了檢查傳回值為nil的情況,程式還是會很高興地執行下去;類似地,JavaScript的null+10結果會是10,undefined+10則是NaN,視程式內容而定,也許這並不會立即讓使用者感覺有錯誤發生。

避免使用null或實現速錯概念

想要避免null的問題,基本上就是避免使用null,或是實現速錯(Fail fast)概念。例如,若既有程式庫允許null,也要避免在具有集合概念的資料使用null,像是Set或Map的鍵,如果用來查找的鍵真的可能會出現null,可使用明確的判斷式來處理。如果索引結構的數列會有null元素的可能性,像是稀疏陣列或List,可考慮以Map來取代,此時可將數字索引當作鍵,而非null元素當作值

至於所謂速錯,就是在問題發生時,快速呈現錯誤,而不是讓程式有機會繼續執行下去。此概念運用在避免null的情況下,就是方法的參數為null就呈現錯誤,或是在方法打算傳回null時,乾脆拋出錯誤。舉例而言,有些開發者會檢查方法參數是否為null,若不是null就執行成立區塊,否則就靜悄悄地結束。以速錯概念來實現的話,如果null無法操作,那麼就完全不檢查null的情況。例如在Java的某方法中若有執行param.doSome(),參數param在null的情況會拋出NullPointerException,方法呼叫者就會知道不該傳入null,NullPointerException本身就是速錯概念的實現;在對null操作不引發錯誤的其他語言中,可以於檢查參數是null的情況下,主動拋出錯誤,這可以是直接撰寫在程式碼中的檢查程式碼、程式內建的斷言(Assert)機制,或者使用斷言程式庫。

類似地,如果既有程式庫的方法會傳回null,有時開發者會在程式中提供預設值,此時也可考慮是否實現速錯概念。例如有段程式:
String securityLevel = System.getProperty("cc.opehhome.securityLevel");
securityLevel = (securityLevel == null ? "medium" : securityLevel);

如果原先程式設定cc.opehhome.securityLevel為high,並正常運行,日後因人員疏失而誤砍設定,結果系統使用了預設的"medium",錯誤可能在運行若干時日後才會發現。如果改為
String prop = "cc.opehhome.securityLevel";
String securityLevel = System.getProperty(prop);
if(securityLevel == null) { throw new NullPointerException("property not found: " + prop); }

如此一來,若因人員疏失而誤砍設定,程式就會立即接收到例外,相關人員也就能夠立即處理。現在有些程式庫都實現了速錯概念,像是guava-libraries中不少API,在參數為null時會拋出例外,群集相關物件查找不到相關元素時也會拋出例外,而不是傳回null

建立明確的null語意

有些情況下,方法要傳回的值可能存在或不存在,但也不適宜以速錯概念實作,且不想傳回null時怎麼辦?有些程式語言不提供null的概念,可以從中借鏡,例如Haskell在傳回值可能存在或不存在時,提供了Maybe型態,分別以Just t與Nothing來代表值存在與不存在。在具有null的語言中,可以自行實作此類型態,像是Scala的Option也分別具有Some與None兩個實例,guava-libraries或是JDK8也有各自提供的Optional。當方法傳回此類型態時,呼叫者(被迫地)要判斷是否有值,並必須明確從中取出真正的值。

舉例來說,如果某方法原先傳回字串,使用guava-libraries的話,可將傳回型態改為Optional<String>,並在方法中原先傳回null的地方,改為Optional.absent()傳回沒有包裏任何值的Optional,對於確實傳回字串實例的地方,改用Optional.of("...")來包裏該實例。由於傳回的是Optional<String>型態,等於明確告知方法呼叫者要用isPresent()確認包裏值存在再以get()取出,否則的話,get()會拋出IllegalStateException,這避免了將傳回值直接傳遞給另一個方法,而傳回值可能是null的可能性。如果真的需要預設值,Optional提供了or方法讓語意更為明確,例如firstName + " " + maybeMiddleName.or("") + " " + lastName就讓人一目瞭然,結果也許會是有maybeMiddleName內含的字串或者是空字串

即使在真正必須使用null的情況下,也可以讓程式擁有明確語意。例如Optional確實也提供了orNull(),如果在沒有包裹任何值時,確實需要一個null,就可以使用orNull()。Optional也有個靜態方法fromNullable(),用來銜接會傳回null的既有API,如果fromNullable()傳入值不是null,它會將值傳給Optional.of()建立一個Optional實例來包裏它,如果傳入null,則會使用Optional.absent()傳回沒有包裹任何值的Optional實例

字串型態是個很適合用來說明null缺點的對象,原因在於:所謂沒有字串,到底應該是空字串或是null呢?無論選用何者,開發程式時必須統一,銜接相關程式庫時,也可使用明確的API來彰顯語意;例如guava-libraries提供了emptyToNull()與nullToEmpty()方法,可分別將空字串轉為null,或是將null轉為空字串,它也提供了isNullOrEmpty()方法,在接收到的字串參考為null或空字串實例時傳回true

對不存在多一份思考

簡單來說,null的意義是含糊不明確的,開發者在將來的程式中應該避免使用,或在被使用時拋出錯誤。然而不少語言與程式庫都允許null的存在,對於一些具有null類似語意的情況,必須讓它更明確,這代表著設計者與呼叫者都要多一點思考與更明確的程式碼,像是使用Optional這類型態,強制呼叫者要思考值不存在的情況,既有程式若有運用到null的情況下,也可運用一些銜接程式庫讓語意更清楚,即便真的要允許null也要明確標識,像是guava-libraries在參數確實可接收null,或傳回值可以是null的情況下,必須明確標註@Nullable,而不是留下讓使用者猜測的空間。