3

万字长文超全C++面经

 8 months ago
source link: https://www.51cto.com/article/779151.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文经自动驾驶之心公众号授权转载,转载请联系出处。

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

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

1.1. C 和 C++ 的区别

设计思想上:

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

2. 变量和基本类型

2.1. 复合类型

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

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

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

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

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

表 2.1 指针与数组的区别

676682a853b0ffe4d4f8908cba8c5ab297f776.png

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

462b09d80e87a920a9c65995fd0d5337cbec26.png

3. 字符串、向量和数组

4. 表达式

4.1. 右值

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

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

4.2. ++i/i++

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

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

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

// 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

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: 为运算对象的位模式提供较低层次的重新解释, 常用于函数指针的转换.

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函数的处理步骤

  • 将inline函数体复制到inline函数调用点处;
  • 为所用inline函数中的局部变量分配内存空间;
  • 将inline函数的的输入参数和返回值映射到调用方法的局部变量空间中;
  • 如果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是一种预处理器宏. 使用一个表达式作为它的条件:

assert(expr);

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

6.6. 函数指针

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

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

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

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

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

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. struct和class的区别

  • struct与class定义的唯一区别就是默认的访问权限(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

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

9.2.2. resize/reserve

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

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

在向容器中添加元素后:

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

从容器删除元素后:

  • 对于list, 指向容器的迭代器指针和引用仍然有效.
  • 对于deque, 在首尾之外的任何位置删除元素, 其他元素的迭代器也会失效.
  • 对于vector或string, 被删元素之前的迭代器仍有效, 尾后迭代器失效.
  • 对于关联式容器(如std::set / std::map), 插入元素不会使任何迭代器失效.
  • 对于无序关联式容器(如std::unordered_set / std::unordered_map), 插入元素之后如果发生了 Rehash(新元素的个数大于max_load_factor() * bucket_count()), 则所有迭代器将失效.

9.3. vector

//动态申请数组
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

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对象是如何增长的

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

  • capacity操作告诉我们容器在不扩张内存空间的情况下可以容纳多少个元素. reserve操作允许我们通知容器它应该准备保存多少个元素.
  • 初始时刻vector的capacity为 0, 塞入第一个元素后capacity增加为 1.
  • 不同的编译器实现的扩容方式不一样, VS2015 中以 1.5 倍扩容, GCC以 2 倍扩容.
  • 从空间上分析, 扩容因子越大, 意味着预留空间越大, 浪费的空间也越多, 所以从空间考虑, 扩容因子因越小越好.
  • 从时间上分析, 如果预留空间不足的话, 就需要重新开辟一段空间, 把原有的数据复制到新空间, 如果扩容因子无限大的话, 那显然就不再需要额外开辟空间了. 所以时间角度看, 扩容因子越大越好.

9.6. 容器适配器

除了顺序容器外, 标准库还定义了三个顺序容器适配器: stack、queue和priority_queue.

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

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

9.6.1. priority_queue

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 表达式具有如下形式:

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

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

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

auto f = [] {return 42};

11. 关联容器

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

12. 动态内存

12.1. 智能指针

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

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

13. 拷贝控制

13.1. 对象移动

  • 右值引用: 所谓右值引用就是必须绑定到右值的引用. 我们通过&&而不是&来获得右值引用. 右值引用有一个重要的性质: 只能绑定到一个将要销毁的对象.
  • 左值持久, 右值短暂: 左值有持久的状态, 而右值要么是字面常量, 要么是在表达式求值过程中创建的临时对象.
  • 通过调用std::move来获得绑定到左值上的右值引用.
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)中.
  • 当子类继承了父类的时候也会继承其虚函数表, 当子类重写父类中虚函数时候, 会将其继承到的虚函数表中的地址替换为重新写的函数地址.
  • 使用了虚函数, 会增加访问内存开销, 降低效率.
18df1ac20925d87d8eb764e77c8e5fe78430ab.png

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

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

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

  • new表达式调用一个名为operate new(或者operate new[])的标准库函数. 该函数(从自由存储区上)分配一块足够大的, 原始的, 未命名的内存空间(无需指定内存块的大小)以便存储特定类型的对象(或对象的数组).
  • 编译器运行相应的构造函数以构造这些对象, 并为其传入初值.
  • 对象被分配了空间并构造完成, 返回一个指向该对象的指针.
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)没有任何意义.
// 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 地址正常情况下不可能有有效的访问数据.
28918c405b9b887f10b080273cc24a2df7d8e6.png

图 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 等, 这种编程语言称为解释型语言, 使用的转换工具称为解释器.

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK