現代程式語言系列2:安全表達可選值

jywhltj發表於2019-07-08

本文原發於我的個人部落格:https://hltj.me/lang/2019/07/08/modern-lang-optional-value.html。本副本只用於圖靈社群,禁止第三方轉載。

這裡的可選值(optional value)是指可能無值也可能有一個值的情況,在一些程式語言中稱為可空值(nullable value)。

問題與解決方案

傳統程式語言中往往使用空值(null 或者 Nonenil 等)來表達可選值,可謂簡單粗暴。 因為這樣一來,就需要在每一處使用的地方判斷相應的值是否為空,一旦疏忽大意就可能導致程式出錯甚至崩潰。 不僅如此,正如著名的《十億美元的錯誤》《電腦科學中的最嚴重錯誤》所說,傳統空值還引入了一系列其他問題:破壞了型別系統、易與空容器混為一談、表意模凌兩可、難以除錯、不便同語言的其他特性結合使用等。

因此,現代程式語言基本都會避免使用傳統空值,而採用更安全的方式來表達可選值,具體方式主要有兩種: 1. 受限的空值:Kotlin、Swift、Hack 等。 2. 可選值型別:Haskell、Rust、Julia、OCaml、Swift、F#、Scala、Java 8+、C++17+ 等。

你沒看錯,Swift 在兩邊都站隊了。這倒並不是它採用了兩種不同的機制,相反,它在一致的底層機制基礎上,同時相容兩種上層語法。

另外 F#、Scala、Java 8+、C++ 17+ 實際上處於灰色地帶,它們雖然推薦使用可選值型別,卻也支援傳統空值。

值得一提的是,無論採用哪種方式,其安全性都是由型別系統來保障的。 雖然這並不僅限於靜態型別語言(Hack 與 Julia 都是動態型別語言),不過確實需要一定程度的靜態型別支援,這也從側面反映了現代程式語言的靜態型別趨勢

歷史包袱

採用受限空值的程式語言與處於灰色地帶的程式語言一般都存在歷史包袱。

採用受限空值的語言可能都與歷史包袱有關:Kotlin 中有 null 因為 Java 中有,Hack 中有 null 因為 PHP 中有,Swift 中有 nil 因為 Objective-C 中有。

處於灰色地帶的 F#、Scala、Java 8+、C++ 17+ 同樣有歷史包袱,因為需要與 .Net/JVM 平臺的其他語言互操作或者要相容本語言的舊版本。

但是兩隊語言做了不同的抉擇:一隊採用受限的空值取代了傳統空值;另一隊引入了可選值型別的同時,卻還保留傳統空值。 於是後面這隊語言雖有安全的方式,卻也無法擺脫傳統空值的糾纏。 Java 8 與 C++ 17 為了相容歷史版本或是無奈之選,但是如果歷史重新給 F# 與 Scala 選擇機會的話,會不會採用 Swift 的方案更好一些?

受限的空值

在採用受限的空值來表達可選值的程式語言中,對空值的使用有以下限制: 1. 語言中嚴格區分可空型別與非空型別,不能直接將可空值用於只接受非空值的地方。 2. 語言中通過特定語法訪問可空型別物件的成員,也需要特定語法由可空值得到非空值。

區分可空與非空型別

Kotlin、Swift、Hack 都嚴格區分可空型別與非空型別,並且型別都預設非空,對於可空型別也都採用加註 ? 的方式來表達(只是 Hack 放在型別名前,Kotlin 與 Swift 放在型別名後)。我們看些具體的示例:

// Kotlin
val a: Int = 1 // OK
val b: Int = null // 錯誤,b 不接受空值
val c: Int? = null // OK,c 是可空型別

val d: Int = a // OK
val e: Int? = a // OK,非空表示式可以賦給可空變數
val f: Int = c // 錯誤,f 不接受可空表示式賦值
val g: Int? = c // OK
val h = c // OK,h 的型別會推斷為 Int?

Swift 版與之非常類似,只需將 val 替換為 letnull 替換為 nil 即可。 Hack 語法與它們差異大些、並且需要採用函式形式來表達上述示例,但其相似性還是很明顯的:

// Hack
function a(): int { return 1; }
function b(): int { return null; } // 錯誤
function c(): ?int { return null; }
function d(): int { return a(); }
function e(): ?int { return a(); }
function f(): int { return c(); } // 錯誤
function g(): ?int { return c(); }
function h() { return c(); }

