軟件開發(fā)探索之道:讓自己成為知識的所有者
在做軟件開發(fā)的時候,總會有一些奇奇怪怪的問題難以解答:
- 棧是向上增長還是向下增長?(這其實是個不嚴謹的問題)
- arm 是 little endian 還是 big endian?
- 閉包究竟是一個什么樣的數據結構?它占用多少內存?
- ...
這些讓人摸不著頭腦的問題,只要你耐心查找,在 stackoverflow 或者各種論壇上,一般能夠找到答案。不過,別人給出來的答案很可能是模棱兩可的,不好理解的,甚至是錯誤的。我們需要花時間甄別那些正確的、并且精準的答案,還需要花時間閱讀這些答案。有時候,即便是你得到了答案甚至記住了答案,你可能還是沒有完全理解別人給出的答案。當你需要把這樣的答案講給別人時,你會發(fā)現自己似乎無法講得清楚。
在我的職業(yè)生涯中,遇見過很多所謂的「高手」,漫長的職業(yè)生涯讓他們遇見了各種奇葩的問題,通過各種知識搜索和整理的手段,他們也記住了這些問題的答案。他們經常能拋出一些冷門的知識,知識儲備之豐富讓我嘆為觀止。但當我想深入下去時,就發(fā)現他們對事物的理解不過是一個指向別處的引用(reference),是借來(borrow)的知識,自己沒有知識的所有權(ownership),所以往往容易語焉不詳,只能給出淺層的回答。
那么,如何避免這種情況,讓自己成為知識的所有者呢?
我們要學會不依賴別人的斷言,單單通過代碼本身來探索問題的答案。作為開發(fā)者,我們最大的優(yōu)勢就是我們研究的對象,計算機和計算機軟件,就放在離我們唾手可得的地方。我們只要想辦法用代碼構造研究這個問題的實驗,就能不斷迭代夠逐漸找到答案。而且,這答案是第一手的,不是別人咀嚼后喂給你的,而是你通過實驗驗證出來的,所以它是你自己的知識,即便過了十年二十年,你依然能清晰地給出答案,或者至少給出通往這個答案的途徑。
問有意思的問題
最近在我的極客時間的專欄《陳天 · Rust 第一課》中,有個同學在看到我畫的這張圖時:
問了這樣一個問題:
虛表是每個類有一份,還是每個對象有一份,還是每個胖指針有一份?
這是一個非常棒的問題。我不知道有多少人在學習的時候會發(fā)出這樣的疑問,但我猜很少,因為至少我之前在直播講 Rust 時,在我公司內部講 Rust 時,沒有人關心過這個問題。
而 問對問題,比知道答案更重要 。一個好的問題,就已經離知識很近了。
<愛因斯坦>
如何才能問出有意思的問題?
我在學習 trait object 的時候,也問過同樣的問題,并且順著問題,找到了答案。你想想,什么樣的思考會觸發(fā)問這個問題呢?
也許來自對比學習(我自己的情況):因為 C++ 每個類有一個自己的虛表,所以不免會好奇 trait object 是不是也是類似的實現?
也許來自對內存效率的擔憂:trait object 有個指針指向虛表,那么如果在每個 trait object 生成時都生成一張?zhí)摫恚敲春芾速M內存啊。對于上面的 Write trait,還好,只有幾個方法,但對一些比較大的 trait,如 Iterator,有近七十個方法,也就是說光這些方法組成的虛表,就有五百多字節(jié)!如果每個 trait object 都自己生成這樣一張表,內存占用多可怕!所以如果不搞明白,不敢大量使用啊。
也許還有其它什么思考觸發(fā)了這個問題。
不管怎么樣,能問出好的問題,一定會現有一些先驗知識,然后通過細致的觀察,深入的思考,才會慢慢萌發(fā)問題。
從假設到通過實驗驗證假設
那么,有了好問題,我們如何解答這個問題呢?
我們可以根據自己已有的知識,思考最可能接近真相的方向,然后動手做實驗來驗證自己的假設。對于這個問題,我認為為每個 trait object 生成一張表效率太低,不太可能,所以傾向于像 C++ 那樣,每個類型都有靜態(tài)的虛表。既然我有了這樣的假設,那么怎么驗證它呢?我可以用兩個字符串分別生成 trait object,然后打印虛表的地址進行對比。如果一致,那么符合我的假設:每個類型都有靜態(tài)的虛表。
實驗一
有了這個方向,查閱資料,寫出下面的第一個實驗的代碼并非難事:
- use std::fmt::Debug;
- use std::mem::transmute;
- fn main() {
- let s1 = String::from("hello");
- let s2 = String::from("goodbye");
- let w1: &dyn Debug = &s1;
- let w2: &dyn Debug = &s2;
- // 強行把 triat object 轉換成兩個地址 (usize, usize)
- // 這是不安全的,所以是 unsafe
- let (addr1, vtable1) = unsafe { transmute::<_, (usize, usize)>(w1 as *const dyn Debug) };
- let (addr2, vtable2) = unsafe { transmute::<_, (usize, usize)>(w2 as *const dyn Debug) };
- // trait object(s / Display) 的 ptr 地址和 vtable 地址
- println!("addr1: 0x{:x}, vtable1: 0x{:x}", addr1, vtable1);
- // trait object(s / Debug) 的 ptr 地址和 vtable 地址
- println!("addr2: 0x{:x}, vtable2: 0x{:x}", addr2, vtable2);
- // String 類型擁有相同的 vtable?
- assert_eq!(vtable1, vtable2);
- }
如果你在 rust playground 里運行,會得到下面的結果:
addr1: 0x7ffd1c524910, vtable1: 0x556591eae4c8
addr2: 0x7ffd1c524928, vtable2: 0x556591eae4c8
從實驗一中,我們得出結論: 虛表是共享的,不是每一個 trait object 都有一張?zhí)摫?/b> 。從虛表的地址上看,它既不是堆地址,也不是棧地址。目測像是代碼段或者數據段的地址?
你看,我們通過觀測實驗結果,又有了新的發(fā)現,同時有了新的問題。
于是我們繼續(xù)迭代。
實驗二
在實驗一的基礎上,我們可以定義一個靜態(tài)變量 V,打印一下它的地址(DATA 段),以及打印一下 main() 函數的地址(TEXT 段)來比較:
- static V: i32 = 0;
- println!("V: {:p}, main(): {:p}", &V, main as *const ());
打印結果(注意每次編譯后運行地址都會不同):
- addr1: 0x7fff2dd3e7f8, vtable1: 0x557a21b9e488
- addr2: 0x7fff2dd3e810, vtable2: 0x557a21b9e488
- V: 0x557a21b910ec, main(): 0x557a21b63e40
Bingo!實驗二證明了我們的猜測沒錯, 虛表是編譯時就生成好,塞入二進制文件中的 。當生成 trait object 時,根據是哪個類型,再指向對應的位置。
那么,Rust 為每個類型(比如 String )編譯時只生成一個 vtable,對么?
我們目前很接近真相,但還有未解的疑問。從目前的實驗中,我們還無法得出這個結論。實驗一里,我們只用了 Debug trait,這個樣本太小,不具備普遍性。如果對同一個數據類型(比如 String)使用不同的 trait,會導致不同的結果么?我們并不知道。如果結果相同,那么我們就大概率可以確定,一個類型一張?zhí)摫?,否則,就應該是每個類型的每個 trait 實現,都有一張?zhí)摫怼?/p>
實驗三
于是在實驗三里,我們用同一個類型的兩個不同的 Trait,來生成不同的 trait object,看看其虛表是否是同一個地址:
- use std::fmt::{Debug, Display};
- use std::mem::transmute;
- fn main() {
- let s1 = String::from("hello world!");
- let s2 = String::from("goodbye world!");
- // Display / Debug trait object for s
- let w1: &dyn Display = &s1;
- let w2: &dyn Debug = &s1;
- // Display / Debug trait object for s1
- let w3: &dyn Display = &s2;
- let w4: &dyn Debug = &s2;
- // 強行把 triat object 轉換成兩個地址 (usize, usize)
- // 這是不安全的,所以是 unsafe
- let (addr1, vtable1) = unsafe { transmute::<_, (usize, usize)>(w1 as *const dyn Display) };
- let (addr2, vtable2) = unsafe { transmute::<_, (usize, usize)>(w2 as *const dyn Debug) };
- let (addr3, vtable3) = unsafe { transmute::<_, (usize, usize)>(w3 as *const dyn Display) };
- let (addr4, vtable4) = unsafe { transmute::<_, (usize, usize)>(w4 as *const dyn Debug) };
- // s 和 s1 在棧上的地址,以及 main 在 TEXT 段的地址
- println!(
- "s1: {:p}, s2: {:p}, main(): {:p}",
- &s1, &s2, main as *const ()
- );
- // trait object(s / Display) 的 ptr 地址和 vtable 地址
- println!("addr1: 0x{:x}, vtable1: 0x{:x}", addr1, vtable1);
- // trait object(s / Debug) 的 ptr 地址和 vtable 地址
- println!("addr2: 0x{:x}, vtable2: 0x{:x}", addr2, vtable2);
- // trait object(s1 / Display) 的 ptr 地址和 vtable 地址
- println!("addr3: 0x{:x}, vtable3: 0x{:x}", addr3, vtable3);
- // trait object(s1 / Display) 的 ptr 地址和 vtable 地址
- println!("addr4: 0x{:x}, vtable4: 0x{:x}", addr4, vtable4);
- // 指向同一個數據的 trait object 其 ptr 地址相同
- assert_eq!(addr1, addr2);
- assert_eq!(addr3, addr4);
- // 指向同一種類型的同一個 trait 的 vtable 地址相同
- // 這里都是 String + Display
- assert_eq!(vtable1, vtable3);
- // 這里都是 String + Debug
- assert_eq!(vtable2, vtable4);
- }
結果令人驚喜:String + Display 生成的 trait object,和 String + Debug 生成的 trait object,使用的是不同的 vtable:
- s1: 0x7ffc7d427a08, s2: 0x7ffc7d427a20, main(): 0x561b76ff2e90
- addr1: 0x7ffc7d427a08, vtable1: 0x561b7702d3b8
- addr2: 0x7ffc7d427a08, vtable2: 0x561b7702d3d8
- addr3: 0x7ffc7d427a20, vtable3: 0x561b7702d3b8
- addr4: 0x7ffc7d427a20, vtable4: 0x561b7702d3d8
所以,我們可以確定, 虛表是每個 (Trait, Type) 一份,在編譯時就生成好了 。
那么,編譯器在什么時機來生成這張?zhí)摫砟兀坑欣碛赏茢?,在編譯器編譯 impl 某個 trait 的代碼時生成了虛表,比如:
- impl Debug for String {...}
因為此時編譯器有生成虛表所需要的一切信息:
- 數據如何銷毀:String 的 drop 方法的地址此時需要已經編譯得出
- 數據的大小和對齊:此刻是 String 類型,所以大小 24 字節(jié),對齊 8 字節(jié)
- trait 方法:在編譯 impl Debug 時就已經得到 fmt() 方法的地址
如果我是編譯器的開發(fā)者,此時不做,更待何時?所以我們可以做出這個推斷。這個推斷邏輯自洽,看上去非常合理,大概率是對的。不過要驗證起來不那么容易,除非我們繼續(xù)在 Rust 編譯器源碼中做實驗。
從實驗結果中最終得出結論
好,綜合上述三個實驗,我們的腦海中,已經可以構筑出這樣一幅圖:
此刻,我們就完美地找到了一開始的問題我們想要的答案。對于開頭的問題,我是這么回答的:
好問題。這個在講 trait 的那一課有講到。虛表在每個 impl TraitA for TypeB {}
實現時就會編譯出一份。比如 String 的 Debug 實現, String 的 Display 實現各有一份虛表,它們在編譯時就生成并放在了二進制文件中(大概是 RODATA 段中)。 所以虛表是每個 (Trait, Type) 一份。并且在編譯時就生成好了。 如果你感興趣,可以在 playground 里運行這段代碼(這是后面講 trait 時使用的代碼): https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=89311eb50772982723a39b23874b20d6 。限于篇幅,代碼就不貼了。
因為我自己通過做實驗,找到了答案,所以,我對自己的結論和推斷都很有信心。同時,因為這是我自己探索出來的知識,我并非借用別人腦海中的想法,而是對它擁有所有權,所以,我可以自如地從各個角度來構筑我的答案。
小結
在韋氏詞典中,是這么定義科學方法的:科學方法是一種有系統(tǒng)地尋求知識的程序,涉及了以下三個步驟:問題的認知與表述、實驗數據的收集、假說的構成與測試。我們在探索 Rust 的 vtable 是如何構建的過程中,使用了科學方法。它是一個不斷迭代的過程,從觀測開始,一路經歷問問題,做出假設,構建實驗來驗證假設,觀察實驗結果,提出新的問題,進一步迭代下去,直到我們形成了一個自洽的理論:
本文我們通過一個 Rust 的例子來探討這個方法。當這個方法本身跟 Rust 無關。我們在學習編程語言,使用第三方庫,構建復雜的系統(tǒng),都可以用這個方法。如果你能夠掌握和使用這個方法,那么,慢慢地你就能成為知識的所有者。