软件开发探索之道:让自己成为知识的所有者

开发 前端
本文我们通过一个 Rust 的例子来探讨这个方法。当这个方法本身跟 Rust 无关。我们在学习编程语言,使用第三方库,构建复杂的系统,都可以用这个方法。

 [[422065]]

在做软件开发的时候,总会有一些奇奇怪怪的问题难以解答:

  • 栈是向上增长还是向下增长?(这其实是个不严谨的问题)
  • arm 是 little endian 还是 big endian?
  • 闭包究竟是一个什么样的数据结构?它占用多少内存?
  • ...

这些让人摸不着头脑的问题,只要你耐心查找,在 stackoverflow 或者各种论坛上,一般能够找到答案。不过,别人给出来的答案很可能是模棱两可的,不好理解的,甚至是错误的。我们需要花时间甄别那些正确的、并且精准的答案,还需要花时间阅读这些答案。有时候,即便是你得到了答案甚至记住了答案,你可能还是没有完全理解别人给出的答案。当你需要把这样的答案讲给别人时,你会发现自己似乎无法讲得清楚。

在我的职业生涯中,遇见过很多所谓的「高手」,漫长的职业生涯让他们遇见了各种奇葩的问题,通过各种知识搜索和整理的手段,他们也记住了这些问题的答案。他们经常能抛出一些冷门的知识,知识储备之丰富让我叹为观止。但当我想深入下去时,就发现他们对事物的理解不过是一个指向别处的引用(reference),是借来(borrow)的知识,自己没有知识的所有权(ownership),所以往往容易语焉不详,只能给出浅层的回答。

那么,如何避免这种情况,让自己成为知识的所有者呢?

我们要学会不依赖别人的断言,单单通过代码本身来探索问题的答案。作为开发者,我们最大的优势就是我们研究的对象,计算机和计算机软件,就放在离我们唾手可得的地方。我们只要想办法用代码构造研究这个问题的实验,就能不断迭代够逐渐找到答案。而且,这答案是第一手的,不是别人咀嚼后喂给你的,而是你通过实验验证出来的,所以它是你自己的知识,即便过了十年二十年,你依然能清晰地给出答案,或者至少给出通往这个答案的途径。

问有意思的问题

最近在我的极客时间的专栏《陈天 · Rust 第一课》中,有个同学在看到我画的这张图时:

 

 

问了这样一个问题:

虚表是每个类有一份,还是每个对象有一份,还是每个胖指针有一份?

这是一个非常棒的问题。我不知道有多少人在学习的时候会发出这样的疑问,但我猜很少,因为至少我之前在直播讲 Rust 时,在我公司内部讲 Rust 时,没有人关心过这个问题。

而 问对问题,比知道答案更重要 。一个好的问题,就已经离知识很近了。

<爱因斯坦>

如何才能问出有意思的问题?

我在学习 trait object 的时候,也问过同样的问题,并且顺着问题,找到了答案。你想想,什么样的思考会触发问这个问题呢?

也许来自对比学习(我自己的情况):因为 C++ 每个类有一个自己的虚表,所以不免会好奇 trait object 是不是也是类似的实现?

也许来自对内存效率的担忧:trait object 有个指针指向虚表,那么如果在每个 trait object 生成时都生成一张虚表,那么很浪费内存啊。对于上面的 Write trait,还好,只有几个方法,但对一些比较大的 trait,如 Iterator,有近七十个方法,也就是说光这些方法组成的虚表,就有五百多字节!如果每个 trait object 都自己生成这样一张表,内存占用多可怕!所以如果不搞明白,不敢大量使用啊。

也许还有其它什么思考触发了这个问题。

不管怎么样,能问出好的问题,一定会现有一些先验知识,然后通过细致的观察,深入的思考,才会慢慢萌发问题。

从假设到通过实验验证假设

那么,有了好问题,我们如何解答这个问题呢?

我们可以根据自己已有的知识,思考最可能接近真相的方向,然后动手做实验来验证自己的假设。对于这个问题,我认为为每个 trait object 生成一张表效率太低,不太可能,所以倾向于像 C++ 那样,每个类型都有静态的虚表。既然我有了这样的假设,那么怎么验证它呢?我可以用两个字符串分别生成 trait object,然后打印虚表的地址进行对比。如果一致,那么符合我的假设:每个类型都有静态的虚表。

实验一

