面试-C++
Abstract: 常见的C++面试题集合,包括Leetcode面试宝典、阅读整理、课堂资料等等。
Quick Link: 面试-2021微信暑期实习
(非完整版~)
1 C++基础
1.1 语言特性
1.1.1 C++11有哪些新特性
auto 类型推导
auto 关键字:自动类型推导,编译器会在 编译期间 通过初始值推导出变量的类型,通过 auto 定义的变量必须有初始值。auto 关键字基本的使用语法如下:
1
auto var = val1 + val2; // 根据 val1 和 val2 相加的结果推断出 var 的类型,
注意:编译器推导出来的类型和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。
decltype 类型推导
decltype 关键字:decltype 是“declare type”的缩写,译为“声明类型”。和 auto 的功能一样,都用来在编译时期进行自动类型推导。如果希望从表达式中推断出要定义的变量的类型,但是不想用该表达式的值初始化变量,这时就不能再用 auto。decltype 作用是选择并返回操作数的数据类型。
区别:1
2auto var = val1 + val2;
decltype(val1 + val2) var1 = 0;auto 根据 = 右边的初始值 val1 + val2 推导出变量的类型,并将该初始值赋值给变量 var;decltype 根据 val1 + val2 表达式推导出变量的类型,变量的初始值和与表达式的值无关。
auto 要求变量必须初始化,因为它是根据初始化的值推导出变量的类型,而 decltype 不要求,定义变量的时候可初始化也可以不初始化。lambda
表达式lambda
表达式,又被称为lambda
函数或者lambda
匿名函数。lambda匿名函数的定义:
1
2
3
4[capture list] (parameter list) -> return type
{
function body;
};其中:
capture list:捕获列表,指 lambda 所在函数中定义的局部变量的列表,通常为空。
return type、parameter list、function body:分别表示返回值类型、参数列表、函数体,和普通函数一样。
举例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
using namespace std;
int main()
{
int arr[4] = {4, 2, 3, 1};
//对 a 数组中的元素进行升序排序
sort(arr, arr+4, [=](int x, int y) -> bool{ return x < y; } );
for(int n : arr){
cout << n << " ";
}
return 0;
}范围
for
语句
语法格式:1
2
3for (declaration : expression){
statement
}参数的含义:
expression:必须是一个序列,例如用花括号括起来的初始值列表、数组、vector ,string 等,这些类型的共同特点是拥有能返回迭代器的 beign、end 成员。
declaration:此处定义一个变量,序列中的每一个元素都能转化成该变量的类型,常用 auto 类型说明符。
实例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
using namespace std;
int main() {
char arr[] = "hello world!";
for (char c : arr) {
cout << c;
}
return 0;
}
/*
程序执行结果为:
hello world!
*/右值引用
右值引用:绑定到右值的引用,用&&
来获得右值引用,右值引用只能绑定到要销毁的对象。为了和右值引用区分开,常规的引用称为左值引用。
举例:1
2
3
4
5
6
7
8
9
10
11
12
13
using namespace std;
int main()
{
int var = 42;
int &l_var = var;
int &&r_var = var; // error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int' 错误:不能将右值引用绑定到左值上
int &&r_var2 = var + 40; // 正确:将 r_var2 绑定到求和结果上
return 0;
}标准库 move() 函数
move() 函数:通过该函数可获得绑定到左值上的右值引用,该函数包括在 utility 头文件中。智能指针
delete 函数和 default 函数
delete 函数:= delete 表示该函数不能被调用。
default 函数:= default 表示编译器生成默认的函数,例如:生成默认的构造函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using namespace std;
class A
{
public:
A() = default; // 表示使用默认的构造函数
~A() = default; // 表示使用默认的析构函数
A(const A &) = delete; // 表示类的对象禁止拷贝构造
A &operator=(const A &) = delete; // 表示类的对象禁止拷贝赋值
};
int main()
{
A ex1;
A ex2 = ex1; // error: use of deleted function 'A::A(const A&)'
A ex3;
ex3 = ex1; // error: use of deleted function 'A& A::operator=(const A&)'
return 0;
}
1.1.2 C 和 C++ 的区别
首先说一下面向对象和面向过程:
- 面向过程的思路:分析解决问题所需的步骤,用函数把这些步骤依次实现。
- 面向对象的思路:把构成问题的事务分解为各个对象,建立对象的目的,不是完成一个步骤,而是描述某个事务在解决整个问题步骤中的行为。
区别和联系:
语言自身:C 语言是面向过程的编程,它最重要的特点是函数,通过 main 函数来调用各个子函数。程序运行的顺序都是程序员事先决定好的。C++ 是面向对象的编程,类是它的主要特点,在程序执行过程中,先由主 main 函数进入,定义一些类,根据需要执行类的成员函数,过程的概念被淡化了(实际上过程还是有的,就是主函数的那些语句。),以类驱动程序运行,类就是对象,所以我们称之为面向对象程序设计。面向对象在分析和解决问题的时候,将涉及到的数据和数据的操作封装在类中,通过类可以创建对象,以事件或消息来驱动对象执行处理。
应用领域:C 语言主要用于嵌入式领域,驱动开发等与硬件直接打交道的领域,C++ 可以用于应用层开发,用户界面开发等与操作系统打交道的领域。
C++ 既继承了 C 强大的底层操作特性,又被赋予了面向对象机制。它特性繁多,面向对象语言的多继承,对值传递与引用传递的区分以及 const 关键字,等等。
C++ 对 C 的“增强”,表现在以下几个方面:类型检查更为严格。增加了面向对象的机制、泛型编程的机制(Template)、异常处理、运算符重载、标准模板库(STL)、命名空间(避免全局命名冲突)。
1.1.3 Java 和 C++ 的区别
二者在语言特性上有很大的区别:
- 指针:C++ 可以直接操作指针,容易产生内存泄漏以及非法指针引用的问题;Java 并不是没有指针,虚拟机(JVM)内部还是使用了指针,只是编程人员不能直接使用指针,不能通过指针来直接访问内存,并且 Java 增加了内存管理机制。
- 多重继承:C++ 支持多重继承,允许多个父类派生一个类,虽然功能很强大,但是如果使用的不当会造成很多问题,例如:菱形继承;Java 不支持多重继承,但允许一个类可以继承多个接口,可以实现 C++ 多重继承的功能,但又避免了多重继承带来的许多不便。
- 数据类型和类:Java 是完全面向对象的语言,所有函数和变量部必须是类的一部分。除了基本数据类型之外,其余的都作为类对象,包括数组。对象将数据和方法结合起来,把它们封装在类中,这样每个对象都可实现自己的特点和行为。而 C++ 允许将函数和变量定义为全局的。
垃圾回收:
- Java 语言一个显著的特点就是垃圾回收机制,编程人员无需考虑内存管理的问题,可以有效的防止内存泄漏,有效的使用空闲的内存。
- Java 所有的对象都是用 new 操作符建立在内存堆栈上,类似于 C++ 中的 new 操作符,但是当要释放该申请的内存空间时,Java 自动进行内存回收操作,C++ 需要程序员自己释放内存空间,并且 Java 中的内存回收是以线程的方式在后台运行的,利用空闲时间。
应用场景:
- Java 运行在虚拟机上,和开发平台无关,C++ 直接编译成可执行文件,是否跨平台在于用到的编译器的特性是否有多平台的支持。
- C++ 可以直接编译成可执行文件,运行效率比 Java 高。
- Java 主要用来开发 Web 应用。
- C++ 主要用在嵌入式开发、网络、并发编程的方面。
1.1.4 Python 和 C++ 的区别
区别:
- 语言自身:Python 为脚本语言,解释执行,不需要经过编译;C++ 是一种需要编译后才能运行的语言,在特定的机器上编译后运行。
- 运行效率:C++ 运行效率高,安全稳定。原因:Python 代码和 C++ 最终都会变成 CPU 指令来跑,但一般情况下,比如反转和合并两个字符串,Python 最终转换出来的 CPU 指令会比 C++ 多很多。首先,Python 中涉及的内容比 C++ 多,经过了更多层,Python 中甚至连数字都是 object ;其次,Python 是解释执行的,和物理机 CPU 之间多了解释器这层,而 C++ 是编译执行的,直接就是机器码,编译的时候编译器又可以进行一些优化。
- 开发效率:Python 开发效率高。原因:Python 一两句代码就能实现的功能,C++ 往往需要更多的代码才能实现。
- 书写格式和语法不同:Python 的语法格式不同于其 C++ 定义声明才能使用,而且极其灵活,完全面向更上层的开发者。
1.1.5 左值和右值的区别?左值引用和右值引用的区别,如何将左值转换成右值?
左值:指表达式结束后依然存在的持久对象。
右值:表达式结束就不再存在的临时对象。
左值和右值的区别:左值持久,右值短暂
右值引用和左值引用的区别:
左值引用不能绑定到要转换的表达式、字面常量或返回右值的表达式。右值引用恰好相反,可以绑定到这类表达式,但不能绑定到一个左值上。
右值引用必须绑定到右值的引用,通过 && 获得。右值引用只能绑定到一个将要销毁的对象上,因此可以自由地移动其资源。
std::move 可以将一个左值强制转化为右值,继而可以通过右值引用使用该值,以用于移动语义。
1 |
|
1.1.6 std::move() 函数的实现原理
std::move()
函数原型:
1 | template <typename T> |
说明:引用折叠原理
右值传递给上述函数的形参 T&& 依然是右值,即 T&& && 相当于 T&&。
左值传递给上述函数的形参 T&& 依然是左值,即 T&& & 相当于 T&。
小结:通过引用折叠原理可以知道,move() 函数的形参既可以是左值也可以是右值。
remove_reference 具体实现:
1 | //原始的,最通用的版本 |
举例:
1 | int var = 10; |
总结:
std::move() 实现原理:
- 利用引用折叠原理将右值经过 T&& 传递类型保持不变还是右值,而左值经过 T&& 变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变;
- 然后通过 remove_refrence 移除引用,得到具体的类型 T;
- 最后通过 static_cast<> 进行强制类型转换,返回 T&& 右值引用。
1.1.7 什么是指针?指针的大小及用法?
指针: 指向另外一种类型的复合类型。
指针的大小: 在 64 位计算机中,指针占 8 个字节空间。
1 |
|
指针的用法:
指向普通对象的指针
1
2
3
4
5
6
7
8
9
10
11
12
13
using namespace std;
class A
{
};
int main()
{
A *p = new A();
return 0;
}指向常量对象的指针:常量指针
1
2
3
4
5
6
7
8
9
10
using namespace std;
int main(void)
{
const int c_var = 10;
const int * p = &c_var;
cout << *p << endl;
return 0;
}指向函数的指针:函数指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using namespace std;
int add(int a, int b){
return a + b;
}
int main(void)
{
int (*fun_p)(int, int);
fun_p = add;
cout << fun_p(1, 6) << endl;
return 0;
}指向对象成员的指针,包括指向对象成员函数的指针和指向对象成员变量的指针。
特别注意:定义指向成员函数的指针时,要标明指针所属的类。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
using namespace std;
class A
{
public:
int var1, var2;
int add(){
return var1 + var2;
}
};
int main()
{
A ex;
ex.var1 = 3;
ex.var2 = 4;
int *p = &ex.var1; // 指向对象成员变量的指针
cout << *p << endl;
int (A::*fun_p)();
fun_p = A::add; // 指向对象成员函数的指针 fun_p
cout << (ex.*fun_p)() << endl;
return 0;
}this 指针:指向类的当前对象的指针常量。
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
35
36
37
38
39
40
41
42
using namespace std;
class A
{
public:
void set_name(string tmp)
{
this->name = tmp;
}
void set_age(int tmp)
{
this->age = age;
}
void set_sex(int tmp)
{
this->sex = tmp;
}
void show()
{
cout << "Name: " << this->name << endl;
cout << "Age: " << this->age << endl;
cout << "Sex: " << this->sex << endl;
}
private:
string name;
int age;
int sex;
};
int main()
{
A *p = new A();
p->set_name("Alice");
p->set_age(16);
p->set_sex(1);
p->show();
return 0;
}
1.1.8 什么是野指针和悬空指针?
悬空指针:
若指针指向一块内存空间,当这块内存空间被释放后,该指针依然指向这块内存空间,此时,称该指针为“悬空指针”。
举例:
1 | void *p = malloc(size); |
野指针:
“野指针”是指不确定其指向的指针,未初始化的指针为“野指针”。
1 | void *p; |
1.1.9 C++ 11 nullptr 比 NULL 优势
- NULL:预处理变量,是一个宏,它的值是 0,定义在头文件
中,即 #define NULL 0。 - nullptr:C++ 11 中的关键字,是一种特殊类型的字面值,可以被转换成任意其他类型。
nullptr 的优势:
- 有类型,类型是 typdef decltype(nullptr) nullptr_t;,使用 nullptr 提高代码的健壮性。
- 函数重载:因为 NULL 本质上是 0,在函数调用过程中,若出现函数重载并且传递的实参是 NULL,可能会出现,不知和哪一个函数匹配的情况;但是传递实参 nullptr 就不会出现这种情况。
1 |
|
1.1.10 指针和引用的区别?
- 指针所指向的内存空间在程序运行过程中可以改变,而引用所绑定的对象一旦绑定就不能改变。(是否可变)
- 指针本身在内存中占有内存空间,引用相当于变量的别名,在内存中不占内存空间。(是否占内存)
- 指针可以为空,但是引用必须绑定对象。(是否可为空)
- 指针可以有多级,但是引用只能一级。(是否能为多级)
1.1.11 常量指针和指针常量的区别
常量指针:
常量指针本质上是个指针,只不过这个指针指向的对象是常量。
特点:const 的位置在指针声明运算符 * 的左侧。只要 const 位于 * 的左侧,无论它在类型名的左边或右边,都表示指向常量的指针。(可以这样理解,* 左侧表示指针指向的对象,该对象为常量,那么该指针为常量指针。)
1 | const int * p; |
注意 1:指针指向的对象不能通过这个指针来修改,也就是说常量指针可以被赋值为变量的地址,之所以叫做常量指针,是限制了通过这个指针修改变量的值。
例如:
1 |
|
注意 2:虽然常量指针指向的对象不能变化,可是因为常量指针本身是一个变量,因此,可以被重新赋值。
例如:
1 |
|
指针常量:
指针常量的本质上是个常量,只不过这个常量的值是一个指针。
特点:const 位于指针声明操作符右侧,表明该对象本身是一个常量,* 左侧表示该指针指向的类型,即以 * 为分界线,其左侧表示指针指向的类型,右侧表示指针本身的性质。
1 | const int var; |
注意 1:指针常量的值是指针,这个值因为是常量,所以指针本身不能改变。
1 |
|
注意 2:指针指向的对象可以改变。
1 |
|
1.1.12 函数指针和指针函数的区别
指针函数:
指针函数本质是一个函数,只不过该函数的返回值是一个指针。相对于普通函数而言,只是返回值是指针。
1 |
|
函数指针:
函数指针本质是一个指针变量,只不过这个指针指向一个函数。函数指针即指向函数的指针。
举例:
1 |
|
1.1.13 强制类型转换有哪几种?
static_cast:用于数据的强制类型转换,强制将一种数据类型转换为另一种数据类型。
- 用于基本数据类型的转换。
- 用于类层次之间的基类和派生类之间 指针或者引用 的转换(不要求必须包含虚函数,但必须是有相互联系的类),进行上行转换(派生类的指针或引用转换成基类表示)是安全的;进行下行转换(基类的指针或引用转换成派生类表示)由于没有动态类型检查,所以是不安全的,最好用 dynamic_cast 进行下行转换。
- 可以将空指针转化成目标类型的空指针。
- 可以将任何类型的表达式转化成 void 类型。
const_cast:强制去掉常量属性,不能用于去掉变量的常量性,只能用于去除指针或引用的常量性,将常量指针转化为非常量指针或者将常量引用转化为非常量引用(注意:表达式的类型和要转化的类型是相同的)。
reinterpret_cast:将一种类型转换为另一种不同的类型。改变指针或引用的类型、将指针或引用转换为一个足够长度的整型、将整型转化为指针或引用类型。
dynamic_cast:
其他三种都是编译时完成的,动态类型转换是在程序运行时处理的,运行时会进行类型检查。
只能用于带有虚函数的基类或派生类的指针或者引用对象的转换,转换成功返回指向类型的指针或引用,转换失败返回 NULL;不能用于基本数据类型的转换。
在向上进行转换时,即派生类类的指针转换成基类类的指针和 static_cast 效果是一样的,(注意:这里只是改变了指针的类型,指针指向的对象的类型并未发生改变)。
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
using namespace std;
class Base
{
};
class Derive : public Base
{
};
int main()
{
Base *p1 = new Derive();
Derive *p2 = new Derive();
//向上类型转换
p1 = dynamic_cast<Base *>(p2);
if (p1 == NULL)
{
cout << "NULL" << endl;
}
else
{
cout << "NOT NULL" << endl; //输出
}
return 0;
}在下行转换时,基类的指针类型转化为派生类类的指针类型,只有当要转换的指针指向的对象类型和转化以后的对象类型相同时,才会转化成功。
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using namespace std;
class Base
{
public:
virtual void fun()
{
cout << "Base::fun()" << endl;
}
};
class Derive : public Base
{
public:
virtual void fun()
{
cout << "Derive::fun()" << endl;
}
};
int main()
{
Base *p1 = new Derive();
Base *p2 = new Base();
Derive *p3 = new Derive();
//转换成功
p3 = dynamic_cast<Derive *>(p1);
if (p3 == NULL)
{
cout << "NULL" << endl;
}
else
{
cout << "NOT NULL" << endl; // 输出
}
//转换失败
p3 = dynamic_cast<Derive *>(p2);
if (p3 == NULL)
{
cout << "NULL" << endl; // 输出
}
else
{
cout << "NOT NULL" << endl;
}
return 0;
}
1.1.14 如何判断结构体是否相等?能否用 memcmp 函数判断结构体相等?
需要重载操作符 == 判断两个结构体是否相等,不能用函数 memcmp 来判断两个结构体是否相等,因为 memcmp 函数是逐个字节进行比较的,而结构体存在内存空间中保存时存在字节对齐,字节对齐时补的字节内容是随机的,会产生垃圾值,所以无法比较。
利用运算符重载来实现结构体对象的比较:
1 |
|
1.1.15 参数传递时,值传递、引用传递、指针传递的区别?
参数传递的三种方式:
- 值传递:形参是实参的拷贝,函数对形参的所有操作不会影响实参。
- 指针传递:本质上是值传递,只不过拷贝的是指针的值,拷贝之后,实参和形参是不同的指针,通过指针可以间接的访问指针所指向的对象,从而可以修改它所指对象的值。
- 引用传递:当形参是引用类型时,我们说它对应的实参被引用传递。
1 |
|
1.1.16 什么是模板?如何实现?
模板:创建类或者函数的蓝图或者公式,分为函数模板和类模板。
实现方式:模板定义以关键字 template 开始,后跟一个模板参数列表。
- 模板参数列表不能为空;
- 模板类型参数前必须使用关键字 class 或者 typename,在模板参数列表中这两个关键字含义相同,可互换使用。
1 | template <typename T, typename U, ...> |
函数模板:通过定义一个函数模板,可以避免为每一种类型定义一个新函数。
- 对于函数模板而言,模板类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。
- 函数模板实例化:当调用一个模板时,编译器用函数实参来推断模板实参,从而使用实参的类型来确定绑定到模板参数的类型。
1 |
|
类模板:类似函数模板,类模板以关键字 template 开始,后跟模板参数列表。但是,编译器不能为类模板推断模板参数类型,需要在使用该类模板时,在模板名后面的尖括号中指明类型。
1 |
|
1.1.17 函数模板和类模板的区别?
- 实例化方式不同:函数模板实例化由编译程序在处理函数调用时自动完成,类模板实例化需要在程序中显示指定。
- 实例化的结果不同:函数模板实例化后是一个函数,类模板实例化后是一个类。
- 默认参数:类模板在模板参数列表中可以有默认参数。
- 特化:函数模板只能全特化;而类模板可以全特化,也可以偏特化。
- 调用方式不同:函数模板可以隐式调用,也可以显示调用;类模板只能显示调用。
函数模板调用方式举例:
1 |
|
1.1.18 什么是可变参数模板?
可变参数模板:接受可变数目参数的模板函数或模板类。将可变数目的参数被称为参数包,包括模板参数包和函数参数包。
- 模板参数包:表示零个或多个模板参数;
- 函数参数包:表示零个或多个函数参数。
用省略号来指出一个模板参数或函数参数表示一个包,在模板参数列表中,class… 或 typename… 指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。当需要知道包中有多少元素时,可以使用 sizeof… 运算符。
1 | template <typename T, typename... Args> // Args 是模板参数包 |
说明:可变参数函数通常是递归的,第一个版本的 print_fun 负责终止递归并打印初始调用中的最后一个实参。第二个版本的 print_fun 是可变参数版本,打印绑定到 t 的实参,并用来调用自身来打印函数参数包中的剩余值。
1.1.19 什么是模板特化?为什么特化?
模板特化的原因:模板并非对任何模板实参都合适、都能实例化,某些情况下,通用模板的定义对特定类型不合适,可能会编译失败,或者得不到正确的结果。因此,当不希望使用模板版本时,可以定义类或者函数模板的一个特例化版本。
模板特化:模板参数在某种特定类型下的具体实现。分为函数模板特化和类模板特化
- 函数模板特化:将函数模板中的全部类型进行特例化,称为函数模板特化。
- 类模板特化:将类模板中的部分或全部类型进行特例化,称为类模板特化。
特化分为全特化和偏特化:
- 全特化:模板中的模板参数全部特例化。
- 偏特化:模板中的模板参数只确定了一部分,剩余部分需要在编译器编译时确定。
说明:要区分下函数重载与函数模板特化
定义函数模板的特化版本,本质上是接管了编译器的工作,为原函数模板定义了一个特殊实例,而不是函数重载,函数模板特化并不影响函数匹配。
实例:
1 |
|
1.1.20 include “ “ 和 <> 的区别
- 查找文件的位置:include<文件名> 在标准库头文件所在的目录中查找,如果没有,再到当前源文件所在目录下查找;#include”文件名” 在当前源文件所在目录中进行查找,如果没有;再到系统目录中查找。
- 使用习惯:对于标准库中的头文件常用 include<文件名>,对于自己定义的头文件,常用 #include”文件名”
1.1.21 switch 的 case 里为何不能定义变量
switch 下面的这个花括号表示一块作用域,而不是每一个 case 表示一块作用域。如果在某一 case 中定义了变量,其作用域在这块花括号内,按理说在另一个 case 内可以使用该变量,但是在实际使用时,每一个 case 之间互不影响,是相对封闭的,参考如下实例。
下述代码中,在 switch 的 case 中定义的变量,没有实际意义,仅为了解释上述原因。
1 |
|
1.1.22 迭代器的作用?
迭代器:一种抽象的设计概念,在设计模式中有迭代器模式,即提供一种方法,使之能够依序寻访某个容器所含的各个元素,而无需暴露该容器的内部表述方式。
作用:在无需知道容器底层原理的情况下,遍历容器中的元素。
1 |
|
1.1.23 泛型编程如何实现?
一种语言机制,能够帮助实现一个通用的标准容器库。所谓通用的标准容器库,就是要能够做到,比如用一个List类存放所有可能类型的对象这样的事;泛型编程让你编写完全一般化并可重复使用的算法,其效率与针对某特定数据类型而设计的算法相同。
泛型编程实现的基础:模板。模板是创建类或者函数的蓝图或者说公式,当时用一个 vector 这样的泛型,或者 find 这样的泛型函数时,编译时会转化为特定的类或者函数。
泛型编程涉及到的知识点较广,例如:容器、迭代器、算法等都是泛型编程的实现实例。面试者可选择自己掌握比较扎实的一方面进行展开。
- 容器:涉及到 STL 中的容器,例如:vector、list、map 等,可选其中熟悉底层原理的容器进行展开讲解。
- 迭代器:在无需知道容器底层原理的情况下,遍历容器中的元素。
- 模板:可参考本章节中的模板相关问题。
1.1.24 什么是类型萃取?
类型萃取使用模板技术来萃取类型(包含自定义类型和内置类型)的某些特性,用以判断该类型是否含有某些特性,从而在泛型算法中来对该类型进行特殊的处理用来提高效率或者其他。
如STL中copy()
的实现,根据容器内部所含对象的不同,选择不同复制方式。又如STL中的算法,根据迭代器种类的不同(单向,双向,随机),可使用不同算法实现来提高效率。
1.2 编译与内存
1.2.1 C++ 程序编译过程
编译过程分为四个过程:编译(编译预处理、编译、优化),汇编,链接。
- 编译预处理:处理以 # 开头的指令;
- 编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;
- 汇编:将汇编代码 .s 翻译成机器指令 .o 文件;
- 链接:汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现:.cpp 文件中的函数引用了另一个 .cpp 文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe 文件。
链接分为两种:
- 静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
- 动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间。
二者的优缺点:
- 静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
- 动态链接:节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。
1.2.2 C++ 内存管理
C++ 内存分区:栈、堆、全局/静态存储区、常量存储区、代码区。
- 栈:存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放。
- 堆:动态申请的内存空间,就是由 malloc 分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。
- 全局区/静态存储区(.bss 段和 .data 段):存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 语言中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。
- 常量存储区(.data 段):存放的是常量,不允许修改,程序运行结束自动释放。
- 代码区(.text 段):存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
1 |
|
1.2.3 栈和堆的区别
- 申请方式:栈是系统自动分配,堆是程序员主动申请。
- 申请后系统响应:分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。
- 栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续的。
- 申请效率:栈是有系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片。
- 存放的内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。
1.2.4 全局变量、局部变量、静态全局变量、静态局部变量的区别
C++ 变量根据定义的位置的不同的生命周期,具有不同的作用域,作用域可分为 6 种:全局作用域,局部作用域,语句作用域,类作用域,命名空间作用域和文件作用域。
从作用域看:
- 全局变量:具有全局作用域。全局变量只需在一个源文件中定义,就可以作用于所有的源文件。当然,其他不包含全局变量定义的源文件需要用 extern 关键字再次声明这个全局变量。(外部链接性、静态持续变量)
- 静态全局变量:具有文件作用域。它与全局变量的区别在于如果程序包含多个文件的话,它作用于定义它的文件里,不能作用到其它文件里,即被 static 关键字修饰过的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。(内部链接性、静态持续变量)
- 局部变量:具有局部作用域。它是自动对象(auto),在程序运行期间不是一直存在,而是只在函数执行期间存在,函数的一次调用执行结束后,变量被撤销,其所占用的内存也被收回。(自动存储持续性的变量)
- 静态局部变量:具有局部作用域。它只被初始化一次,自从第一次被初始化直到程序运行结束都一直存在,它和全局变量的区别在于全局变量对所有的函数都是可见的,而静态局部变量只对定义自己的函数体始终可见。(无链接性、静态持续变量)
从分配内存空间看:
- 静态存储区:全局变量,静态局部变量,静态全局变量。
- 栈:局部变量。
说明:
- 静态变量和栈变量(存储在栈中的变量)、堆变量(存储在堆中的变量)的区别:静态变量会被放在程序的静态数据存储区(.data 段)中(静态变量会自动初始化),这样可以在下一次调用的时候还可以保持原来的赋值。而栈变量或堆变量不能保证在下一次调用的时候依然保持原来的值。
- 静态变量和全局变量的区别:静态变量用 static 告知编译器,自己仅仅在变量的作用范围内可见。
1.2.5 全局变量定义在头文件中有什么问题?
如果在头文件中定义全局变量,当该头文件被多个文件 include
时,该头文件中的全局变量就会被定义多次,导致重复定义,因此不能在头文件中定义全局变量。
#ifndef只防止重复include同一头文件,不同的文件include同一头文件是可以的;如果这个头文件里定义了全局变量,每个include该头文件的文件都会生成各自的同名全局变量,导致重复定义。
1.2.6 如何限制类的对象只能在堆上创建?如何限制对象只能在栈上创建?
C++ 中的类的对象的建立分为两种:静态建立、动态建立。
- 静态建立:由编译器为对象在栈空间上分配内存,直接调用类的构造函数创建对象。例如:A a;
- 动态建立:使用 new 关键字在堆空间上创建对象,底层首先调用 operator new() 函数,在堆空间上寻找合适的内存并分配;然后,调用类的构造函数创建对象。例如:A *p = new A();
限制对象只能建立在堆上:
最直观的思想:避免直接调用类的构造函数,因为对象静态建立时,会调用类的构造函数创建对象。但是直接将类的构造函数设为私有并不可行,因为当构造函数设置为私有后,不能在类的外部调用构造函数来构造对象,只能用 new 来建立对象。但是由于 new 创建对象时,底层也会调用类的构造函数,将构造函数设置为私有后,那就无法在类的外部使用 new 创建对象了。因此,这种方法不可行。
方法一:将析构函数设置为私有。原因:静态对象建立在栈上,是由编译器分配和释放内存空间,编译器为对象分配内存空间时,会对类的非静态函数进行检查,即编译器会检查析构函数的访问性。当析构函数设为私有时,编译器创建的对象就无法通过访问析构函数来释放对象的内存空间,因此,编译器不会在栈上为对象分配内存。
1
2
3
4
5
6
7
8
9
10
11
12
13
14class A
{
public:
A() {}
void destory()
{
delete this;
}
private:
~A()
{
}
};该方法存在的问题:
- 用 new 创建的对象,通常会使用 delete 释放该对象的内存空间,但此时类的外部无法调用析构函数,因此类内必须定义一个 destory() 函数,用来释放 new 创建的对象。
- 无法解决继承问题,因为如果这个类作为基类,析构函数要设置成 virtual,然后在派生类中重写该函数,来实现多态。但此时,析构函数是私有的,派生类中无法访问。
方法二:构造函数设置为 protected,并提供一个 public 的静态函数来完成构造,而不是在类的外部使用 new 构造;将析构函数设置为 protected。原因:类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用 create() 函数在堆上创建对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class A
{
protected:
A() {}
~A() {}
public:
static A *create()
{
return new A();
}
void destory()
{
delete this;
}
};
限制对象只能建立在栈上:
解决方法:将 operator new() 设置为私有。原因:当对象建立在堆上时,是采用 new 的方式进行建立,其底层会调用 operator new() 函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。
1 | class A |
1.2.7 什么是内存对齐?内存对齐的原则?为什么要进行内存对齐,有什么优点?
内存对齐:编译器将程序中的每个“数据单元”安排在字的整数倍的地址指向的内存之中
内存对齐的原则:
- 结构体变量的首地址能够被其最宽基本类型成员大小与对齐基数中的较小者所整除;
- 结构体每个成员相对于结构体首地址的偏移量 (offset) 都是该成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在成员之间加上填充字节 (internal padding);
- 结构体的总大小为结构体最宽基本类型成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在最末一个成员之后加上填充字节 (trailing padding)。
实例:
1 | /* |
进行内存对齐的原因:(主要是硬件设备方面的问题)
- 某些硬件设备只能存取对齐数据,存取非对齐的数据可能会引发异常;
- 某些硬件设备不能保证在存取非对齐数据的时候的操作是原子操作;
- 相比于存取对齐的数据,存取非对齐的数据需要花费更多的时间;
- 某些处理器虽然支持非对齐数据的访问,但会引发对齐陷阱(alignment trap);
- 某些硬件设备只支持简单数据指令非对齐存取,不支持复杂数据指令的非对齐存取。
内存对齐的优点:
- 便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;
- 提高内存的访问效率,因为 CPU 在读取内存时,是一块一块的读取。
1.2.8 类大小的计算
说明:类的大小是指类的实例化对象的大小,用 sizeof
对类型名操作时,结果是该类型的对象的大小。
计算原则:
- 遵循结构体的对齐原则。
- 与普通成员变量有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员被类的对象共享,并不属于哪个具体的对象。
- 虚函数对类的大小有影响,是因为虚函数表指针的影响。
- 虚继承对类的大小有影响,是因为虚基表指针带来的影响。
- 空类的大小是一个特殊情况,空类的大小为 1,当用 new 来创建一个空类的对象时,为了保证不同对象的地址不同,空类也占用存储空间。
实例:
简单情况和空类情况
1 | /* |
带有虚函数的情况:(注意:虚函数的个数并不影响所占内存的大小,因为类对象的内存中只保存了指向虚函数表的指针。)
1 | /* |
1.2.9 什么是内存泄露
内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。
进一步解释:
- 并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
- 常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。
- 使用 malloc、calloc、realloc、new 等分配内存时,使用完后要调用相应的 free 或 delete 释放内存,否则这块内存就会造成内存泄漏。
- 指针重新赋值
1 | char *p = (char *)malloc(10); |
开始时,指针 p
和 p1
分别指向一块内存空间,但指针 p
被重新赋值,导致 p
初始时指向的那块内存空间无法找到,从而发生了内存泄漏。
1.2.10 怎么防止内存泄漏?内存泄漏检测工具的原理?
防止内存泄漏的方法:
内部封装:将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存。
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
35
36
37
38
39
40
41
42
using namespace std;
class A
{
private:
char *p;
unsigned int p_size;
public:
A(unsigned int n = 1) // 构造函数中分配内存空间
{
p = new char[n];
p_size = n;
};
~A() // 析构函数中释放内存空间
{
if (p != NULL)
{
delete[] p; // 删除字符数组
p = NULL; // 防止出现野指针
}
};
char *GetPointer()
{
return p;
};
};
void fun()
{
A ex(100);
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
}
int main()
{
fun();
return 0;
}说明:但这样做并不是最佳的做法,在类的对象复制时,程序会出现同一块内存空间释放两次的情况,请看如下程序:
1
2
3
4
5
6
7
8void fun1()
{
A ex(100);
A ex1 = ex;
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
}简单解释:对于 fun1 这个函数中定义的两个类的对象而言,在离开该函数的作用域时,会两次调用析构函数来释放空间,但是这两个对象指向的是同一块内存空间,所以导致同一块内存空间被释放两次,可以通过增加计数机制来避免这种情况,只有当计数变量为 0 的时候才会释放该块内存空间,看如下程序:
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
using namespace std;
class A
{
private:
char *p;
unsigned int p_size;
int *p_count; // 计数变量
public:
A(unsigned int n = 1) // 在构造函数中申请内存
{
p = new char[n];
p_size = n;
p_count = new int;
*p_count = 1;
cout << "count is : " << *p_count << endl;
};
A(const A &temp)
{
p = temp.p;
p_size = temp.p_size;
p_count = temp.p_count;
(*p_count)++; // 复制时,计数变量 +1
cout << "count is : " << *p_count << endl;
}
~A()
{
(*p_count)--; // 析构时,计数变量 -1
cout << "count is : " << *p_count << endl;
if (*p_count == 0) // 只有当计数变量为 0 的时候才会释放该块内存空间
{
cout << "buf is deleted" << endl;
if (p != NULL)
{
delete[] p; // 删除字符数组
p = NULL; // 防止出现野指针
if (p_count != NULL)
{
delete p_count;
p_count = NULL;
}
}
}
};
char *GetPointer()
{
return p;
};
};
void fun()
{
A ex(100);
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
A ex1 = ex; // 此时计数变量会 +1
cout << "ex1.p = " << ex1.GetPointer() << endl;
}
int main()
{
fun();
return 0;
}
count is : 1
Test
count is : 2
ex1.p = Test
count is : 1
count is : 0
buf is deleted解释下:程序运行结果的倒数 2、3 行是调用两次析构函数时进行的操作,在第二次调用析构函数时,进行内存空间的释放,从而会有倒数第 1 行的输出结果。
智能指针
智能指针是 C++ 中已经对内存泄漏封装好了一个工具,可以直接拿来使用。
内存泄漏检测工具的实现原理:
内存检测工具有很多,这里重点介绍下 valgrind 。
valgrind 是一套 Linux 下,开放源代码(GPL V2)的仿真调试工具的集合,包括以下工具:
- Memcheck:内存检查器(valgrind 应用最广泛的工具),能够发现开发中绝大多数内存错误的使用情况,比如:使用未初始化的内存,使用已经释放了的内存,内存访问越界等。
- Callgrind:检查程序中函数调用过程中出现的问题。
- Cachegrind:检查程序中缓存使用出现的问题。
- Helgrind:检查多线程程序中出现的竞争问题。Massif:检查程序中堆栈使用中出现的问题。
- Extension:可以利用 core 提供的功能,自己编写特定的内存调试工具。
Memcheck 能够检测出内存问题,关键在于其建立了两个全局表:
- Valid-Value 表:对于进程的整个地址空间中的每一个字节(byte),都有与之对应的 8 个 bits ;对于 CPU 的每个寄存器,也有一个与之对应的 bit 向量。这些 bits 负责记录该字节或者寄存器值是否具有有效的、已初始化的值。
- Valid-Address 表:对于进程整个地址空间中的每一个字节(byte),还有与之对应的 1 个 bit,负责记录该地址是否能够被读写。
检测原理:
- 当要读写内存中某个字节时,首先检查这个字节对应的 Valid-Address 表中对应的 bit。如果该 bit 显示该位置是无效位置,Memcheck 则报告读写错误。
- 内核(core)类似于一个虚拟的 CPU 环境,这样当内存中的某个字节被加载到真实的 CPU 中时,该字节在 Valid-Value 表对应的 bits 也被加载到虚拟的 CPU 环境中。一旦寄存器中的值,被用来产生内存地址,或者该值能够影响程序输出,则 Memcheck 会检查 Valid-Value 表对应的 bits,如果该值尚未初始化,则会报告使用未初始化内存错误。
1.2.11 智能指针有哪几种?智能指针的实现原理?
智能指针是为了解决动态内存分配时带来的内存泄漏以及多次释放同一块内存空间而提出的。C++11 中封装在了
C++11 中智能指针包括以下三种:
- 共享指针(shared_ptr):资源可以被多个指针共享,使用计数机制表明资源被几个指针共享。通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptr、weak_ptr 来构造,调用 release() 释放资源的所有权,计数减一,当计数减为 0 时,会自动释放内存空间,从而避免了内存泄漏。
- 独占指针(unique_ptr):独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造和移动赋值构造(调用 move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过该方法进行赋值,目的是实现所有权的转移。
- 弱指针(weak_ptr):指向 share_ptr 指向的对象,能够解决由shared_ptr带来的循环引用问题。
智能指针的实现原理: 计数原理。
1 |
|
1.2.12 使用智能指针会出现什么问题?怎么解决?
智能指针可能出现的问题:循环引用,比如在双向链表中。
在如下例子中定义了两个类 Parent、Child,在两个类中分别定义另一个类的对象的共享指针,由于在程序结束后,两个指针相互指向对方的内存空间,导致内存无法释放。
1 |
|
强引用和弱引用
一个强引用当被引用的对象活着的话,这个引用也存在(就是说,当至少有一个强引用,那么这个对象就不能被释放)。share_ptr就是强引用。相对而言,弱引用当引用的对象活着的时候不一定存在。仅仅是当它存在的时候的一个引用。弱引用并不修改该对象的引用计数,这意味这弱引用它并不对对象的内存进行管理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。
循环引用的解决方法: weak_ptr, weak_ptr的出现就是为了辅助shared_ptr的工作,弥补shared_ptr的不足,解决shared_ptr造成的循环引用问题,而weak_ptr的这种解决方法也就是弱引用。
循环引用:该被调用的析构函数没有被调用,从而出现了内存泄漏。
- weak_ptr 对被 shared_ptr 管理的对象存在 非拥有性(弱)引用,在访问所引用的对象前必须先转化为 shared_ptr;
- weak_ptr 用来打断 shared_ptr 所管理对象的循环引用问题,若这种环被孤立(没有指向环中的外部共享指针),shared_ptr 引用计数无法抵达 0,内存被泄露;令环中的指针之一为弱指针可以避免该情况;
- weak_ptr 用来表达临时所有权的概念,当某个对象只有存在时才需要被访问,而且随时可能被他人删除,可以用 weak_ptr 跟踪该对象;需要获得所有权时将其转化为 shared_ptr,此时如果原来的 shared_ptr 被销毁,则该对象的生命期被延长至这个临时的 shared_ptr 同样被销毁。
1 |
|
1.3 关键字与库函数
1.3.1 sizeof 和 strlen 的区别
strlen 是头文件
中的函数,sizeof 是 C++ 中的运算符。 strlen 测量的是字符串的实际长度(其源代码如下),以 \0 结束。而 sizeof 测量的是字符数组的分配大小。
strlen
源代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21size_t strlen(const char *str) {
size_t length = 0;
while (*str++)
++length;
return length;
}
using namespace std;
int main()
{
char arr[10] = "hello";
cout << strlen(arr) << endl; // 5
cout << sizeof(arr) << endl; // 10
return 0;
}若字符数组 arr 作为函数的形参,sizeof(arr) 中 arr 被当作字符指针来处理,strlen(arr) 中 arr 依然是字符数组,从下述程序的运行结果中就可以看出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using namespace std;
void size_of(char arr[])
{
cout << sizeof(arr) << endl; // warning: 'sizeof' on array function parameter 'arr' will return size of 'char*' .
cout << strlen(arr) << endl;
}
int main()
{
char arr[20] = "hello";
size_of(arr);
return 0;
}
/*
输出结果:
8
5
*/strlen 本身是库函数,因此在程序运行过程中,计算长度;而 sizeof 在编译时,计算长度;
sizeof 的参数可以是类型,也可以是变量;strlen 的参数必须是 char* 类型的变量。
1.3.2 lambda 表达式(匿名函数)的具体应用和使用场景
1 | [capture list] (parameter list) -> return type |
capture list:捕获列表,指 lambda 表达式所在函数中定义的局部变量的列表,通常为空,但如果函数体中用到了 lambda 表达式所在函数的局部变量,必须捕获该变量,即将此变量写在捕获列表中。捕获方式分为:引用捕获方式 [&]、值捕获方式 [=]。
return type、parameter list、function body:分别表示返回值类型、参数列表、函数体,和普通函数一样。
lambda
表达式常搭配排序算法使用。
1 |
|
1.3.3 explicit 的作用(如何避免编译器进行隐式类型转换)
作用:用来声明类构造函数是显示调用的,而非隐式调用,可以阻止调用构造函数时进行隐式转换。只可用于修饰单参构造函数,因为无参构造函数和多参构造函数本身就是显示调用的,再加上 explicit 关键字也没有什么意义。
隐式转换:
1 |
|
上述代码中,A ex = 10; 在编译时,进行了隐式转换,将 10 转换成 A 类型的对象,然后将该对象赋值给 ex,等同于如下操作:
为了避免隐式转换,可用 explicit 关键字进行声明:
1 |
|
1.3.4 static 的作用
保持变量内容持久:
static
作用于局部变量,改变了局部变量的生存周期,使得该变量存在于定义后直到程序运行结束的这段时间。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using namespace std;
int fun(){
static int var = 1; // var 只在第一次进入这个函数的时初始化
var += 1;
return var;
}
int main()
{
for(int i = 0; i < 10; ++i)
cout << fun() << " "; // 2 3 4 5 6 7 8 9 10 11
return 0;
}隐藏:static 作用于全局变量和函数,改变了全局变量和函数的作用域,使得全局变量和函数只能在定义它的文件中使用,在源文件中不具有全局可见性。(注:普通全局变量和函数具有全局可见性,即其他的源文件也可以使用。)
static 作用于类的成员变量和类的成员函数,使得类变量或者类成员函数和类有关,也就是说可以不定义类的对象就可以通过类访问这些静态成员。注意:类的静态成员函数中只能访问静态成员变量或者静态成员函数,不能将静态成员函数定义成虚函数。
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
using namespace std;
class A
{
private:
int var;
static int s_var; // 静态成员变量
public:
void show()
{
cout << s_var++ << endl;
}
static void s_show()
{
cout << s_var << endl;
// cout << var << endl; // error: invalid use of member 'A::a' in static member function. 静态成员函数不能调用非静态成员变量。无法使用 this.var
// show(); // error: cannot call member function 'void A::show()' without object. 静态成员函数不能调用非静态成员函数。无法使用 this.show()
}
};
int A::s_var = 1; // 静态成员变量在类外进行初始化赋值,默认初始化为 0
int main()
{
// cout << A::sa << endl; // error: 'int A::sa' is private within this context
A ex;
ex.show();
A::s_show();
}
1.3.5 ★ static 在类中使用的注意事项(定义、初始化和使用)
static 静态成员变量:
静态成员变量是在类内进行声明,在类外进行定义和初始化,在类外进行定义和初始化的时候不要出现 static 关键字和private、public、protected 访问规则。
静态成员变量相当于类域中的全局变量,被类的所有对象所共享,包括派生类的对象。
静态成员变量可以作为成员函数的参数,而普通成员变量不可以。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using namespace std;
class A
{
public:
static int s_var;
int var;
void fun1(int i = s_var); // 正确,静态成员变量可以作为成员函数的参数
void fun2(int i = var); // error: invalid use of non-static data member 'A::var'
};
int main()
{
return 0;
}静态数据成员的类型可以是所属类的类型,而普通数据成员的类型只能是该类类型的指针或引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using namespace std;
class A
{
public:
static A s_var; // 正确,静态数据成员
A var; // error: field 'var' has incomplete type 'A'
A *p; // 正确,指针
A &var1; // 正确,引用
};
int main()
{
return 0;
}
static 静态成员函数:
- 静态成员函数不能调用非静态成员变量或者非静态成员函数,因为静态成员函数没有 this 指针。静态成员函数做为类作用域的全局函数。
- 静态成员函数不能声明成虚函数(virtual)、const 函数和 volatile 函数。
1.3.6 static 全局变量和普通全局变量的异同
相同点:
存储方式:普通全局变量和 static 全局变量都是静态存储方式。
不同点:
- 作用域:普通全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,普通全局变量在各个源文件中都是有效的;静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
- 初始化:静态全局变量只初始化一次,防止在其他文件中使用。???
1.3.7 ★ const 作用及用法
作用:
- const 修饰成员变量,定义成 const 常量,相较于宏常量,可进行类型检查,节省内存空间,提高了效率。
- const 修饰函数参数,使得传递过来的函数参数的值不能改变。
- const 修饰成员函数,使得成员函数不能修改任何类型的成员变量(mutable 修饰的变量除外),也不能调用非 const 成员函数,因为非 const 成员函数可能会修改成员变量。
在类中的用法:
const 成员变量:
- const 成员变量只能在类内声明、定义,在构造函数初始化列表中初始化。
- const 成员变量只在某个对象的生存周期内是常量,对于整个类而言却是可变的,因为类可以创建多个对象,不同类的 const 成员变量的值是不同的。因此不能在类的声明中初始化 const 成员变量,类的对象还没有创建,编译器不知道他的值。
const
成员函数:
- 不能修改成员变量的值,除非有
mutable
修饰;只能访问成员变量。 - 不能调用非常量成员函数,以防修改成员变量的值。
1 |
|
1.3.8 define 和 const 的区别
区别:
- 编译阶段:define 是在编译预处理阶段进行替换,const 是在编译阶段确定其值。
- 安全性:define 定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全的检查;const 定义的常量是有类型的,是要进行判断的,可以避免一些低级的错误。
- 内存占用:define 定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段空间;const 定义的常量占用静态存储区的空间,程序运行过程中只有一份。
- 调试:define 定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const 定义的常量可以进行调试。
const
的优点:
- 有数据类型,在定义式可进行安全性检查。
- 可调式。
- 占用较少的空间。
1.3.9 define 和 typedef 的区别
- 原理:#define 作为预处理指令,在编译预处理时进行替换操作,不作正确性检查,只有在编译已被展开的源程序时才会发现可能的错误并报错。typedef 是关键字,在编译时处理,有类型检查功能,用来给一个已经存在的类型一个别名,但不能在一个函数定义里面使用 typedef 。
- 功能:typedef 用来定义类型的别名,方便使用。#define 不仅可以为类型取别名,还可以定义常量、变量、编译开关等。
- 作用域:#define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用,而 typedef 有自己的作用域。
- 指针的操作:typedef 和 #define 在处理指针时不完全一样
1 |
|
1.3.10 用宏实现比较大小,以及两个数中的最小值
1 |
|
1.3.11 inline 作用、使用方法和原理
作用:
inline 是一个关键字,可以用于定义内联函数。内联函数,像普通函数一样被调用,但是在调用时并不通过函数调用的机制而是直接在调用点处展开,这样可以大大减少由函数调用带来的开销,从而提高程序的运行效率。
使用方法:
类内定义成员函数默认是内联函数
在类内定义成员函数,可以不用在函数头部加 inline 关键字,因为编译器会自动将类内定义的函数(构造函数、析构函数、普通成员函数等)声明为内联函数,代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using namespace std;
class A{
public:
int var;
A(int tmp){
var = tmp;
}
void fun(){
cout << var << endl;
}
};
int main()
{
return 0;
}类外定义成员函数,若想定义为内联函数,需用关键字声明
当在类内声明函数,在类外定义函数时,如果想将该函数定义为内联函数,则可以在类内声明时不加 inline 关键字,而在类外定义函数时加上 inline 关键字。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using namespace std;
class A{
public:
int var;
A(int tmp){
var = tmp;
}
void fun();
};
inline void A::fun(){
cout << var << endl;
}
int main()
{
return 0;
}另外,可以在声明函数和定义函数的同时加上 inline;也可以只在函数声明时加 inline,而定义函数时不加 inline。只要确保在调用该函数之前把 inline 的信息告知编译器即可。
原理:
- 内联函数不是在调用时发生控制转移关系,而是在编译阶段将函数体嵌入到每一个调用该函数的语句块中,编译器会将程序中出现内联函数的调用表达式用内联函数的函数体来替换。
- 普通函数是将程序执行转移到被调用函数所存放的内存地址,当函数执行完后,返回到执行此函数前的地方。转移操作需要保护现场,被调函数执行完后,再恢复现场,该过程需要较大的资源开销。
1.3.12 宏定义(define)和内联函数(inline)的区别
- 内联函数是在编译时展开,而宏在编译预处理时展开;在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。
- 内联函数是真正的函数,和普通函数调用的方法一样,在调用点处直接展开,避免了函数的参数压栈操作,减少了调用的开销。而宏定义编写较为复杂,常需要增加一些括号来避免歧义。
- 宏定义只进行文本替换,不会对参数的类型、语句能否正常编译等进行检查。而内联函数是真正的函数,会对参数的类型、函数体内的语句编写是否正确等进行检查。
1 |
|
1.3.13 new 和 malloc 如何判断是否申请到内存?
malloc :成功申请到内存,返回指向该内存的指针;分配失败,返回 NULL 指针。
new :内存分配成功,返回该对象类型的指针;分配失败,抛出 bac_alloc 异常。
1.3.14 delete 实现原理?delete 和 delete[] 的区别?
delete
的实现原理:
- 首先执行该对象所属类的析构函数;
- 进而通过调用
operator delete()
的标准库函数来释放所占的内存空间。
delete 和 delete [] 的区别:
- delete 用来释放单个对象所占的空间,只会调用一次析构函数;
- delete [] 用来释放数组空间,会对数组中的每个成员都调用一次析构函数。
1.3.15 new 和 malloc 的区别,delete 和 free 的区别
在使用的时候 new、delete 搭配使用,malloc、free 搭配使用。
- malloc、free 是库函数,而new、delete 是关键字。
new 申请空间时,无需指定分配空间的大小,编译器会根据类型自行计算;malloc 在申请空间时,需要确定所申请空间的大小。 - new 申请空间时,返回的类型是对象的指针类型,无需强制类型转换,是类型安全的操作符;malloc 申请空间时,返回的是 void* 类型,需要进行强制类型的转换,转换为对象类型的指针。
- new 分配失败时,会抛出 bad_alloc 异常,malloc 分配失败时返回空指针。
- 对于自定义的类型,new 首先调用 operator new() 函数申请空间(底层通过 malloc 实现),然后调用构造函数进行初始化,最后返回自定义类型的指针;delete 首先调用析构函数,然后调用 operator delete() 释放空间(底层通过 free 实现)。malloc、free 无法进行自定义类型的对象的构造和析构。
- new 操作符从自由存储区上为对象动态分配内存,而 malloc 函数从堆上动态分配内存。(自由存储区不等于堆)
堆区和自由存储区的区别与联系:
(1)malloc申请的内存在堆上,使用free释放。new申请的内存在自由存储区,用delete释放
(2)堆(heap)是c语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当程序运行时调用malloc()时就会从中分配,调用free可把内存交换。而自由存储区是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认用堆来实现自由存储区,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来实现,这时由new运算符分配的对象,说它在堆上也对,说它在自由存储区也对。
记住:
(1)堆是c语言和操作系统的术语,是操作系统维护的一块内存。自由存储是C++中通过new和delete动态分配和释放对象的抽象概念。
(2)new所申请的内存区域在C++中称为自由存储区,编译器用malloc和free实现new和delete操作符时,new申请的内存可以说是在堆上。
(3)堆和自由内存区有相同之处,但并不等价。
1.3.16 malloc 的原理?malloc 的底层实现?
malloc()
的原理:
- 当开辟的空间小于 128K 时,调用 brk() 函数,通过移动 _enddata 来实现;
- 当开辟空间大于 128K 时,调用 mmap() 函数,通过在虚拟地址空间中开辟一块内存空间来实现。
brk()
函数实现原理:向高地址的方向移动指向数据段的高地址的指针 _enddata
。
mmap()
内存映射原理:
- 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域;
- 调用内核空间的系统调用函数 mmap(),实现文件物理地址和进程虚拟地址的一一映射关系;
- 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。
1.3.17 C 和 C++ struct 的区别?
- 在 C 语言中 struct 是用户自定义数据类型;在 C++ 中 struct 是抽象数据类型,支持成员函数的定义。
- C 语言中 struct 没有访问权限的设置,是一些变量的集合体,不能定义成员函数;C++ 中 struct 可以和类一样,有访问权限,并可以定义成员函数。
- C 语言中 struct 定义的自定义数据类型,在定义该类型的变量时,需要加上 struct 关键字,例如:struct A var;,定义 A 类型的变量;而 C++ 中,不用加该关键字,例如:A var;
C++ 是在 C 语言的基础上发展起来的,为了与 C 语言兼容,C++ 中保留了 struct
。
1.3.18 struct 和 union 的区别
说明:union 是联合体,struct 是结构体。
区别:
- 联合体和结构体都是由若干个数据类型不同的数据成员组成。使用时,联合体只有一个有效的成员;而结构体所有的成员都有效。
- 对联合体的不同成员赋值,将会对覆盖其他成员的值,而对于结构体的对不同成员赋值时,相互不影响。
- 联合体的大小为其内部所有变量的最大值,按照最大类型的倍数进行分配大小;结构体分配内存的大小遵循内存对齐原则。
1 |
|
1.3.19 class 和 struct 的异同
- struct 和 class 都可以自定义数据类型,也支持继承操作。
- struct 中默认的访问级别是 public,默认的继承级别也是 public;class 中默认的访问级别是 private,默认的继承级别也是 private。
- 当 class 继承 struct 或者 struct 继承 class 时,默认的继承级别取决于 class 或 struct 本身, class(private 继承),struct(public 继承),即取决于派生类的默认继承级别。
class
可以用于定义模板参数,struct
不能用于定义模板参数。
1 | struct A{}; |
1.3.20 volatile 的作用?是否具有原子性,对编译器有什么影响?
- volatile 的作用:当对象的值可能在程序的控制或检测之外被改变时,应该将该对象声明为 volatile,告知编译器不应对这样的对象进行优化。
- volatile不具有原子性。
- volatile 对编译器的影响:使用该关键字后,编译器不会对相应的对象进行优化,即不会将变量从内存缓存到寄存器中,防止多个线程有可能使用内存中的变量,有可能使用寄存器中的变量,从而导致程序错误。
1.3.21 什么情况下一定要用 volatile, 能否和 const 一起使用?
使用 volatile 关键字的场景:
- 当多个线程都会用到某一变量,并且该变量的值有可能发生改变时,需要用 volatile 关键字对该变量进行修饰;
- 中断服务程序中访问的变量或并行设备的硬件寄存器的变量,最好用 volatile 关键字修饰。
volatile 关键字和 const 关键字可以同时使用,某种类型可以既是 volatile 又是 const ,同时具有二者的属性。
1.3.22 extern C 的作用?
当 C++ 程序 需要调用 C 语言编写的函数,C++ 使用链接指示,即 extern "C"
指出任意非 C++ 函数所用的语言。
C++ 和 C语言编译函数签名方式不一样, extern关键字可以让两者保持统一,这样才能找到对应的函数.。
1 | // 可能出现在 C++ 头文件<cstring>中的链接指示 |
1.3.23 sizeof(1==1) 在 C 和 C++ 中分别是什么结果?
C语言
sizeof(1 == 1) === sizeof(1)按照整数处理,所以是4字节,这里也有可能是8字节(看操作系统)
C++
因为有bool 类型
sizeof(1 == 1) == sizeof(true) 按照bool类型处理,所以是1个字节
1.3.24 ★ memcpy和memmove 函数的底层原理?
1 | // 不保证拷贝正确性 |
1.3.25 strcpy 函数有什么缺陷?
strcpy 函数的缺陷:strcpy 函数不检查目的缓冲区的大小边界,而是将源字符串逐一的全部赋值给目的字符串地址起始的一块连续的内存空间,同时加上字符串终止符,会导致其他变量被覆盖。
1 |
|
说明:从上述代码中可以看出,变量 var 的后六位被字符串 “hello world!” 的 “d!\0” 这三个字符改变,这三个字符对应的 ascii 码的十六进制为:\0(0x00),!(0x21),d(0x64)。
原因:变量 arr 只分配的 10 个内存空间,通过上述程序中的地址可以看出 arr 和 var 在内存中是连续存放的,但是在调用 strcpy 函数进行拷贝时,源字符串 “hello world!” 所占的内存空间为 13,因此在拷贝的过程中会占用 var 的内存空间,导致 var的后六位被覆盖。
1.3.26 auto 类型推导的原理
编译器根据初始值来推算变量的类型,要求用 auto 定义变量时必须有初始值。编译器推断出来的 auto 类型有时和初始值类型并不完全一样,编译器会适当改变结果类型使其更符合初始化规则。
1.4 面向对象
1.4.1 什么是面向对象?面向对象的三大特性
面向对象:对象是指具体的某一个事物,这些事物的抽象就是类,类中包含数据(成员变量)和动作(成员方法)。
面向对象的三大特性:
- 封装:将具体的实现过程和数据封装成一个函数,只能通过接口进行访问,降低耦合性。
- 继承:子类继承父类的特征和行为,子类有父类的非 private 方法或成员变量,子类可以对父类的方法进行重写,增强了类之间的耦合性,但是当父类中的成员变量、成员函数或者类本身被 final 关键字修饰时,修饰的类不能继承,修饰的成员不能重写或修改。
- 多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。
1.4.2 重载、重写、隐藏的区别
重载:是指同一可访问区内被声明几个具有不同参数列(参数的类型、个数、顺序)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
1 | class A |
重写(覆盖):是指派生类中存在重新定义的函数。函数名、参数列表、返回值类型都必须同基类中被重写的函数一致,只有函数体不同。派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有 virtual 修饰。
1 |
|
隐藏:是指派生类的函数屏蔽了与其同名的基类函数,主要只要同名函数,不管参数列表是否相同,基类函数都会被隐藏。
1 |
|
说明:上述代码中 ex.fun(1, 0.01); 出现错误,说明派生类中将基类的同名函数隐藏了。若是想调用基类中的同名函数,可以加上类型名指明 ex.Base::fun(1, 0.01);,这样就可以调用基类中的同名函数。
重写和重载的区别:
- 范围区别:对于类中函数的重载或者重写而言,重载发生在同一个类的内部,重写发生在不同的类之间(子类和父类之间)。
- 参数区别:重载的函数需要与原函数有相同的函数名、不同的参数列表,不关注函数的返回值类型;重写的函数的函数名、参数列表和返回值类型都需要和原函数相同,父类中被重写的函数需要有 virtual 修饰。
virtual 关键字:重写的函数基类中必须有 virtual关键字的修饰,重载的函数可以有 virtual 关键字的修饰也可以没有。
隐藏和重写,重载的区别:
- 范围区别:隐藏与重载范围不同,隐藏发生在不同类中。
- 参数区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同;当参数不同时,无论基类中的函数是否被 virtual 修饰,基类函数都是被隐藏,而不是重写。
1.4.3 什么是多态?多态如何实现?
多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
实现方法:多态是通过虚函数实现的,虚函数的地址保存在虚函数表中,虚函数表的地址保存在含有虚函数的类的实例对象的内存空间中。
实现过程:
- 在类中用 virtual 关键字声明的函数叫做虚函数;
- 存在虚函数的类都有一个虚函数表,当创建一个该类的对象时,该对象有一个指向虚函数表的虚表指针(虚函数表和类对应的,虚表指针是和对象对应);
- 当基类指针指向派生类对象,基类指针调用虚函数时,基类指针指向派生类的虚表指针,由于该虚表指针指向派生类虚函数表,通过遍历虚表,寻找相应的虚函数。
当基类的指针指向派生类的对象时,通过派生类的对象的虚表指针找到虚函数表(派生类的对象虚函数表),进而找到相应的虚函数 Derive::f()
进行调用。
1 |
|
1.4.4 什么是虚函数?什么是纯虚函数?两者有何区别
虚函数:被 virtual
关键字修饰的成员函数,就是虚函数。
1 |
|
纯虚函数:
- 纯虚函数在类中声明时,加上 =0;
- 含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口,没有具体的实现方法;
- 继承纯虚函数的派生类,如果没有完全实现基类纯虚函数,依然是抽象类,不能实例化对象。
说明:
- 抽象类对象不能作为函数的参数,不能创建对象,不能作为函数返回类型;
- 可以声明抽象类指针,可以声明抽象类的引用;
- 子类必须继承父类的纯虚函数,并全部实现后,才能创建子类的对象。
区别:
- 虚函数和纯虚函数可以出现在同一个类中,该类称为抽象基类。(含有纯虚函数的类称为抽象基类)
- 使用方式不同:虚函数可以直接使用,纯虚函数必须在派生类中实现后才能使用;
- 定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上virtual 关键字还需要加上 =0;
- 虚函数必须实现,否则编译器会报错;
- 对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;
- 析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象。
1.4.5 虚函数的实现机制
实现机制:虚函数通过虚函数表来实现。虚函数的地址保存在虚函数表中,在类的对象所在的内存空间中,保存了指向虚函数表的指针(称为“虚表指针”),通过虚表指针可以找到类对应的虚函数表。虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数。
虚函数表相关知识点:
- 虚函数表存放的内容:类的虚函数的地址。
- 虚函数表建立的时间:编译阶段,即程序的编译过程中会将虚函数的地址放在虚函数表中。
- 虚表指针保存的位置:虚表指针存放在对象的内存空间中最前面的位置,这是为了保证正确取到虚函数的偏移量。
注:虚函数表和类绑定,虚表指针和对象绑定。即类的不同的对象的虚函数表是一样的,但是每个对象都有自己的虚表指针,来指向类的虚函数表。
★编译器处理虚函数表:
- 编译器将虚函数表的指针放在类的实例对象的内存空间中,该对象调用该类的虚函数时,通过指针找到虚函数表,根据虚函数表中存放的虚函数的地址找到对应的虚函数。
- 如果派生类没有重新定义基类的虚函数 A,则派生类的虚函数表中保存的是基类的虚函数 A 的地址,也就是说基类和派生类的虚函数 A 的地址是一样的。
- 如果派生类重写了基类的某个虚函数 B,则派生的虚函数表中保存的是重写后的虚函数 B 的地址,也就是说虚函数 B 有两个版本,分别存放在基类和派生类的虚函数表中。
- 如果派生类重新定义了新的虚函数 C,派生类的虚函数表保存新的虚函数 C 的地址。
1.4.6 单继承的虚函数表(无虚函数覆盖)
基类和派生类的继承关系:
基类的虚函数表:
派生类的虚函数表:
1.4.7 单继承的虚函数表(有虚函数覆盖)
派生类的虚函数表:
1.4.8 多继承的虚函数表(无虚函数覆盖)
基类和派生类的继承关系:
派生类的虚函数表:
1.4.9 多继承的虚函数表(有虚函数覆盖)
基类和派生类的继承关系:
派生类的虚函数表:
1.4.10 构造函数、析构函数是否需要定义成虚函数?为什么?
构造函数一般不定义为虚函数,原因:
- 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
- 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用,构造函数是在创建对象时自动调用的。
- 从实现上考虑:虚函数表是在创建对象之后才有的,因此不能定义成虚函数。
- 从类型上考虑:在创建对象时需要明确其类型。
析构函数一般定义成虚函数,原因:
析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。
1.4.11 如何避免拷贝?
最直观的想法是:将类的拷贝构造函数和赋值构造函数声明为私有 private,但对于类的成员函数和友元函数依然可以调用,达不到完全禁止类的对象被拷贝的目的,而且程序会出现错误,因为未对函数进行定义。
解决方法:声明一个基类,具体做法如下。
- 定义一个基类,将其中的拷贝构造函数和赋值构造函数声明为私有 private
- 派生类以私有 private 的方式继承基类
1 | class Uncopyable |
简单解释:
能够保证,在派生类 A 的成员函数和友元函数中无法进行拷贝操作,因为无法调用基类 Uncopyable 的拷贝构造函数或赋值构造函数。同样,在类的外部也无法进行拷贝操作。
1.4.12 为什么用成员初始化列表会快一些?/如何减少构造函数开销?
说明:数据类型可分为内置类型和用户自定义类型(类类型),对于用户自定义类型,利用成员初始化列表效率高。
原因:用户自定义类型如果使用类初始化列表,直接调用该成员变量对应的构造函数即完成初始化;如果在构造函数中初始化,因为 C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,那么在执行构造函数的函数体之前首先调用默认的构造函数为成员变量设初值,在进入函数体之后,调用该成员变量对应的构造函数。因此,使用列表初始化会减少调用默认的构造函数的过程,效率高。**(使用构造函数初始化成员变量:产生临时对象;成员初始化列表:不产生临时对象)**
1.4.13 简述C++的类访问控制
private, public, protected 访问标号的访问范围:
private:只能由1.该类中的函数、2.其友元函数访问。不能被任何其他访问,该类的对象也不能访问。
protected:可以被1.该类中的函数、2.子类的函数、以及3.其友元函数访问。但不能被该类的对象访问。
public:可以被1.该类中的函数、2.子类的函数、3.其友元函数访问,也可以由 4.该类的对象访问。
注:友元函数包括3种:设为友元的普通的非成员函数;设为友元的其他类的成员函数;设为友元类中的所有成员函数。
类的继承后方法属性变化:
1.4.14 多重继承时会出现什么状况?如何解决?
命名冲突和数据冗余问题。
1 |
|
上述程序的继承关系如下:(菱形继承)
上述代码中存的问题:
对于派生类 Derive
上述代码中存在直接继承关系和间接继承关系。
- 直接继承:
Base2
、Base3
- 间接继承:
Base1
对于派生类中继承的的成员变量 var1
,从继承关系来看,实际上保存了两份,一份是来自基类 Base2
,一份来自基类 Base3
。因此,出现了命名冲突。
解决方法 1: 声明出现冲突的成员变量来源于哪个类
1 |
|
解决方法 2: 虚继承
使用虚继承的目的:保证存在命名冲突的成员变量在派生类中只保留一份,即使间接基类中的成员在派生类中只保留一份。在菱形继承关系中,间接基类称为虚基类,直接基类和间接基类之间的继承关系称为虚继承。
实现方式:在继承方式前面加上 virtual 关键字。
1 |
|
类之间的继承关系:
1.4.15 空类占多少字节?C++ 编译器会给一个空类自动生成哪些函数?
空类声明时编译器不会生成任何成员函数:
对于空类,声明编译器不会生成任何的成员函数,只会生成 1 个字节的占位符。
1 |
|
空类定义时编译器会生成 6 个成员函数:
当空类 A 定义对象时,sizeof(A) 仍是为 1,但编译器会生成 6 个成员函数:缺省的构造函数、拷贝构造函数、析构函数、赋值运算符、两个取址运算符。
1 |
|
1.4.16 为什么拷贝构造函数必须为引用?
原因:避免拷贝构造函数无限制的递归,最终导致栈溢出。
1 |
|
说明 1:ex2 = ex1; 和 A ex3 = ex1; 为什么调用的函数不一样?
对象 ex2 已经实例化了,不需要构造,此时只是将 ex1 赋值给 ex2,只会调用赋值函数;但是 ex3 还没有实例化,因此调用的是拷贝构造函数,构造出 ex3,而不是赋值函数,这里涉及到构造函数的隐式调用。
说明 2:如果拷贝构造函数中形参不是引用类型,A ex3 = ex1;会出现什么问题?
构造 ex3,实质上是 ex3.A(ex1);,假如拷贝构造函数参数不是引用类型,那么将使得 ex3.A(ex1); 相当于 ex1 作为函数 A(const A tmp)的形参,在参数传递时相当于 A tmp = ex1,因为 tmp 没有被初始化,所以在 A tmp = ex1 中继续调用拷贝构造函数,接下来的是构造 tmp,也就是 tmp.A(ex1) ,必然又会有 ex1 作为函数 A(const A tmp); 的形参,在参数传递时相当于即 A tmp = ex1,那么又会触发拷贝构造函数,就这下永远的递归下去。
说明 3:为什么 ex2.fun(ex1); 会调用拷贝构造函数?
ex1 作为参数传递给 fun 函数, 即 A tmp = ex1;,这个过程会调用拷贝构造函数进行初始化。(临时对象)
1.4.17 C++ 类对象的初始化顺序
构造函数调用顺序:
- 按照派生类继承基类的顺序,即派生列表中声明的顺序,依次调用基类的构造函数;
- 按照派生类中成员变量的声名顺序,依次调用派生类中成员变量所属类的构造函数;
- 执行派生类自身的构造函数。
综上可以得出,类对象的初始化顺序:基类构造函数–>派生类成员变量的构造函数–>自身构造函数
注:
- 基类构造函数的调用顺序与派生类的派生列表中的顺序有关;
- 成员变量的初始化顺序与声明顺序有关;
- 析构顺序和构造顺序相反。
1 |
|
程序运行结果分析:
- 首先调用基类 A 和 B 的构造函数,按照派生列表 public A, public B 的顺序构造;
- 然后调用派生类 Test 的成员变量 ex1 和 ex2 的构造函数,按照派生类中成员变量声明的顺序构造;
- 最后调用派生类的构造函数;
- 接下来调用析构函数,和构造函数调用的顺序相反。
1.4.18 如何禁止一个类被实例化?
方法一:
在类中定义一个纯虚函数,使该类成为抽象基类,因为不能创建抽象基类的实例化对象;
方法二:
将类的构造函数声明为私有 private
1.4.19 实例化一个对象需要哪几个阶段
- 分配空间
创建类对象首先要为该对象分配内存空间。不同的对象,为其分配空间的时机未必相同。全局对象、静态对象、分配在栈区域内的对象,在编译阶段进行内存分配;存储在堆空间的对象,是在运行阶段进行内存分配。 - 初始化
首先明确一点:初始化不同于赋值。初始化发生在赋值之前,初始化随对象的创建而进行,而赋值是在对象创建好后,为其赋上相应的值。这一点可以联想下上一个问题中提到:初始化列表先于构造函数体内的代码执行,初始化列表执行的是数据成员的初始化过程,这个可以从成员对象的构造函数被调用看的出来。 - 赋值
对象初始化完成后,可以对其进行赋值。对于一个类的对象,其成员变量的赋值过程发生在类的构造函数的函数体中。当执行完该函数体,也就意味着类对象的实例化过程完成了。(总结:构造函数实现了对象的初始化和赋值两个过程,对象的初始化是通过初始化列表来完成,而对象的赋值则才是通过构造函数的函数体来实现。)
注:对于拥有虚函数的类的对象,还需要给虚表指针赋值。
- 没有继承关系的类,分配完内存后,首先给虚表指针赋值,然后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。
- 有继承关系的类,分配内存之后,首先进行基类的构造过程,然后给该派生类的虚表指针赋值,最后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。
1.4.20 友元函数的作用及使用场景
作用:友元提供了不同类的成员函数之间、类的成员函数与一般函数之间进行数据共享的机制。通过友元,一个不同函数或另一个类中的成员函数可以访问类中的私有成员和保护成员。
使用场景:
普通函数定义为友元函数,使普通函数能够访问类的私有成员。
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
using namespace std;
class A
{
friend ostream &operator<<(ostream &_cout, const A &tmp); // 声明为类的友元函数
public:
A(int tmp) : var(tmp)
{
}
private:
int var;
};
ostream &operator<<(ostream &_cout, const A &tmp)
{
_cout << tmp.var;
return _cout;
}
int main()
{
A ex(4);
cout << ex << endl; // 4
return 0;
}友元类:类之间共享数据。
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
35
36
37
38
39
using namespace std;
class A
{
friend class B;
public:
A() : var(10){}
A(int tmp) : var(tmp) {}
void fun()
{
cout << "fun():" << var << endl;
}
private:
int var;
};
class B
{
public:
B() {}
void fun()
{
cout << "fun():" << ex.var << endl; // 访问类 A 中的私有成员
}
private:
A ex;
};
int main()
{
B ex;
ex.fun(); // fun():10
return 0;
}
1.4.21 静态绑定和动态绑定是怎么实现的?
静态类型和动态类型:
- 静态类型:变量在声明时的类型,是在编译阶段确定的。静态类型不能更改。
- 动态类型:目前所指对象的类型,是在运行阶段确定的。动态类型可以更改。
静态绑定和动态绑定:
- 静态绑定是指程序在 编译阶段 确定对象的类型(静态类型)。
- 动态绑定是指程序在 运行阶段 确定对象的类型(动态类型)。
静态绑定和动态绑定的区别:
- 发生的时期不同:如上。
- 对象的静态类型不能更改,动态类型可以更改。
注:对于类的成员函数,只有虚函数是动态绑定,其他都是静态绑定。
1 |
|
1.4.22 深拷贝和浅拷贝的区别
如果一个类拥有资源,该类的对象进行复制时,如果资源重新分配,就是深拷贝,否则就是浅拷贝。
- 深拷贝:该对象和原对象占用不同的内存空间,既拷贝存储在栈空间中的内容,又拷贝存储在堆空间中的内容。
- 浅拷贝:该对象和原对象占用同一块内存空间,仅拷贝类中位于栈空间中的内容。
当类的成员变量中有指针变量时,最好使用深拷贝。因为当两个对象指向同一块内存空间,如果使用浅拷贝,当其中一个对象的删除后,该块内存空间就会被释放,另外一个对象指向的就是垃圾内存。
浅拷贝实例
1 |
|
说明:上述代码中,类对象 ex1、ex2 实际上是指向同一块内存空间,对象析构时,ex2 先将内存释放了一次,之后 析构对象 ex1 时又将这块已经被释放过的内存再释放一次。对同一块内存空间释放了两次,会导致程序崩溃。
深拷贝实例:
1 |
|
1.4.23 编译时多态和运行时多态的区别
编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。
运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。
编译时多态和运行时多态的区别:
- 时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
- 实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。
1.4.24 实现一个类成员函数,要求不允许修改类的成员变量?
如果想达到一个类的成员函数不能修改类的成员变量,只需用 const 关键字来修饰该函数即可。
该问题本质是考察 const 关键字修饰成员函数的作用,只不过以实例的方式来考察,面试者应熟练掌握 const 关键字的作用。
1 |
|
1.4.25 如何让类不能被继承?
解决方法一:借助 final
关键字,用该关键字修饰的类不能被继承。
1 |
|
解决方法二:借助友元、虚继承和私有构造函数来实现
1 |
|
说明:在上述代码中 B
类是不能被继承的类。
具体原因:
- 虽然 Base 类构造函数和析构函数被声明为私有 private,在 B 类中,由于 B 是 Base 的友元,因此可以访问 Base 类构造函数,从而正常创建 B 类的对象;
- B 类继承 Base 类采用虚继承的方式,创建 C 类的对象时,C 类的构造函数要负责 Base 类的构造,但是 Base 类的构造函数私有化了,C 类没有权限访问。因此,无法创建 C 类的对象, B 类是不能被继承的类。
注意:在继承体系中,友元关系不能被继承,虽然 C
类继承了 B
类,B
类是 Base
类的友元,但是 C
类和 Base
类没有友元关系。
2 STL
2.1 基础
2.1.1 什么是STL
标准模板库,增强代码复用性,降低数据结构与算法的耦合关系(容器和算法的分离),提升各自的独立性、弹性和交互性,依据泛型编程的思维架构。
2.1.2 简述STL的六大组件及功能
- 空间配置器(allocator):负责STL相关数据结构的空间配置与管理。本质是一个实现了动态空间配置、空间管理、空间释放的class template,STL容器、迭代器的模板参数中都含有它。
- 迭代器(iterator):在不暴露容器内部数据结构的情况下,提供遍历容器内元素的方法,是容器和算法的胶合剂,所谓的泛型指针。本质上是一种将operator*、operator->、operator++、operator–等指针操作重载的class template。所有STL容器都附带专属的迭代器。原生指针是偏特化的迭代器。
- 容器(container):存放数据的数据结构。本质上是一种class template。
- 算法(algorithm):本质是是一种function template。
- 仿函数/函数对象(functor):函数对象,本质上是一种重载了operator()的class或class template。函数指针可视为狭义的仿函数。
- 配接器(adapter):修改已经存在的接口令其展现新的风貌,也是一种设计模式,可以修饰容器、仿函数或迭代器的接口。
2.2 空间配置器
2.2.1 容器如何获取空间配置器
每一个容器都已经指定其缺省的空间配置器为alloc:
1 | template<class T, class Alloc = alloc> |
2.2.2 空间配置器如何进行构造和析构操作
construct()
和destroy()
被设计为全局函数。
construct()
接收指针和初值,将初值设定到指针所指的空间上(调用对象的构造函数)。
destroy()
先萃取迭代器所指对象的型别,判断该对象是否具有trivial-destructor,如果是则什么也不做;否则调用对象的析构函数。
2.2.3 空间配置器的内存配置与释放策略是什么
采用双层配置器。
- 当配置区块足够大,使用第一级配置器,
allocate()
直接使用malloc()
,deallocate()
直接使用free()
。 - 当配置区块太小,为防止内存碎片,使用第二级配置器,维护16个自由链表,负责16种小型区块的次配置能力(类似于Linux伙伴系统算法)。维护内存池来填充自由链表。若内存不足,则调用第一级配置器。
2.3 迭代器
2.3.1 什么是迭代器模式
提供一种方法,使之能依序巡防某个聚合物(容器)所含的各个元素,而又无需暴露该聚合物的内部表达方式。
2.3.2 为什么需要萃取(traits技法)出迭代器的相应型别
在算法种运用迭代器时,需要用到其相应型别,这样才能在不同情况下提供最大效率。
如:针对随机访问的迭代器和单向访问的迭代器,实现某种算法的步骤可能不同。
2.3.3 迭代器有哪些内嵌的相应型别
所有迭代器都应该定义下列型别。可以直接继承STL提供的std::iterator
。
- value type:迭代器所指对象的型别
T
- difference type:表示两个迭代器之间的距离。如原生指针之间的距离就可以是整型
- reference type:
T&
- pointer type:
T*
- iterator_category:根据移动特性和施行操作,被分为五类
- Input Iter: 只读
- Output Iter: 只写
- Forward Iter: 单向
- Bidirectional Iter: 可双向移动。如某些算法可利用这个特性进行逆向巡防。
- Random Access Iter: 随机访问,涵盖所有指针算数能力。
2.4 容器
2.4.1 STL容器的分类
序列式:vector, list, deque, stack, queue, heap, priority_queue
关联式:RB-tree, set, multiset, map, multimap, hashtable, hash_set, hash_multiset, hash_map, hash_multimap
2.4.2 vector与array有哪些区别
- array是静态空间,一旦配置就不能改变。若需要扩大,需要自己配置新空间,复制元素到新空间,最后释放原空间。
- vector是动态空间,使用array实现,内部机制会自行进行空间配置。
2.4.3 vector的数据结构
线性连续空间,迭代器start和finish分别指向分配的连续空间中目前已被使用的范围,end_of_storage指向包括备用空间在内的整块连续空间的尾端。因此vector的capacity往往大于size。
vector的迭代器其实是原生指针。
2.4.4 简述vector的内存管理策略
初始化时分配一定空间。
新元素插入尾端时:
- 若还有备用空间,则构造元素,finish++
- 若没有备用空间,则进行扩容操作:重新分配(不是原空间)两倍大小的较大空间,将原内容拷贝过来,释放原空间。
因此,若空间重新配置,则指向原vector的迭代器会失效。
2.4.5 list的数据结构
节点结构:
list结构:
是一个环状双向链表,iter.begin()
指向第一个非空节点,iter.end()
指向链表尾端的空白节点。
2.4.6 简述deque和vector的区别
- vector是单向开口的连续线性空间,deque是双向开口的连续线性空间。
- deque允许常数时间内对头端进行插入删除,而vector头部插入删除效率低
- deque没有容量的概念,它是动态地以分段连续空间组合而成,因此扩容时不需要 ‘重新配置空间复制再释放’ 这样的操作。
2.4.7 简述deque的实现原理
deque使用map作为主控。map是一小段连续空间,每个元素node都指向另一段连续线性空间(缓冲区512bytes)。缓冲区是deque的存储主体。
start迭代器的cur、first均指向所有缓冲区的第一个元素,last指向第一个缓冲区的末尾。node指向map中的已使用的第一个节点,该节点指向第一个缓冲区。
finish迭代器的cur指向当前deque的最后一个元素,first指向最后一个缓冲区的第一个元素,last指向最后一个缓冲区的末尾,node指向分配最后一个缓冲区的map节点。
2.4.8 如何在deque中随机获取元素
先计算出元素所在缓冲区,然后计算元素在该缓冲区的偏移量。
2.4.9 简述deque的内存管理策略
deque使用两个空间配置器,一个用来配置map节点,一个用来配置缓冲区。
初始化时,分配至少8个map节点,为最中央的node分配一段缓冲区作为可用空间。
当元素插入时:
- 被插入的缓冲区有大于1个的可用空间,直接放置元素,更新start或finish
- 被插入的缓冲区只有1个可用空间,为当前node的前/后一个node分配新的缓冲区,插入元素
- 若map都已经没有可用空间时,配置更大的map,拷贝原map内容,释放原map
当元素删除时:
若导致缓冲区为空,则释放相应缓冲区。但保证至少有1个缓冲区。
2.4.10 简述deque、stack、queue的关系
deque作为缺省情况下的stack和queue的底部结构,stack、queue是容器配接器,都没有迭代器。
2.4.11 简述heap的实现原理
以vector作为底部结构,一个隐式表述的完全二叉树。元素被取出时总是按照一定的次序。没有迭代器。STL默认供应max-heap。
make_heap()
:将一段现有的数据转化成一个heap
push_heap()
:将元素插入到堆中合适的位置(先插入到尾端,然后上溯)
pop_heap()
:将最大的元素放置到尾端,然后调整堆(令欲调整的节点下溯到最深深度,然后执行一次上溯令其上升到合适位置),此时最后一个元素为最大值,而前面的元素依然是一个max-heap
sort_heap()
:持续对堆使用pop_heap()
最终可以得到升序的序列。
2.4.12 简述priority_queue的实现原理
内部元素按照权值排列的队列。缺省情况下的priority_queue利用一个max-heap完成,总是弹出键值最大的元素(pop_heap()
)。是一个配接器。
2.4.13 什么是关联式容器,有哪几类
每个元素都有一个**键值(key)**和一个实值(value),键值可以就是实值。当元素被插入到容器中时,容器内部结构(平衡二叉树或哈希表)按照key的大小以某种特定规则将这个元素放置到合适位置。
分类:set集合和map映射表
2.4.14 简述set的特点及实现原理
set中元素的键值就是实值,实值就是键值。set中不允许有相同键值, multiset允许。所有元素都会根据键值自动被排序。set的迭代器不能改变元素。(否则会破坏排序)
实现原理:以红黑树作为底层机制,是一种配接器。
具有双向迭代器。
2.4.15 ★ 简述map的特点及实现原理
map的所有元素都是pair,同时拥有键值和实值,第一元素是键值,不允许有相同键值, multimap允许。所有元素都会根据键值自动排序。不可以通过迭代器修改键值,但是可以修改键值对应的实值。
实现原理:以红黑树作为底层机制,是一种配接器。
具有双向迭代器。
2.4.16 简述哈希表的特点
hashtable(散列表),在插入、删除、搜寻等操作上具有常数平均时间,不需要依赖输入元素的随机性。
利用散列函数,将元素映射到数组的特定位置。
2.4.17 ★ 什么是碰撞问题,如何解决
碰撞问题:使用散列函数时,可能有不同的元素被映射到同一位置,产生冲突。
解决方法:
- 线性探测:若原插入位置不可用,则循序往下一一寻找。primary clustering问题会造成性能下降。
- 二次探测:不一一寻找,而是在第i次探测时,探测第H+i^2个位置。secondary clustering问题造成性能下降。
- 开链:在每个表格元素中维护一个list,在list上执行元素操作。list较短时,效率很高。
2.4.18 ★ 简述hashtable的实现原理
以开链法实现hashtable:
图中buckets结构指向链表头,链表中存放hashtable的元素。buckets聚合体以vector作为底层机制,利于动态扩充。
具有单向迭代器。
2.4.19 简述hashtable的内存管理策略
hashtable预定义了一组素数,初始化时选择最近的那个素数作为表格大小。
插入元素时:
- 若插入后元素个数不大于表格大小,则计算出索引后直接插入到链表中。若检测到重复键值,则不插入。
- 否则,需要重建表格(说明hashtable元素最大个数与表格大小相同),执行重新分配,复制,释放操作。
2.4.20 hashtable的键值可以任意取吗
下列类型:char*, short, int, long,可以直接被处理。如果是其它类型,需要用户自定义散列函数,如string。
2.4.21 简述hash_set(unordered_set)的特点及实现原理
hash_set中的元素与set类似,但没有自动排序功能,支持快速查找元素,hash_multiset支持重复键值。
实现原理:以hashtable作为底层机制。
2.4.22 ★ 简述hash_map(unordered_map)的特点及实现原理
hash_map中的元素与map类似,但没有自动排序功能,支持快速查找元素,hash_multimap支持重复键值。
实现原理:以hashtable作为底层机制。
2.4.23 对比STL的各种容器
2.5 算法
2.5.1 简述什么是算法的泛型化
将算法独立于其处理的数据结构:把操作对象的型别抽象化,把操作对象的表示法和区间目标的移动行为抽象化。
迭代器便充当了此处的操作对象,它把数据结构和算法连接在一起。
2.5.2 简述sort()算法的实现原理
1 | template<class RandomAccessIterator, class Compare> |
接收两个随机存取迭代器(vector和deque),将区间内的元素按仿函数comp定义的规则排序。
数据量很大时,采用快速排序(分段递归)。数据量较小时,采用插入排序
2.6 仿函数
2.6.1 什么是仿函数,有什么作用
新名称:函数对象,可以像函数一样被调用。
作用:主要用于搭配STL算法使用,将某种操作(加减、比大小等)作为一个参数传递给算法,类似于函数指针。
为什么不直接使用函数指针?函数指针不能满足STL对抽象性的要求,也无法与STL其它组件搭配,产生更灵活的变化。
2.6.2 如何定义一个仿函数
定义一个对象,然后重载函数调用运算符operator()。
2.6.3 有哪些常见的仿函数
1 | // 算术类 |
2.7 配接器
(未整理完~)
3 对象模型
(未整理完~)
4 设计模式
4.1 基础
4.1.1 有哪些设计模式
设计模式分为三类:
- 创造型模式:单例模式、工厂模式、建造者模式、原型模式
- 结构型模式:适配器模式、组合模式、装饰模式、桥接模式、外观模式、享元模式、代理模式
- 行为型模式:迭代器模式、观察者模式、策略模式、责任链模式、命令模式、解释器模式、中介者模式、备忘录模式、状态模式、模板方法模式、访问者模式
4.1.2 设计模式的设计原则
设计模式有 6 大设计原则:
- 单一职责原则:就一个类而言,应该仅有一个引起它变化的原因。
- 开放封闭原则:软件实体可以扩展,但是不可修改。即面对需求,对程序的改动可以通过增加代码来完成,但是不能改动现有的代码。
- 里氏代换原则:一个软件实体如果使用的是一个基类,那么一定适用于其派生类。即在软件中,把基类替换成派生类,程序的行为没有变化。
- 依赖倒转原则:抽象不应该依赖细节,细节应该依赖抽象。即针对接口编程,不要对实现编程。
- 迪米特原则:如果两个类不直接通信,那么这两个类就不应当发生直接的相互作用。如果一个类需要调用另一个类的某个方法的话,可以通过第三个类转发这个调用。
- 接口隔离原则:每个接口中不存在派生类用不到却必须实现的方法,如果不然,就要将接口拆分,使用多个隔离的接口。
4.2 单例模式
4.2.1 什么是单例模式,什么时候应用
保证类的实例化对象仅有一个,并且提供一个访问他的全局访问点。
应用场景:
- 表示文件系统的类,一个操作系统一定是只有一个文件系统,因此文件系统的类的实例有且仅有一个。
- 打印机打印程序的实例,一台计算机可以连接好几台打印机,但是计算机上的打印程序只有一个,就可以通过单例模式来避免两个打印作业同时输出到打印机。
4.2.2 实现思路
单例模式可以通过全局或者静态变量的形式实现,这样比较简单,但是这样会影响封装性,难以保证别的代码不会对全局变量造成影响。
- 默认的构造函数、拷贝构造函数、赋值构造函数声明为私有的,这样禁止在类的外部创建该对象;
- 全局访问点也要定义成 静态类型的成员函数,没有参数,返回该类的指针类型。因为使用实例化对象的时候是通过类直接调用该函数,并不是先创建一个该类的对象,通过对象调用。
4.2.3 不安全的实现方式(懒汉)
考虑当两个线程同时调用 getInstance
方法,并且同时检测到 instance
是 NULL
,两个线程会同时实例化对象,不符合单例模式的要求。
1 | // 懒汉模式:直到第一次用到类的实例时才去实例化,上面是懒汉实现。 |
4.2.4 线程安全的实现方式(懒汉)
方法:加锁
存在的问题:每次判断实例对象是否为空,都要被锁定,如果是多线程的话,就会造成大量线程阻塞。
1 | class Singleton{ |
方法:内部静态变量,在全局访问点 getInstance
中定义静态实例。
1 | class Singleton{ |
4.2.5 饿汉模式实现(本身即线程安全)
1 | // 饿汉模式:类定义的时候就实例化。 |
4.3 工厂模式
工厂模式:包括简单工厂模式、抽象工厂模式、工厂方法模式
- 简单工厂模式:主要用于创建对象。用一个工厂来根据输入的条件产生不同的类,然后根据不同类的虚函数得到不同的结果。
- 工厂方法模式:修正了简单工厂模式中不遵守开放封闭原则。把选择判断移到了客户端去实现,如果想添加新功能就不用修改原来的类,直接修改客户端即可。
- 抽象工厂模式:定义了一个创建一系列相关或相互依赖的接口,而无需指定他们的具体类。
4.3.1 什么是简单工厂模式,什么时候应用
主要用于创建对象。用一个工厂来根据输入的条件产生不同的类,然后根据不同类的虚函数得到不同的结果。
应用场景:
适用于针对不同情况创建不同类时,只需传入工厂类的参数即可,无需了解具体实现方法。例如:计算器中对于同样的输入,执行不同的操作:加、减、乘、除。
1 |
|
4.3.2 什么是工厂方法模式,什么时候应用
修正了简单工厂模式中不遵守开放封闭原则。把选择判断移到了客户端去实现,如果想添加新功能就不用修改原来的类,直接修改客户端即可。
应用场景:
- 一个类不知道它所需要的对象的类:在工厂方法模式中,客户端不需要知道具体产品类的类名,只需要知道所对应的工厂即可,具体的产品对象由具体工厂类创建;客户端需要知道创建具体产品的工厂类。
- 一个类通过其派生类来指定创建哪个对象:在工厂方法模式中,对于抽象工厂类只需要提供一个创建产品的接口,而由其派生类来确定具体要创建的对象,利用面向对象的多态性和里氏代换原则,在程序运行时,派生类对象将覆盖父类对象,从而使得系统更容易扩展。
- 将创建对象的任务委托给多个工厂派生类中的某一个,客户端在使用时可以无须关心是哪一个工厂派生类创建产品派生类,需要时再动态指定,可将具体工厂类的类名存储在配置文件或数据库中。
1 |
|
4.3.3 什么是抽象工厂模式,什么时候应用
定义了一个创建一系列相关或相互依赖的接口,而无需指定他们的具体类。
应用场景:
- 一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节,这对于所有类型的工厂模式都是重要的。
- 系统中有多于一个的产品族,而每次只使用其中某一产品族。
- 属于同一个产品族的产品将在一起使用,这一约束必须在系统的设计中体现出来。
- 产品等级结构稳定,设计完成之后,不会向系统中增加新的产品等级结构或者删除已有的产品等级结构。
1 |
|
4.4 观察者模式
4.4.1 什么是观察者模式,什么时候应用
观察者模式:定义一种一(被观察类)对多(观察类)的关系,让多个观察对象同时监听一个被观察对象,被观察对象状态发生变化时,会通知所有的观察对象,使他们能够更新自己的状态。
观察者模式中存在两种角色:
- 观察者:内部包含被观察者对象,当被观察者对象的状态发生变化时,更新自己的状态。(接收通知更新状态)
- 被观察者:内部包含了所有观察者对象,当状态发生变化时通知所有的观察者更新自己的状态。(发送通知)
应用场景:
- 当一个对象的改变需要同时改变其他对象,且不知道具体有多少对象有待改变时,应该考虑使用观察者模式;
- 一个抽象模型有两个方面,其中一方面依赖于另一方面,这时可以用观察者模式将这两者封装在独立的对象中使它们各自独立地改变和复用。
4.4.2 观察者模式的实现方式
1 |
|
(未整理完~)
Reference:
- 《C++ Primer Plus》
- 《深度探索C++对象模型》- Stanley B. Lippman
- 《STL源码解析》 - 侯捷
- C++突击