C++基础高频问题(二)

1. 32位系统和64位系统寻址范围都是多少?

首先,现代系统为了多个进程能够安全地共享主存,提供了一个抽象概念:虚拟内存(CSAPP 3th chapter 9有清晰讲解 )
所以在计算机中就有两种寻址方式:物理寻址和虚拟寻址。一般问系统寻址范围是多少,都指的是虚拟寻址,换而言之,就是在问虚拟地址空间有多大。虚拟地址空间的大小是由系统内逻辑地址编码位数决定的。
理论上32位系统的逻辑地址编码位数最大可达到32,所以其最大寻址范围就是232B = 4GB,同理,64位系统理论上是264B = 224TB。
但实际上,由于CPU限制,64位系统基本上都达不到理论上的虚拟寻址范围。
Linux系统下输入 cat /proc/cpuinfo | grep "address" 命令可以查看你的CPU支持寻址范围


这个CPU支持 物理地址编码位数是40 逻辑地址编码位数是48
也就是说这个64位系统最大的虚拟寻址范围是248B = 28TB。物理寻址范围是由物理地址编码位数决定的,这台电脑可以支持的最大物理内存是240B = 2TB。


2. Linux进程的内存布局是怎样的?

Linux为每个进程都维护了一个独立的虚拟地址空间,分为内核空间和用户空间两部分

  1. 内核空间:用户无法直接访问的一段内存。
    a) 对每个进程都不同的区域,主要包括页表,任务结构和内核栈等数据结构。
    b) 对每个进程都相同的区域,看似每个进程都独立拥有的区域,实际上被映射到所有进程共享的物理页面上。主要包含两部分

    • 内核代码和数据
    • Linux内核为了能够便利地访问物理内存中任何特定的位置,其开辟了一段连续的虚拟页面(大小等于系统中DRAM的总量) 映射到相应的一组连续物理页面。
  2. 用户空间:用户可以直接访问的一段内存,但只读区域和预留区域是没有办法进行修改的。
    a) 栈
    用于维护程序运行时函数调用的上下文,栈低在高地址,向低地址增长。
    b) 堆
    用来容纳应用程序动态分配的内存区域,堆底在某一固定的低地址,向高地址增长。
    c) 可执行文件映射区
    存储的是可执行文件在内存中的映像。如果将可执行文件映射区细分,可以包含以下几个区域

    • 数据区:可读写,对应了可执行文件中.data和.bss段,.data保存的是已初始化的全局变量和静态变量。.bss保存的是未初始化的全局变量和静态变量的内存占用情况的说明。当程序运行时,.data中保存的变量有值,所以直接拷贝到数据区,.bss保存的是未初始化变量的内存占用说明,所以我们需要按照这个说明为未初始化的变量分配实际的内存空间,并全部初始化为0,放在数据区的末尾。
    • 代码区:只读区域,存放的是程序代码和只读数据,例如初始化代码,const变量等。

    d) 保留区
    保留区并不是某一单一的区域,而是系统中受保护禁用区域的总称,比如说地址为nullptr的区域。


3. 调用约定

首先,我们知道系统调用函数是通过栈进行的,栈在调用函数的过程中保存了函数执行所需的一系列维护信息,这些信息常常被成为堆栈帧,堆栈帧中主要包含了以下几个方面的内容:1.参数和返回值;2.函数运行时创建的非静态局部变量和临时变量;3.保存上下文,程序调用函数时,主要可以抽象为以下几个步骤:

  1. 将函数参数压栈
  2. 保存函数执行完毕后应该返回的指令地址
  3. 保存函数执行执行之前的上下文,以便此函数执行完毕后的上下文恢复
  4. 执行函数
  5. 恢复上下文
    那么函数参数以怎样的规则压入栈,怎样恢复上下文,以及怎样查找函数代码位置,这些都需要函数调用者和和函数的执行方明确规则,假如规则不一致的话,会出现什么情况呢?假如函数调用者将函数以从右到左的方式压入栈,但是函数执行者却从左到右读取参数,这样执行函数时就会出现莫名其妙的错误。
    所以统一调用约定是必要的。

那么调用约定一般都约定了哪些东西呢?

  1. 函数参数入栈顺序
  2. 函数名字修饰策略,比如说一个函数是以C语言的形式进行修饰,如果以C++的形式查找,就无法找到函数的执行代码的位置
  3. 栈的维护方式,恢复上下文理论上既可以由函数调用方完成也可以由函数被调用方完成,具体由谁完成需要提前约定。

