知识点-C++

记录 C++ 相关知识点。

会一直持续更新。

( 放几个Java学习网站在开头,不要问为什么 ^_^ )

主页 | Java学习&面试指南-程序员大彬 (topjavaer.cn)

三天吃透Java面试八股文(2023最新整理) - 知乎 (zhihu.com)

(85 封私信 / 81 条消息) 背Java面试八股文有用嘛? - 知乎 (zhihu.com)

关于 | 闪烁之狐 (blinkfox.github.io)

随便寻个地方’Blog (zxh3032.github.io)

1. 开始

本文目的是整理面试常见的会问到的题目, 具体细节的学习需要参考 C++ Primer / Effective C++ 系列书籍 / Inside the C++ Object Model 进行学习.

为了方便查阅, 补充了可能没有面试内容的一级标题. 这样一级标题可以和 C++ Primer 书籍保持一致.

1.1. C 和 C++ 的区别

设计思想上:

语法上:

  • C++ 具有封装/继承/多态三种特性.
  • C++ 相比 C, 增加了类型安全的功能, 比如强制类型转换.
  • C++ 支持范式编程, 比如模板类/函数模板等.

2. 变量和基本类型

2.1. 复合类型

复合类型(compound type)是指基于其他类型定义的类型. 最常见的是引用和指针.

引用即别名: 引用(reference)为对象起了另外一个名字, 引用类型引用(refers to)另外一种类型.

  • 定义引用时, 程序把引用和它的初始值绑定在一起, 而不是将初始值拷贝给引用. 一旦初始化完成, 引用将和它的初始值对象一直绑定在一起. 因为无法令引用重新绑定到另外一个对象, 因此引用必须初始化.
  • 因为引用不是一个对象, 所以不能定义引用的引用.

指针(pointer)是指向(point to)另外一种类型的复合类型.

  • 指针无需在定义时赋初值.
  • 指针本身就是一个对象, 允许对指针赋值和拷贝, 而且在指针的生命周期内它可以先后指向几个不同的对象.

表 2.1 指针与数组的区别

指针 数组
保存数据的地址. 保存数据.
间接访问数据, 首先获得指针的内容, 然后将其作为地址, 从该地址中提取数据. 直接访问数据.
通常用于动态的数据结构. 通常用于固定数目且数据类型相同的元素.
通过malloc分配内存, free释放内存. 隐式的分配和删除.
通常指向匿名数据, 操作匿名函数. 自身即为数据名.
指针取地址得到的是指针变量自身的地址. 数组名取地址得到的是数组名所指元素的地址(数组的第一个元素的地址).
指针能更改名字 数组是固定大小, 数组一经定义, 就不能改变数组名.

2.2. const限定符

2.2.1. 作用

  • 修饰变量: 表明该变量的值不可以被改变.
  • 修饰指针: 区分指向常量的指针和常量指针.
  • 修饰引用: 用于形参, 既避免了拷贝, 又避免了函数对值的修改.
  • 修饰成员函数: 表示函数不能修改成员变量(实际上是修饰this指针)

补充:

  • 对于局部对象,常量存放在栈区;
  • 对于全局对象, 常量存放在全局/静态存储区;
  • 对于字面值常量, 常量存放在常量存储区(代码段).

2.2.2. 指向常量的指针 VS 常量指针

参考 C++ Primer 2.4.2 指针和const:

  • 指向常量的指针(pointer to const):

    • 具有只能够读取内存中数据, 却不能够修改内存中数据的属性的指针(底层 const).
    • const int * p;或者int const * p;
  • 常量指针(const pointer): 常量指针是指指针所指向的位置不能改变, 即指针本身是一个常量(顶层 const), 但是指针所指向的内容可以改变.

    • 常量指针必须在声明的同时对其初始化, 不允许先声明一个指针常量随后再对其赋值, 这和声明一般的常量是一样的.
    • int * const p = &a;

2.2.3. cosntexpr

  • 常量表达式(const expression)是指值不会改变并且在编译过程就能得到计算结果的表达式.

  • 一般来说, 如果认定变量是一个常量表达式, 那就把它声明成constexpr类型.

  • 一个constexpr指针的初始值必须是nullptr或者0, 或者是存储于某个固定地址中的对象.

  • constexpr函数是指能用于常量表达式的函数.

    • 函数的返回类型及所有的形参的类型都得是字面值类型.
    • 函数体中必须有且只有一条return语句.

2.2.4. #define VS const

#define const
宏定义, 相当于字符替换 常量声明
预处理器处理 编译器处理
无类型安全检查 有类型安全检查
不分配内存 要分配内存
存储在代码段(.text) 存储在数据段(.data, .bbs)
可通过#undef取消 不可取消

3. 字符串、向量和数组

4. 表达式

4.1. 右值

C++的表达式要不然是右值(rvalue), 要不然是左值(lvalue). 这两个名词是从 C 语言继承过来的, 原本是为了帮助记忆: 左值可以位于赋值语句的左侧, 右值则不能.

当一个对象被用做右值的时候, 用的是对象的值(内容); 当对象被用做左值的时候, 用的是对象的身份(在内存中的位置).

4.2. ++i/i++

前置版本++i: 首先将运算对象加 1, 然后将改变后的对象作为求值结果.

后置版本i++: 也会将运算对象加 1, 但是求解结果是运算对象改变之前的那个值的副本.

以下摘录自 More Effective C++ Item 6:

1
2
3
4
5
6
7
8
9
10
11
12
13
// prefix form(++i): increment and fetch
UPInt& UPInt::operator++()
{
*this +=1// increment
return *this// fetch
}
// postfix form(i++): fetch and increment
const UPInt UPInt::operator++(int)
{
const UpInt oldValue = *this; // fetch
++(*this); // increment
return oldValue; // return what was fetched
}

4.3. sizeof运算符

4.3.1. 普通变量执行sizeof

sizeof运算符的结果部分地依赖于其作用的类型:

  • char或者类型为char的表达式执行sizeof运算, 结果得 1.
  • 对引用类型执行sizeof运算得到被引用对象所占空间的大小.
  • 对指针执行sizeof运算得到指针本身所占空间的大小.
  • 对解引用指针执行sizeof运算得到指针指向的对象所占空间的大小.
  • 对数组执行sizeof运算得到整个数组所占空间的大小, 等价于对数组中所有元素各执行一次sizeof运算并将所得结果求和.
  • string对象或vector对象执行sizeof运算只返回该类型固定部分的大小.

4.3.2. 类执行sizeof

