直击 special-150分面重载
前言
重载在工程实践中的价值一直备受争议,却一直很受面试官青睐。本文不评论派系问题,只探讨主流语言的现状。主旨在于阐明重载的核心原理,助力面试;同时希望大家以后不要滥用重载。更多想法,可以评论、可以私聊,进一步探讨,此文尽量派系中立。
太长不看 —— 直击 special
-
[30分] 重载的定义
-
[60分] 重载的代码怎么写,c++、java、c、golang
-
[100分] 重载的实现原理:符号改写
-
[150分] 重载的运行机制 —— 编译、链接
-
[180分] 进程内存空间,动态库加载机制(原因、利弊、必要性)
-
[200分] 如何写出工程化代码,体现自己的工程师素养
我们熟悉的重载
直观定义:函数名相同,但参数不同的多个函数体,调用时,智能选择使用哪个函数
参数不同类型
void func_do_sth(int i) { std::cout << "int func"; } void func_do_sth(std::string s) { std::cout << "string func"; } func_do_sth(1); // 输出 int func func_do_sth("1"); // 输出 string func;
参数不同个数
void func_do_sth(int i) { std::cout << "int func"; } void func_do_sth(int i, int j) { std::cout << "int 2 func"; } func_do_sth(1); // 输出 int func func_do_sth(1, 2); // 输出 int 2 func
参数默认值
void func_do_sth(int i, int j=0) { } func_do_sth(1); func_do_sth(1, 2);
如果是 java 如何实现上述功能?
参数不同类型
void func_do_sth_int(int i); void func_do_sth_string(std::string s);
参数不同个数
void func_do_sth_1(int i); void func_do_sth(int i, int j) ;
参数默认值
java 不支持这种语义
void func_do_sth(int i) { func_do_sth(i, 0); // 转接到 func_do_sth(int i, int j); } void func_do_sth(int i, int j);
如果是 c 如何实现上述功能?
手工实现重载
void func_do_sth_int(int i); void func_do_sth_string(std::string s); void func_do_sth_1(int i); void func_do_sth(int i, int j) ;
那么 golang 是怎么做的?
golang 采用 c 的思路,完全不支持重载。
func func_do_sth_int_1(i int) {} func func_do_sth_int_2(i, j int) {}
What ?! 这么现代化的语言,竟然不支持这么经典的语法?!
是的,而且你应该注意到,java 还***了 c++ 的 default。
这其实是在编码便利与工程可维护性的一种妥协,只不过 golang 比较激进!代码在什么样的角度去取舍,可以衡量一个工程师的素养。c++、java、go 等语法开放程度,可以认为是语言设计者对使用者 “工程素养” 的信任程度,笔者更喜欢 golang。
—— 这是一段插曲,2年以下工作经验尽量不要人云亦云地在面试中评论这些;比较有经验的可以视面试官能力探讨,这是一个很好的加分项(把握不好就是送命,就像不要在面试中讨论: 4个空格 vs Tab)。
回答完上面这些基本点,有可能针对此项的考查就结束了。你可以完美的拿到 60分!切记,这是在浪费机会!!
面试官想知道什么
为什么 c 做不到,c++ 能做到
它们同样是一个 a.out 二进制,怎么 C++ 就可以重载,C 就不行呢。同样是 0101 你 C++ 凭什么得瑟!
那么我们就来看下 a.out 的表象
// a.cpp void func_do_sth(int i) { } void func_do_sth(int i, int j) { } // b.c void func_do_sth_1(int i) { } void func_do_sth_2(int i, int j) { }
这段代码变成了什么
g++ -c a.cpp -o a.o
nm a.o # 输出 a.o 中的符号 ------
0000000000000000 T __Z11func_do_sthi
0000000000000010 T __Z11func_do_sthii
gcc -c b.c -o b.o && nm b.o
------
0000000000000000 T _func_do_sth_1
0000000000000010 T _func_do_sth_2
__Z11func_do_sthi 这个鬼符是什么?试试 c++filt __Z11func_do_sthi。
其实就是 _func_do_sth_1,只不过编译器帮我们实现了区分。
g++ 相比于 gcc 更加智能(机械)地做了这个事情。—— 结论很简单,g++ 只是变了个魔术而已。
那么我们来看看 java
// a.java class a{ void func_do_sth(int i) {} void func_do_sth(int i, int j) {} void test() { func_do_sth(1); func_do_sth(2, 4); } } javac a.java; javap -verbose a.class |grep func_do_sth ---- #2 = Methodref #4.#17 // a.func_do_sth:(I)V #3 = Methodref #4.#18 // a.func_do_sth:(II)V
java 编译器做了一个类似 g++ 的魔术,将 func_do_sth 进行了机械的包装。
java 不支持默认值,通过 _func_do_sth_1 调用 _func_do_sth_2 的思路来实现的,也就是利用了上面原理。
那么,c++ 默认值又是怎么回事
void func_do_sth(int i, int j=0) {} nm a.o ------- 0000000000000000 T __Z11func_do_sthii
什么情况!没有 __Z11func_do_sthi,那 func_do_sth(1) 可怎么活啊!!
void func_do_sth(int i, int j=2) { } void test() { func_do_sth(1, 3); func_do_sth(1); } g++ -S a.cpp -o a.s && cat a.s ---- movl $1, %edi movl $3, %esi callq __Z11func_do_sthii // 正常调用 func_do_sth(1, 3) movl $1, %edi movl $2, %esi // 填充了一个 2 !!! callq __Z11func_do_sthii // 调用 func_do_sth(1, 2)
还是编译器的魔术,只不过换了个思路。
-
改变定义符号
-
改变调用行为
回答完上面这些点,我觉得你可以稳稳拿到至少 100分!它体现出很多东西。
-
比如,你对技术的钻研精神 —— 这些秒杀自我评价一名 ”热爱技术”(无论你表达的多么有文采)
-
比如,涉及编译,涉及汇编,可以很好的告诉面试官,你学了很多东西,而且“会用”!!
-
比如,跨多语言,说明你心态 open,是研究而不只是听说,而是有自己的思考。
我们还应该告诉面试官……
上面提到了编译符号,提到了调用(术语——链接)。编译 + 链接,才是一个程序运行的真正秘密。
编译,是一个静态的符号描述,好比生产很多零件;而链接是把程序组装成一个产品。编译只是提供检索索引(目录),是链接把他们搞在了一起。
gcc -c a.c -o a.o --save-temps; ls a.* ---- a.c # 源文件 a.i # 宏解析完成的文件 a.s # 汇编解析完成的文件 a.o # 汇编解析成二制的文件
!!a.s 是人能看懂的最底层表示;分析 a.s 可以看到一切 g++、gcc 的“魔术”
g++ a.c b.c c.c -o bin --save-temps; ls *.o ---- a.o b.o c.o
!! bin 就是我们的可执行程序,也就是你在各大网站上下载的 app。那么从 a.c/b.c/c.c -> a.o/b.o/c.o 之后怎么就变成了 bin 文件。—— 链接
链接的基础就是符号查找,比如 a.c 调用了 b.c 中的 func_do_sth(int i, int j)。a.o 会声明自己需要 __Z11func_do_sthii 然后 g++ 就会找哪个文件有 __Z11func_do_sthii 找到之后就把两个文件 a.o、b.o 搞到了一起。
按上述理论,所有的 o 文件串在一起,就会得到一个最终的大文件 bin,供人下载。所以这个文件应该会很大,下载会很慢;同时把这个文件加载进内存去运行也会很占用资源。
所以链接技术进一步优化:静态链接、动态链接。可以认为静态链接就是刚才的串在一起的方式;动态链接其实是把刚才的“串在一起”等到程序运行的时候才做。为什么呢?
-
部分 b.o 对方电脑已经有了,下载的 bin 没必要包括 b.o
-
其它进程已经把 b.o 已经带入内存里了,共享这块内存,不需要再用“额外资源”去存储。所以也就有了官方名称 shared object,即 b.so
-
b.so 可以有其它合作方维护,b.so 与 bin 可以独立更新
...
具体怎么实现呢,篇幅问题此处不深入( plt 原理)
#include <stdlib.h> void main(){ malloc(1); }
objdump -d a.out > t --- 0000000000400400 <malloc@plt>: 400400: ff 25 e2 04 20 00 jmpq *0x2004e2(%rip) # 6008e8 <_GLOBAL_OFFSET_TABLE_+0x28> 400406: 68 02 00 00 00 pushq $0x2 40040b: e9 c0 ff ff ff jmpq 4003d0 <_init+0x28> 0000000000400506 <main>: ... 40050a: bf 01 00 00 00 mov $0x1,%edi 40050f: e8 ec fe ff ff callq 400400 <malloc@plt> ...
调用 malloc@plt 其实就是调用 _GLOBAL_OFFSET_TABLE_+0x28 处放的函数指针,而这个函数指针正是指向 stdlib.so 的 malloc 函数。
你还可以侃一些...
动态链接也好,静态链接也好,无非是把这些符号怎么串在一起。也就是函数的放在哪里,我怎么找到(函数指针)
-
进程的内存布局
-
线程的内存布局
-
操作系统的内存管理
展现自己的工程师素养
回顾上面的插入点,思考下
-
为什么 JAVA 不支持默认值,golang 干脆不支持重载?
-
为什么 C 语言之后,天才的设计者们设计了重载,而到了最新的 golang 却摒弃重载
为什么语言如此差异 —— 宗旨不同,派系之辩。
-
c++ 作者,这个好,我们搞一把。作者觉得 c++ 的使用者也像他一样水平高
-
java作者,c++这个地方不太地道,方便确实是,不过容易滥用啊,我们别搞了
-
go作者,google 一群搞工程的人,肯定见过超级多的好代码、矬代码,而且反思架构优化的方法,这些不地道的东西我一概不要
前辈们想到了什么?发现了什么?
-
衡量架构的好坏不是设计的多么漂亮,而是可以长久有生命力
-
一个不错的思考点:《码农的幸福生活》中提到的一句,好的架构致力于缩小“作者、维护者、读者”,三者的信息 gap。
写在最后的话
面试是双向的,假如你有 100分
-
好的面试官可以引导出你的 110分,一般的面试官只能看到你的 60分
-
一般的面试者至多发挥出自己的 60分,成熟的面试者可以发挥出自己的 100分,真正优秀的面试者应该努力去争取 150分,当然知识足够丰富你可以做到 200分
希望大家都去想下,为什么别人可以拿到 special!我以往的面试怎么做会更好点?
当然,也欢迎评论 or 私聊,我们一起复盘,看有没有 200分的机会……
你想知道哪些面试的秘密 不妨留言,我们一起探讨