本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版。
會寫程式的人都曉得,變數就是用來儲存和操作資料的、常數就是用來存放固定內容的。然而,有的程式語言在這基礎之上細分為「可變變數」、「不可變變數」、「常數」三個種類,Rust 就採用了這樣的設計。
? 常數
我們知道圓周率大約是 3.1415926。若想要用程式計算跟圓周率有關的式子(例如從半徑求出圓的面積),那麼使用到的圓周率的值在程式裡肯定是被寫死的,無論如何它都不該在某些情況下被換成 1 或是 5,而且我們在執行程式之前就已經知道它的值了,它不需要被「計算」而得到。
另一方面,它可能會在程式裡多次被使用,比如這個程式還可能會計算圓的體積、周長等等。為了方便閱讀和統一程式裡所有地方使用的圓周率,我們就會把圓周率定義為「常數」,直接使用它:
在大部分的程式語言當中,像這樣所有使用到常數 MATH_PI 的地方,實際上都會在編譯的時候被取代成為 3.1415926,就這點而言,Rust 和其他語言並沒有什麼不同。
Rust 的常數使用 UPPER_SNAKE_CASE 命名。我曾經提到 Rust 的變數當中,整數的預設型態是 i32、浮點數的預設型態是 f64,但常數就沒有型別推論的功能了。Rust 規定你在宣告常數時必須明確地寫出型態,所以如果上面例子中 MATH_PI 的 f64 被拿掉了,就沒辦法正常編譯。
? 變數的可變性
然而有些東西是我們不希望它輕易被修改,卻又不能在編譯時確定的數。比方說,我們透過使用者輸入的身高體重,計算 BMI 值:
為了不失焦,我們假定 weight_kg 和 height_cm 都是使用者在程式執行階段才輸入的,所以前面兩行就當作沒看到吧。
上例當中,變數 bmi 是電腦計算出來的絕對結果,我們沒有理由在任何時候修改它,也不希望接手程式碼的其他工程師在程式裡的某個時間點去更動它的值,進而導致潛在的 bug。就算你的變數名稱取得再好,不該被修改的東西就永遠不應該被修改,我們也必須從根本保證其他人(包括未來的自己)不會意外變更它的值。
因此,Rust 語言引入了「可變性」的概念。
在 Rust 撰寫的程式裡,變數分為「可變變數(mutable variable)」和「不可變變數(immutable variable)」。事實上,我們剛才利用 let 關鍵字宣告出來的變數 bmi 正是一個「不可變變數」,如果我們嘗試在過程中更動它:
編譯器就會阻止你更動變數 bmi 的內容。也就是說,這些不可變變數只有宣告的那一刻可以被賦值,從而確保了 bmi 的值自始至終是來自前面輸入的體重和身高,中間沒有受到其他程式碼的影響。
這就類似最小權限原則的概念:不需要修改的東西,我們就肯定不讓人改。假如一個程式裡每個變數都是可以被修改的,就很難確保變數前後的一致性,尤其是程式碼很多行、專案比較複雜的時候。也許這個程式在一段時間過後需要寫新功能,開發者(可能是你,也可能是其他人)誤以為變數 bmi 是其他的意思,從而不小心把它設為完全無關的值,那就產生人為的 bug 了。
你必須在經過萬全思考以後,才決定要不要讓變數能夠被修改。
假如我們想要宣告一個可變變數,就在宣告時加上 mut 關鍵字(即 mutable 之意):
這樣就宣告了一個可讀可寫的變數。
順帶一提,如果你在程式裡宣告了可變變數,卻從頭到尾都沒有任何程式碼去變更它,編譯器也會提醒你這個變數不需要被宣告為可變變數。
? 如何看待變數的可變性
其實一些程式語言也有類似的功能:例如 ES6(2015)之後的 JavaScript 就用 const 跟 let 作了區別;Swift 語言有 let 和 var 作為不可變與可變之分;Kotlin 則是分別用 val 和 var 來表示,這些語言把可變變數和不可變變數用兩個完全不同的關鍵字區隔開來。
Java 語言的設計在這方面和 Rust 比較接近,採用「額外附加的關鍵字」來調整變數的可變性,像是 int 用來表示整數、final int 表示不可變的整數,這種「加了關鍵字才能令變數不可變」的邏輯和 Rust 的「加了關鍵字才能令變數可變」恰恰相反。
站在語法設計的角度來看,我個人更加推崇 Rust 的做法。有些對語言本身不熟(或壓根沒有這概念)的程式設計師容易懶得區分可變變數和不可變變數,比方說他們可能會在使用 JavaScript 開發的時候,不分青紅皂白地一律用 let 來宣告變數、儲存計算結果;在 Java 語言當中,所有的變數一律不加上 final 去凍結那些計算結果。
畢竟人是懶惰的嘛,既然在 Java 語言當中完全不使用 final 關鍵字也能讓程式正常運作,那麼使用者就更有可能令全部的變數都保持可變。也許 Rust 早期就是考量了這點,以「必須附加 mut 關鍵字才能讓變數具有可變性」的設計,提高開發者濫用可變變數的成本,減少了讓開發者自己製造 bug 的機會,而這也較符合「能不變的就別變」的原則。
這也是為何我曾經說學習 Rust 語言的收益很容易反映在其他語言上。在學習 Rust 之前我很難單純透過個人專案的開發經驗去參悟這樣的道理,即使早已知道 Java 有 final 關鍵字,卻也因為它的存在與否不會影響到我寫的 Java 程式,而不會花時間深入了解它。
? 不支援「不可變變數」的語言
大多數的語言不會刻意把不可變變數和常數明確分開來。
在那些語言(e.g. Golang, Python, C)當中,要想實現不可變變數的功能,多半需要依賴更進一步的包裝,利用私有成員無法被外部修改的特性,使這些內容為唯讀狀態。
以 Golang 作例,如果我們想要計算 BMI 值,並且讓計算結果不被修改,我們就得把計算結果封裝成一個新的結構體,將 result 藏在結構體裡面,只允許使用 getter 取得其值:
這麼做就會變得非常麻煩(對於這種很簡單的案例而言)。為了不讓程式碼變得臃腫,程式設計師可能會寧願讓計算結果的可變性暴露在外,甚至根本不會意識到這件事。
總而言之,透過這些避免潛在人為 bug 的極致追求,Rust 會引入「不可變變數」這樣看似囉嗦的設計也就讓人可以理解了。
本來想連帶介紹 Rust 的字串,寫到這裡發現跟字串一起講的話內容好像太多了,那就等到本系列下一篇再一起介紹吧。
? 常數
我們知道圓周率大約是 3.1415926。若想要用程式計算跟圓周率有關的式子(例如從半徑求出圓的面積),那麼使用到的圓周率的值在程式裡肯定是被寫死的,無論如何它都不該在某些情況下被換成 1 或是 5,而且我們在執行程式之前就已經知道它的值了,它不需要被「計算」而得到。
另一方面,它可能會在程式裡多次被使用,比如這個程式還可能會計算圓的體積、周長等等。為了方便閱讀和統一程式裡所有地方使用的圓周率,我們就會把圓周率定義為「常數」,直接使用它:
const MATH_PI: f64 = 3.1415926; let r: f64 = 2.5; // 使用 :.3 表示四捨五入到小數點後第三位 println!("The area of circle is: {area:.3}", area = r * r * MATH_PI); println!( "The perimeter of circle is: {perimeter:.3}", perimeter = 2.0 * r * MATH_PI ); |
在大部分的程式語言當中,像這樣所有使用到常數 MATH_PI 的地方,實際上都會在編譯的時候被取代成為 3.1415926,就這點而言,Rust 和其他語言並沒有什麼不同。
Rust 的常數使用 UPPER_SNAKE_CASE 命名。我曾經提到 Rust 的變數當中,整數的預設型態是 i32、浮點數的預設型態是 f64,但常數就沒有型別推論的功能了。Rust 規定你在宣告常數時必須明確地寫出型態,所以如果上面例子中 MATH_PI 的 f64 被拿掉了,就沒辦法正常編譯。
? 變數的可變性
然而有些東西是我們不希望它輕易被修改,卻又不能在編譯時確定的數。比方說,我們透過使用者輸入的身高體重,計算 BMI 值:
let weight_kg = 60.0; let height_cm = 175.0; let bmi = (weight_kg / height_cm / height_cm) * 10000.0; if bmi >= 24.0 { println!("你的 BMI 值為: {bmi:.2}, 過重"); } else if bmi < 18.5 { println!("你的 BMI 值為: {bmi:.2}, 過輕"); } else { println!("你的 BMI 值為: {bmi:.2}, 健康範圍"); } |
為了不失焦,我們假定 weight_kg 和 height_cm 都是使用者在程式執行階段才輸入的,所以前面兩行就當作沒看到吧。
上例當中,變數 bmi 是電腦計算出來的絕對結果,我們沒有理由在任何時候修改它,也不希望接手程式碼的其他工程師在程式裡的某個時間點去更動它的值,進而導致潛在的 bug。就算你的變數名稱取得再好,不該被修改的東西就永遠不應該被修改,我們也必須從根本保證其他人(包括未來的自己)不會意外變更它的值。
因此,Rust 語言引入了「可變性」的概念。
在 Rust 撰寫的程式裡,變數分為「可變變數(mutable variable)」和「不可變變數(immutable variable)」。事實上,我們剛才利用 let 關鍵字宣告出來的變數 bmi 正是一個「不可變變數」,如果我們嘗試在過程中更動它:
let bmi = (weight_kg / height_cm / height_cm) * 10000.0; bmi = 100.0; if bmi >= 24.0 { println!("你的 BMI 值為: {bmi:.2}, 過重"); } else if bmi < 18.5 { println!("你的 BMI 值為: {bmi:.2}, 過輕"); } else { println!("你的 BMI 值為: {bmi:.2}, 健康範圍"); } |
編譯器就會阻止你更動變數 bmi 的內容。也就是說,這些不可變變數只有宣告的那一刻可以被賦值,從而確保了 bmi 的值自始至終是來自前面輸入的體重和身高,中間沒有受到其他程式碼的影響。
這就類似最小權限原則的概念:不需要修改的東西,我們就肯定不讓人改。假如一個程式裡每個變數都是可以被修改的,就很難確保變數前後的一致性,尤其是程式碼很多行、專案比較複雜的時候。也許這個程式在一段時間過後需要寫新功能,開發者(可能是你,也可能是其他人)誤以為變數 bmi 是其他的意思,從而不小心把它設為完全無關的值,那就產生人為的 bug 了。
你必須在經過萬全思考以後,才決定要不要讓變數能夠被修改。
假如我們想要宣告一個可變變數,就在宣告時加上 mut 關鍵字(即 mutable 之意):
let mut balance = 0; println!("你的帳戶餘額: {balance}"); balance += 1000; println!("存入了 1000 元"); println!("你的帳戶餘額: {balance}"); |
這樣就宣告了一個可讀可寫的變數。
順帶一提,如果你在程式裡宣告了可變變數,卻從頭到尾都沒有任何程式碼去變更它,編譯器也會提醒你這個變數不需要被宣告為可變變數。
? 如何看待變數的可變性
其實一些程式語言也有類似的功能:例如 ES6(2015)之後的 JavaScript 就用 const 跟 let 作了區別;Swift 語言有 let 和 var 作為不可變與可變之分;Kotlin 則是分別用 val 和 var 來表示,這些語言把可變變數和不可變變數用兩個完全不同的關鍵字區隔開來。
Java 語言的設計在這方面和 Rust 比較接近,採用「額外附加的關鍵字」來調整變數的可變性,像是 int 用來表示整數、final int 表示不可變的整數,這種「加了關鍵字才能令變數不可變」的邏輯和 Rust 的「加了關鍵字才能令變數可變」恰恰相反。
站在語法設計的角度來看,我個人更加推崇 Rust 的做法。有些對語言本身不熟(或壓根沒有這概念)的程式設計師容易懶得區分可變變數和不可變變數,比方說他們可能會在使用 JavaScript 開發的時候,不分青紅皂白地一律用 let 來宣告變數、儲存計算結果;在 Java 語言當中,所有的變數一律不加上 final 去凍結那些計算結果。
畢竟人是懶惰的嘛,既然在 Java 語言當中完全不使用 final 關鍵字也能讓程式正常運作,那麼使用者就更有可能令全部的變數都保持可變。也許 Rust 早期就是考量了這點,以「必須附加 mut 關鍵字才能讓變數具有可變性」的設計,提高開發者濫用可變變數的成本,減少了讓開發者自己製造 bug 的機會,而這也較符合「能不變的就別變」的原則。
這也是為何我曾經說學習 Rust 語言的收益很容易反映在其他語言上。在學習 Rust 之前我很難單純透過個人專案的開發經驗去參悟這樣的道理,即使早已知道 Java 有 final 關鍵字,卻也因為它的存在與否不會影響到我寫的 Java 程式,而不會花時間深入了解它。
? 不支援「不可變變數」的語言
大多數的語言不會刻意把不可變變數和常數明確分開來。
在那些語言(e.g. Golang, Python, C)當中,要想實現不可變變數的功能,多半需要依賴更進一步的包裝,利用私有成員無法被外部修改的特性,使這些內容為唯讀狀態。
以 Golang 作例,如果我們想要計算 BMI 值,並且讓計算結果不被修改,我們就得把計算結果封裝成一個新的結構體,將 result 藏在結構體裡面,只允許使用 getter 取得其值:
package bmi type BMIResult struct { result float64 // This property is private } func CalculateBMI(weightKG, heightCM float64) (*BMIResult, error) { if weightKG <= 0 || heightCM <= 0 { return nil, fmt.Errorf("invalid weight or height") } resultWrapped := &BMIResult{ result: weightKG / heightCM / heightCM * 10000, } return resultWrapped, nil } func (r *BMIResult) Result() float64 { return r.result } |
這麼做就會變得非常麻煩(對於這種很簡單的案例而言)。為了不讓程式碼變得臃腫,程式設計師可能會寧願讓計算結果的可變性暴露在外,甚至根本不會意識到這件事。
總而言之,透過這些避免潛在人為 bug 的極致追求,Rust 會引入「不可變變數」這樣看似囉嗦的設計也就讓人可以理解了。
本來想連帶介紹 Rust 的字串,寫到這裡發現跟字串一起講的話內容好像太多了,那就等到本系列下一篇再一起介紹吧。
HackMD 好讀版:https://hackmd.io/@upk1997/rust-variable-mutability
縮圖素材原作者:Karen Rustad T?lva(CC0 1.0)