1
2
3
4
5
6
7
8
9
10
11
12
13
class A {};
class B { B(); ~B() {} };
class C { C(); virtual ~C() {} };
class D { D(); ~D() {} int d; };
class E { E(); ~E() {} static int e; };
int main(int argc, char* argv[]) {
std::cout << sizeof(A) << std::endl; // 输出结果为1
std::cout << sizeof(B) << std::endl; // 输出结果为1
std::cout << sizeof(C) << std::endl; // 输出结果为8,实例中有一个指向虚函数表的指针
std::cout << sizeof(D) << std::endl; // 输出结果为4,int占4个字节
std::cout << sizeof(E) << std::endl; // 输出结果为1,static不算
return 0;
}
  • 定义一个空类型, 里面没有成员变量和成员函数, 求sizeof结果为 1. 空类型的实例中不包括任何信息, 本来求sizeof得到0, 但是当我们声明该类型的实例的时候, 它必须在内存中占有一定的空间, 否则则无法使用这些实例, 至于占用多少内存, 由编译器决定, 一般有一个char类新的内存.
  • 如果在该类型中添加一个构造函数和析构函数, 再对该类型求sizeof结果仍为 1. 调用构造函数和析构函数只需要知道函数的地址即可, 而这些函数的类型只与类型相关, 而与类型的实例无关, 编译器也不会因为这两个函数在实例内添加任何额外的信息.
  • 如果把析构函数标记为虚函数, 就会为该类型生成虚函数表, 并在该类型的每一个实例中添加一个指向虚函数表的指针. 在 32 位的机器上, 一个指针占 4 字节的空间, 因此求sizeof得到 4; 在 64 位机器上, 一个指针占 8 字节的空间, 因此求sizeof得到 8.

4.4. 显式转换

  • static_cast: 任何具有明确定义的类型转换, 只要不包含底层const, 都可以使用static_cast.
  • dynamic_cast: 用于(动态)多态类型转换. 只能用于含有虚函数的类, 用于类层次间的向上向下转化.
  • const_cast: 去除”指向常量的指针”的const性质.
  • reinterpret_cast: 为运算对象的位模式提供较低层次的重新解释, 常用于函数指针的转换.

5. 语句

6. 函数

6.1. 函数基础

6.1.1. 形参和实参

实参是形参的初始值.

6.1.2. static

  • 修饰局部变量: 使得被修饰的变量成为静态变量, 存储在静态区. 存储在静态区的数据生命周期与程序相同, 在main函数之前初始化, 在程序退出时销毁. 默认初始化为 0.

  • 修饰全局变量: 限制了链接属性, 使得全局变量只能在声明它的源文件中访问.

  • 修饰普通函数: 使得函数只能在声明它的源文件中访问.

  • 修饰类的成员变量和成员函数: 使其只属于类而不是属于某个对象. 对多个对象来说, 静态数据成员只存储一处, 供所有对象共用.

    • 静态成员调用格式<类名>::<静态成员>
    • 静态成员函数调用格式<类名>::<静态成员函数名>(<参数表>)

6.2. 参数传递

指针参数传递本质上是值传递, 它所传递的是一个地址值.

一般情况下, 输入用传值或者传const reference. 输出传引用(或者指针).

6.3. 内联函数

6.3.1. 使用

将函数指定为内联函数(inline), 通常就是将它在每个调用点上”内联地”展开.

一般来说, 内联机制用于优化规模较小(Google C++ Style 建议 10 行以下)、流程直接、频繁调用的函数.

在类声明中定义的函数, 除了虚函数的其他函数都会自动隐式地当成内联函数.

6.3.2. 编译器对inline函数的处理步骤

  1. inline函数体复制到inline函数调用点处;
  2. 为所用inline函数中的局部变量分配内存空间;
  3. inline函数的的输入参数和返回值映射到调用方法的局部变量空间中;
  4. 如果inline函数有多个返回点, 将其转变为inline函数代码块末尾的分支(使用 GOTO).

6.3.3. 优缺点

优点:

  1. 内联函数同宏函数一样将在被调用处进行代码展开, 省去了参数压栈、栈帧开辟与回收, 结果返回等, 从而提高程序运行速度.
  2. 内联函数相比宏函数来说, 在代码展开时, 会做安全检查或自动类型转换(同普通函数), 而宏定义则不会.
  3. 在类中声明同时定义的成员函数, 自动转化为内联函数, 因此内联函数可以访问类的成员变量, 宏定义则不能.
  4. 内联函数在运行时可调试, 而宏定义不可以.

缺点:

  1. 代码膨胀. 内联是以代码膨胀(复制)为代价, 消除函数调用带来的开销. 如果执行函数体内代码的时间, 相比于函数调用的开销较大, 那么效率的收获会很少. 另一方面, 每一处内联函数的调用都要复制代码, 将使程序的总代码量增大, 消耗更多的内存空间.
  2. inline函数无法随着函数库升级而升级. inline函数的改变需要重新编译, 不像non-inline可以直接链接.
  3. 是否内联, 程序员不可控. 内联函数只是对编译器的建议, 是否对函数内联, 决定权在于编译器.

6.4. 返回类型和return语句

调用一个返回引用的函数得到左值, 其他返回类型得到右值.

6.5. 特殊用途语言特性

6.5.1. 调试帮助

assert是一种预处理器宏. 使用一个表达式作为它的条件:

1
assert(expr);

首先对expr求值, 如果表达式为false. assert输出信息并终止程序的执行. 如果表达式为true. assert什么也不做.

6.6. 函数指针

函数指针指向的是函数而非对象. 和其他指针一样, 函数指针指向某种特定类型. 函数的类型由它的返回类新和形参共同决定, 与函数名无关.

C 在编译时, 每一个函数都有一个入口地址, 该入口地址就是函数指针所指向的地址.

有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样

用途: 调用函数和做函数的参数, 比如回调函数.

1
2
3
4
char * fun(char * p)  {…}  // 函数fun
char * (*pf)(char * p); // 函数指针pf
pf = fun; // 函数指针pf指向函数fun
pf(p); // 通过函数指针pf调用函数fun

7. 类

7.1. 定义抽象数据类型

7.1.1. this指针

  • this指针是一个隐含于每一个非静态成员函数中的特殊指针. 它指向调用该成员函数的那个对象.

  • this的目的总是指向”这个”对象, 所以this是一个常量指针, 被隐含地声明为:ClassName *const this, 这意味着不能给this指针赋值;

  • ClassName类的const成员函数中, this指针的类型为:const ClassName* const, 这说明不能对this指针所指向对象进行修改.

  • 当对一个对象调用成员函数时, 编译程序先将对象的地址赋给this指针, 然后调用成员函数, 每次成员函数存取数据成员时, 都隐式使用this指针.

  • 当一个成员函数被调用时, 自动向它传递一个隐含的参数, 该参数是一个指向这个成员函数所在的对象的指针.

  • this并不是一个常规变量, 而是个右值, 所以不能取得this的地址(不能&this).

  • 在以下场景中, 经常需要显式引用this指针:

    • 为实现对象的链式引用;
    • 为避免对同一对象进行赋值操作;
    • 在实现一些数据结构时, 如list.