有了这个方向,查阅资料,写出下面的第一个实验的代码并非难事:

  1. use std::fmt::Debug; 
  2. use std::mem::transmute; 
  3.  
  4. fn main() { 
  5.     let s1 = String::from("hello"); 
  6.     let s2 = String::from("goodbye"); 
  7.     let w1: &dyn Debug = &s1; 
  8.     let w2: &dyn Debug = &s2; 
  9.  
  10.     // 强行把 triat object 转换成两个地址 (usize, usize) 
  11.     // 这是不安全的,所以是 unsafe 
  12.     let (addr1, vtable1) = unsafe { transmute::<_, (usize, usize)>(w1 as *const dyn Debug) }; 
  13.     let (addr2, vtable2) = unsafe { transmute::<_, (usize, usize)>(w2 as *const dyn Debug) }; 
  14.      
  15.     // trait object(s / Display) 的 ptr 地址和 vtable 地址 
  16.     println!("addr1: 0x{:x}, vtable1: 0x{:x}", addr1, vtable1); 
  17.     // trait object(s / Debug) 的 ptr 地址和 vtable 地址 
  18.     println!("addr2: 0x{:x}, vtable2: 0x{:x}", addr2, vtable2); 
  19.  
  20.     // String 类型拥有相同的 vtable? 
  21.     assert_eq!(vtable1, vtable2); 

如果你在 rust playground 里运行,会得到下面的结果:

addr1: 0x7ffd1c524910, vtable1: 0x556591eae4c8
addr2: 0x7ffd1c524928, vtable2: 0x556591eae4c8

从实验一中,我们得出结论: 虚表是共享的,不是每一个 trait object 都有一张虚表 。从虚表的地址上看,它既不是堆地址,也不是栈地址。目测像是代码段或者数据段的地址?

你看,我们通过观测实验结果,又有了新的发现,同时有了新的问题。

于是我们继续迭代。

实验二

在实验一的基础上,我们可以定义一个静态变量 V,打印一下它的地址(DATA 段),以及打印一下 main() 函数的地址(TEXT 段)来比较:

  1. static V: i32 = 0
  2. println!("V: {:p}, main(): {:p}", &V, main as *const ()); 

打印结果(注意每次编译后运行地址都会不同):

  1. addr1: 0x7fff2dd3e7f8, vtable1: 0x557a21b9e488 
  2. addr2: 0x7fff2dd3e810, vtable2: 0x557a21b9e488 
  3. V: 0x557a21b910ec, main(): 0x557a21b63e40 

Bingo!实验二证明了我们的猜测没错, 虚表是编译时就生成好,塞入二进制文件中的 。当生成 trait object 时,根据是哪个类型,再指向对应的位置。

那么,Rust 为每个类型(比如 String )编译时只生成一个 vtable,对么?

我们目前很接近真相,但还有未解的疑问。从目前的实验中,我们还无法得出这个结论。实验一里,我们只用了 Debug trait,这个样本太小,不具备普遍性。如果对同一个数据类型(比如 String)使用不同的 trait,会导致不同的结果么?我们并不知道。如果结果相同,那么我们就大概率可以确定,一个类型一张虚表,否则,就应该是每个类型的每个 trait 实现,都有一张虚表。

实验三

于是在实验三里,我们用同一个类型的两个不同的 Trait,来生成不同的 trait object,看看其虚表是否是同一个地址:

  1. use std::fmt::{Debug, Display}; 
  2. use std::mem::transmute; 
  3.  
  4.  
  5. fn main() { 
  6.     let s1 = String::from("hello world!"); 
  7.     let s2 = String::from("goodbye world!"); 
  8.     // Display / Debug trait object for s 
  9.     let w1: &dyn Display = &s1; 
  10.     let w2: &dyn Debug = &s1; 
  11.  
  12.  
  13.     // Display / Debug trait object for s1 
  14.     let w3: &dyn Display = &s2; 
  15.     let w4: &dyn Debug = &s2; 
  16.  
  17.  
  18.     // 强行把 triat object 转换成两个地址 (usize, usize) 
  19.     // 这是不安全的,所以是 unsafe 
  20.     let (addr1, vtable1) = unsafe { transmute::<_, (usize, usize)>(w1 as *const dyn Display) }; 
  21.     let (addr2, vtable2) = unsafe { transmute::<_, (usize, usize)>(w2 as *const dyn Debug) }; 
  22.     let (addr3, vtable3) = unsafe { transmute::<_, (usize, usize)>(w3 as *const dyn Display) }; 
  23.     let (addr4, vtable4) = unsafe { transmute::<_, (usize, usize)>(w4 as *const dyn Debug) }; 
  24.  
  25.  
  26.     // s 和 s1 在栈上的地址,以及 main 在 TEXT 段的地址 
  27.     println!( 
  28.         "s1: {:p}, s2: {:p}, main(): {:p}"
  29.         &s1, &s2, main as *const () 
  30.     ); 
  31.     // trait object(s / Display) 的 ptr 地址和 vtable 地址 
  32.     println!("addr1: 0x{:x}, vtable1: 0x{:x}", addr1, vtable1); 
  33.     // trait object(s / Debug) 的 ptr 地址和 vtable 地址 
  34.     println!("addr2: 0x{:x}, vtable2: 0x{:x}", addr2, vtable2); 
  35.  
  36.  
  37.     // trait object(s1 / Display) 的 ptr 地址和 vtable 地址 
  38.     println!("addr3: 0x{:x}, vtable3: 0x{:x}", addr3, vtable3); 
  39.  
  40.  
  41.     // trait object(s1 / Display) 的 ptr 地址和 vtable 地址 
  42.     println!("addr4: 0x{:x}, vtable4: 0x{:x}", addr4, vtable4); 
  43.  
  44.  
  45.     // 指向同一个数据的 trait object 其 ptr 地址相同 
  46.     assert_eq!(addr1, addr2); 
  47.     assert_eq!(addr3, addr4); 
  48.  
  49.  
  50.     // 指向同一种类型的同一个 trait 的 vtable 地址相同 
  51.     // 这里都是 String + Display 
  52.     assert_eq!(vtable1, vtable3); 
  53.     // 这里都是 String + Debug 
  54.     assert_eq!(vtable2, vtable4); 

结果令人惊喜:String + Display 生成的 trait object,和 String + Debug 生成的 trait object,使用的是不同的 vtable:

  1. s1: 0x7ffc7d427a08, s2: 0x7ffc7d427a20, main(): 0x561b76ff2e90 
  2. addr1: 0x7ffc7d427a08, vtable1: 0x561b7702d3b8 
  3. addr2: 0x7ffc7d427a08, vtable2: 0x561b7702d3d8 
  4. addr3: 0x7ffc7d427a20, vtable3: 0x561b7702d3b8 
  5. addr4: 0x7ffc7d427a20, vtable4: 0x561b7702d3d8 

所以,我们可以确定, 虚表是每个 (Trait, Type) 一份,在编译时就生成好了 。

那么,编译器在什么时机来生成这张虚表呢?有理由推断,在编译器编译 impl 某个 trait 的代码时生成了虚表,比如:

  1. impl Debug for String {...} 

因为此时编译器有生成虚表所需要的一切信息:

  • 数据如何销毁:String 的 drop 方法的地址此时需要已经编译得出
  • 数据的大小和对齐:此刻是 String 类型,所以大小 24 字节,对齐 8 字节
  • trait 方法:在编译 impl Debug 时就已经得到 fmt() 方法的地址

如果我是编译器的开发者,此时不做,更待何时?所以我们可以做出这个推断。这个推断逻辑自洽,看上去非常合理,大概率是对的。不过要验证起来不那么容易,除非我们继续在 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 。限于篇幅,代码就不贴了。

因为我自己通过做实验,找到了答案,所以,我对自己的结论和推断都很有信心。同时,因为这是我自己探索出来的知识,我并非借用别人脑海中的想法,而是对它拥有所有权,所以,我可以自如地从各个角度来构筑我的答案。

小结

在韦氏词典中,是这么定义科学方法的:科学方法是一种有系统地寻求知识的程序,涉及了以下三个步骤:问题的认知与表述、实验数据的收集、假说的构成与测试。我们在探索 Rust 的 vtable 是如何构建的过程中,使用了科学方法。它是一个不断迭代的过程,从观测开始,一路经历问问题,做出假设,构建实验来验证假设,观察实验结果,提出新的问题,进一步迭代下去,直到我们形成了一个自洽的理论:

 

 

本文我们通过一个 Rust 的例子来探讨这个方法。当这个方法本身跟 Rust 无关。我们在学习编程语言,使用第三方库,构建复杂的系统,都可以用这个方法。如果你能够掌握和使用这个方法,那么,慢慢地你就能成为知识的所有者。

 

责任编辑:张燕妮 来源: 迷思
相关推荐

2011-06-08 00:09:30

RationalInnovate 20软件开发

2009-08-19 09:40:57

软件方法论

2009-06-23 09:39:22

2013-05-15 10:02:08

软件开发开发者

2011-09-04 15:16:45

Innovate 20Rational云计算

2009-02-13 10:00:41

面试软件开发程序员

2019-11-22 08:00:00

软件软件开发

2012-04-27 10:36:02

OracleJava谷歌

2010-11-08 09:36:23

移动软件开发者

2014-12-09 15:51:31

Android LAPI

2023-10-16 08:00:00

2020-03-20 11:43:20

开发编程语言技术

2022-09-07 11:56:53

汽车软件

2022-05-31 17:36:25

技术面试编程

2015-05-19 09:11:32

OpenStackOpenStack贡献

2020-02-28 15:14:41

Docker大数据软件开发

2016-12-14 14:09:56

华为软件DevOps

2023-12-13 10:02:01

软件开发框架

2024-03-26 10:00:00

NVIDIA开发工具Omniverse

2009-02-10 17:11:53

SaaSSaaS开发PaaS
点赞
收藏

51CTO技术栈公众号