1. 程式人生 > 其它 >Rust In Action 五 Data in depth

Rust In Action 五 Data in depth

這一章包含

  • 學習計算機如何表示資料
  • 構建一個可以工作的CPU模擬器
  • 建立你自己的數字型別
  • 理解浮點數

這一章完全是關於理解0與1是如何構成像文字、圖片以及聲音這樣的大型物件的,我們也將瞭解計算機如何進行計算。

在這一章的末尾,我們將模擬一個功能完備的,具有CPU、記憶體以及使用者定義方法的計算機,你將分解浮點數以構建一個你自己的,只佔用一位元組的數字型別。這一章還會引入一些術語,比如位元組順序(endianness)以及整數溢位(integer overflow),這些屬於對於並沒有做過系統程式設計的程式設計師來說可能不太熟悉。

位模式以及型別

一個小而重要的事是——一個位模式(在不同情況下)可能代表不同的東西

,像Rust這樣的高階語言的型別系統只是對現實世界的人工抽象,在你開始挖掘這些抽象之下的東西,並且想要得到對計算機如何執行的更深層次的理解之前,知道這件事是很有必要的。

Listing 5.1 (ch5-int-vs-int.rs)是一個使用相同的位模式來表示兩種不同數字型別的例子,區分這些位模式的型別的工作是由型別系統(並不是CPU)來做的。下面展示了listing的輸出:

a: 1100001111000011 50115
b: 1100001111000011 -15421
// LISTING 5.1
fn main() {
    let a: u16 = 50115;
    let b: i16 = -15421;

    // 這兩個值具有相同的位模式 但它們型別不同
    println!("a: {:016b} {}", a, a);
    println!("b: {:016b} {}", b, b);
}

在位字串和數字之間的不同對映解釋了二進位制檔案和文字檔案之間的部分區別。文字檔案僅僅是遵循了在位字串以及字元之間的某種一致的對映的二進位制檔案,這一種對映就稱為一個編碼。Arbitrary files don’t describe their meaning to the outside world, which makes these opaque.

我們可以更深一步,當我們讓Rust將一個型別產生的位模式當成另一種型別使用時,會發生什麼?下面的listing提供了一個答案。

// Listing 5.2
fn main() {
    let a: f32 = 42.42;

    let frankentype: u32 = unsafe {
        std::mem::transmute(a)
    };
    // 將f32型別的42.42的位串按小數檢視
    println!("{}", frankentype);
    // {:032b}代表以32位通過std::fmt::Binary trait來格式化,左面補0
    println!("{:032b}", frankentype);

    let b: f32 = unsafe {
        std::mem::transmute(frankentype)
    };
    println!("{}", b);
    // 確定操作是對稱的
    assert_eq!(a, b); 
}

編譯並執行,Listing 5.2中的程式碼產生了如下輸出:

1110027796
01000010001010011010111000010100
42.42

mem::transmute()方法告訴rust將f32解釋成一個u32,但不修改任何底層位。

在程式中(像如上那樣)混用資料型別容易造成混亂,所以我們需要在unsafe塊中包裹這些操作。unsafe告訴Rust編譯器,“別動,我會自己小心的”,這是你給編譯器的一個訊號,你告訴它你具有比它更多的上下文來驗證程式的正確性。

使用unsafe關鍵字並不意味著(其內部的)程式碼就是不安全的了。舉個例子,它並不允許你忽視rust的借用檢查,它只是指出了編譯器無法靠自己來保證程式的記憶體正確性。使用unsafe意味著程式設計師具有必須保證程式的完好的責任。

警告: 一些允許在unsafe塊中使用的功能可能比其它的更加難以驗證。比如,std::mem::transmute()方法就是語言中最不安全的方法之一,它打破了所有的型別安全。在你使用之前,請確認是否有其它的替代辦法。

在Rust社群中,不必要的使用unsafe塊是非常不推薦的,這可能讓你的軟體暴露在嚴重的安全風險下。它的主要目的是允許Rust與外部程式碼互動,例如一些使用其它語言編寫的庫以及OS介面。比起其它專案來說,這本書會經常使用unsafe塊,這是因為我們的程式碼只是教學的工具,而不是工業軟體。unsafe允許你檢視和修改獨立的位元組,這是那些想去理解計算機如何工作的人的基礎知識。

整數的一生

在前面的章節中,我們花了一些時間來討論i32u8usize這些整數型別的意義。整數就像小巧精妙的魚,它們完美的完成它們該做的,但是如果將它們帶離它們的自然水域(range 範圍),它們很快就會去世。

整數具有固定的範圍(range)。每一個整數型別,當我們在計算機中表示它們時,它們都佔用一個固定的位數。不像浮點數,整數不能犧牲它們的精度去擴充套件它們的範圍。一旦這些位已經被填滿1,唯一的前進方向就是所有位都回到0。

一個16位的整數可以表示0~65535(不包含)這些數字,當你想加到65536時會發生什麼?我們來試試。

我們需要研究的這類技術術語稱作——整數溢位(integer overflow)。溢位整數的一個方式是一直遞增。

// Listing 5.3
fn main() {
    let mut i: u16 = 0;
    print!("{}..", i);
    loop {
        i += 1000;
        print!("{}..", i);
        if i % 10000 == 0 {
            println!("");
        }
    }
}

當我們嘗試執行listing 5.3,我們的程式並沒有正常的結束,讓我們來看下輸出:

0..1000..2000..3000..4000..5000..6000..7000..8000..9000..10000..
11000..12000..13000..14000..15000..16000..17000..18000..19000..20000..
21000..22000..23000..24000..25000..26000..27000..28000..29000..30000..
31000..32000..33000..34000..35000..36000..37000..38000..39000..40000..
41000..42000..43000..44000..45000..46000..47000..48000..49000..50000..
51000..52000..53000..54000..55000..56000..57000..58000..59000..60000..
thread 'main' panicked at 'attempt to add with overflow', examples/integer_overflow.rs:6:9
stack backtrace:
    ... omit some lines ...
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
61000..62000..63000..64000..65000..
Process finished with exit code 101

一個panic了的程式是一個死程式(dead program 也許是說是一個錯誤的程式),panic意味著程式設計師讓程式做了一些做不到的事,程式不知道該怎麼處理,於是它把自己關掉了。

為了瞭解為什麼這是一類嚴重的bug,讓我們看看底層發生了什麼。Listing 5.4(ch5/ch5-bit-patterns.rs)列印了以位模式字面量形式定義的六個數字。

// Listing 5.4
fn main() {
    let zero: u16 = 0b0000_0000_0000_0000;
    let one: u16 = 0b0000_0000_0000_0001;
    let two: u16 = 0b0000_0000_0000_0010;
    // ...
    let sixtyfivethousand_533: u16 = 0b1111_1111_1111_1101;
    let sixtyfivethousand_534: u16 = 0b1111_1111_1111_1110;
    let sixtyfivethousand_535: u16 = 0b1111_1111_1111_1111;

    print!("{}, {}, {}, ..., ", zero, one, two);
    println!("{}, {}, {}", sixtyfivethousand_533, sixtyfivethousand_534, sixtyfivethousand_535);
}

當編譯後,listing列印了下面這短短的一行:

0, 1, 2, ..., 65533, 65534, 65535

嘗試通過rustc -O ch5-to-oblivioin.rs在開啟優化的情況下編譯程式碼,並執行編譯後的可執行檔案。行為有點不同,我們感興趣的問題是當沒有剩餘的位時會發生什麼,65536無法通過u16表示。(應該是在說我們無法通過字面量形式將65535賦值給u16變數,這樣無法通過編譯)

有一個更簡單的使用類似技術殺掉一個程式的方法。在listing 5.5中,我們讓Rust向u8中填入400,而它實際只能容納255個值。看下面的listing中的內容:

// 下面這行是必須的,rust編譯器可以檢測到這種明顯的溢位情況(所以必須加上它讓編譯器忽略)
#[allow(arithmetic_overflow)]
fn main() {
    let (a, b) = (200, 200);
    let c: u8 = a + b;
    println!("200 + 200 = {}", c);
}

程式碼編譯了,但是下面兩件事發生了:

  • 程式panic:

    thread 'main' panicked at 'attempt to add with overflow', examples/ch5-bit-patterns.rs:4:17
    stack backtrace:
        ... omit some lines ...
    note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
    

    這個行為可以在使用rustc的預設選項下發生

    rustc ch5-impossible-add.rs && ch5-impossible-add
    
  • 程式給了你錯誤的答案:

    200 + 200 = 144
    

    這個行為會在使用-O引數執行rustc時發生

    rustc -O ch5-impossible-add.rs && ch5-impossible-add
    