7.1.2. 拷贝函数

  • C++深拷贝与浅拷贝
  • 在未定义显示拷贝构造函数的情况下, 系统会调用默认的拷贝函数——即浅拷贝, 它能够完成成员的一一复制. 当数据成员中没有指针时, 浅拷贝是可行的; 但当数据成员中有指针时, 如果采用简单的浅拷贝, 则两类中的两个指针将指向同一个地址, 当对象快结束时, 会调用两次析构函数, 而导致指针悬挂现象, 所以此时必须采用深拷贝.
  • 深拷贝与浅拷贝的区别就在于深拷贝会在堆内存中另外申请空间来储存数据, 从而也就解决了指针悬挂的问题. 简而言之, 当数据成员中有指针时, 必须要用深拷贝.

7.1.3. 析构函数

(TODO: 整理析构函数的特性)

  • 析构顺序与构造函数的构造顺序相反.
  • 当对象结束生命周期时, 系统会自动执行析构函数.
  • 析构函数声明时在函数名前加取反符~, 不带任何参数, 也没有返回值.
  • 如果用户没有声明析构函数, 系统会自动生成一个缺省的析构函数.
  • 如果类中有指针, 且在使用的过程中动态申请了内存, 那么需要显示构造析构函数, 在销毁类之前, 释放掉申请的内存空间, 避免内存泄漏.

7.2. 访问控制与封装

7.2.1. public/private/protected

  • 定义在public说明符之后的成员在整个程序内可被访问, public成员定内的接口.
  • 定义在private说明符之后的成员可以被类的成员函数访问, 但是不能被使用该类的代码访问, private部分封装了(即隐藏了)类的实现细节.
  • 基类希望它的派生类有权访问该成员, 同时禁止其他用户访问. 我们用受保护的(protected)访问运算符说明这样的成员.

7.2.2. structclass的区别

  • structclass定义的唯一区别就是默认的访问权限(struct默认是public, class默认是private).
  • 使用习惯上, 只有少量成员变量的的用struct定义.

7.2.3. 友元

类可以允许其他类或者函数访问它的非公有成员, 方法是令其他类或者函数成为它的有元(friend).

7.3. 构造函数再探

7.3.1. 初始化顺序

成员变量的初始化顺序与它们在类定义中的出现顺序一致: 构造函数初始值列表中初始值的前后位置关系不会影响

7.3.2. explicit

  • 用于类的构造函数, 阻止其执行隐式类型转换, 但是仍可以被用来进行显式类型转换.

8. I/O 库

9. 顺序容器

9.1. 容器库概览

9.1.1. 迭代器

  • 迭代器(Iterator)模式又称游标(Cursor)模式, 用于提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示.
  • 迭代器本质上是类模板, 只是表现地像指针.

9.2. 顺序容器操作

9.2.1. emplace

当调用pushinsert成员函数时, 我们将元素类型的对象传递给它们, 这些对象被拷贝到容器中. 而当我们调用一个emplace成员函数时, 则是将参数传递给元素类型的构造函数. emplace成员使用这些参数在容器管理的内存空间中直接构造元素.

9.2.2. resize/reserve

  • resize: 改变容器内含有元素的数量.
  • reserve: 改变容器的最大容量.

9.2.3. 容器操作可能使迭代器失效

在向容器中添加元素后:

  • 如果容器是vectorstring, 且存储空间被重新分配, 则指向容器的迭代器, 指针和引用都会失效.
  • 对于deque, 插入到除首尾位置之外的任何位置都会导致迭代器指针和引用失效.
  • 对于list, 指向容器的迭代器指针和引用仍然有效.

从容器删除元素后:

  • 对于list, 指向容器的迭代器指针和引用仍然有效.
  • 对于deque, 在首尾之外的任何位置删除元素, 其他元素的迭代器也会失效.
  • 对于vectorstring, 被删元素之前的迭代器仍有效, 尾后迭代器失效.

对于关联式容器(如std::set / std::map), 插入元素不会使任何迭代器失效.

对于无序关联式容器(如std::unordered_set / std::unordered_map), 插入元素之后如果发生了 Rehash(新元素的个数大于max_load_factor() * bucket_count()), 则所有迭代器将失效

9.3. vector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//动态申请数组
const int M = 10;
const int N = 10;

//使用new申请一个一维数组.访问p_arr[i].
int* p_arr = new int[N];
//使用new申请一个二维数组.访问:p_arr[i][j].
int(*p_arr)[N] = new int[M][N];
//一维数组转化为二维数组.访问:p_arr[i*N+j].
int* p_arr = new int[M*N];
//指向指针的指针(指向一维指针数组).访问p[i][j]
int** p_arr = new int* [M]
for(int i = 0; i < M; i++)
p_arr[i] = new int[N];
//回收内存
for(int i = 0; i < M; i++)
delete []p_arr[i];
delete []p_arr;

//使用vector申请一个一维数组
vector<int> v_arr(n, 0);
vector<int> v_arr{1,0};
//使用vector申请一个二维数组, 如果不初始化, 使用[]会报错
vector<vector<int>> v_arr(m, vector<int>(n, 0));
vector<vector<int>> v_arr = {{1,0}};

//一维数组作为函数参数
void function(int* a);
void function(int a[]);
void function(int a[N]);
//二维数组作为函数参数,他们合法且等价
void function(int a[M][N]);
void function(int a[][N]);
void function(int (*a)[N])

9.4. string

1
2
3
4
5
6
7
8
9
10
11
12
string s("hello world")
string s2 = s.substring(0, 5); // s2 = hello
string s3 = s.substring(6); // s3 = world
string s4 = s.substring(6, 11);// s4 = world
string s5 = s.substring(12); // 抛出一个out_of_range异常

isalpha(ch); //判断一个字符是否是字母
isalnum(ch); //判断一个字符是数字或字母
tolower(ch); //将字母转化成小写
toupper(ch); //将字母转化为大写

string str = to_string(num); //将数字转换成字符串

9.5. vector对象是如何增长的

当不得不获取新的内存空间时, vectorstring的实现通常会分配一个比新的空间需求更大的内存空间. 容器预留这些空间作为备用, 可以用来保存更多的新元素. 这样, 就不需要每次添加新元素都重新分配容器的内存空间了.

  • capacity操作告诉我们容器在不扩张内存空间的情况下可以容纳多少个元素. reserve操作允许我们通知容器它应该准备保存多少个元素.

  • 初始时刻vectorcapacity为 0, 塞入第一个元素后capacity增加为 1.

  • 不同的编译器实现的扩容方式不一样, VS2015 中以 1.5 倍扩容, GCC以 2 倍扩容.

    • 从空间上分析, 扩容因子越大, 意味着预留空间越大, 浪费的空间也越多, 所以从空间考虑, 扩容因子因越小越好.
    • 从时间上分析, 如果预留空间不足的话, 就需要重新开辟一段空间, 把原有的数据复制到新空间, 如果扩容因子无限大的话, 那显然就不再需要额外开辟空间了. 所以时间角度看, 扩容因子越大越好.

9.6. 容器适配器

除了顺序容器外, 标准库还定义了三个顺序容器适配器: stackqueuepriority_queue.

本质上, 一个适配器是一种机制, 能使某种事物的行为看起来像另外一种事物一样.

默认情况下, stackqueue是基于deque实现的, priority_queue是在vector之上实现的.

9.6.1. priority_queue

1
2
3
4
5
6
std::priority_queue<int> q1; // 默认大根堆
std::priority_queue<int, std::vector<int>, std::greater<int>>
q2(data.begin(), data.end()); // 小根堆
// 使用lambda表达式
auto cmp = [](int left, int right) { return (left ^ 1) < (right ^ 1); };
std::priority_queue<int, std::vector<int>, decltype(cmp)> q3(cmp);

10. 泛型算法

10.1. lambda 表达式

一个 lambda 表达式表示一个可调用的代码单元. 我们可以将其理解为一个未命名的内联函数. 一个 lambda 表达式具有如下形式:

1
[capture list](parameter list) -> return type {function body}

其中capture list(捕获列表)是一个 lambda 所在函数中定义的局部变量的列表(通常为空); return type, parameter listfunction body与任何普通函数一样, 分别表示返回类型、参数列表和函数体. 但是与普通函数不同, lambda 必须使用尾置返回来制定返回类新.

我们可以忽略参数列表和返回类型, 但必须包含捕获列表和函数体:

1
auto f = [] {return 42};

11. 关联容器

  • map: 关键字-值对; set: 关键字即值.
  • map: 按关键字有序保存元素(底层为红黑树); unordered_map: 无序集合(底层为哈系表).
  • map: 关键字不可重复出现; multimap: 关键字可重复出现.

12. 动态内存

12.1. 智能指针

智能指针的行为类似常规指针, 重要的区别在于它负责自动释放所指向的对象.

1
shared_ptr
  • 允许多个指针指向同一个对象.
  • 我们可以认为每个shared_ptr都有一个关联的计数器, 通常称其为引用计数. 一旦一个shared_ptr的计数器变为 0, 他就会自动释放自己所管理的对象.
1
unique_ptr
  • “独占”所指向的对象.
1
weak_ptr
  • weak_ptr是一种弱引用, 指向shared_ptr所管理的对象.
  • 可打破环状引用(cycles of references, 两个其实已经没有被使用的对象彼此相互指向, 使之看似还在 “被使用” 的状态)的问题.
1
make_shared
  • make_shared 在动态内存中分配一个对象并初始化它, 返回指向此对象的shared_ptr.

13. 拷贝控制

13.1. 对象移动

  • 右值引用: 所谓右值引用就是必须绑定到右值的引用. 我们通过&&而不是&来获得右值引用. 右值引用有一个重要的性质: 只能绑定到一个将要销毁的对象.
  • 左值持久, 右值短暂: 左值有持久的状态, 而右值要么是字面常量, 要么是在表达式求值过程中创建的临时对象.
  • 通过调用std::move来获得绑定到左值上的右值引用.
1
2
3
int &&rr1 = 42;  // 正确: 字面常量是右值
int &&rr2 = rr1; // 错误: 表达式rr1是左值
int &&rr3 = std::move(rr1); // ok

14. 重载运算与类型转换

15. 面向对象程序设计

15.1. OOP: 概述

面向对象程序设计(object-oriented programming)的核心思想是数据抽象(封装)、继承和动态绑定(多态).

  • 通过数据抽象, 我们可以将接口与实现分离;
  • 使用继承, 可以定义相似的类型并对其相似关系建模;
  • 使用动态绑定, 可以在一定程度上忽略相似类型的区别, 而以统一的方式使用它们的对象.

15.2. 定义派生类和基类

15.2.1. 初始化顺序

  • 每个类控制它自己的成员初始化过程
  • 首先初始化基类的部分, 然后按照声明的顺序依次初始化派生类的成员.

15.2.2. 静态多态/动态多态

  • 静态多态是通过重载和模板技术实现,在编译的时候确定.
  • 动态多态通过虚函数和继承关系来实现,执行动态绑定, 在运行的时候确定.
  • 重载: 两个函数名相同,但是参数的个数或者类型不同.
  • 重写: 子类继承父类,符类中函数被声明为虚函数,子类中重新定义了这个虚函数.

15.3. 虚函数

  • 虚函数: 基类希望派生类覆盖的函数, 可以将其定义为虚函数, 这样每一个派生类可以各自定义适合自生的版本.

    • 当基类定义virtual函数的时候, 它希望派生类可以自己定义这个函数.
    • 如果使用virtual, 程序依据引用或者指针所指向对象的类型来选择方法(method).
    • 如果不使用virtual, 程序依据引用类型或者指针类型选择一个方法(method).
  • 虚函数表指针: 在有虚函数的类的对象最开始部分是一个虚函数表的指针, 这个指针指向一个虚函数表.

  • 虚函数表中放了虚函数的地址, 实际的虚函数在代码段(.text)中.

  • 当子类继承了父类的时候也会继承其虚函数表, 当子类重写父类中虚函数时候, 会将其继承到的虚函数表中的地址替换为重新写的函数地址.

  • 使用了虚函数, 会增加访问内存开销, 降低效率.

img

15.3.1. 虚析构函数

Q: 析构函数为什么是虚函数?

A: 将可能会被继承的基类的析构函数设置为虚函数, 可以保证当我们new一个派生类, 然后使用基类指针指向该派生类对象, 基类指针时可以释放掉派生类的空间, 防止内存泄漏.

Q: 为什么 C++ 默认析构函数不是虚函数?

A: C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针, 占用额外的内存; 所以只有当一个类会被用作基类时才将其设置为虚函数.

15.4. 抽象基类

  • 纯虚函数是一种特殊的虚函数, 在基类中不能对虚函数给出有意义的实现, 而把它声明为纯虚函数, 它的实现留给该基类的派生类去做. 书写=0就可以将一个虚函数说明为纯虚函数.
  • 含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class).