那么又几种函数约定方式呢?
主要是以下四种:

__cdecl thiscall __stdcall __fastcall

注意:这些约定为非标准关键字,不同的编译器可能有不同的写法,在gcc中写法为__attribute__((cdecl)),__attribute__((stdcall)),__attribute__((fastcall))
下面我们详细的描述这四种方式

  1. __cdecl
    • 参数入栈规则:右->左
    • 上下文恢复责任方:函数调用者
    • 说明:i386C/C++缺省的调用约定,允许参数不固定,这也是C/C++语言的一大特色,在ARM和x64处理器上,可以接受__cdecl,但通常会被编译器忽略。
      参考:
      vs-2019 __cdecl
      gcc cdecl字段
  2. thiscall
    • 参数入栈规则:右->左,如果参数个数确定,this指针通过ecx传递给调用者,如果参数个数不确定,this指针在所有参数压入栈后被压入栈。
    • 上下文恢复责任方:如果参数确定时,为函数自身,否则为函数调用者
    • 说明:i386架构C++类成员函数缺省的调用约定,因为其不是关键字,程序员不能使用,它也是唯一一个不能显示指明的函数修饰符,在ARM,ARM64和x64计算机上,__thiscall编译器会接受并忽略它们。这是因为默认情况下,它们使用基于寄存器的调用约定。
      参考:
      vs-2019 __thiscall
      gcc thiscall字段
  3. __stdcall
    • 参数入栈规则:右->左
    • 上下文恢复责任方:函数自己
    • 说明:很多时候也被称为Pascal调用约定,实际上它是Pascal调用约定的一种变体,_stdcall调用约定用于调用Win32 API函数,并且是其微软创建的,并不是所有编译器都支持,gcc通过__attribute__((stdcall))进行了支持。
      参考:
      vs-2019 __stdcall
      gcc stdcall字段
  4. __fastcall
    • 参数入栈规则:仅适用于X86体系用ECX和EDX传送前两个参数(从左到右评估,并且类型长度在32位长度以下),剩下的参数仍旧自右向左压栈传送,在x86-64处理器上,可以接受,但通常会被编译器忽略。
    • 上下文恢复责任方:函数自己
    • 说明:是快速调用约定,通过寄存器来传递参数
      参考:
      vs-2019 __fastcall
      gcc fastcall字段

从上述描述中,我们发现了这样一个现象,那就是如果一个函数约定支持可变参数,那么当函数调用后恢复上下文的责任就落在了调用者的肩头,这是为什么呢??
根据函数调用的过程我们知道首先调用者将参数入栈,然后再将函数的下一步指令位置入栈后,才会调用函数,如果是可变参数,那么只有调用者知晓到底入栈了多少参数,被调用的函数是无法知晓参数的个数的。所以,恢复上下文清空调用栈内的参数的任务必须由函数调用者来完成。这也解释了thiscall约定可变参数和固定参数两种情况下恢复上下文的行为也是两种状态的问题。
另外,你是否发现这些调用约定在X86-64下全部是可接受但忽略的?
因为x86-64下的约定调用与之前的8086,i386有了很大的飞跃,这得益于硬件的发展--拥有了更多的寄存器可供传参,X86-64下将以上几种调用同一成一种,Windows平台下遵Microsoft-X64调用约定,此平台下可用Microsoft Visual C++或GCC进行编译,Solaris,Linux,FreeBSD,macOS 平台支遵循System V AMD64 ABI,可用GCC进行编译。
两种平台下的异同可参照此文章X86 calling conventions中6.X86-64 calling conventions
如果你对入栈规则以及恢复责任方等概念有疑问,建议看《程序员的自我修养》书中<10.2栈与调用习惯>


5. C++代码中怎样调用C编译的库?

我们知道C++编译时的函数符号修饰规则和C编译时的函数符号修饰规则时不一样的。C++为了实现函数的重载,在函数符号修饰时会将函数参数类型考虑进去。
比如说按照C语言规则进行编译,void test(int)和void test(double)都会被编译成_test,C语言下同名函数是不能定义在相同作用域的。而C++编译后(g++编译,其他编译器可能会不同,当原理相同),void test(int)会被编译成_Z4testi,void test(double)会被编译成_Z4testd,所以C++通过这种机制实现了函数的重载。
假如说C编译的库,库中包含了void test(int),这个函数,然后直接在C++代码中使用test这个函数,就会发现找不到符号,因为C++寻找的是_Z4testi这个函数符号名,而C库中导出的函数符号是_test。那么我们应该怎么办呢?
使用 链接指示 extern "C"
利用extern “C” {}包含C库中的头文件或者函数,告知C++查找函数名时按照C规则进行链接查找。这样就实现了C++中调用C库。

