跳至主要内容

原始型別 - 數字篇

為你自己學 Rust

幾乎每款程式語言都有設計不同的資料型別,像是數字、字串、布林值之類的。Rust 自然也不例外,這個章節我們來看看在 Rust 裡的原始型別(Primitives)資料型態的「數字」。

數字

在寫 JavaScript 的時候,如果要宣告或定義數字,大概就是這樣寫:

let age = 20;

在 Rust 也差不多:

let age = 20;

看起來一模一樣對吧!表面上看起來一樣,但 Rust 的數字的種類分的比較細,在 JavaScript 不管是一般的整數或是小數,統統都是 Number 型別,但在 Rust 的數字就有細分成整數(Integer)跟浮點數(Floating-Point)兩種,而且分別還細分不同的範圍。

整數

整數,也就是不帶小數點的數字,根據不同的需求在 Rust 有 8 bit、16 bit、32 bit、64 bit 以及 128 bit 等不同的型別,8 bit 表示「我給你 8 個格子給你放東西,裡面可以放 0 或 1」,16 bit 就是 16 格,以此類推。如果我這樣宣告:

let age: i8 = 20;

這裡的 i8 表示宣告了一個 8 bit 的整數,但是 i8 的這 8 個格子,並不是全部都給你放 0 跟 1,它的第 1 個格子是給你放正負號,所以事實上只剩 7 個格子可以存放值,所以 i8 型別的最小值就是負 27,也就是 -128,而最大值是 27 - 1, 也就是 127。咦?為什麼正數要減 1,但負的不用?因為還要把卡在中間的 0 也算進來。

同理,i32 的最大值是 231 - 1 也就是 2,147,483,647,最小值是 -231,也就是 -2,147,483,648。

i 系列有點像的還有 u 系列,例如:

let money: u32 = 28825252;

這個 uunsigned 的意思,也就是給你的格子全部都可以拿來放值,第 1 格不用拿來放正負號,也就是說所有的值都會是正數。因此,i32 的最小值就是 0,最大值就是 232 - 1,也就是 4,294,967,295。

對於一般的網站工程師,這時候腦袋裡可能會有幾個問題:

1. 為什麼要分這麼細?就全部都數字就好了啊!

簡單的說,電腦的資源是有限的,如果明明知道用不到那麼多,幹嘛要拿那麼多資源?例如人類的年紀以目前的科學來說,沒意外的話,用 u8 應該很夠用(年齡不會是負數,而且正常人類活的歲數應該也不會超過 28 - 1 歲)。同時各位也可以想看看如果要宣告一個變數來存放你的銀行存款,該用多大的數字?

2. 如果超過範圍怎麼辦?

u8 來說,我故意放一個明顯超過這個範圍的數值:

let age: u8 = 1000;
println!("{}", age);

只要一執行就會發現 Rust 的編譯器比你更早發現這個問題,而且告訴你原因:

error: literal out of range for `u8`
|
2 | let age: u8 = 1000;
| ^^^^
|
= note: the literal `1000` does not fit into the type `u8` whose range is `0..=255`

它告訴你 type u8 whose range is 0..=255 就是原因。Rust 這個程式語言的特別之一,就是它的錯誤訊息夠明顯。

如果我調皮一點,故意在邊界值再加一點點,像這樣:

let age: u8 = 255;
let new_age: u8 = age + 1;

println!("{}", age);
println!("{}", new_age);

各位在開車或騎車的時候,有沒有遇過車子的哩程表跑到 99999 公里之後再繼續跑會變多少公里?是會 + 1 變 100000 還是全部歸零成 00000?這在電腦科學領域有個專有名詞叫做「整數溢出(Integer Overflow)」,不同的程式語言在處理 overflow 的做法也不太一樣,有些會像哩程表一樣重頭再算過,有些則是會直接出錯。

Rust 在開發模式遇到這問題的時候會給個 Panic:

$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.10s
Running `target/debug/hello-rust`
thread 'main' panicked at 'attempt to add with overflow', src/main.rs:3:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Panic 在後面的章節還有更多的介紹,簡單的說,就是出錯並且中止程式。不過如果在 release 模式的話不會 Panic,而是給你「繞一圈」的答案:

$ cargo run --release
Finished release [optimized] target(s) in 0.09s
Running `target/release/hello-rust`
255
0

如果是 u8 型別,255 + 1 會變成 0, i8 型別的話 127 + 1 會變成 -128。以結果來說,程式執行不會出錯,但我想算出來的答案不會是你想要的。

附帶一提,我們人類比較習慣在千位數的地方加上逗號,能更快識別出這個數字是幾位數,在 Rust 你可以使用 _ 把數字稍微分開:

let books: u16 = 1_000_00_0;

但其實這個 _ 並沒有什麼意思,所以像我上面這樣隨便亂加也無所謂。(其實在其它程式語言像是 JavaScript、Python、Ruby 也都可以這樣寫,這不是 Rust 特有的寫法)

除了固定的 8、16、32、64 以及 128 位元外,還有兩個比較特別的 isizeusize,從字面上大概可以猜的出來 iu 的意思,而 size 則是會依據作業系統本身的 CPU 架構而有所不同,例如在 32 位元的作業系統,isize 就等同於 i32,同理,如果是在 64 位元的作業系統,isize 就等於 i64

浮點數

浮點數其實就是帶有小數點的數字,跟整數一樣,浮點數也有分 bit,但只有 32 bit(f32) 跟 64 bit(f64),而且第 1 個 bit 都是帶正負號的,不像整數還有 unsigned 的設計。根據 Rust 手冊上寫著,根據 IEEE-754 標準,f32 是「單精準度(single-precision)」浮點數,f64 則是「雙精準度(double-precision)」浮點數。

蛤?等等...什麼單雙倍的?這是什麼意思,不就是加個小數點嗎?這裡就有一些計算機概論的內容需要科普一下了。

科學記號表示法

我記得以前讀書時候,老師有時候會把一些特別大或是特別小數字用另一種方式來表示,例如在我高中化學曾經學過的亞佛加厥常數 6.02 × 1023 或是原子質量 1.66 × 10-27,老實說當時我不知道學這個常數或是原子質量要幹嘛,只知道用這樣的寫法可以讓數字看起來簡單一點,這樣的表示法稱之「科學記號表示法(Scientific Notation)」。使用科學計算表示法除了可以簡化原本很大或很小的數字外,在做運算的時候也挺方便,例如 30,000,000,000 乘以 0.000000015 等於多少?我相信這不難算,但那麼多個零看了眼睛都花了,如果改寫成科學記號表示法的話會變成 3 x 1010 乘以 1.5 x 10-8,這樣一來計算的時候就可以分開算,前面 3 x 1.5 = 4.5,而後面的 1010 x 10-8 就會得到 102,最後答案就是 4.5 x 102,也就是 450。

我們人類最常見的數字系統是十進位,我們能用科學記號表示法寫出 4.5 x 102 就是建立在十進位的系統之上。

我們再看看電腦的二進位,例如數字 7.625,它要怎麼轉成 2 進位?整數的部份比較簡單,5 可以分解成:

1 x 22 + 1 x 21 + 1 x 20 = 4 + 2 + 1 = 7

所以 7 轉成二進位就是 111。小數 .625 的部份也是差不多的原理,只是指數的部份要改用負數:

1 x 2-1 + 0 x 2-2 + 1 x 2-3 = 0.5 + 0 + 0.125 = 0.625

所以 7.625 轉成二進位就是 111.101。如果再轉換成二進位的科學記號表示法就會變成 1.11101 x 22。7.625 是剛好可以完美轉換成二進位的數字,但不是每天都在過年的,如果再大一點點,例如 7.626 呢?整數部份沒問題,還是 111,但小數部份就麻煩了,這有點難算,所以你可以用 JavaScript 幫你算:

console.log((0.626).toString(2));

你會得到一個超級長的結果 0.10100000010000011000100100110111010010111100011010101。事實上這根本算不完,就跟 10 除以 3 會得到 0.333333333... 一樣的無限循環,你在畫面上看到的只是一小部份。所以 7.626 轉換成二進位就變成 111.10100000010000011000100110...,轉換成科學記號表示法就會變成 1.1110100000010000011000100110... x 22,這看起來還是差不多囉嗦,沒什麼幫助。目前很多程式語言都是根據 IEEE 754 的規範來顯示小數部份,IEEE 754 規範了幾種用來呈現浮點數的方式,其中 32 位元的就是「單精準度」,而 64 位元因為是 32 位元的兩倍,所以就是「雙精準度」。就以 32 位元的單精確度的遊戲規則來說:

第 1 位元是放正負數的符號(sign bit),如果 0 表示正數,1 表示負數。 第 2 ~ 9 這 8 個位元是指數(exponent) 剩下第 10 ~ 32 這 23 個位元則是放實際的值(fraction)

也就是說,一個 32 位元的浮點數,只能存放 23 位有效數值。如果是雙精準度的 64 位元的話,它的指數部份佔 11 位元,所以實際能存放的有效位數只有 52 位數。

但問題是,後面會無限循環的數字就算是能放 1000 位數也沒用,再怎麼樣就是不夠放,沒辦法顯示完整怎麼辦?不完整也沒辦法了,就算了吧。也就是因為有效位數沒辦法放完整的數值,所以這也是為什麼大家常說浮點數不是 100% 精準的原因。

參考資料:https://zh.wikipedia.org/zh-tw/IEEE_754

0.1 + 0.2 = ?

就是 0.3 啊,不然呢?這是個很好的面試題,以人類的常識來說, 0.1 + 0.2 就是 0.3,但以電腦來說就不是這樣了。如上面所說,電腦裡存放的 0.1 跟 0.2 都不是剛好真的 0.1 跟 0.2,只是非常接近而已。所以在電腦上運算 0.1 + 0.2 的結果也會很接近 0.3,但因為有效位數沒辦法存放所有的位數,剛好在相加進位之後變成 0.30000000000000004,所以在 JavaScript 常會看到大家在笑它這個:

console.log(0.1 + 0.2 === 0.3); // 印出 false

然後就笑說 JavaScript 這什麼爛語言,事實上只要浮點數是照 IEEE 754 標準實作的,像 Python 跟 Ruby,包括 Rust 也是,印出來的答案都不會剛好等於 0.3。

型別推斷(Type Inference)

不像 JavaScript,Rust 對於型別是很要求的,型別不對就是不給過,所以照理說應該每當在宣告的時候都應該要明確的講明白它的型態。

let name: &str = "Hello Kitty";
let age: u8 = 20;

println!("hi, my name is {}, and I am {} years old", name, age);

那個 &str 的寫法是「字串切片」的意思,但現在可以暫時先略過它。但 Rust 的編譯器足夠聰明,就算沒有標記型態,它也能根據你給它的值推斷出來應該是哪個型別,所以這樣寫也是可以的:

let name = "Hello Kitty";
let age = 20;

println!("hi, my name is {}, and I am {} years old", name, age);

這樣寫起來清爽多了。不過型別推斷歸推斷,像這樣的程式碼之前在 JavaScript 寫起來沒什麼問題:

let age = 20; // 一開始是數字
age = "hello world"; // 後來給它字串

console.log(age); // 最後印出 hello world 字串

但在 Rust 就沒辦法這樣了:

let mut age = 20;
age = 3.14;

println!("{}", age);

那個 mut 同樣可先暫時略過它,在後續的章節有更詳細的介紹,它是表示這個 age 變數是可以修改的。然而因為一開始你給 age 這個變數一個整數值 20 ,所以 Rust 就推斷 age 應該是個整數,但後來你把它改成浮點數 3.14,這就會造成型別上的錯誤:

error[E0308]: mismatched types
2 | let mut age = 20;
| -- expected due to this value
3 | age = 3.14;
| ^^^^ expected integer, found floating-point number

這個抱怨內容大概就是「不是說好是整數嗎?怎麼變成浮點數了」。

但是整數有那麼多種,如果只寫 let age = 18,它會給哪一種?沒特別講的話,就算你只給它一個小小的數字 1,Rust 預設還是會給你 i32。如果沒特別標記型別的話,Rust 的確是會看你是整數或浮點數,分別給你 i32 以及 f64,但並不會自動依據數值的大小自動調整成 i8i64(誰知道你這數字以後會長多大?)。所以如果這樣寫:

let age = 100000000000000000000000000;  // 明顯超過 i32 的範圍

執行的時候就會出錯了:

error: literal out of range for `i32`
2 | let age = 100000000000000000000000000; // 明顯超過 i32 的範圍
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: the literal `100000000000000000000000000` does not fit into the type `i32` whose range is `-2147483648..=2147483647`

不得不說,Rust 難寫歸難寫,但它給的錯誤訊息還算挺清楚的。