虚函数 VS 纯虚函数

  • 类里如果声明了虚函数, 这个函数是实现的, 哪怕是空实现, 它的作用就是为了能让这个函数在它的子类里面可以被覆盖(override), 这样的话, 编译器就可以使用后期绑定来达到多态了. 纯虚函数只是一个接口, 是个函数的声明而已, 它要留到子类里去实现.
  • 虚函数在子类里面可以不重写; 但纯虚函数必须在子类实现才可以实例化子类.
  • 虚函数的类用于 “实作继承”, 继承接口的同时也继承了父类的实现. 纯虚函数关注的是接口的统一性, 实现由子类完成.
  • 带纯虚函数的类叫抽象类, 这种类不能直接生成对象, 而只有被继承, 并重写其虚函数后, 才能使用. 抽象类被继承后, 子类可以继续是抽象类, 也可以是普通类.

15.5. 访问控制与继承

  • 公有继承保持原始状态(没有特殊要求一般用公有继承)
  • 私有继承基类的所有成员都作为派生类的私有成员
  • 保护继承基类的public作为派生类的保护成员, 其他不变.

16. 模板与泛型编程

17. 标准库特殊实施

18. 用于大型程序的工具

18.1. 多重继承与虚继承

  • 虚继承是解决 C++ 多重继承问题的一种手段, 从不同途径继承来的同一基类, 会在子类中存在多份拷贝, 即浪费存储空间, 又存在二义性的问题.
  • 底层实现原理与编译器相关, 一般通过虚基类指针和虚基类表实现, 每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间, 4 字节)和虚基类表(不占用类对象的存储空间)(需要强调的是, 虚基类依旧会在子类里面存在拷贝, 只是仅仅最多存在一份而已, 并不是不在子类里面了); 当虚继承的子类被当做父类继承时, 虚基类指针也会被继承.
  • 实际上, vbptr 指的是虚基类表指针(virtual base table pointer), 该指针指向了一个虚基类表(virtual table), 虚表中记录了虚基类与本类的偏移地址; 通过偏移地址, 这样就找到了虚基类成员, 而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝, 节省了存储空间.

19. 特殊工具和技术

19.1. 控制内存分配

19.1.1. new & delete

