当前位置: 首页 > 科技观察

软件开发的探索之路:让自己成为知识的主人

时间:2023-03-21 10:26:31 科技观察

在做软件开发的时候,总有一些奇怪的问题难以回答:栈是向上增长还是向下增长?(这其实是个不严谨的问题)arm是小端还是大端?闭包是一种什么样的数据结构?需要多少内存?...这些令人费解的问题,只要耐心搜索,通常都能在stackoverflow或各种论坛上找到答案。但是,别人给出的答案很可能是模棱两可、难以理解,甚至是错误的。我们需要花时间确定正确和准确的答案,我们需要花时间阅读这些答案。有时,即使你有了答案,甚至记住了它,你也可能无法完全理解别人给你的答案。当你需要向别人传达这样的答案时,你可能会发现自己无法清楚地表达出来。在我的职业生涯中,我遇到过很多所谓的“高手”。漫长的职业生涯让他们遇到了各种奇葩问题。通过各种知识搜索和整理的方式,他们也记住了这些问题的答案。他们经常能抛出一些冷门知识,丰富的知识储备让我惊叹。但是当我想深入的时候,发现他们对事物的理解只是指向他处的一种参照,借来的知识,而不是他们自己的知识,所以往往是模糊的。可以给出肤浅的答案。那么,如何避免这种情况,让自己成为知识的拥有者呢?我们要学会不依赖别人的断言,要通过代码本身去探索问题的答案。作为开发人员,我们最大的优势是我们研究的对象,计算机和计算机软件,触手可及。只要想办法用代码构建实验来研究这个问题,就可以不断迭代,逐步找到答案。而且,这个答案是第一手的,不是别人嚼过喂给你的,而是你通过实验验证出来的,所以是你自己的知识,即使过了十年、二十年,你仍然可以清楚地给出答案,或者至少给出该答案的路径。问个有趣的问题最近在我的极客时间专栏《陈天 · Rust 第一课》里,有同学看到我画的这张图,问了这样一个问题:是每个班级都有一张虚表,还是每个班级都有?对象一份,还是每个胖指针一份?这是一个非常好的问题。我不知道有多少人在学习的时候会问这样的问题,但是我估计是很少的,因为至少我之前直播讲Rust的时候,在公司里面讲Rust的时候,没有人很关心这个问题。提出正确的问题比知道答案更重要。一个好的问题已经很接近知识了。《爱因斯坦》如何提出有趣的问题?我在学习traitobject的时候也问过同样的问题,跟着问题找答案。想一想,什么样的思维会引发这个问题?可能是对比学习而来的(我自己的情况):因为C++的每个类都有自己的虚表,难免疑惑trait对象是不是也有类似的实现?可能是出于对内存效率的考虑:一个trait对象有一个指向虚表的指针,所以如果在每个trait对象生成的时候都生成一个虚表,会造成内存的浪费。对于上面的Writetrait,还好只有几个方法,但是对于一些比较大的trait,比如Iterator,就有将近70个方法,也就是说光是这些方法组成的虚表就有500多个字节!如果每个trait对象都自己生成这样一张表,内存占用将是多么可怕!所以不懂就别敢大量使用。也许还有其他想法引发了这个问题。不管怎样,要想问出好问题,一定要有一些先验知识,然后通过细心的观察和深入的思考,问题才会逐渐萌发。从假设到假设的实验检验那么,有了一个好问题,我们该如何回答呢?我们可以根据已有的知识去思考最接近真相的方向,然后做实验来验证我们的假设。对于这个问题,我觉得为每个trait对象都生成一个表,效率太低,也不可能,所以我倾向于像C++一样,对每个类型都有一个静态虚表。既然有了这样一个假设,我该如何验证呢?我可以用两个字符串分别生成trait对象,然后打印vtable的地址进行比较。如果一致,那么我的假设就成立了:每种类型都有一个静态vtable。有了实验一的这个方向,不难查阅资料,写出下面第一个实验的代码:usestd::fmt::Debug;usestd::mem::transmute;fnmain(){lets1=String::from("hello");lets2=String::from("goodbye");letw1:&dynDebug=&s1;letw2:&dynDebug=&s2;//强制将triatobject转换成两个地址(usize,usize)//这样不安全是的,所以它是unsafelet(addr1,vtable1)=unsafe{transmute::<_,(usize,usize)>(w1as*constdynDebug)};let(addr2,vtable2)=unsafe{transmute::<_,(usize,usize)>(w2as*constdynDebug)};//traitobject(s/Display)ptr地址和vtable地址println!("addr1:0x{:x},vtable1:0x{:x}",addr1,vtable1);//traitobject(s/Debug)的ptr地址和vtable地址println!("addr2:0x{:x},vtable2:0x{:x}",addr2,vtable2);//String类型有相同的vtable?assert_eq!(vtable1,vtable2);}如果你在Rust操场上运行它,你会得到以下结果:addr1:0x7ffd1c524910,vtable1:0x556591eae4c8addr2:0x7ffd1c524928,vtable2:0x556591eae4c8从实验1中,我们得出结论vtables是共享的,不是每个特征对象有一个vtable。从虚表的地址来看,既不是堆地址也不是栈地址。从视觉上看,它看起来像代码段或数据段的地址?你看,我们通过观察实验结果,有了新发现,新问题。所以我们继续迭代。实验二在实验一的基础上,我们可以定义一个静态变量V,打印它的地址(DATA段),打印main()函数的地址(TEXT段)进行对比:staticV:i32=0;println!("V:{:p},main():{:p}",&V,mainas*const());打印结果(注意每次编译后运行地址会不同):addr1:0x7fff2dd3e7f8,vtable1:0x557a21b9e488addr2:0x7fff2dd3e810,vtable2:0x557a21b9e488V:0x557a21b910ec,main():0x557a21b63e40Bingo!实验2证明我们的猜测是正确的。虚拟表在编译时生成并塞入二进制文件中。生成trait对象时,根据是什么类型指向对应的位置。所以,Rust编译每个类型(比如String)只有一个虚表,对吧?我们现在非常接近真相,但仍有未解之谜。从目前的实验来看,我们还不能得出这个结论。在实验一中,我们只使用了Debugtrait,这个trait太小了,不具备普适性。如果我对相同的数据类型(比如String)使用不同的trait,会不会导致不同的结果?我们不知道。如果结果相同,那么我们可以确定每种类型都有一个虚表的可能性很大,否则,每种类型的每个特征实现都应该有一个虚表。实验三所以在实验三中,我们使用相同类型的两个不同的Traits生成不同的trait对象,看它们的vtables是否有相同的地址:usestd::fmt::{Debug,Display};usestd::mem::transmute;fnmain(){lets1=String::from("helloworld!");lets2=String::from("goodbyeworld!");//显示/Debugtraitobjectforsletw1:&dynDisplay=&s1;letw2:&dynDebug=&s1;//显示/Debugtraitobjectfors1letw3:&dynDisplay=&s2;letw4:&dynDebug=&s2;//强制将triatobject转换成两个地址(usize,usize)//这是不安全的,所以是unsafelet(addr1,vtable1)=unsafe{transmute::<_,(usize,usize)>(w1as*constdynDisplay)};let(addr2,vtable2)=unsafe{transmute::<_,(usize,usize)>(w2as*constdynDebug)};let(addr3,vtable3)=unsafe{transmute::<_,(usize,usize)>(w3as*constdynDisplay)};let(addr4,vtable4)=unsafe{transmute::<_,(usize,usize)>(w4as*constdynDebug)};//s和s1在栈上的地址,以及TEXT段中main的地址println!("s1:{:p},s2:{:p},main():{:p}",&s1,&s2,mainas*const());//traitobject(s/Display)的ptr地址和vtable地址println!("addr1:0x{:x},vtable1:0x{:x}",addr1,vtable1);//traitobject(s/Debug)的ptr地址和vtable地址println!("addr2:0x{:x},vtable2:0x{:x}",addr2,vtable2);//traitobject(s1/Display)ptr地址和vtable地址println!("addr3:0x{:x},vtable3:0x{:x}",addr3,vtable3);//traitobject(s1/Display)'sptraddressandvtableaddressprintln!("addr4:0x{:x},vtable4:0x{:x}",addr4,vtable4);//指向同一个数据traitobject,其ptr地址为同一个assert_eq!(addr1,addr2);assert_eq!(addr3,addr4);//指向同一个vtable地址的同一个trait的同类型//这里都是String+Displayassert_eq!(vtable1,vtable3);//这里都是String+Debubuuassert_eq!(vtable2,vtable4);}结果很意外:String+Display生成的trait对象和String+Debug生成的trait对象使用了不同的vtables:s1:0x7ffc7d427a08,s2:0x7ffc7d427a20,main():0x561b76ff2e90addr1:0x7ffc7d427a08,vtable1:0x561b7702d3b8addr2:0x7ffc7d427a08,vtable2:0x561b7702d3d8addr3:0x7ffc7d427a20,vtable3:0x561b7702d3b8addr4:0x7ffc7d427a20,vtable4:0x561b7702d3d8所以,我们可以确定,虚拟表是每个(Trait,Type)一份,在编译时就生成好了那么,编译器什么时候生成这个虚表呢?可以合理推断,当编译器编译impl中某个trait的代码时,就生成了一个虚表,比如:implDebugforString{...},因为此时编译器已经具备了生成虚表所需的所有信息:如何销毁数据:dropofString这个时候需要编译方法的地址,得到数据的大小和对齐方式:目前是String类型,所以大小为24字节,对齐方式为8bytestrait方法:fmt()方法的地址已经在编译impl的时候获取了Debug如果我是编译器开发者,现在不做,等到什么时候?所以我们可以做出这样的推断。这个推论在逻辑上是一致的,看起来很有道理,大概率是正确的。但要验证它并不是那么容易,除非我们继续用Rust编译器源代码进行实验。从实验结果来看,终于可以得出结论了。结合以上三个实验,我们已经可以在脑海中构建出这样一个画面:此时此刻,我们已经完美地找到了我们想要的一开始问题的答案。这是我对开头问题的回答:好问题。这在关于特质的课程中有提到。当实现每个implTraitAforTypeB{}时,将编译虚拟表的副本。比如String的Debug实现和String的Display实现各有一个虚表,在编译时生成并放在二进制文件中(可能在RODATA段)。所以vtable是每个(特征,类型)。它是在编译时生成的。有兴趣的可以在playground中运行这段代码(这是后面讲traits时用到的代码):https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=89311eb50772982723a39b23874b20d6。限于篇幅,代码就不贴出来了。因为我通过自己的实验找到了答案,所以我对自己的结论和推论很有信心。同时,因为这是我自己摸索出来的知识,我不是借用别人脑子里的想法,而是自己拥有,所以我可以自由地从各个角度构建自己的答案。总结在韦氏词典中,科学方法是这样定义的:科学方法是一个系统的求知过程,包括以下三个步骤:问题的认识和表述、实验数据的收集、假设的形成和检验。我们使用科学方法探索Rust的vtables是如何构建的。这是一个迭代过程,从观察开始,提出问题,提出假设,构建实验来验证假设,观察实验结果,提出新问题,并进一步迭代,直到我们形成一个自洽的理论:在本文中,我们探索这种方法生锈的例子。当方法本身与Rust无关时。我们在学习编程语言、使用第三方库、构建复杂系统时都可以使用这种方法。如果你能掌握并运用这个方法,那么慢慢的你就可以成为知识的拥有者。