对链接指示extern "C"的扩展
1.extern "C"{}叫复合链接指示,作用是一次性建立多个C类型的链接指示,花括弧的作用是适用于该链接指示的多个声明聚合在一起。
2.这种多重声明的形式也可以用于头文件,头文件中所有普通函数声明都会被认为是链接指示的语言编写的。
3.链接指示可以嵌套,当前链接指示并不影响头文件中其他的链接指示函数
4.指向extern "C"函数的指针:编写函数所用的语言是函数类型的一部分,因此,对于使用链接指示定义的函数来说,它的每一声明都必须使用相同的链接指示,指向此函数指针依然如此,例如extern "C" void (*ptr)(int),在C++代码中使用此指针,编译器就会判定当前调用的是一个C函数。指向C函数的指针和指向C++函数的指针是不一样的类型,所以尽管都是指针,但是并不能相互赋值。


4. 静态绑定和动态绑定

与C++多态,相关的概念,首先我们要知道静态类型和动态类型
1.静态类型:是变量在声明是所采用的类型,编译期决定的。
2.动态类型:是指指针或引用代表的实际类型,运行期决定的。
静态绑定:又名前期绑定,绑定的是静态类型所对应的函数或者属性,发生在编译期;
动态绑定:又名后期绑定,绑定的是动态类型所对应的方法或属性,发生在运行期。
一般来说虚函数是动态类型,具体绑定的哪一个函数由运行期决定,其他的函数都是静态绑定类型,还有函数参数的缺省值也是静态绑定


5. 虚函数及实现原理

虚函数就是通过virtual声明在基类,可以被派生类继承或覆盖的函数。任何构造函数之外的非静态函数都可以是虚函数。这句话透露出一个知识点,静态函数和构造函数都不可以是虚函数。

为什么静态函数不可以是虚函数?
静态函数和普通函数的主要区别就是,静态函数没有this指针,而虚函数依靠的是虚函数表和对象中的虚函数指针vptr实现的多态,vptr是一个指针在类的构造函数中创建而成,只能通过this指针进行访问。静态成员函数没有this指针顾无法访问虚函数指针和虚函数表,也就实现虚函数的功能了。

为什么构造函数不可以是虚函数?
虚函数是通过this指针调用虚函数指针vptr调用虚函数表实现的多态,虚函数指针是在构造函数中创建的,如果构造函数都是虚函数,那么根本就没有办法实现构造函数的虚函数特性,所以构造函数也不可以是虚函数。

❗️存在继承关系的类,析构函数一定要被声明成虚函数?
因为不过不被声明成虚函数,析构函数将会是静态绑定类型,通过基类指针操作派生类对象的情况下,通过delete释放内存时,只会根据静态类型调用基类的析构函数,导致内存泄漏。

在上面的问题解答中我们知道两个概念:虚函数指针和虚函数表。
这两个东西是支持虚函数实现其多态特性最主要的因素。
虚函数表:一个类记录虚函数真实地址的一张表,是类独有的,类的每个对象都公用这张表,虚函数表实际上被编译器视为只读数据,存储在.rodata段(只读数据段)
虚函数指针:如果一个类拥有虚函数,那么在构造时会在对象实例的最前面位置创建虚函数指针,指向类对应的虚函数表。
那么虚函数表和虚函数指针是怎样构成的呢?
我们看下面这个例子


派生类child继承了father1 father2 father3,三个基类。利用g++查看child对象内存分配情况如下

由于child 继承于三个基类,所有将会有三个虚函数指针。
注意

  1. 三个虚函数指针并不是相邻摆放。每个虚函数指针对应着一个基类所产生的虚函数表,如我们例子中说的那样,child 内存排布是第一个虚函数指针,后面跟着从第一个基类继承的成员变量,然后是第二个虚函数指针,后面跟着从第二个基类继承的成员变量,然后是第三个虚函数指针,跟着从第三个基类继承的成员变量,之后才会构造派生类的成员变量。
  2. 第一个虚函数指针指向的虚函数表保存的是第一个顺位基类和派生类自己的虚函数。比如:第一个基类func1() func2()两个虚函数,派生类中重写了func1() func2(),并且又增加了只属于自己的虚函数func5。第二个虚函数指针指向的虚函数表保存的是第二个
    基类的虚函数,如果基类虚函数被派生类重写,则相应的地址替换成派生类函数的地址。