這裡有兩個小知識:

  • 理解你所使用的型別的限制是很重要的
  • 即使Rust很強,用Rust編寫的程式依然可能會歇菜

開發防止整數溢位的策略是系統程式設計師區別於其它程式設計師的方式之一。只有動態型別語言經驗的程式設計師幾乎不太可能遇到整數溢位。動態語言通常檢測整數表示式的結果可以匹配型別的限制,當它們不能時,接收結果的變數就會變成一個更寬的整數型別。

當開發一個性能很重要的程式碼時,你可以選擇要調整的引數。如果你使用一個固定大小的型別,你獲得了速度,但是你需要接受一些風險。為了減輕這些風險,你可以檢查溢位不會在執行時發生,同時,施加這些檢查會拖慢你的效率。另外,更廣泛的一個選項是,犧牲空間,使用更大的整數型別,比如i64。當你仍需要更大時,你可以選擇任意大的整數,當然,它們也有自己的成本。

理解位元組順序

CPU廠商們爭論的是構成整數的獨立位元組該如何排列。一些CPU將多個位元組的序列從左到右排列,而另一些則從右到左排列,這被稱為CPU的位元組順序(endianness)。這也是為什麼複製一臺計算機上的可執行程式到另一臺計算機上可能不可用的原因之一。

譯者:位元組順序是指多個位元組之間的排序方式,別用位來思考,昨天沒細看我就陷進去了,數了半天數

讓我們考慮一個32位的整數,它其中有四個位元組:AABBCC以及DD。Listing 5.6中,在我們的老朋友sys::mem::transmute()的幫助下,展示了位元組序帶來的問題。當編譯並執行後,listing5.6中的程式碼可能是兩種輸出其中之一,這取決於你機器的位元組順序。大多數人們日常工作的計算機會列印如下內容:

-573785174 vs. -1430532899

但是更加少見的硬體會交換兩個數,就像這樣:

-1430532899 vs. -573785174
// Listing 5.6
use std::mem::transmute;

fn main() {
    let big_endian: [u8; 4] = [0xAA, 0xBB, 0xCC, 0xDD];
    let little_endian: [u8; 4] = [0xDD, 0xCC, 0xBB, 0xAA];

    let a: i32 = unsafe { transmute(big_endian) };
    let b: i32 = unsafe { transmute(little_endian) };

    println!("{} vs. {}", a, b);

}

術語來自於位元組序列中位元組的意義,我們回到學習加法的時候,我們可以將數字123分解成三個部分:

表示式 結果
100x1 100
10x2 20
1x3 3

將所有這些部分相加,我們得到了初始的數字。第一部分,100,是重要的部分(係數最大)。當我們使用慣常的方式來寫這個數字時,我們把123寫作123,這就是大端法格式。當我們反轉這個順序,將123寫成321,這就是小端法格式。

二進位制數也是一樣的。每一個數字部分是2的一個冪次(\(2^0, 2^1, 2^2, ..., 2^n\))。

在1990年代後期,位元組順序是一個大問題,尤其是在伺服器市場上。由於忽略了很多處理器可以支援雙端位元組序這一事實,Sun Microsystems、Cray、Motorola以及SGI都選擇了其中一條路(大端或小端)。ARM開發了一款雙端架構,Intel則走向了另一條路,顯然另一條路贏了。整數幾乎全部以小端法儲存。

除了多位元組序列的問題,這還有一個相關的位元組問題。一個代表3的u8型別資料,它應該看起來像0000_0011還是1100_0000?計算機的獨立位佈局偏好被稱為位順序(bit endiannes或bit numbering)。不過無論如何,這種內部順序很難影響到你每天的程式設計。如果想進一步瞭解的話,去看看你的平臺的文件,看看最重要的位被排在了哪一邊?

表示小數數字

我在這一章開始的時候說過,更多的瞭解位模式可以讓你壓縮你的資料。讓我們到實踐中來。在這一部分,你將學到如何提取位來表示一個浮點數,並且將這些注入到一個你自己創造的單位元組格式中。

