几种常见特性的编译器实现

文摘   2024-12-09 12:04   新加坡  


你好,我是雨乐~

大概在接触了一段cpp以后,很多人都想知道编译器对某些特性到底是怎么实现的,今天我们挑了几个常见的特性,借助外部工具,来进行分析~

lambda

这个也是我面试必问的问题之一。

对于这种,我会先让其实现一个功能:借助lambda,对一个整型数组进行排序,预期的代码会是下面这种:

  std::vector v{3, 4,1,5,2};
   std::sort(v.begin(), v.end(), [](auto x, auto y) {
       return x < y;
   });

如果答出来了,那么我会紧接着问编译器对这块的具体实现。

编译器实现

此处可以借助cppinsights来获取编译器的视线,比如上述代码,其输出如下:

class __lambda_6_35
{
public:
template<classtype_parameter_0_0,classtype_parameter_0_1>
inline/*constexpr */auto operator()(type_parameter_0_0 x, type_parameter_0_1 y) const
   
{
return x < y;
}

#ifdef INSIGHTS_USE_TEMPLATE
template<>
inline/*constexpr */bool operator()<int, int>(int x, int y) const
   
{
return x < y;
}
#endif

private:
template<classtype_parameter_0_0,classtype_parameter_0_1>
staticinline/*constexpr */auto __invoke(type_parameter_0_0 x, type_parameter_0_1 y)
{
return __lambda_6_35{}.operator()<type_parameter_0_0, type_parameter_0_1>(x, y);
}
public:
// inline /*constexpr */ __lambda_6_35 & operator=(const __lambda_6_35 &) /* noexcept */ = delete;
// inline /*constexpr */ __lambda_6_35(const __lambda_6_35 &) noexcept = default;
// inline /*constexpr */ __lambda_6_35(__lambda_6_35 &&) noexcept = default;

};

std::sort(a.begin(), a.end(), __lambda_6_35{});

嗯,从上述输出看,编译器是通过一个匿名类来实现所谓的lambda功能。

如果想对lambda这块有更加详细深入的了解,可以参考揭开lambda的神秘面纱这篇文章。

虚函数

这个特性可以说是被问烂了,但是即使烂了,也得聊聊,否则总感觉缺点东西。

以下面这段代码为例:

class Base1{
public:
void f0() {}
virtual void f1() {}
int a;
};

classBase2{
public:
virtual void f2() {}
int b;
};

classDerived:publicBase1,publicBase2{
public:
void d() {}
void f2() {}// override Base2::f1()
int c;
};

int main() {
Base2*b2 =newBase2;
 b2
Derived*d =newDerived;
}

我们通过g++的命令-fdump-class-hierarchy进行编译,Base2内存布局如下:

Vtable forBase2
Base2::_ZTV5Base2:3u entries
0(int(*)(...))0
8(int(*)(...))(& _ZTI5Base2)
16(int(*)(...))Base2::f2

ClassBase2
  size=16 align=8
base size=12base align=8
Base2(0x0x7ff572e6b600)0
   vptr=((&Base2::_ZTV5Base2)+16u)

如下图所示:

再看下Derived的布局:

Vtable forDerived
Derived::_ZTV7Derived:7u entries
0(int(*)(...))0
8(int(*)(...))(& _ZTI7Derived)
16(int(*)(...))Base1::f1
24(int(*)(...))Derived::f2
32(int(*)(...))-16
40(int(*)(...))(& _ZTI7Derived)
48(int(*)(...))Derived::_ZThn16_N7Derived2f2Ev

ClassDerived
  size=32 align=8
base size=32base align=8
Derived(0x0x7f2708268af0)0
   vptr=((&Derived::_ZTV7Derived)+16u)
Base1(0x0x7f2708127840)0
     primary-forDerived(0x0x7f2708268af0)
Base2(0x0x7f27081278a0)16
     vptr=((&Derived::_ZTV7Derived)+48u)

如下图所示:

嗯,如果想要详细了解这块的内容,建议阅读我之前的文章:

再议内存布局

C++:从技术实现角度聊聊RTTI

Overload

C++允许函数重载,因此,我们可以创建多个相同的函数名,只要参数类型、数量或顺序不同即可,如下这种:

void print(int i) {
   std::cout <<"Integer: "<< i << std::endl;
}

void print(double d) {
   std::cout <<"Double: "<< d << std::endl;
}

void print(const std::string& s) {
   std::cout <<"String: "<< s << std::endl;
}

在上面代码中,我们定义了3个名为print的函数,返回类型都是void,唯一的区别是参数类型不同,我们将上述这种函数名一样函数参数不一样的定义方式称为重载

编译器实现

那么,我们一定会很好奇,编译器是如何根据传的参数去选择使用哪个同名函数呢?这种其实编译器是通过Name Mangling来实现的。

我们可以使用如下命令来查看编译器对其进行Name Mangling的结果:

g++ -g test.cc -o test && nm test | grep print

输出如下:

000000000040089c T _Z5printd
0000000000400862 T _Z5printi
00000000004008dc T _Z5printRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE

单从上述规则上看:

print(int) 会被修饰为 _Z5printiprint(double) 会被修饰为 _Z5printd print(const std::string& s)会被修饰为_Z5printRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE

当然了,编译器提供了c++filt来对Name Mangling修饰后的符号进行解码,我们以上面的第三个为例:

c++filt _Z5printRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
print(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)

Namespace

大致看我公众号比较久的,都知道我在做广告引擎相关业务,这个业务很多,涉及到很多模块,源文件就成百上千个,假如同时在有多个人在贡献代码时,两个开发人员在各自模块中使用相同函数名和相同函数签名的情况是很有可能发生的。举个例子,可能有一个开发人员正在编写一个广告加载模块,并实现了一个名为 Load 的 API,用于获取品牌广告;而另一个开发人员则正在开发一个广告模块,里面也有一个同样叫 Load 的函数,用于获取效果。