除了變數宣告與賦值、函式返回值之外,三門語言對函式傳參、數學運算等各種表示式都會嚴格區分可空與非空型別。

可空性傳播

在採用受限空值的程式語言中,無法直接訪問可空型別物件的成員,需要使用特殊語法。在 Kotlin 與 Swift 中使用 ?. 語法,在 Hack 中使用 ?-> 語法。例如輸出一個可空字串的長度:

Kotlin 程式碼,輸出是 12

val hello: String? = "Hello, World"
println(hello?.length)
hello?.length.plus(10) // 錯誤,可空值不能直接呼叫方法

Swift 程式碼,輸出是 Optional(12)

let hello: String? = "Hello, World"
print(hello?.count)
hello?.count + 10 // 錯誤,可空值不能用於算術運算

Hack 程式碼有些複雜,因為內建字串值不是物件,所以需要模擬出一個物件,其輸出是 12:

class String0 {
    private string $s;
    public function __construct(string $s) {
        $this->s = $s;
    }
    public function length(): int {
        return strlen($this->s);
    }
}

function hello(): ?String0 {
    return new String0("Hello, World");
}

function printLength(?String0 $s) {
   echo $s?->length(), "\n";
}

printLength(new String0("Hello, World"));

function foo(): int { return hello()?->length(); } // 錯誤,函式簽名要求非空返回值

Swift 輸出的是 Optional(12),它明確表明這是一個 Int? 值。Kotlin 與 Swift 雖然直接輸出了數字,但其值同樣是可空整型,不能用於只接受非空整數的地方。?./?->的求值邏輯為: 1. 如果物件非空,那麼訪問相應成員。 2. 如果物件為空,返回空。 3. 返回型別為可空型別。

以 Kotlin 為例,雖然 Stringlength 屬性是非空成員,但因為 hello 是可空的,進而導致 hello?.length 也是可空的,因此如需繼續呼叫 plus 也要使用 ?. 語法:

>>> hello?.length?.plus(10)
22

並且這一表示式依然是可空的,因此如果還有後續成員訪問,就還需使用 ?. 語法:

>>> hello?.length?.plus(10)?.times(1.2)?.toLong()
26

可空性就像病毒一樣感染了整個呼叫鏈條,並且會繼續傳播下去。在 Hack 中與此類似,只是使用 ?-> 語法。Swift 的語法與它們不同,對於上述情況,後續鏈條中用 . 即可,但是最終結果仍然是可空值:

3> let a = hello?.utf8.count.advanced(by: 10).distance(to: 100)
a: Int? = 78

Swift 中只有後續成員的返回值本身也是可空型別時才需要再次使用 ?.,參見其官網介紹

(Swift 官網中的示例)

if let johnsStreet = john.residence?.address?.street {
    print("John's street name is \(johnsStreet).")
} else {
    print("Unable to retrieve the address.")
}

當然,在 Kotlin 中也可以通過高階函式 let 來簡化多級 ?. 的語法,進而達到接近 Swift 的效果:

>>> hello?.let{ it.length.plus(10).times(1.2).toLong() }
26

可空值用於常規函式

如果不是訪問成員,而是用於普通函式,例如將上述鏈式呼叫的結果傳給 sin 函式並輸出其結果,該如何實現呢? 這在 Kotlin 與 Swift 中分別有不同的語法:

Kotlin 程式碼:

>>> hello?.length?.plus(10)?.times(1.2)?.toLong()?.let {
...     println(Math.sin(it * 1.0))
... }
0.7625584504796028

Swift 程式碼:

4> import Foundation
5> if let a = hello?.utf8.count.advanced(by: 10).distance(to: 100) {
6.     print(sin(Double(a)))
7. }
0.513978455987535

目前在 Hack 中沒有類似語法,可以通過更通用的由可空表示式獲得非空值的方式實現。

由可空表示式獲得非空值

這裡只討論安全獲得非空值的方式。由可空表示式安全地獲得非空值還需要提供一個預設值,這樣就一定能夠取得非空值:當表示式求值結果非空時取求值結果,否則取預設值。這在 Kotlin 中通過 Elvis 操作符(?:)來實現,在 Swift 與 Hack 中通過空接合操作符(??)來實現。

現在有沒有覺得受限空值與問號(?)結下了不解之緣 :P