下图是网络流传比较广的图,除非所有基类都没有成员变量,才会是这样的内存结构,否则这个图就是错误的!



5. 虚继承

假设original是基类,father1 和father2都是其派生类,然后child 又同时继承于father1 和father2,这是child中就会有两份original类的成员,直接访问就会出现错误,这种继承也被叫做菱形继承,为了解决此类情况,就引入了虚继承的概念。
虚继承的目的是令某一个类作出声明,承诺愿意共享它的基类。
使用规则如下

class original{}
class father1: public virtual original{}
class father2:public virtual original{}
class child :public father1,public father2{}

这样child 中就值继承了一份original的成员变量。
️注意
含有虚基类的类构造顺序与普通构造顺序不同:首先构造函数要保证虚基类的完成构造,然后再按照直接基类在派生列表中的顺序进行初始化,析构时也是最后析构虚基类。


6. 派生的访问控制

6.1 基类成员访问控制在派生类中的作用

首先说一下访问控制,类中又三种访问控制:public,protected,private
public和private很好理解,public 就是只要是和类有关系的都可访问,包括派生类,友员函数等。private就是除了类自己还有授权的友员,其他的任何类和函数都不可以使用。
比较麻烦的是下面这个访问控制
protected
就是一个类希望和派生类分享但不想被其他公共访问使用的成员,当然友员也可以访问。

插一句题外话,友员就像是寄生虫,然后把自己当成主人,可以任意访问主人的一切,这中状态都源于主人声明的那一句话 friend void func()。
如果在派生类或者友员中,你想通过对象获取protected成员的话,只能通过派生类对象访问基类中的protected成员,不可以通过基类对象获取protected成员
这是为什么呢??
假如派生类中可以通过基类对象获取基类的protected成员,那么派生类的友员也可以基类对象获取protected成员,但是要注意派生类的友员不是基类的友员,但是它通过这种机制可以在外部改变基类对象的内容。按此思路,如果我想在外部修改基类的protected成员,直接定义一个基类的派生类,然后在派生类友员中修改protected成员的值,这样做就直接规避了protected访问保护。这样protected就失去了意义。

6.2 派生列表中的访问说明符

  1. 如果你是在派生类中直接访问基类的成员或者函数的话,派生列表中的访问说明符对此种行为没有任何影响
class father{
protected:
  int c;
}
class child :private father{
public :
void test(){ c = 100;}✅

那么派生类列表中的访问说明符影响的是什么呢?
其影响的是控制派生类用户(包括派生类的派生类对象在内)对基类成员的访问
换句话说,其修改了派生类中继承的基类成员的权限
比如上段代码中father 的成员变量c是protected的,派生类继承下来后成员变量c的权限就变成了private的了,这样虽然不影响派生类的访问,但是如果有一个类继承了派生了,那么这个新的类就没有办法再访问成员变量c了。

类中访问说明private 类中访问说明protected 类中访问说明public
访问列表中private private private private
访问列表中protected private protected protected
访问列表中public private protected public

从上表中可以看出,派生类中的成员的访问权限取的是min(基类原访问权限,访问列表中访问说明)

6.3 改变部分成员在派生类的访问权限

派生列表中的访问控制是在改变基类的所有成员在派生类中的访问权限,如果我只想改变某个基类成员的访问权限,其他的不想变应该怎么办呢?用using

class father{
public :
int a;
protected:
int b;
private:
int c;
}
class child :public father{
public :
  using father::b;✅
  using father::c;❌

为什么修改c 变量就不行呢??
注意使用using 修改继承的成员的访问权限的首要条件是派生类中能够访问。如果在派生类连基类成员的访问权限都没有,谈何修改权限?


7. 继承中的重载函数