这种情况会导致命名冲突,即两个函数名相同,但实现和用途完全不同。如果这两个函数定义在同一个全局命名空间里,链接器就无法区分它们,从而产生错误。

对于这种情况,在c语言中是通过不同函数名(当然了c语言不支持函数重载)来实现此功能,比如我们常见的printf、fprintf以及sprintf等。

然而,C++ 提供了一种更加灵活和简洁的解决方案。C++ 通过 命名空间(namespace) 机制允许将函数、类和变量封装在一个特定的命名空间中,这样即使不同模块中的函数名相同,它们也可以通过不同的命名空间来区分。开发者只需要在调用函数时指定相应的命名空间,就能唯一地标识该函数。

namespace Brand{
void Load() {}
}

namespaceAdx{
void Load() {}
}

int main() {
Brand::Load();
Adx::Load();
}

编译器实现

还记得Name mangling吗?对于函数重载使用此技术,对于命名空间(Namespace)仍然采用此方式。

仍然使用编译器来辅助看其实现:

g++ -g test.cc -o test && nm test | grep Load

输出如下:

00000000004004d9 T _ZN3Adx4LoadEv
00000000004004d2 T _ZN5Brand4LoadEv

同样的,C++也使用名称修饰(name mangling)技术来为类的成员函数创建全局符号。

在C语言中,结构体的成员函数是作为函数指针嵌入在结构体体内的,但在 C++ 中,类的成员函数并不会被直接嵌入到类对象中作为函数指针。相反,C++通过直接调用适当的函数来实现对成员函数的访问。

00000000004004ee W _ZN2Ad5Brand4LoadEv

引用

传递参数有多种方式,比如传值、传指以及传引用等。以函数参数传参为例,对于大对象,如果按值传递的话,难免会涉及到拷贝等操作,这种会很影响性能,这个时候就有了传指或者传引用,尤其是在函数内部需要对参数进行修改的时候。

对于传指这种方式大家都了解,就不赘述了,那么我们聊聊引用传递这种方式,编译器是如何实现的。

常见的例子如下:

#include <iostream>
structBigObj{};
void print(const BigObj &x) {
}

int main() {
BigObj obj;
print(obj);

return0;
}

编译器实现

先看下面这个例子:

void UsePtr(int *x) {
*x =0;
}
void UseRef(int& x) {
   x =0;
}
void Caller() {
int x;
UsePtr(&x);
UseRef(x);
}

汇编如下(下面内容使用godbolt.org生成):

UsePtr(int*):
       push    rbp
       mov     rbp, rsp
       mov     QWORD PTR [rbp-8], rdi
       mov     rax, QWORD PTR [rbp-8]
       mov     DWORD PTR [rax], 0
       nop
       pop     rbp
       ret
UseRef(int&):
       push    rbp
       mov     rbp, rsp
       mov     QWORD PTR [rbp-8], rdi
       mov     rax, QWORD PTR [rbp-8]
       mov     DWORD PTR [rax],0
       nop
       pop     rbp
       ret
Caller():
       push    rbp
       mov     rbp, rsp
sub     rsp,16
       lea     rax,[rbp-4]
       mov     rdi, rax
       call    UsePtr(int*)
       lea     rax,[rbp-4]
       mov     rdi, rax
       call    UseRef(int&)
       nop
       leave
       ret

嗯,UsePtr和UseRef汇编完全一样,那是不是也可以说明对于这种传递引用方式实际上编译器也是通过传递指针来实现的,或者说与传递指针使用的方式完全一致。

Template

如果有人问我,哪个特性是C没有而C++有的,我首先想到的肯定是Template,当然了有的人可能是虚函数😁。

假设现在有这样一个需求,需要实现一个功能,支持各种类型的加,比如类型为int、float等,假如用c实现的话,可能就是如下这种方式:

int add_int(int a, int b) {
   return a + b;
}
float add_float(float a, float b) {
   return a + b;
}

假如用C++的话,会使用下面Template方式:

template <typename T>
T add(T a, T b) {
return a + b;
}

int main() {
int int_sum =add(5,3);
float float_sum =add(3.5f,2.2f);
return0;
}

正如你所看到的,只要作为参数传递的数据类型支持使用 + 运算符,你就可以利用模板函数来实现对两个数字(甚至对象)进行加法操作。

编译器实现

仍然对于上面的例子:

template <typename T>
T add(T a, T b) {
return a + b;
}

int main() {
int int_sum =add(5,3);
float float_sum =add(3.5f,2.2f);
return0;
}

借助编译器,看下:

g++ -g test.cc -o test && nm test | grep add

编译器输出如下:

0000000000400525 W _Z3addIfET_S0_S0_
0000000000400511 W _Z3addIiET_S0_S0_

借助c++filter:

c++filt _Z3addIiET_S0_S0_
int add<int>(int, int)

c++filt _Z3addIfET_S0_S0_
float add<float>(float, float)

也就是说对模板进行了实例化,其实例化类型分别为int和float。


今天的文章就到这,我们下期见!

如果对本文有疑问可以加笔者微信直接交流,笔者也建了C/C++相关的技术群,有兴趣的可以联系笔者加群。

推荐阅读  点击标题可跳转

1、【Modern Cpp】constexpr、consteval傻傻分不清楚

2、【Modern cpp】常见面试题之move语义

3、C++采坑系列之空指针调用


雨乐聊编程
毕业于中国科学技术大学,现任某互联网公司高级技术专家一职。本公众号专注于技术交流,分享日常有意思的技术点、线上问题等,欢迎关注