![f22a46f7dc6c7621310ce7acf4d8845c.png](https://i-blog.csdnimg.cn/blog_migrate/5d481ca94c6ace9dbda2596b01c53195.jpeg)
前言
重载在工程实践中的价值一直备受争议,却一直很受面试官青睐。本文不评论派系问题,只探讨主流语言的现状。主旨在于阐明重载的核心原理,助力面试;同时希望大家以后不要滥用重载。更多想法,可以评论、可以私聊,进一步探讨,此文尽量派系中立。
太长不看 —— 直击 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 搞到了一起。
![120886cde69a6193fe5eaf5e1167beed.png](https://i-blog.csdnimg.cn/blog_migrate/874eff7c9f7625971f8d7c663226f786.jpeg)
按上述理论,所有的 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分的机会……
你想知道哪些面试的秘密 不妨留言,我们一起探讨
![58d17544a55fef85a14d43e9707f8098.png](https://i-blog.csdnimg.cn/blog_migrate/4734e56db10981d263a1c909a0391d0a.jpeg)