現在,我們手裡有一些困難。機器學習的那些實踐者們通常需要儲存以及分派很大的模型,我們這裡的目標中的一個模型僅僅是一個數字(小數)陣列。這些模型中的數字經常落在0..=1-1..=1的範圍中,這取決於具體的應用。從給定的資訊來看,我們不需要f32f64的完整範圍,為什麼要使用所有的位元組呢?讓我們看看只使用1個位元組能走多遠。因為我們有一個已知的有限範圍,所以建立一個可以緊湊的模擬這個範圍的的十進位制數字格式是可能的。

開始前,我們需要學學,在當今的計算機中,小數資料是如何表示的。

浮點數

每一個浮點數在記憶體中都使用科學計數法表示,如果你不熟悉科學計數法,這是一個簡單的入門

科學家們使用\(1.898 \times 10^{27}\)來描述木星的質量,使用\(3.801 \times 10^{-4}\)來描述螞蟻的質量。這一計數法的本質是可以使用相同數量的字元來描述具有截然不同的規模的數字。電腦科學家們將這一優點提取,來建立一個固定寬度的格式來編碼大量的數字。一個數字中的每一位在科學計數法中都有一個角色:

  • 符號:我們的兩個例子中沒有顯式出現,它用於表示負數(負無窮到零)
  • 尾數/數值部分(mantissa/significand):例子中的1.898、3.801
  • 基數(radix/base):我們例子中的10
  • 指數(exponent):數值的規模,也就是我們例子中的27和-4

可以很輕易地從浮點數中找到這些概念,一個浮點數是一個具有三個屬性的容器:

  • 一個符號位
  • 一個指數
  • 一個數值部分

看看f32內部

圖5.1展示了Rust中f32型別的記憶體佈局。這個佈局在IEEE 754-2019IEEE 754-2008標準中被稱作binary32,它們的前身在IEE 754-1985中被稱作single

42.42被編碼成具有位模式01000010001010011010111000010100f32資料。這個位模式可以更緊湊的寫成0x4229AE14。表5.1展示了(浮點數的)三個屬性的值以及它們代表了什麼

下面的等式將浮點數的屬性解碼成單一數字(用人話來說就是具有三個屬性的浮點數位模式如何代表一個具體的小數)。標準中的變數(Radix、Bias)使用首字母大寫的格式來寫,位模式中的變數(sign_bit、mantissa、exponent)使用小寫字母來寫。

\[n=-1^{sign\_bit}\times mantissa\times Radix^{(exponent-Bias)}\\ n=-1^{sign\_bit}\times mantissa\times Radix^{(exponent-127)}\\ n=-1^{sign\_bit}\times mantissa\times Radix^{(132-127)}\\ n=-1^{sign\_bit}\times mantissa\times 2^{(132-127)}\\ n=-1^{sign\_bit}\times 1.325625\times 2^{(132-127)}\\ n=-1^{0}\times 1.325625\times 2^{5}\\ n=1\times 1.325625\times 32\\ n=42.42 \]

浮點數中的一個怪事是,sign_bit允許正0和負0出現,也就是說,不同的位模式比較起來可能是相同的(0和-0),並且相同的位模式比較起來可能是不同的(NAN值)。

抽取符號位

為了抽取符號位,需要將其它的位移開。比如f32,右移31位(>>31)。下面的listing是一個簡短的執行右移的程式碼片段:

// Listing 5.7
fn main() {
    let n: f32 = 42.42;
    let n_bits: u32 = n.to_bits();
    let sign_bit = n_bits >> 31;
    // 譯者自己新增的程式碼
    // if sign_bit == 0 {
    //     println!("positive");
    // } else {
    //     println!("negative");
    // }
}

為了保證你對發生了什麼有一個更深的直覺,下面用圖形繪製了詳情:

  1. 從一個f32值開始
    let n: f32 = 42.42;
    
  2. 使用u32來解釋f32的位,以允許位運算:
    let n_bits: u32 = n.to_bits();
    
  3. 將位右移31個位置:
    let sign_bit = n_bits >> 31;
    

抽取指數