Kotlin 示例:

>>> val hello: String? = "Hello, World"
>>> val len: Int = hello?.length ?: 0
>>> ((hello?.length ?: 0) + 10) * 1.2
26.4
>>> val hello: String? = null
>>> ((hello?.length ?: 0) + 10) * 1.2
12.0

Swift 示例:

  1> let hello: String? = "Hello, World" 
hello: String? = "Hello, World"
  2> let len: Int = hello?.count ?? 0 
len: Int = 12
  3> 100 - ((hello?.count ?? 0) + 10)
$R0: Int = 78
  4> let hello: String? = nil
hello: String? = nil
  5> 100 - ((hello?.count ?? 0) + 10) 
$R1: Int = 90

可選值型別

更多的現代程式語言都是採用可選值型別的方式。在這些語言中,都是通過一種專門的包裝型別來表達可選值。 下表列舉了一些語言中可選字串的型別以及有無值的字面值表示法:

採用可選值型別的現代語言

包裝後的型別與原型別明顯不同,因此無法當作原型別來用。那麼應該如何使用呢?

顯式判斷與模式匹配

最簡單的使用方式是顯式判斷,例如求一個可選字串的長度(Julia):

julia> lengthOfOptionalString(us::Union{Some{String}, Nothing}) =
           if us === nothing
               0
           else
               length(us.value)
           end
lengthOfOptionalString (generic function with 1 method)

julia> lengthOfOptionalString(nothing)
0

julia> lengthOfOptionalString(Some("Hello"))
5

如果用 C++ 或者 Java 實現會與之非常相似。而對於上文所列的其他使用可選值型別的語言,都可以採用模式匹配的方式實現類似功能。例如(Haskell):

ghci > :{
ghci | lengthOfMaybeString:: Maybe String -> Int
ghci | lengthOfMaybeString Nothing = 0
ghci | lengthOfMaybeString (Just s) = length s
ghci | :}
ghci > lengthOfMaybeString Nothing
0
ghci > lengthOfMaybeString (Just "Hello")
5

在 Haskell 中只需在宣告函式時對 Maybe String 型別引數的不同模式 NothingJust s 分別編寫實現即可。函式呼叫時 Haskell 會根據實參型別自動匹配到相應實現。

實際上,Julia 雖然沒有在語言級支援完整的模式匹配,但是在 Julia 中可以通過泛型函式實現與上述 Haskell 程式碼類似的寫法:

julia> lengthOfOptionalString(nothing) = 0
lengthOfOptionalString (generic function with 1 method)

julia> lengthOfOptionalString(ss::Some{String}) = length(ss.value)
lengthOfOptionalString (generic function with 2 methods)

julia> lengthOfOptionalString(nothing)
0

julia> lengthOfOptionalString(Some("Hello"))
5

我們再看一下 Rust 的寫法:

irust> let a: Option<&str> = None;
()
irust> let b: Option<&str> = Some("Hello");
()
irust> match a { None => 0, Some(ref s) => s.len() }
0
irust> match b { None => 0, Some(ref s) => s.len() }
5

這段程式碼乍一看跟傳統語言的 switch-case 很類似,但實際上要強大的多。 上述程式碼中的 Some(ref s) => 含 s 的表示式 就是傳統 switch-case 無法支援的。對於匹配到模式 Some(ref s)Option,Rust 能夠自動提取模式中對應的 s,並用於後續處理。

我們可以通過顯式判斷或模式匹配來處理可選值型別,但通常並不這麼做,因為還有更便捷的方式。

函式式方式

以函式式方式實現求一個可選字串的長度的程式碼,可以這樣寫(Java):

jshell> int lengthOfOptionalString(Optional<String> opStr) {
   ...>     return opStr.map(String::length).orElse(0);
   ...> }
   ...>
| 已建立 方法 lengthOfOptionalString(Optional<String>)

jshell> lengthOfOptionalString(Optional.empty())
$2 ==> 0

jshell> lengthOfOptionalString(Optional.of("Hello"))
$3 ==> 5

這裡用到了 Optional<T> 的兩個方法:map()orElse()

其中 Optional<T>.map() 接受一個函式式介面引數 mapper(可以傳入 lambda 表示式或者方法引用),如果可選值無值,那麼直接返回 Optional.empty();而如果有值,那麼返回對其值呼叫 mapper 所得結果的 Optional<U> 包裝。

