直击 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(12); // 输出 int 2 func 

参数默认值

void func_do_sth(int i, int j=0) {
} func_do_sth(1); func_do_sth(12);

如果是 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(13);
    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,供人下载。所以这个文件应该会很大,下载会很慢;同时把这个文件加载进内存去运行也会很占用资源。

所以链接技术进一步优化:静态链接、动态链接。可以认为静态链接就是刚才的串在一起的方式;动态链接其实是把刚才的“串在一起”等到程序运行的时候才做。为什么呢?

  1. 部分 b.o 对方电脑已经有了,下载的 bin 没必要包括 b.o

  2. 其它进程已经把 b.o 已经带入内存里了,共享这块内存,不需要再用“额外资源”去存储。所以也就有了官方名称 shared object,即 b.so

  3. 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分的机会……

你想知道哪些面试的秘密 不妨留言,我们一起探讨

全部评论
希望能给大家一些面试的帮助
点赞 回复 分享
发布于 2022-05-19 10:15

相关推荐

牛客5655:其他公司的面试(事)吗
点赞 评论 收藏
分享
评论
点赞
收藏
分享
牛客网
牛客企业服务