為了抽取指數,必須有兩個位運算。第一個,執行一個右移來覆蓋數值位(>>23),然後使用AND掩碼(& 0xff)來排除符號位(因為指數位有8位,右移23後剩9位,掩碼排除了最高位)。

指數位仍需要進行解碼,為了解碼指數位,將它解釋為一個8位的有符號整數,然後從結果中減去127。(就像我們在5.3.2節中討論的,127是bias)下面的listing展示了描述上兩段的步驟的程式碼。

let n: f32 = 42.42;
let n_bits: u32 = n.to_bits();
let exponent_ = n_bits >> 23;
let exponent_ = exponent_ & 0xff;
let exponent = (exponent_ as i32) - 127;

解釋:

  1. f32數字開始

    let n: f32 = 42.42;
    
  2. 解釋f32u32以允許位運算

    let n_bits: u32 = n.to_bits();
    
  3. 移動指數的8位到右側,以覆蓋數值位:

    let exponent_ = n_bits >> 23;
    
  4. 使用AND掩碼過濾符號位,只有右側八位可以通過掩碼:

    let exponent_ = exponent_ & 0xff;
    
  5. 將剩下的位解釋成一個有符號整數然後減去標準中定義的bias

    let exponent = (exponent_ as i32) - 127;
    

抽取數值位

為了抽取數值位的23位,你可以使用一個AND掩碼區溢位符號位以及指數位(& 0x7fffff)。然而你實際上不需要這樣做,因為隨後的解碼步驟可以簡單的忽略這些無關的位。不幸的是,數值位的解碼步驟要比指數複雜很多。

