本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版。
Rust 的數值資料型態名稱,可以說是我在各種程式語言裡頭看過最簡練的。
在許多靜態型別、重視性能的程式語言當中,我們時常會需要給變數顯式地指定數值型態的大小。比方說,當我們使用 Java 寫遊戲,需要宣告一個變數來儲存玩家通關次數的時候,我們可能會考慮到一個玩家不可能累積通關數萬次,因而選擇使用最適合的 short 型態,而不是更大的 int 或 long 型態,以節省更多的記憶體空間。
然而,對於一個程式設計師而言,想分清楚這些資料型態的個別長度,多少還是需要一點記憶成本的。程式設計師難以單從「short」這個名字看出它佔 2 bytes 大小,且若遇上了像 C++ 那樣「int 長度因編譯器和平臺而異」的情況,則更是折磨人。
? Rust 的數值型態
對於 Rust 使用者而言,這種難題不存在──你只需要記得 Rust 的內建的數值型態分為整數、無號整數、浮點數,接下來就只剩長度大小的事了。
事實上,Rust 不是唯一一個給資料型態採用長度命名的程式語言。就我能查到的資料來看,Nim(2008)、Golang(2009)、Rust(2010)、Swift(2014)、Zig(2016)都原生使用長度來給基本數值型態命名。以 Golang 作例,它就給整數分成了 int、int8、int16、int32、int64,正整數則有 uint、uint8、uint16、uint32、uint64,以及浮點數 float32 和 float64,而 Rust 在這方面把資料型態的名稱省略得更極致:它的數值型態分為整數的 isize、i8、i16、i32、i64、i128,無號整數的 usize、u8、u16、u32、u64、u128,以及浮點數的 f32、f64。
i 代表的是整數(integer):
u 代表的是無號整數(unsigned integer):
f 代表的是浮點數(float):
這樣的命名方式已經短得無法再更短,卻能夠讓有一定底子的程式設計師一眼就曉得它的長度、確定它的範圍,即便是第一次接觸 Rust 語言。例如,我們透過 u32 這個名字就可以知道是無號整數 unsigned integer,佔用了 32 個位元,範圍是 0 ~ 4294967295;透過 i16 這個名字就可以知道是包含正負號的整數 integer,佔用了 16 個位元,範圍是 -32768 ~ 32767;透過 f64 就可以知道是佔用了 64 個位元的浮點數 float。
一般而言,isize 和 usize 型態是為了底層的指標操作而存在,通常在涉及記憶體分配的時候會比較容易用得到,像是我們給陣列指定長度的時候,編譯器預期輸入的形態就會是 usize,以確保在不同平臺上存取記憶體位址的時候是安全的。在不涉及底層操作的情況下,我們通常不會使用 isize 或 usize。
如果你還記得我曾說過 Rust 語言會從各方面逼迫你寫出考慮周詳的程式碼,那你或許已經能從這樣的設計瞥見其中一角──當你需要定義變數欄位(包括函數、結構體)的時候,你必須清楚地寫出你使用的數值型態大小,而不是像其他語言一樣使用「int」這種依賴於慣例、由編譯器來決定的名稱,所有的選項細節都應該要是程式設計師自己也能清楚掌握的。
? 宣告變數
在 Rust 當中,變數和函數都採 lower_snake_case 命名法。
在許多靜態型別、重視性能的程式語言當中,我們時常會需要給變數顯式地指定數值型態的大小。比方說,當我們使用 Java 寫遊戲,需要宣告一個變數來儲存玩家通關次數的時候,我們可能會考慮到一個玩家不可能累積通關數萬次,因而選擇使用最適合的 short 型態,而不是更大的 int 或 long 型態,以節省更多的記憶體空間。
然而,對於一個程式設計師而言,想分清楚這些資料型態的個別長度,多少還是需要一點記憶成本的。程式設計師難以單從「short」這個名字看出它佔 2 bytes 大小,且若遇上了像 C++ 那樣「int 長度因編譯器和平臺而異」的情況,則更是折磨人。
? Rust 的數值型態
對於 Rust 使用者而言,這種難題不存在──你只需要記得 Rust 的內建的數值型態分為整數、無號整數、浮點數,接下來就只剩長度大小的事了。
事實上,Rust 不是唯一一個給資料型態採用長度命名的程式語言。就我能查到的資料來看,Nim(2008)、Golang(2009)、Rust(2010)、Swift(2014)、Zig(2016)都原生使用長度來給基本數值型態命名。以 Golang 作例,它就給整數分成了 int、int8、int16、int32、int64,正整數則有 uint、uint8、uint16、uint32、uint64,以及浮點數 float32 和 float64,而 Rust 在這方面把資料型態的名稱省略得更極致:它的數值型態分為整數的 isize、i8、i16、i32、i64、i128,無號整數的 usize、u8、u16、u32、u64、u128,以及浮點數的 f32、f64。
i 代表的是整數(integer):
- isize:長度大小取決於執行平臺的整數,例如在 64 位元環境時,isize 就相當於 i64
- i8:佔用 8 位元的整數,可表示範圍是 -128 ~ 127
- i16:佔用 16 位元的整數 -32768 ~ 32767
- i32:佔用 32 位元的整數 -2147483648 ~ 2147483647
- i64:佔用 64 位元的整數 -9.2e18 ~ 9.2e18
- i128:佔用 128 位元的整數 -1.7e38 ~ 1.7e38
u 代表的是無號整數(unsigned integer):
- usize:長度大小取決於執行平臺的無號整數,例如在 32 位元環境時,usize 就相當於 u32
- u8:佔用 8 位元的無號整數,可表示範圍是 0 ~ 255
- u16:佔用 16 位元的無號整數,可表示範圍是 0 ~ 65535
- u32:佔用 32 位元的無號整數,可表示範圍是 0 ~ 4294967295
- u64:佔用 64 位元的無號整數,可表示範圍是 0 ~ 1.8e19
- u128:佔用 128 位元的無號整數,可表示範圍是 0 ~ 3.4e38
f 代表的是浮點數(float):
- f32:佔用 32 位元的浮點數
- f64:佔用 64 位元的浮點數
這樣的命名方式已經短得無法再更短,卻能夠讓有一定底子的程式設計師一眼就曉得它的長度、確定它的範圍,即便是第一次接觸 Rust 語言。例如,我們透過 u32 這個名字就可以知道是無號整數 unsigned integer,佔用了 32 個位元,範圍是 0 ~ 4294967295;透過 i16 這個名字就可以知道是包含正負號的整數 integer,佔用了 16 個位元,範圍是 -32768 ~ 32767;透過 f64 就可以知道是佔用了 64 個位元的浮點數 float。
一般而言,isize 和 usize 型態是為了底層的指標操作而存在,通常在涉及記憶體分配的時候會比較容易用得到,像是我們給陣列指定長度的時候,編譯器預期輸入的形態就會是 usize,以確保在不同平臺上存取記憶體位址的時候是安全的。在不涉及底層操作的情況下,我們通常不會使用 isize 或 usize。
如果你還記得我曾說過 Rust 語言會從各方面逼迫你寫出考慮周詳的程式碼,那你或許已經能從這樣的設計瞥見其中一角──當你需要定義變數欄位(包括函數、結構體)的時候,你必須清楚地寫出你使用的數值型態大小,而不是像其他語言一樣使用「int」這種依賴於慣例、由編譯器來決定的名稱,所有的選項細節都應該要是程式設計師自己也能清楚掌握的。
? 宣告變數
在 Rust 當中,變數和函數都採 lower_snake_case 命名法。
透過 let name: type = value 來宣告一個變數:
從「20_000_000_000」可以看見,我們可以利用底線來給較大的數值分位,這樣一來在上述的例子當中,一眼就可以看得出 var_2 的值是兩百億了。透過實際執行最後的 if 條件判斷句,我們可以得知分位底線無論加不加都不會對程式本身造成影響,但可以讓程式設計師更易於閱讀。
在一些特殊場合下,我們也許還會用到二進制或十六進制等表示方法(例如加密、檔案讀寫、資料校驗、字串轉碼等),這也可以直接透過在數值前加上 0b、0o、0x 來分別表示二進制、八進制、十六進制,省去自行使用計算機轉換的麻煩。在 println 當中,則可以使用 :X、:x、:o、:b 來指定一個數值應該被以什麼樣的進位方式顯示出來。
一般而言,如果沒有特別指定型態的話,通常編譯器會透過前後文來推斷變數的形態,整數預設是 i32,浮點數預設是 f64:
這裡也趁機說說 println 的用法:當你想要使用 println 印出變數內容的時候,你可以選擇直接把變數包在大括號當中,或者是先放入大括號,再隨後把變數排在 format 字串的後面,就像 C 語言當中的 printf 一樣。原則上會推薦把變數包在大括號裡頭,會更利於直接閱讀。
在 Rust 當中,資料的顯示行為還分成 Display 和 Debug。我們通常會在 format 字串的大括號當中加上「:?」來表示我們是依照 Debug 特徵來處理,通常用來顯示更詳細的資訊給開發者閱讀。這個實際講起來大概要等到提及特徵(trait)和定義結構體(struct)的時候才會比較明白,就留到之後再特別拿出來講吧。
? Rust 內建的 overflow 處理機制
Rust 是安全至上的語言──這樣的理念貫穿了 Rust 的原則,極大地反映在內建的函式庫和許多語法的設計上面。
我們都知道溢位可能會帶來預期以外的行為,甚至是災難性的後果:若有一個 i32 型態的變數(範圍:-2147483648 ~ 2147483647)值為 2147483647,當電腦試圖把它往上加 1 的時候,它就會從最小值開始循環,變成 -2147483648。如果這變數是發生在遊戲當中角色的持有金錢數上面,這就會導致非常有錢的角色因為太過富有而一瞬間破產,從而引發其他更多潛在的 bug(例如存檔整個廢掉、角色卡關)。
對於一個上古時代以來就這麼具備代表性的常見漏洞,Rust 提供了完整的內建函式庫來處理。像是我們可以使用飽和加法 saturating_add 來包裝那些「可能會引發溢位」的加法:
在某些特殊情況下,你也可以基於情境的需要而允許它產生 overflow,使用 wrapping_add 來向 Rust 保證「我很確定這裡發生的溢位是我刻意為之的,你不要阻止我」:
你還能透過 overflowing_add 來檢查是否發生溢位。因為得到的結果是一對沒有實現 Display 特徵的複合型態 tuple,我們需要用 :? 讓它能夠正常顯示:
除了 add 以外,也有 sub(減法)、mul(乘法)、div(除法)、abs(絕對值)、pow(冪次)、neg(負數)……可以說是基本運算都能夠使用內建的溢位處理機制,你完全不需要自己從頭判斷它具體的計算過程。
在 Rust 編譯器的預設行為當中,如果你不使用任何的處理機制,直接執行加法:
那麼當你使用 cargo run / cargo build 執行或編譯出來的程式,會在發生溢位的時候觸發 panic,使得程式強制中止;使用 cargo run --release / cargo build --release 執行或編譯出來的程式,則會允許溢位行為發生而得到 -128。
僅僅是內建的方法,我們就能從這些貼心的細節當中感受到 Rust 所散發出來的魅力了。在接下來的許多章節,我們還可以看見 Rust 是如何提供你豐富的選項和工具,讓你能夠依照需求精細地量身訂製。
? 其他的基本資料型態
若要講起「Rust 的基本資料型態」,就有 bool、char、tuple、array、fn。其中 bool 實在是沒有必要單獨拿出來說,畢竟會來寫 Rust 的人通常都是已經具備程式設計基礎的人,而其他的型態涉及的東西又多得沒辦法一口氣說完,這也是為何這篇文只著重於「數值型態」,而不是「資料型態」了。
在本系列的下一篇文當中,就來說說變數的可變性,以及 Rust 的字串是如何設計的吧。
let var_1: u64 = 20000000000; let var_2: u64 = 20_000_000_000; let var_3: u64 = 0xDEADCAFE; // 十六進制的 DEADCAFE let var_4: u32 = 0o455; // 八進制的 455 let var_5: u8 = 0b11101011; // 二進制的 11101011 println!("var_1: {var_1}"); println!("var_2: {var_2}"); println!("var_3: {var_3:X} in hexadecimal, {var_3} in decimal"); println!("var_4: {var_4:o} in octal, {var_4} in decimal"); println!("var_5: {var_5:b} in binary, {var_5} in decimal"); if var_1 == var_2 { println!("`var_1` equals to `var_2`!"); } |
從「20_000_000_000」可以看見,我們可以利用底線來給較大的數值分位,這樣一來在上述的例子當中,一眼就可以看得出 var_2 的值是兩百億了。透過實際執行最後的 if 條件判斷句,我們可以得知分位底線無論加不加都不會對程式本身造成影響,但可以讓程式設計師更易於閱讀。
在一些特殊場合下,我們也許還會用到二進制或十六進制等表示方法(例如加密、檔案讀寫、資料校驗、字串轉碼等),這也可以直接透過在數值前加上 0b、0o、0x 來分別表示二進制、八進制、十六進制,省去自行使用計算機轉換的麻煩。在 println 當中,則可以使用 :X、:x、:o、:b 來指定一個數值應該被以什麼樣的進位方式顯示出來。
一般而言,如果沒有特別指定型態的話,通常編譯器會透過前後文來推斷變數的形態,整數預設是 i32,浮點數預設是 f64:
let my_int = 123; // -> i32 let my_float = 456.0; // -> f64 println!("The value of `my_int` is: {my_int}"); // -> 123 println!("The value of `my_int` is: {my_int:?}"); // -> 123 println!("The value of `my_int` is: {}", my_int); // -> 123 println!("The value of `my_int` is: {:?}", my_int); // -> 123 println!("The value of `my_float` is: {my_float}"); // -> 456 println!("The value of `my_float` is: {my_float:?}"); // -> 456.0 println!("The value of `my_float` is: {}", my_float); // -> 456 println!("The value of `my_float` is: {:?}", my_float); // -> 456.0 |
這裡也趁機說說 println 的用法:當你想要使用 println 印出變數內容的時候,你可以選擇直接把變數包在大括號當中,或者是先放入大括號,再隨後把變數排在 format 字串的後面,就像 C 語言當中的 printf 一樣。原則上會推薦把變數包在大括號裡頭,會更利於直接閱讀。
在 Rust 當中,資料的顯示行為還分成 Display 和 Debug。我們通常會在 format 字串的大括號當中加上「:?」來表示我們是依照 Debug 特徵來處理,通常用來顯示更詳細的資訊給開發者閱讀。這個實際講起來大概要等到提及特徵(trait)和定義結構體(struct)的時候才會比較明白,就留到之後再特別拿出來講吧。
? Rust 內建的 overflow 處理機制
Rust 是安全至上的語言──這樣的理念貫穿了 Rust 的原則,極大地反映在內建的函式庫和許多語法的設計上面。
我們都知道溢位可能會帶來預期以外的行為,甚至是災難性的後果:若有一個 i32 型態的變數(範圍:-2147483648 ~ 2147483647)值為 2147483647,當電腦試圖把它往上加 1 的時候,它就會從最小值開始循環,變成 -2147483648。如果這變數是發生在遊戲當中角色的持有金錢數上面,這就會導致非常有錢的角色因為太過富有而一瞬間破產,從而引發其他更多潛在的 bug(例如存檔整個廢掉、角色卡關)。
對於一個上古時代以來就這麼具備代表性的常見漏洞,Rust 提供了完整的內建函式庫來處理。像是我們可以使用飽和加法 saturating_add 來包裝那些「可能會引發溢位」的加法:
let x = i8::MAX; // 127 let y = x.saturating_add(1); // 計算 x+1,如果會導致溢位的話則維持在上限值 println!("x: {x}, y: {y}"); // x: 127, y:127 |
在某些特殊情況下,你也可以基於情境的需要而允許它產生 overflow,使用 wrapping_add 來向 Rust 保證「我很確定這裡發生的溢位是我刻意為之的,你不要阻止我」:
let x = i8::MAX; // 127 let y = x.wrapping_add(1); // 計算 x+1,如果會導致溢位則放任其溢位,得到 -128 println!("x: {x}, y: {y}"); // x: 127, y: -128 |
你還能透過 overflowing_add 來檢查是否發生溢位。因為得到的結果是一對沒有實現 Display 特徵的複合型態 tuple,我們需要用 :? 讓它能夠正常顯示:
let x = i8::MAX; // 127 let add_result = x.overflowing_add(1); // 計算 x+1,允許溢位的同時確認過程中是否有發生溢位 println!("add_result: {add_result:?}"); // -> (-128, true) |
除了 add 以外,也有 sub(減法)、mul(乘法)、div(除法)、abs(絕對值)、pow(冪次)、neg(負數)……可以說是基本運算都能夠使用內建的溢位處理機制,你完全不需要自己從頭判斷它具體的計算過程。
在 Rust 編譯器的預設行為當中,如果你不使用任何的處理機制,直接執行加法:
let x = i8::MAX; // 127 let y = x + 1; println!("x: {x}, y: {y}"); |
那麼當你使用 cargo run / cargo build 執行或編譯出來的程式,會在發生溢位的時候觸發 panic,使得程式強制中止;使用 cargo run --release / cargo build --release 執行或編譯出來的程式,則會允許溢位行為發生而得到 -128。
僅僅是內建的方法,我們就能從這些貼心的細節當中感受到 Rust 所散發出來的魅力了。在接下來的許多章節,我們還可以看見 Rust 是如何提供你豐富的選項和工具,讓你能夠依照需求精細地量身訂製。
? 其他的基本資料型態
若要講起「Rust 的基本資料型態」,就有 bool、char、tuple、array、fn。其中 bool 實在是沒有必要單獨拿出來說,畢竟會來寫 Rust 的人通常都是已經具備程式設計基礎的人,而其他的型態涉及的東西又多得沒辦法一口氣說完,這也是為何這篇文只著重於「數值型態」,而不是「資料型態」了。
在本系列的下一篇文當中,就來說說變數的可變性,以及 Rust 的字串是如何設計的吧。
HackMD 好讀版:https://hackmd.io/@upk1997/rust-number-types
縮圖素材原作者:Karen Rustad T?lva(CC0 1.0)