Optional<U>.orElse() 接受一個 U 型別的引數 default,如果可選值有值則返回其值,如果無值返回 default,因此通過 Optional<U>.orElse() 總能得到一個 U 型別的值。

實際上,可選值型別是 Functor、Applicative、Monad,上述 Optional.map() 相當於 Haskell 中 Functorfmap/<$>。此外,常用的還有相當於 Monad>>= 的函式,如 Java 的 Optional.flatMap()、Rust 的 Option::and_then()、F# 的 Option.bind 等。我們看一個 F# 的示例——對一個整數可選值求餘:

> let (%?) a b =
-     match b with
-     | 0 -> None
-     | _ -> Some (a % b);;
val ( %? ) : a:int -> b:int -> int option

> 18 %? 5;;
val it : int option = Some 3

> 18 %? 0;;
val it : int option = None

> Option.map ((%?) 18) (Some 5);;
val it : int option option = Some (Some 3)

> Option.map ((%?) 18) (Some 0);;
val it : int option option = Some None

> Option.map ((%?) 18) None;;
val it : int option option = None

> Option.bind ((%?) 18) (Some 5);;
val it : int option = Some 3

> Option.bind ((%?) 18) (Some 0);;
val it : int option = None

> Option.bind ((%?) 18) None;;
val it : int option = None

示例中首先定義了一個安全求餘運算子 %?,當除數為 0 時它返回 None,否則返回 Some 餘數%? 只接受整數作除數,如需將其應用到 int option 可以藉助 Option.map,但是這樣得到的結果是巢狀的 option(即 int option option)。 有沒有可能直接得到單層的 int option 呢?——這就需要 Option.bind 大顯身手了,如例中所示。

注:上文所列的其他使用可選值型別的語言的標準庫沒有為可選值型別實現類似 Haskell 中 ApplicativeliftA2/<*> 的函式,可選用第三方實現或者參考 Applicative 文件Kotlin 版圖解 Functor、Applicative 與 Monad 自行實現。

綜合示例

我們看一個 Kotlin 心印中的示例:實現一個給客戶發訊息的函式,其中客戶、訊息、客戶的個人資訊欄位、個人資訊的郵箱地址欄位都可能無值。 用傳統 Java 程式碼實現如下所示:

public void sendMessageToClient(
    @Nullable Client client, @Nullable String message, @NotNull Mailer mailer
) {
    if (client == null || message == null) return;

    PersonalInfo personalInfo = client.getPersonalInfo();
    if (personalInfo == null) return;

    String email = personalInfo.getEmail();
    if (email == null) return;

    mailer.sendMessage(email, message);
}

上述程式碼使用了衛語句,可以說已經是質量很高的傳統 Java 程式碼了。 但由於對其中每處可空值都需要判空,有意義的程式碼與判空程式碼各有三行,可以說一半的程式碼都浪費在了毫無業務價值而又不得不做的事情上了。 同時該程式碼中有 4 個分支、4 個出口,程式碼雖不多,流程卻已略顯複雜。 而如果使用 Java 8 的 Optional,就可以流暢很多:

public void sendMessageToClient(
    Optional<Client> client, Optional<String> message, @NotNull Mailer mailer
) {
    message.ifPresent(
            message1 -> client
                    .flatMap(Client::getPersonalInfo)
                    .flatMap(PersonalInfo::getEmail)
                    .ifPresent(
                        email -> mailer.sendMessage(email, message1)
                    )
    );
}

在 Swift 語言中,可以綜合使用受限空值與可選值型別的語法,程式碼會更簡潔一些:

func sendMessageToClient(client: Client?, message: String?, mailer: Mailer) {
    message.map {
        message1 in client?.personalInfo?.email.map {
            email in mailer.sendMessage(email, message1)
        }
    }
}

在 Kotlin 語言中,可以綜合使用受限空值與 return 表示式,程式碼會非常簡潔:

fun sendMessageToClient(client: Client?, message: String?, mailer: Mailer) {
    mailer.sendMessage(client?.personalInfo?.email ?: return, message ?: return)
}

綜述

傳統空值會帶來一系列問題,為避免這些問題,現代程式語言通常採用受限的空值或者可選值型別來表達可選值。 這些現代程式語言不僅通過型別系統確保了可選值的安全性,還提供了各種相對便利的使用方式來提升可選值的易用性。

相關文章