為了解碼數值位,使用每一位的權重乘以每一位,然後將結果相加。第一位的權重是0.5(\(2^{-1}\)),後續的每一個位的權重都是當前權重的一半,例如:0.5(\(2^{-1}\))、0.25(\(2^{-2}\))、...、0.00000011920928955078125 (\(2^{–23}\))。一個隱式的第24位可以表示為1.0(\(2^{-0}\))總被認為是存在的,除非觸發特殊情況。特殊情況可以被如下的指數狀態觸發:

  • 當指數位全為0,數值位將作為次正規數表示(也叫非正規數)。實際而言,這個改變讓小數可以表示更加接近0的數,一個次正規數是一個在0與正規數行為下能表示的最小數之間的數。
  • 當指數位全為1,小數表示無窮大和負無窮,或者Not a Number(NANNAN值代表數值結果在數學上未定義的特殊情況(比如0 / 0)或者其它無效值。

引入NAN值的操作通常是違反直覺的。例如,測試兩個值是否相等總會返回false,儘管兩個位模式實際是一樣的。一個有趣的事兒是f32有大約4.2億個(\(~2^{22}\))個位模式可以表示NAN

下面的listing提供了非特殊情況下程式碼的實現:

// Listing 5.9
let n: f32 = 42.42;
let n_bits: u32 = n.to_bits();
let mut mantissa: f32 = 1.0;
for i in 0..23 {
    let mask = 1 << i;
    let one_at_bit_i = n_bits & mask;
    if one_at_bit_i != 0 {
        let i_ = i as f32;
        let weight = 2_f32.powf( i_ - 23.0 );
        mantissa += weight;
    }
}

再次慢放上面的過程:

  1. f32值開始:

    let n: f32 = 42.42;
    
  2. f32轉為u32以允許位運算:

    let n_bits: u32 = n.to_bits();
    
  3. 建立一個可變的f32值初始化為1.0(\(2^{-0}\))。這代表隱含的24位的權重:

    let mut mantissa: f32 = 1.0;
    
  4. 迭代計算數值位的小數位,新增這些這些位所代表的值到mantissa變數中:

    for i in 0..23 {
        let mask = 1 << i;
        let one_at_bit_i = n_bits & mask;
        if one_at_bit_i != 0 {
            let i_ = i as f32;
            let weight = 2_f32.powf( i_ - 23.0 );
            mantissa += weight;
        }
    }
    
    1. 使用一個臨時變數i作為迭代數,從0迭代到23
      for i in 0..23 {
      
    2. 建立一個位掩碼,將迭代號i作為允許通過的位,並將結果分配給mask
      let mask = 1 << i;
      
    3. 使用mask作為一個儲存在n_bits中的原始數的過濾器,當原始數的第\(i\)位不是0時,one_at_bit_i將被賦予一個非零值
      let one_at_bit_i = n_bits & mask;
      
    4. 如果one_at_bit_i是非零,則執行:
      if one_at_bit_i != 0 {
      
    5. 計算在位置\(i\)處的位的權重,公式是:\(2^{i-23}\)
      let i_ = i as f32;
      let weight = 2_f32.powf( i_ - 23.0 );
      
    6. 原地新增權重到mantissa
      mantissa += weight;
      

解析Rust的浮點字面量比看起來要難
Rust的數字具有方法。為了返回離1.2更近的整數,rust使用1.2_f32.ceil()方法而不是ceil(1.2)這一函式呼叫。雖然這通常很方便,但這會在編譯器解析你程式碼時導致一些問題。

舉例來說,一元負號的優先順序低於方法呼叫,這意味著可能發生預期之外的數學異常。使用括號來向編譯器澄清你的意圖是有幫助的。為了計算\(-1^0\),將\(1.0\)包裹在括號中:

(-1.0_f32).powf(0.0)

而不是

-1.0_f32.powf(0.0)

這將會被解釋成\(-(1^0)\),因為\(-1^0\)\(-(1^0)\)在數學上都是有效的,Rust將不會在你省略括號時抗議。

解剖一個浮點數

就像在5.4節開始時提到的,浮點數是一個具有三個屬性的容器的格式,5.4.1到5.4.3節已經給了我們解剖每一個屬性的工具,讓我們把它們放到工作中。

Listing 5.10幹了一個往返,它解剖了數字42.42的每一個屬性,編碼為f32的多個獨立部分,然後再將它們整合起來建立另一個數字。為了將一個浮點數中的位轉換成另一個浮點數,有三個任務要做:

  1. 從容器中解開這些值的位(1到26行上的to_parts()
  2. 從它們的原始位模式中解碼每個值到實際的值(28到47行的decode()
  3. 執行轉換科學計數法到一個普通數字的轉換(49到55行的from_parts()

當我們執行listing 5.10時,它提供了編碼成一個f32的數——42.42內部的兩個檢視:、

42.42 -> 42.42
field     |  as bits | as real number
sign      |        0 | 1
exponent  | 10000100 | 32
mantissa  | 01010011010111000010100 | 1.325625

在listing 5.10中,deconstruct_f32()使用位運算技術解剖了浮點數值的每一個屬性。decode_f32_parts()展示瞭如何轉換這些屬性到相關的數。f32_from_parts()方法組合這些去建立一個單一的小數。

// Listing 5.10 複製它的程式碼比較難,譯者這個程式碼和原書的稍有出入
fn to_parts(float: f32) -> (u32, u32, u32) {
    let n_bits: u32 = float.to_bits();
    let sign_bit = n_bits >> 31;
    let exponent_bit = (n_bits >> 23) & 0xff;
    let mantissa_bit = n_bits & 0x7fffff;
    (sign_bit, exponent_bit, mantissa_bit)
}

fn decode_f32_parts(
    sign_bit: u32,
    exponent_bit: u32,
    mantissa_bit: u32
) -> (f32, f32, f32) {
    let sign = (-1.0f32).powf(sign_bit as f32);
    let exponent = (exponent_bit as i32) - 127;
    let exponent = 2f32.powf(exponent as f32);

    let mut mantissa = 1.0;

    for i in 0..23 {
        let mask = 1 << i;
        if mantissa_bit & mask != 0 {
            let weight = 2f32.powf((i as f32) - 23.0);
            mantissa += weight;
        }
    }

    (sign, exponent, mantissa)
}

fn from_parts(sign: f32, exponent: f32, mantissa: f32) -> f32 {
    sign * exponent * mantissa
}

fn main() {
    let n: f32 = 42.42;

    let (sign_bit, exponent_bit, mantissa_bit)  = to_parts(n);
    let (sign, exponent, mantissa) = decode_f32_parts(sign_bit, exponent_bit, mantissa_bit);
    let result = from_parts(sign, exponent, mantissa);

    println!("{}", result);
}

理解如何從位元組中解出位,意味著在你的職業生涯中,當你面對著需要解釋從網路中傳遞過來的無型別位元組的問題時,你將處於更有利的位置。

未完...無力氣了