1
2
string *sp = new string("a value); // 分配并初始化一个string对象
string *arr = new string[10]; // 分配10个默认初始化的string对象

当我们使用一条new表达式时, 实际执行了三步操作:

  1. new表达式调用一个名为operate new(或者operate new[])的标准库函数. 该函数(从自由存储区上)分配一块足够大的, 原始的, 未命名的内存空间(无需指定内存块的大小)以便存储特定类型的对象(或对象的数组).
  2. 编译器运行相应的构造函数以构造这些对象, 并为其传入初值.
  3. 对象被分配了空间并构造完成, 返回一个指向该对象的指针.
1
2
delete sp;  // 销毁*sp, 然后释放sp指向的内存空间
delete [] arr; // 销毁数组中的元素, 然后释放对应的内存空间

当我们使用一条delete表达式删除一个动态分配的对象时, 实际执行了两步操作:

  1. sp所指的对象或者arr所指的数组中的元素执行对应的析构函数.
  2. 编译器调用名为operate delete(或者operate delete[])的标准库函数释放内存空间.

19.1.2. malloc&free

  • malloc需要显式的指出内存大小: 函数接受一个表示待分配字节数的size_t.
  • 返回指向分配空间的指针(void*)或者返回 0 以表示分配失败. (从堆上动态分配内存)
  • free函数接受一个void*, 它是malloc返回的指针的副本, free将相关内存返回给系统. 调用free(0)没有任何意义.
1
2
3
4
5
6
7
8
9
// operate new的一种简单实现
void *operater new(size_t size) {
if (void *men = malloc(size))
return mem;
else
throw bad_alloc();
}
// opearte delete的一种简单实现
void operator delete(void *mem) noexcept { free(mem); }

19.2. 固有的不可移植特性

19.2.1. volatile

  • 当对象的值可能在程序控制或检测之外(操作系统、硬件、其它线程等)被改变时, 应该将该对象声名为volatile. 关键字volatile告诉编译器不应对这样的对象进行优化.
  • volatile关键字声明的变量, 每次访问时都必须从内存中取出值(没有被volatile修饰的变量, 可能由于编译器的优化, 从 CPU 寄存器中取值).

19.2.2. extern

  • 在多个文件之间共享对象.
  • extern "C"的作用是让 C++ 编译器将extern "C"声明的代码当作 C 语言代码处理, 可以避免 C++ 因符号修饰导致代码不能和 C 语言库中的符号进行链接的问题.

20. 链接装载与库

本小节内容大部分摘录自《程序员的自我修养 - 链接装载与库》

20.1. .h 和 .cpp 文件的区别

  • .h文件里面放申明, .cpp文件里面放定义.
  • .cpp文件会被编译成实际的二进制代码, 而.h文件是在被 include 中之后复制粘贴到 .cpp 文件里.

20.2. 编译和链接

  1. 预编译(预处理): 预编译过程主要处理那些源代码文件中的以”#”开始的预编译指令. 比如”#include“、”#define“等. 生成.i或者.ii文件.
  2. 编译: 把预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件(.s文件).
  3. 汇编: 将汇编代码转变成机器可以执行的指令(机器码), 生成.o文件.
  4. 链接: 链接器进行地址和空间分配、符号决议、重定位等步骤, 生成 .out文件.

20.3. 程序的内存布局

一般来讲, 应用程序使用的内存空间里有如下”默认”区域.

  • 栈: 栈用于维护函数调用的上下文. 由操作系统自动分配释放, 一般包含以下几个方面:

    • 函数的返回地址和参数
    • 临时变量: 包括函数的非静态局部变量以及编译器自动生成的其他临时变量
    • 保存上下文: 包括函数调用前后需要保持不变的寄存器
  • 堆: 堆是用来容纳应用程序动态分配的内存区域. 由程序员分配释放 ,当程序使用malloc或者new分配内存时, 得到的内存来自堆里.

  • 可执行文件映像: 存储着可执行文件在内存里的映像, 由装载器在装载时将可执行文件的内存读取或映射到这里.

    • .data: 静态区, 存放全局变量和局部静态变量.
    • .bss: 存放未初始化的全局变量和局部静态变量.
    • .text: 代码区, 存放 C 语言编译后的机器代码, 不可在运行期间修改.
  • 保留区: 保留区并不是一个单一的内存区域, 而是对内存中受到保护而禁止访问的内存区域的总称. 如通常 C 语言将无效指针赋值为 0(NULL), 因此 0 地址正常情况下不可能有有效的访问数据.

img

图 20.1 Linux 进程地址空间布局

20.3.1. 段错误

Q: 程序出现”段错误(segment fault)”或者”非法操作, 该内存地址不能 read/wirte”的错误信息, 是什么原因?

A: 这是典型的非法指针解引用造成的错误. 当指针指向一个不允许读或写的内存地址, 而程序却试图利用指针来读或写该地址的时候, 就会出现这个错误. 可能的段错误发生的时机如下:

  • 指针没有初始化或者初始化为nullptr, 之后没有给它一个合理的值就开始使用指针.
  • 使用野指针(指向一个已删除的对象或者未申请访问受限内存区域的指针).
  • 指向常量的指针试图修改相关内容.

20.4. 编译型语言 VS 解释型语言

  • 有的编程语言要求必须提前将所有源代码一次性转换成二进制指令, 也就是生成一个可执行程序(Windows 下的 .exe), 比如 C 语言、C++、Golang、Pascal(Delphi)、汇编等, 这种编程语言称为编译型语言, 使用的转换工具称为编译器.
  • 有的编程语言可以一边执行一边转换, 需要哪些源代码就转换哪些源代码, 不会生成可执行程序, 比如 Python、JavaScript、PHP、MATLAB 等, 这种编程语言称为解释型语言, 使用的转换工具称为解释器.

其他常见问题

finaloverride的作用?final为什么能提高代码执行效率?

override:保证在派生类中声明的重载函数,与基类的虚函数有相同的签名,作用就是用于编译期代码检查。
final:阻止类的进一步派生和虚函数的进一步重写,同时也是一种名为去虚拟化的优化技巧,相当于把运行期多态转换为了编译期多态,提高了执行效率。

(2)static的3种作用?

修饰局部变量,这意味着该变量只被初始化一次,并保留其值直到程序结束。
修饰全局变量,这意味着该变量具有文件作用域。
修饰成员变量,这意味着变量不会绑定到对象上。

(3)thread_local作用和原理?

thread_local用来定义一个线程本地变量,每个线程都拥有自己的thread_local对象副本,这些副本放在各个线程自己的TLS空间。thread_local描述的对象在thread开始时分配,而在thread结束时释放。

(4)一个变量可以既是const又是volatile吗?

可以。const的作用是告诉编译器,编译期间不允许对变量进行修改,编译器在编译期间往往会对const变量执行一种名为字符替换的优化。volatile的作用是告诉编译器,第一,编译期间不要对该变量进行优化;第二,运行期间,每次必须从内存中加载变量的值。const volatile表示一个变量在程序编译期不能被修改且不能被优化;在程序运行期,每次必须从内存中加载变量的值。

(5)NULLnullptr区别?为什么要引入nullptr

主流编译器中,NULL 实际上是一个整数常量,被定义为 0,在 C++11 之前,当我们想要将一个指针初始化为空时,我们通常使用 NULLnullptr 是 C++11 中引入的新的关键字,专门用于表示空指针,它不是整数类型,而是特殊的指针类型nullptr_t。之所以引入nullptr,第一,NULL是整数类型,用户调用foo(NULL)的时候,不能区分调用的是foo(int)还是foo(int*)函数;第二,主流编译器中NULL值为0,通过0表示一个无效地址,但是有的架构下,0地址有特定用途,而nullptr指向的永远是一个无效地址。

(6)为什么noexcept能提高性能?

使用 noexcept 可以让编译器对代码进行优化,从而提高代码的性能。具体来说,为实现异常捕获的功能,c++引入了“栈回退”机制,编译器在编译函数的时候,会为函数生成额外的叫做“栈回退”的代码,使用noexcept 可以避免生成额外的代码来处理异常情况,这样可以减少代码量和执行时间。关闭异常捕获是比较危险的行为,一般只建议用在构造函数。

(7)delete[]是怎样知道数组长度的?

没有标准实现,一种常见的实现方法是,申请内存时,会在返回的指针前面存放这段内存的大小,调用delete[]的时候,就可以知道数组长度了。

(8)newplacement newoperator new的区别?怎么在把对象new在栈上?

operator new作用是分配一块内存,placement new作用是在已分配内存地址处,创建一个对象,new的作用则等于operator new + placement new。先在栈上声明一个数组,然后通过placement new 在这段地址处创建对象,这就实现了在栈上new一个对象。

(9)__cdecl__stdcall区别?

都是 Microsoft Visual C++ 中用于声明函数调用约定的关键字。__cdeclC/C++ 默认的调用约定,在 __cdecl 调用约定下,参数从右往左入栈,由调用方负责清理堆栈;在__stdcall 调用约定下,函数参数从右向左压入堆栈中,函数堆栈的清除工作由被调用方负责。这些关键字主要用于跨语言调用,以确保参数传递和堆栈清理的一致性。

(10)重载类的delete运算符,delete的时候会发生什么?

new的默认行为是先分配一段内存,然后调用对象的构造函数,把对象创建在这段内存上;delete的默认行为是先调用析构函数,然后释放内存。重载全局newdelete运算符号,会修改所有的newdelete内存行为,重载类的newdelete运算符,会修改针对这个类的newdelete内存行为。

(11)函数调用压栈流程?

不同系统下压栈的具体操作不同,但大致都有这么个过程:函数调用的时候,把被调用函数参数压栈,把预留的返回值存放位置压栈,把当前函数上下文,比如栈地址相关的寄存器和指令地址相关的寄存器内容压栈,函数返回的时候,弹出函数参数和返回值,弹出函数上下文内容到寄存器,恢复现场。

(12)声明和定义的作用,从编译角度说明?

声明的作用主要两点,第一,提供链接时需要的符号信息,这些信息存储在目标文件的重定位表和符号表当中;第二,提供类型大小信息,c++采取的是单文件编译策略,当不知道某个符号对应类型的定义的时候,需要在链接前预留出合适大小的内存空间,供链接时填充。

(13)现代大部分编程语言都没有头文件,c++为什么有头文件?头文件和模块的优劣比较?#include和前置声明的区别?

c++和采取模块机制的编程语言的一个重大区别在于,c++把函数和变量签名这部分信息保存在头文件内,而采取模块机制的编程语言把这部分信息保存在库内。头文件和模块相比,会拷贝很多无用的声明信息到当前文件内,从而导致编译非常慢,另一个缺点就是头文件机制很容易引发符号重定义错误。c++之所以采用头文件机制是因为,早期计算机的内存资源非常珍贵,如果把函数和变量签名信息都保存到二进制库中,会浪费更多的内存资源。
#include和前置声明本质上都是声明,区别在于#include在预处理期间做了一次拷贝声明的操作,前置声明的优势在于可以按需导入函数,而且可以解决循环依赖问题。

(14)C++11为什么引入枚举类?

传统的 C++ 枚举类型会将枚举值暴露在命名空间中,容易造成命名冲突,而枚举类则通过引入了作用域限定符来解决这个问题。其次,传统的 C++ 枚举类型是基于整数的,可以进行隐式的类型转换和比较操作,这可能会导致一些意想不到的错误,而枚举类则可以避免这个问题,因为它们只能进行显式的类型转换和比较操作。

(15)程序是从main 函数开始执行的吗?

不是,程序在执行前,会经历一个从磁盘加载程序到内存的过程,这个过程会执行全局变量的初始化。

(16)虚函数怎么实现的?真的更慢吗?

虚函数是通过虚函数表实现的,每个类都有自己的虚表,对象的首地址处存放有指向虚表的指针。当具体调用哪个虚函数可以在编译期间确定的时候,虚函数不一定更慢。

(17)构造函数、析构函数、重载运算符函数可以是虚函数吗?类成员函数模板可以是虚函数吗?

析构函数和重载运算符函数都可以是虚函数,而构造函数不能是虚函数,首先C++编译器层面不允许这种操作,第二构造函数不需要动态多态,C++引入虚函数的目的就是为了解决编译期间无法确定调用对象的问题,而对于构造函数这类特殊函数,编译期间就已经明确知道需要创建的对象类型。类成员函数模板不能是虚函数,因为C++在链接前是不知道成员函数模板被实例化多少次的,这就会导致编译器无法在编译期间确定虚表的大小。

(18)成员函数指针和普通函数指针区别?

普通函数指针属于指针类型,成员函数指针不是指针类型。通常来说,函数指针的长度等于机器字长,而成员函数指针长度比函数指针更长,其内部存放了对象地址和成员函数地址信息。在没有给出对象地址的情况下,调用成员函数指针会报错。

(19)各种变量存放在虚拟内存的哪个分区?

直接声明的变量、函数实参存储在栈区;new创建的对象,较小的对象存放在堆 区,较大的对象存放在共享内存区;常量和静态变量存放在静态存储区中的非代码区;所有函数存放在静态存储区中的代码区;字符常量也存放在代码区。

(20)对象的内存模型?发生继承时候的对象内存模型?

成员函数存放在代码区;静态成员变量存放在静态存储区;普通成员变量存放在对象内,且按照声明顺序依次存放;如果类声明了虚函数,那么对象的首地址处往往会存放一个指向虚表的指针,另外访问权限关键字可能会影响对象的内存布局,至于怎么影响,标准没有规定,不同编译器的实现可能不同。发生继承的时候,基类对象怎样存放,标准也没有规定,一般是按照继承顺序依次存放在内存当中,每个对象都可以有自己的虚表。

(21)什么是标准布局类型和trivial类型?有什么用?

引入标准布局类型是为了向C语言兼容,使得用户能够通过对象第一个成员的指针类型指向对象;引入trivial类型是为了提高对象初始化效率,memcpy比构造函数初始化效率效率更高。不考虑继承,一个类没有虚函数、所有非静态变量的访问权限相同,则是标准布局类型;不考虑继承,一个类没有自定义构造、自定义析构函数,没有虚函数,则是trivial类型。

(22)什么是类型擦除?实现方式?

类型擦除是一种,使得不同类型变量能够得到统一处理的技术。实现方式上可分为静态类型擦除了动态类型擦除,静态类型擦除通过模板或者宏实现,动态类型擦除可通过继承虚函数或者void类型实现。

(23)什么是多态?实现方式?

多态指的是一种相同的形式表现出不同行为的概念,分为静态多态和动态多态。代码层面,静态多态通过重载(overload)实现,动态多态通过覆盖(override)实现;原理层面,静态多态通过name mangling实现,动态多态通过虚表实现。

(24)inline的作用和原理?

c++17以前,inline关键字主要有两个作用:第一,作为内联优化建议,告诉编译器在调用处展开函数,只不过是否展开函数还是由编译器决定;第二,解决符号重定义问题,不同文件内定义了同签名的函数,若被inline关键字修饰,则不会引发符号重定义错误。c++17开始,inline只保留第二个作用,若用户希望函数内联展开,则可以使用__attribute((always_inline))__ 关键字,它是 GCC 和 Clang 中的一个扩展,用于强制内联函数。。
原理上,第一,内联展开相比于普通函数调用,少了函数上下文压栈的过程,因此效率更高,缺点就是容易引起代码膨胀。第二,被inline关键字修饰的函数名,编译期间会被标记为weak符号,链接目标文件的时候,多个同签名weak符号不会引发编译器报错,运行期间,会选取其中一个函数进行调用。

(25)inline用作内联展开这层含义的时候,构造函数、析构函数、虚函数可以被inline修饰吗?可以获取inline函数的指针吗?static inlineextern inline含义?

任何函数都可以被inline修饰,包括构造函数、析构函数、虚函数。这里提一下为什么虚函数可以内联,inline函数涉及到的是编译期解析,虚函数地址大多数情况下在运行期解析,但是某些情况下,具体调用哪个虚函数可以在编译期间确定,这个时候虚函数就能内联展开了。
inline只作为内联建议,是否展开由编译器决定,因此是可以获取inline函数指针的。
static inline指的是具有文件作用域的inline函数;extern inline作用比较特殊,外部单元把它当作普通函数进行调用,同单元内把它当作inline函数调用。

(26)mallocnew区别?malloc实现原理?free后,内存被释放了吗?

malloc只分配一段内存,new会先分配一段内存,然后在这段内存上创建对象。malloc实现上,先从用户态切换到内核态,分配一段空闲物理内存,接着在虚拟内存堆空间或者共享内存空间分配一段虚拟内存,然后填充页表,把虚拟内存映射到物理内存,最后返回用户态。free后,内存没有被立即释放,而是保留在内存当中,作为内存池的一部分供下次使用。

(27)谈谈lambda函数

lambda函数可以看作是函数对象的语法糖,可以随地定义和调用。可通过lambda和智能指针实现闭包,c++17以前,lambda不支持*this捕获,c++17开始支持*this捕获,即非静态成员函数内部定义的lambda函数不需要通过显式指定this,就可以访问对象成员。

(28)unionstructclass的区别?

structclass都可以用来定义类,struct成员默认publicclass成员默认private,只不过从语义上来说,建议用struct定义数据块,class定义类。struct 每个成员变量都有自己的内存地址;union 内存占用大小为其成员中需要空间最大者,每个成员变量都占用相同的内存单元。

(29)什么是零三五原则?

零之法则:对于不需要通过析构函数回收资源的类,只定义普通构造函数。
三之法则:如果某个类需要用户定义析构函数回收资源,那么这个类除了要定义普通构造函数外,也一定要定义复制构造函数、赋值运算函数。
五之法则:因为用户定义的析构函数、复制构造函数或复制赋值运算符的存在会阻止移动构造函数移动赋值运算符的隐式定义,所以任何想要移动语义的类必须声明全部五个特殊成员函数

(30)C++可调用类型有哪些?

函数指针、函数对象、lambda表达式、成员函数指针。

(31)为什么把析构函数定义为虚函数?

解决delete 指向子类对象的基类指针的时候,只析构基类、不析构子类的问题。

(32)构造函数和析构函数的调用顺序?

创建对象过程,先调用基类的构造函数,然后依次调用类非静态成员的构造函数,最后调用自己的构造函数;销毁对象过程,先调用自己的析构函数,然后依次调用非静态成员的析构函数,最后调用基类的析构函数。

(33)指针和引用的区别?

引用和指针在汇编层面都是内存地址,引用可以看作是指针常量,只能在声明的时候初始化,相比于指针,引用的优势在于编译器帮我们检查地址是否初始化。

(34)符号重定义的解决方法?

通过externstaticinlineconst关键字都可以解决符号重定义问题,也可以通过命名空间、前置声明、#ifndef#pragma once宏解决这个问题。

(35)四种指针类型转换的区别?

reinterpret_cast用于任意指针(引用)类型之间的转换,不进行类型检查。
static_cast用于基类和子类指针(引用)之间的转换,编译期进行类型检查。
dynamic_cast用于基类和子类指针(引用)之间的转换,运行期进行类型检查。
const_cast用于指针(引用)类型,用于删除限定符,不进行类型检查。

(36)知道什么是RVO吗?

RVO是一种返回值优化手段,它通过避免创建临时对象来提高代码性能。当一个函数返回一个非引用类型的变量时,编译器会尝试将该对象直接构造在调用者的栈帧空间中,而不是为该对象分配新的内存并在函数返回后再将其拷贝到调用者的栈帧空间中。

(37)RTTI的实现原理?

RTTI指的是运行时类型识别,通过虚表实现,指向类型信息的指针存放在虚表上。

(38)extern C的作用?

extern "C" 是 C++ 提供的一个关键字,用于指示编译器将某个函数或变量的名称按照 C 语言的方式进行处理,以便与C语言进行交互。其原理上就是关闭编译器的name mangling。

(39)可以在运行时访问private成员吗?

可以,访问权限关键字只在编译期有效,运行期是没有访问权限关键字这些概念的,可以在运行时访问对象内的任何成员。

(40)C++的编译流程?

先预处理,然后编译成目标文件,接着把目标文件链接成库文件或者可执行文件。

(41)动态库和静态库的区别?知道动态库延迟加载优化吗?

链接动态库和静态库的时候,静态库会被复制到可执行程序当中,而动态库不会。相比动态库,静态库的执行效率更高,但占用磁盘空间更多,不方便更新。动态库的延迟加载指的是,在运行时按需加载动态链接库中的函数和数据,而不是在启动的时候加载库函数和数据,从而降低启动时间,在linux系统下,延迟加载是通过PLT表和GOT表配合实现的。

(42)智能指针是什么?几种智能指针的区别?

智能指针是RAII思想的一种应用,shared_ptr是最常用的智能指针,但是,第一,效率低,可以通过在特定场合使用unique_ptr弥补这点;第二,有循环引用的问题,故引入weak_ptr;第三,不能直接封装this并返回,否则会引起引用计数错误,故引入enable_shared_from_this

(43)四种智能指针的简单实现?

不考虑删除器的实现,unique_ptr内部封装一个指针,在构造函数内把地址传给指针,析构函数内销毁指针指向的对象;shared_ptr内部封装一个指针,和一个存放在堆空间的引用计数,重新实现构造、拷贝构造、赋值构造、析构函数,每次调用构造函数、赋值构造函数、拷贝构造函数的时候,通过原子操作,对引用计数加1,每次调用析构函数,通过原子操作对引用计数减1,计数为0则销毁对象;weak_ptr实现和shared_ptr类似,不同在于它不影响引用计数;enable_shared_from_this通过CRTP实现。

(44)什么是左值和右值?它们是C++11才有的吗?string literal是左值还是右值?i++++i是左值还是右值

左值是可以取地址的值,右值是不可取地址的值,右值之所以不能取地址,往往是因为这些值可能在寄存器上、可能是指令的一部分、可能是栈上的匿名变量。左右值是C语言出现开始,一直都有的概念,只是没有给他们明确下定义。string literal是左值,++i是返回值是i本身,是左值,i++会返回一个临时变量,是右值。

(45)什么是左右值引用?和左右值有关系吗?右值引用适合什么场景下用?

左值引用和右值引用在汇编层面都是地址,右值引用的出现是伴随着移动构造函数出现的,之所以引入右值引用类型的语法,是为了区分拷贝构造函数和移动构造函数,更准确地来说是为了区分深拷贝和浅拷贝。只有右值才可以赋值给右值引用,但是右值和右值引用没有严格意义上的关系,把右值赋值给右值引用往往是不合理的,反而会降低运行效率,不要把字面值赋值给右边值引用,不要以右值引用的方式返回函数返回值。右值引用仅仅适用于把将亡值传递给函数参数这类场景。

(46)基本类型的长度?

这些长度可能会因编译器、操作系统和计算机体系结构的不同而有所变化。char长度是1字节;short长度至少2字节,大多情况下2字节;int长度至少2字节,大多数情况下4字节;long int长度大于等于int长度;float长度4字节;double长度8字节。所以为了移植性,一般不建议直接使用这些类型,建议使用int8_tint16_tint32_t等类型。

(47)内存对齐规则?为什么要内存对齐?

内存对齐有两个要求,第一,C++中有对齐系数这个概念,任何类型在内存中的首地址必须是自身对齐系数的整数倍,基本类型的对齐系数等于自身大小,结构体类型的内存对齐系数等于内存占用最大的基本类型成员的大小;第二,结构体内类型,相对于结构体首地址的偏移必须等于自身对齐系数的整数倍。引入内存对齐,是为了减少CPU访问内存数据的次数,提高取数据的效率。

(48)通过指针访问数组,系统是如何知道指针越界的?

编译器编译代码期间会增加额外的代码用于检测数组是否越界。生成下标越界检查代码,C语言默认关闭;C++默认开启。

(49)断言是什么?断言和条件语句的优劣?

断言用于在代码编译或者执行期间检查特定条件是否成立,不成立则报错终止。静态断言和动态断言是两种不同类型的断言。静态断言在代码编译期间进行验证,并在发现问题时引发编译时错误;动态断言在代码运行期间进行验证,并在发现问题时引发异常或错误。C++内,动态断言通常只在调试模式下启用,而在发布模式下会被忽略。断言相比于条件语句,效率更高,但降低了程序安全性。

(50)继承和组合的优劣?

继承和组合都是代码复用的方案,继承的耦合性更高,但提供了更多复用特性,比如publicprivate复用、比如多态。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

扫一扫,分享到微信

微信分享二维码

请我喝杯咖啡吧~

支付宝
微信