  1. 基类相当于外层作用域,派生类相当于内层作用域,如果基类中的有一个名叫test的重载函数,那么如果派生类中有一个或者一个以上的test函数,基类的test的所有重载函数都会被隐藏,因为内层(派生类)的test函数已经屏蔽掉了外层(基类)的test函数。
  2. 上述情况下,需要访问基类中被屏蔽掉的test函数,就需要使用作用域运算符访问
class father{
  public:
      father(int a):a(a){}
      ~father(){}
      void test(int a){ cout<< a <

3.如果需要派生类想要重定义部分重载函数,其余的重载函数原样继承,又应该怎么做呢?一个一个赋值太麻烦。这时候我们可以用到using 显示声明继承全部重载函数,然后重定义派生类需要修改的函数

class father{
  public:
      father(int a):a(a){}
      ~father(){}
      void test(int a){ cout<< a <

8.lambda表达式与bind

8.1 lambda表达式

lambda表达式是C++11新增的特性,一般用于编写回调函数。实际上是一个未命令的内联函数,具体形式如下:

[capture list] (parameter list) -> return type { function body}
capture list :表示lambda所在代码块中定义,并传递给lambda体中的变量列表,lambda函数体中可以直接使用这些变量。
parameter list:参数列表
return type:返回值

参数列表和返回值不是必须的,但是必须永远包含捕获列表和函数体,不管它们是不是空的
注意:省略返回值的情况下:函数体内有且只有一句return语句,那么lambda表达式会自动推断返回值类型,但是如果还包含其他语句,那么未指定返回值类型,将默认返回void。也就是说,lambda包含一句以上的代码并且期望有返回值,那么你就不能省略返回值类型。如果显示指定了返回值类型,那么参数列表将不可被省略。
比如下面的代码

vector word;
stable_sort(word.begin(),word.end(),
[](const string &a,const string& b){return a.size() < b.size();}
);✅有且只有一句return,所以不需要显示指定返回值类型。
stable_sort(word.begin(),word.end(),
[] (const string &a,const string& b) {bool rs = a.size() < b.size(); return rs;}
);❌必须显示指定返回值类型。
stable_sort(word.begin(),word.end(),
[] (const string &a,const string& b) -> bool {bool rs = a.size() < b.size(); return rs;}
);✅

下面我们来说一下捕获列表,捕获列表有以下几种形式:

捕获列表形式 捕获列表使用方法
[] 空捕获列表,lambda不可省略的部分
[name,...,&name1,...] name的形式叫做值捕获,相当于函数普通参数,通过拷贝传值,&name形式叫做引用捕获,相当于函数的引用参数,其代表的就是原数据的引用
[&] 采用引用捕获的方式捕获lambda函数体所在代码块的全部对象
[=] 采用值捕获的方式捕获lambda函数体所在代码块的全部对象
[&,identifier_list] 除了identifier中出现的变量是值捕获,其他隐式捕获的全部采用引用捕获方式
[=,identifier_list] 除了identifier中出现的变量是引用捕获,其他的隐式捕获全部采用的是值捕获的方式

关于捕获列表我们还需要注意以下几点:

  1. 以值捕获的方式捕获的变量,是一种const备份,lambda函数体中不可改变其值,如果想要改变,那么需要在参数列表后边加上mutable关键字即可。
  2. 以值捕获的变量的值,是在lambda创建的那个时刻的变量的值。
void func(){
  int v = 10;
  auto f = [v]{return ++v;};❌
  auto f = [v] () mutable {return ++v;};✅
  auto f1 = [&v]{ return ++v;};✅
  cout<< f1() <

8.2 bind绑定

bind如下,主要用于接口适配,可以按照自己的需求随意改变参数的顺序和个数。

auto newcallee = bind(func, arg_list);

arg_list可以是实际值,也可以是占位符(型如_n的名字),它们表示newcallee的参数,用于占据func中的位置,_1表示newcallee第1个参数,_n表示newcallee第n个参数。
_n的名字全部定义在placeholders命名空间中,如果使用_n,需要用 using namespace std::placeholders先声明。
那么bind是怎样实现的这种函数转化的呢?
实际上bind是创建了一个类,类中通过函数指针保存了需要绑定的函数地址,还保存了绑定时传进来的参数,然后重写了括号操作符,用于仿函数调用,这样就完成了绑定和新函数的调用,新函数实际上是一个类对象。

void my_handler(int count);
auto f = bind(my_handler,123);
f();

上面的绑定的参数流动如下图



那么如果是类成员函数的绑定是什么样的呢?

void session::handler();
auto f = bind(session::handler,this);
f();

上面的绑定参数流动如下图。



上面的绑定是直接将this指针传递到新函数中,只能在类内使用,如果是类外绑定呢?可以按照下面的绑定方法进行调用,参数流动方式是一样的。

void session::handler();
auto f = bind(session::handler,_1);
session *s = new session;
f(s);

你可能感兴趣的:(C++基础高频问题(二))