面试可能会遇到的与 C\C++ 相关的问答记录。


整理自Mr希灵的博文、面经以及《C++ Primer, 5E》等。

1. C++ 基础知识点

1.1. 有符号类型和无符号类型

  • 当我们赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模之后的余数。
  • 当我们赋给带符号类型一个超出它表示范围的值时,结果是未定义的;此时,程序可能继续工作、可能崩溃。也可能生成垃圾数据。
  • 如果表达式中既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动转换成无符号数。
1
2
3
4
5
6
7
int a = 1;
unsigned int b = -2;    // case 1, b = 4294967294 
cout << a + b << endl;  // 输出 4294967295
int c = a + b;          // c = -1
int d = -3;
unsigned int e = 2;
cout << d + e << endl;  // case 3, 输出 4294967295

1.2. 引用与指针

引用并非对象,它只是为一个已经存在的对象起的一个别名。在定义引用时,程序把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。

指针是指向另外一种类型的符合类型。与引用类似,指针也实现了对其他对象的间接访问。然而指针与引用相比又有许多不同点:

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

void* 是一种特殊的指针类型,可以存放任意对象的地址。但我们对该地址中存放的是什么类型的对象并不了解,所以也不能直接操作 void* 指针所指的对象。

1.3. static 关键字

  • 声明为 static 的局部变量,存储在静态存储区,其生存期不再局限于当前作用域,而是整个程序的生存期。
  • 对于全局变量而言, 普通的全局变量和函数,其作用域为整个程序或项目,外部文件(其它 cpp 文件) 可以通过 extern 关键字访问该变量和函数;static 全局变量和函数,其作用域为当前 cpp 文件,其它的 cpp 文件不能访问该变量和函数。
  • 当使用 static 修饰成员变量和成员函数时,表示该变量或函数属于一个类,而不是该类的某个实例化对象。

1.4. const 限定符

const 的作用:

  • 在定义常变量时必须同时对它初始化,此后它的值不能再改变。常变量不能出现在赋值号的左边 (不为“左值”);

  • 对指针来说,可以指定指针本身为 const,也可以指定指针所指的数据为 const,或二者同时指定为 const

  • 在一个函数声明中,const 可以修饰形参,表明它是一个输入参数,在函数内部不能改变其值;

  • 对于类的成员函数,若指定其为 const 类型,则表明其是一个常函数,不能修改类的成员变量;

  • 对于类的成员函数,有时候必须指定其返回值为 const 类型,以使得其返回值不为"左值"。例如:

    1
    2
    3
    4
    5
    
    // operator* 的返回结果必须是一个 const 对象,否则下列代码编译出错
    const classA operator* (const classA& a1,const classA& a2);  
    classA a, b, c;
    // 对 a * b 的结果赋值。操作 (a * b) = c 显然不符合编程者的初衷,也没有任何意义
    (a * b) = c;
    

const 修饰符号常量的区别:

  • const 位于 * 的左边,表示被指物是常量
  • const 位于 * 的右边,表示指针自身是常量(常量指针)。

左定值,右定向

1
2
3
const char * p;        // 指向 const 对象的指针,指针可以被修改,但指向的对象不能被修改。
char * const p;        // 指向 char 类型的常量指针,指针不能被修改,但指向的对象可以被修改。
const char * const p;  // 指针及指向对象都不能修改。

const#define 的区别:

  • const 常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查,并且在字符替换可能会产生意料不到的错误(边际效应)。
  • 有些集成化的调试工具可以对 const 常量进行调试,但是不能对宏常量进行调试。
  • 在 C++ 程序中只使用 const 常量而不使用宏常量,即 const 常量完全取代宏常量。

1.5. 数组与指针的区别

  • 数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。指针可以随时指向任意类型的内存块。
  • 用运算符 sizeof 可以计算出数组的容量(字节数)。sizeof(p)p 为指针得到的是一个指针变量的字节数,而不是 p 所指的内存容量。C/C++ 语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。
  • C++ 编译系统将形参数组名一律作为指针变量来处理。实际上在函数调用时并不存在一个占有存储空间的形参数组,只有指针变量。
    • 实参数组名 a 代表一个固定的地址,或者说是指针型常量,因此要改变 a 的值是不可能的。例如:a++ 是错误的。
    • 形参数组名 array 是指针变量,并不是一个固定的地址值。它的值是可以改变的。例如:array++ 是合法的。

为了节省内存,C/C++ 把常量字符串放到单独的一个内存区域。当几个指针赋值给相同的常量字符串时,它们实际上会指向相同的内存地址。但用常量字符串初始化数组时,情况却有所不同。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// str1 和 str2 会为它们分配两个长度为 12 个字节的空间
// 并把 Hello World 的内容分别复制到数组中去,这是两个初始地址不同的数组。
char str1[] = Hello World;
char str2[] = Hello World;

// str3 和 str4 是两个指针,我们无须为它们分配内存以存储字符串的内容
// 而只需要把它们指向 Hello World 在内存中的地址就可以了。
// 由于 Hello World 是常量字符串,它在内存中只有一个拷贝,
// 因此 str3 和 str4 指向的是同一个地址。
char *str3 = Hello World;
char *str4 = Hello World;

1.6. sizeof 运算符

sizeof 是 C 语言的一种单目操作符,它并不是函数。操作数可以是一个表达式或类型名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
int a[50];              // sizeof(a) = 200
int *a = new int[50];   // sizeof(a) = 4
union{
    char flag[3];
    short value;
} sampleUnion;          // sizeof(sampleUnion) = 4

Class Test {
    int a; 
    static double c
};                      // sizeof(Test) = 4 

Test *s;                // sizeof(s) = 4
Class Test{};           // sizeof(Test) = 1
int func(char s[5]);    // sizeof(s) = 4

当操作数不同时需要注意:

  • 数组类型,其结果是数组的总字节数;指向数组的指针,其结果是该指针的字节数。
  • 函数中的数组形参或函数类型的形参,其结果是指针的字节数。
  • 联合类型,其结果采用成员最大长度对齐。
  • 结构类型或类类型,其结果是这种类型对象的总字节数,包括任何填充在内。

对类进行操作时需要注意:

  • 类中的静态成员不对结果产生影响,因为静态变量的存储位置与结构或者类的实例地址无关;
  • 没有成员变量的类的大小为 1,因为必须保证类的每一个实例在内存中都有唯一的地址;
  • 有虚函数的类都会建立一张虚函数表,表中存放的是虚函数的函数指针,这个表的地址存放在类中,所以不管有几个虚函数,都只占据一个指针大小。

1.7. 结构体的内存对齐

内存对齐规则:

  • 每个成员相对于这个结构体变量地址的偏移量正好是该成员类型所占字节的整数倍。为了对齐数据,可能必须在上一个数据结束和下一个数据开始的地方插入一些没有用处字节。
  • 最终占用字节数为成员类型中最大占用字节数的整数倍。
1
2
3
4
5
6
7
struct AlignData1
{
    char c;
    short b;
    int i;
    char d;
}Node;

这个结构体在编译以后,为了字节对齐,会被整理成这个样子:

1
2
3
4
5
6
7
8
9
struct AlignData1
{
    char c;
    char padding[1];
    short b;
    int i;
    char d;
    char padding[3];
}Node;

所以编译前总的结构体大小为:8 个字节。编译以后字节大小变为:12 个字节。但是,如果调整顺序:

1
2
3
4
5
6
7
struct AlignData2
{
    char c;
    char d;
    short b;
    int i;
}Node;

那么这个结构体在编译前后的大小都是 8 个字节。编译后不用填充字节就能保持所有的成员都按各自默认的地址对齐。这样可以节约不少内存!所以一般的结构体成员按照默认对齐字节数递增或是递减的顺序排放,会使总的填充字节数最少。

1.8. 强制类型转换

C++ 中有以下四种命名的强制类型转换:

  • static_cast: 任何具有明确定义的类型转换,只要不包含底层 const,都可以使用 static_cast
  • const_cast: 去 const 属性,只能改变运算对象的底层 const。常用于有函数重载的上下文中
  • reinterpret_cast: 通常为运算对象的位模式提供较低层次的重新解释,本质依赖于机器
  • dynamic_cast: 主要用来执行安全向下转型,也就是用来决定某对象是否归属继承体系中的某个类型。主要用于多态类之间的转换

一般来说,如果编译器发现一个较大的算术类型试图赋值给较小的类型,就会给出警告;但是当我们执行了显式的类型转换之后,警告信息就被关闭了。

1
2
3
4
5
6
7
// 进行强制类型转换以便执行浮点数除法
int j = 1, i = 2;
double slope = static_cast<double>(j)/i;

//任何非常量对象的地址都能存入 void*,通过 static_cast 可以将指针转换会初始的指针类型
void* p = &slope;
double *dp = static_cast<double*>(p);

只有 const_cast 能够改变表达式的常量属性,其他形式的强制类型转换改变表达式的常量属性都将引发编译器错误。

1
2
3
4
5
// 利用 const_cast 去除底层 const
const char c = 'a';
const char *pc = &c;
char* cp = const_cast<char*>(pc);
*cp = 'c';

reinterpret_cast 常用于函数指针类型之间进行转换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int doSomething(){
    // ...
    return 0;
}
// FuncPtr 是一个指向函数的指针,该函数没有参数,返回值类型为 void
typedef void (*FuncPtr)(); 
// 假设你希望把一个指向下面函数的指针存入 funcPtrArray 数组
FuncPtr funcPtrArray[10]; 
// 编译错误!类型不匹配
funcPtrArray[0] = &doSomething;
// 不同函数指针类型之间进行转换
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething);

dynamic_cast: 有条件转换,动态类型转换,运行时类型安全检查(转换失败返回 NULL):

  • 安全的基类和子类之间转换。
  • 必须要有虚函数。
  • 相同基类不同子类之间的交叉转换。但结果是 NULL
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Base {
public:
    int m_iNum;
    // 基类必须有虚函数。保持多态特性才能使用 dynamic_cast
    virtual void foo(){};
};

class Derive: public Base {
public:
    char*m_szName[100];
    void bar(){};
};

Base* pb =new Derive();
//子类->父类,静态类型转换,正确但不推荐
Derive *pd1 = static_cast<Derive *>(pb); 
//子类->父类,动态类型转换,正确
Derive *pd2 = dynamic_cast<Derive *>(pb); 

Base* pb2 =new Base();
// 父类->子类,静态类型转换,危险!访问子类 m_szName 成员越界
Derive *pd21 = static_cast<Derive *>(pb2); 
//父类->子类,动态类型转换,安全的。结果是 NULL
Derive *pd22 = dynamic_cast<Derive *>(pb2);

1.9. malloc/free 与 new/delete 的区别

  • mallocfree 是 C/C++ 语言的标准库函数,new/delete 是 C++ 的运算符。它们都可用于申请和释放动态内存。
  • 对于非内部数据类型的对象而言,用 malloc/free 无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于 malloc/free 是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于 malloc/free,因此C++ 语言需要一个能完成动态内存分配和初始化工作的运算符 new,和一个能完成清理与释放内存工作的运算符 delete
  • new 可以认为是 malloc 加构造函数的执行。new 出来的指针是直接带类型信息的。而 malloc 返回的都是 void* 指针。new/delete 在实现上其实调用了 malloc/free 函数。
  • new 建立的是一个对象;malloc 分配的是一块内存。

2. 面向对象编程

2.1. String 类的实现

 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
class MyString
{
public:
    MyString();
    MyString(const MyString &);
    MyString(const char *);
    MyString(const size_t, const char);
    ~MyString()

    size_t length();
    bool isEmpty();
    const char * c_str();
    friend ostream & operator << (ostream &, const MyString &);
    friend istream & operator >> (istream &, MyString &);

    // add operation
    friend MyString operator + (const MyString &, const MyString &);

    // compare operations
    friend bool operator == (const MyString &, const MyString &);
    friend bool operator != (const MyString &, const MyString &);
    friend bool operator <= (const MyString &, const MyString &);
    friend bool operator >= (const MyString &, const MyString &);

    // 成员函数实现运算符重载,其实一般需要返回自身对象的,成员函数运算符重载会好一些
    char & operator[](const size_t);
    const char & operator[](const size_t)const;
    MyString & operator = (const MyString &);
    MyString & operator += (const MyString &);

    // 成员操作函数
    MyString substr(size_t pos, const size_t n);
    MyString append(const MyString &);
    MyString insert(size_t, const MyString &);
    MyString erase(size_t, size_t);
    int find(const char * str, size_t index = 0);

private:
    char *p_str;
    size_t strLength;
};

2.2. 派生类中构造函数与析构函数的调用顺序

构造函数的调用顺序总是如下:

  1. 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
  2. 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。如果有的成员不是类对象,而是基本类型,则初始化顺序按照声明的顺序来确定,而不是在初始化列表中的顺序。
  3. 派生类构造函数。

析构函数正好和构造函数相反。

2.3. 虚函数的实现原理

虚函数表:编译器会为每个有虚函数的类创建一个虚函数表,该虚函数表将被该类的所有对象共享。类的虚函数表是一块连续的内存,每个内存单元中记录一个 JMP 指令的地址。类的每个虚函数占据虚函数表中的一块,如果类中有 $N$ 个虚函数,那么其虚函数表将有 $4N$ 字节的大小。

编译器在有虚函数的类的实例中创建了一个指向这个表的指针,该指针通常存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能)。这意味着可以通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

有虚函数或虚继承的类实例化后的对象大小至少为 4 字节(确切的说是一个指针的字节数;说至少是因为还要加上其他非静态数据成员,还要考虑对齐问题);没有虚函数和虚继承的类实例化后的对象大小至少为 1 字节(没有非静态数据成员的情况下也要有 1 个字节来记录它的地址)。

哪些函数适合声明为虚函数,哪些不能?

  • 当存在类继承并且析构函数中有必须要进行的操作时(如需要释放某些资源,或执行特定的函数)析构函数需要是虚函数,否则若使用父类指针指向子类对象,在 delete 时只会调用父类的析构函数,而不能调用子类的析构函数,从而造成内存泄露或达不到预期结果;
  • 内联函数不能为虚函数:内联函数需要在编译阶段展开,而虚函数是运行时动态绑定的,编译时无法展开;
  • 构造函数不能为虚函数:构造函数在进行调用时还不存在父类和子类的概念,父类只会调用父类的构造函数,子类调用子类的,因此不存在动态绑定的概念;但是构造函数中可以调用虚函数,不过并没有动态效果,只会调用本类中的对应函数;
  • 静态成员函数不能为虚函数:静态成员函数是以类为单位的函数,与具体对象无关,虚函数是与对象动态绑定的。

2.4. 虚继承的实现原理

为了解决从不同途径继承来的同名的数据成员在内存中有不同的拷贝造成数据不一致问题,将共同基类设置为虚基类。这时从不同的路径继承过来的同名数据成员在内存中就只有一个拷贝,同一个函数名也只有一个映射。这样不仅解决了二义性问题,也节省了内存,避免了数据不一致的问题。

构造函数和析构函数的顺序:虚基类总是先于非虚基类构造,与它们在集成体系中的次序和位置无关。如果有多个虚基类,则按它们在派生列表中出现的顺序从左到右依次构造。

编译器按照直接基类的声明顺序依次检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明顺序依次构造其他非虚基类。因此下例中构造函数的顺序是:ZooAnimal -> ToyAnimal -> Character -> BookCharacter -> Bear -> TeddyBear。析构过程与构造过程正好相反。

 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
#include <iostream>
using namespace std;

class ZooAnimal{
public:
    ZooAnimal(){
        cout << "ZooAnimal construct" << endl;
    }
};

class Bear : virtual public ZooAnimal{
public:
    Bear(){
        cout << "Bear construct" << endl;
    }
};

class ToyAnimal{
public: 
    ToyAnimal(){
        cout << "ToyAnimal construct" << endl;
    }
};

class Character{
public: 
    Character(){
        cout << "Character construct" << endl;
    }
};

class BookCharacter : public Character{
public: 
    BookCharacter(){
        cout << "BookCharacter construct" << endl;
    }
};

class TeddyBear : public BookCharacter, public Bear, virtual public ToyAnimal{
public: 
    TeddyBear(){
        cout << "TeddyBear construct" << endl;
    }
};

int main()
{
    TeddyBear();
}

3. 内存管理

3.1. 程序加载时的内存分布

在多任务操作系统中,每个进程都运行在一个属于自己的虚拟内存中,而虚拟内存被分为许多页,并映射到物理内存中,被加载到物理内存中的文件才能够被执行。这里我们主要关注程序被装载后的内存布局,其可执行文件包含了代码段,数据段,BSS 段,堆,栈等部分,其分布如下图所示。

内存分布

  • 代码段(.text):用来存放可执行文件的机器指令。存放在只读区域,以防止被修改。
  • 只读数据段(.rodata):用来存放常量存放在只读区域,如字符串常量、全局 const 变量等。
  • 可读写数据段(.data):用来存放可执行文件中已初始化全局变量,即静态分配的变量和全局变量。
  • BSS 段(.bss):未初始化的全局变量和局部静态变量一般放在 .bss 的段里,以节省内存空间。
  • 堆:用来容纳应用程序动态分配的内存区域。当程序使用 mallocnew 分配内存时,得到的内存来自堆。堆通常位于栈的下方。
  • 栈:用于维护函数调用的上下文。栈通常从用户空间的最高地址处分配。
  • 动态链接库映射区:如果程序调用了动态链接库,则会有这一部分。该区域是用于映射装载的动态链接库。
  • 保留区:内存中受到保护而禁止访问的内存区域。

3.2. 堆与栈的区别

  • 申请管理方式

    • 栈:由编译器自动管理,无需我们手工控制。
    • 堆:堆的申请和释放工作由程序员控制,容易产生内存泄漏。
  • 申请后系统的响应

    • 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
    • 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的 delete 语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
  • 申请大小的限制

    • 栈:在 Windows 下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 Windows 下,栈的大小是 1 M(可修改),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小。
    • 堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
  • 申请效率的比较

    • 栈: 由系统自动分配,速度较快。但程序员是无法控制的。
    • 堆: 是由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。另外,在 Windows 下,最好的方式是用 VirtualAlloc 分配内存,它不是在堆,也不是在栈而是直接在进程的地址空间中保留一块内存,虽然用起来最不方便。但是速度快,也最灵活。
  • 堆与栈中的存储内容

    • 栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的 C 编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
    • 堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。

总结:堆和栈相比,由于大量 new/delete 的使用,容易造成大量的内存碎片;并且可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,ebp 和局部变量都采用栈的方式存放。所以,推荐大家尽量用栈,而不是用堆。虽然栈有如此众多的好处,但是向堆申请内存更加灵活,有时候分配大量的内存空间,还是用堆好一些。

3.3. 常见的内存错误及其对策

  • 内存分配未成功,却使用了它,因为没有意识到内存分配会不成功。

    • 解决办法:在使用内存之前检查指针是否为 NULL。如果指针 p 是函数的参数,那么在函数的入口处用 assert(p != NULL) 进行检查。如果是用 mallocnew 来申请内存,应该用 if(p != NULL) 进行防错处理。
  • 内存分配虽然成功,但是尚未初始化就引用它。犯这种错误主要有两个起因:

    • 一是 没有初始化的观念;
    • 二是 误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。
    • 解决方法:不要忘记为数组和动态内存赋初值,即便是赋零值也不可省略。防止将未被初始化的内存作为右值使用。
  • 内存分配成功并且已经初始化,但操作越过了内存的边界。特别是在 for 循环语句中,循环次数很容易搞错,导致数组操作越界。

    • 解决方法:避免数组或指针的下标越界
  • 忘记了释放内存,造成内存泄露。含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。

    • 解决方法:动态内存的申请与释放必须配对,程序中 mallocfree 的使用次数一定要相同,否则肯定有错误(new/delete 同理)。
  • 释放了内存却继续使用它。有三种情况:

    • 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
    • 函数的 return 语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
    • 使用 freedelete 释放了内存后,没有将指针设置为 NULL。导致产生“野指针”。因此用 freedelete 释放了内存之后,立即将指针设置为 NULL,防止产生“野指针”。

3.4. 智能指针

智能指针是在 <memory> 头文件中的 std 命名空间中定义的,该指针用于确保程序不存在内存和资源泄漏且是异常安全的。它们对 RAII (获取资源即初始化) 编程至关重要,RAII 的主要原则是将任何堆分配资源(如动态分配内存或系统对象句柄)的所有权提供给其析构函数,包含用于删除或释放资源的代码以及任何相关清理代码的堆栈分配对象。大多数情况下,当初始化原始指针或资源句柄以指向实际资源时,会立即将指针传递给智能指针。在 C++11 中,定义了 3 种智能指针: unique_ptrshared_ptrweak_ptr,并删除了 C++98 中的 auto_ptr

智能指针的设计思想:将基本类型指针封装为类对象指针(这个类肯定是个模板,以适应不同基本类型的需求),并在析构函数里编写 delete 语句删除指针指向的内存空间。

unique_ptr “独占”所指向的对象。unique_ptr 小巧高效;大小等同于一个指针且支持 rvalue 引用,从而可实现快速插入和对 STL 集合的检索。

shared_ptr 采用引用计数的智能指针,主要用于要将一个原始指针分配给多个所有者(例如,从容器返回了指针副本又想保留原始指针时)的情况。当所有的 shared_ptr 所有者超出了范围或放弃所有权,才会删除原始指针。大小为两个指针:一个用于对象,另一个用于包含引用计数的共享控制块。最安全的分配和使用动态内存的方法是调用 make_shared 标准库函数,此函数在动态分配内存中分配一个对象并初始化它,返回对象的 shared_ptr

智能指针支持的操作:

  • 使用重载的 ->* 运算符访问对象。
  • 使用 get 成员函数获取原始指针,提供对原始指针的直接访问。你可以使用智能指针管理你自己的代码中的内存,还能将原始指针传递给不支持智能指针的代码。
  • 使用删除器定义自己的释放操作。
  • 使用 release 成员函数,作用是放弃智能指针对指针的控制权,将智能指针置空,并返回原始指针。(只支持 unique_ptr
  • 使用 reset 释放智能指针对对象的所有权。

智能指针的使用示例:

 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
#include <iostream>
#include <string>
#include <memory>
using namespace std;

class Base
{
public:
    Base(int _a): a(_a){
        cout << "构造函数" << endl;
    }
    ~Base(){
        cout << "析构函数" << endl;
    }
    int a;
};

int main()
{
    unique_ptr<Base> up1(new Base(2));
    // unique_ptr<Base> up2 = up1;    // 编译器提示未定义
    unique_ptr<Base> up2 = move(up1); // 转移对象的所有权 
    // cout << up1->a << endl;        // 运行时错误 
    cout << up2->a << endl;           // 通过解引用运算符获取封装的原始指针 
    up2.reset();                      // 显式释放内存 
    
    shared_ptr<Base> sp1(new Base(3));
    shared_ptr<Base> sp2 = sp1;       // 增加引用计数 
    cout << "共享智能指针的数量:" << sp2.use_count() << endl;  // 2
    sp1.reset();  
    cout << "共享智能指针的数量:" << sp2.use_count() << endl;  // 1
    cout << sp2->a << endl; 
    auto sp3 = make_shared<base>(4); // 利用 make_shared 函数动态分配内存 
}

4. C++ 对象内存模型

在 C++ 中有两种类的数据成员:staticnonstatic,以及三种类的成员函数:staticnonstaticvirtual

在 C++ 对象模型中

  • 非静态数据成员被配置于每一个类的对象之中,静态数据成员则被存放在所有的类对象之外;
  • 静态及非静态成员函数也被放在类对象之外,虚函数则通过以下两个步骤支持:
    1. 每一个类产生出一堆指向虚函数的指针,放在表格之中,这个表格被称为虚函数表 (virtual table, vtbl)。
    2. 每一个类对象被添加了一个指针,指向相关的虚函数表,通常这个指针被称为 vptrvptr 的设定和重置都由每一个类的构造函数、析构函数和拷贝赋值运算符自动完成。另外,虚函数表地址的前面设置了一个指向 type_info 的指针,RTTI (Run Time Type Identification) 运行时类型识别是由编译器在编译器生成的特殊类型信息,包括对象继承关系,对象本身的描述,RTTI 是为多态而生成的信息,所以只有具有虚函数的对象才会生成。

4.1. 继承下的对象内存模型

C++ 支持单一继承、多重继承和虚继承。在虚继承的情况下,虚基类不管在继承链中被派生多少次,永远只会存在一个实体。

  • 单一继承,继承关系为 class Derived : public Base。其对象的内存布局为:虚函数表指针、Base 类的非 static 成员变量、Derived 类的非 static 成员变量。

  • 多重继承,继承关系为 class Derived : public Base1, public Base2。其对象的内存布局为:基类 Base1 子对象和基类 Base2 子对象及 Derived 类的非 static 成员变量组成。基类子对象包括其虚函数表指针和其非 static 的成员变量。

  • 重复继承,继承关系如下。Derived 类的对象的内存布局与多继承相似,但是可以看到基类 Base 的子对象在 Derived 类的对象的内存中存在一份拷贝。这样直接使用 Derived 中基类 Base 的相关成员时,就会引发歧义,可使用多重虚拟继承消除之。

    1
    2
    3
    
    class Base1 : public Base
    class Base2: public Base
    class Derived : public Base1, public Base2
    
  • 虚继承,继承关系如下。其对象的内存布局与重复继承的类的对象的内存分布类似,但是基类 Base 的子对象没有拷贝,在对象的内存中仅存在一个 Base 类的子对象,但是它的非 static 成员变量放置在对象的末尾处。

    1
    2
    3
    
    class Base1 : virtual public Base
    class Base2: virtual public Base
    class Derived : public Base1, public Base2
    

继承下的对象内存模型

5. 常见的设计模式

5.1. 单例模式

当仅允许类的一个实例在应用中被创建的时候,我们使用单例模式 (Singleton Pattern)。它保护类的创建过程来确保只有一个实例被创建,它通过设置类的构造方法为私有 (private) 来实现。要获得类的实例,单例类可以提供一个方法,如 GetInstance(),来返回类的实例。该方法是唯一可以访问类来创建实例的方法。

优点

  • 由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁地创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显。
  • 减少了系统的性能开销,当一个对象的产生需要比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后永久驻留内存的方式来解决。
  • 避免对资源的多重占用。如避免对同一个资源文件的同时写操作。
  • 单例模式可以在系统设置全局的访问点,优化和共享资源访问。

缺点:单例模式一般没有接口,扩展困难。不利于测试。

使用场景

  • 在整个项目中需要一个共享访问点或共享数据。
  • 创建一个对象需要消耗的资源过多,如要访问 IO 和数据库等资源。
  • 需要定义大量的静态常量和静态方法的环境。

实现:懒汉实现与饿汉实现

  • 懒汉实现,即实例化在对象首次被访问时进行。可以使用类的私有静态指针变量指向类的唯一实例,并用一个公有的静态方法获取该实例。同时需将默认构造函数声明为 private,防止用户调用默认构造函数创建对象。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    // Singleton.h
    class Singleton{
    public:
        static Singleton* GetInstance();
    private:
        Singleton(){ }
        static Singleton *m_pInstance;
    };
    
    // Singleton.cpp
    Singleton* Singleton::m_pInstance = NULL;
    Singleton* Singleton::GetInstance(){
        if (m_Instance == NULL){
            Lock();
            if (m_Instance == NULL){
                m_Instance = new Singleton();
            }
            UnLock(); 
        }
        return m_pInstance;
    }
    

    该类有以下特征:

    • 它的构造函数是私有的,这样就不能从别处创建该类的实例。
    • 它有一个唯一实例的静态指针 m_pInstance,且是私有的。
    • 它有一个公有的函数,可以获取这个唯一的实例,并在需要的时候创建该实例。
    • 此处进行了两次 m_Instance == NULL 的判断,是借鉴了 Java 的单例模式实现时,使用的所谓的“双检锁”机制。因为进行一次加锁和解锁是需要付出对应的代价的,而进行两次判断,就可以避免多次加锁与解锁操作,同时也保证了线程安全。

    上面的实现存在一个问题,就是没有提供删除对象的方法。一个妥善的方法是让这个类自己知道在合适的时候把自己删除。程序在结束的时候,系统会自动析构所有的全局变量。事实上,系统也会析构所有的类的静态成员变量,就像这些静态成员也是全局变量一样。利用这个特征,我们可以在单例类中定义一个这样的静态成员变量,而它的唯一工作就是在析构函数中删除单例类的实例。如下面的代码中的 CGarbo 类 (Garbo 意为垃圾工人):

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    class Singleton{
    public:
        static Singleton* GetInstance() {}
    private:
        Singleton() {};
        static Singleton *m_pInstance;
        // CGarbo 类的唯一工作就是在析构函数中删除 CSingleton 的实例
        class CGarbo{
        public:
            ~CGarbo(){
                if (Singleton::m_pInstance != NULL)
                    delete Singleton::m_pInstance;
            }
        };
        // 定义一个静态成员,在程序结束时,系统会调用它的析构函数
        static CGarbo Garbo;
    };
    

    CGarbo 被定义为 Singleton 的私有内嵌类,以防该类被在其他地方滥用。程序运行结束时,系统会调用 Singleton 的静态成员 Garbo 的析构函数,该析构函数会删除单例的唯一实例。

  • 饿汉实现:在程序开始时就自行创建实例。如果说懒汉实现是“时间换空间”,那么饿汉实现就是“空间换时间”,因为一开始就创建了实例,所以每次用到的之后直接返回就好了。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    // Singleton.h
    class Singleton{
    public:
        static Singleton* GetInstance();
    private:
        Singleton() {}
        static Singleton *m_pInstance;
        class CGarbo{
        public:
            ~CGarbo(){
                if (Singleton::m_pInstance != NULL)
                    delete Singleton::m_pInstance;
            }
        };
        static CGarbo garbo;
    };
    
    // Singleton.cpp
    Singleton* Singleton::m_pInstance = new Singleton();
    Singleton* Singleton::GetInstance(){
        return m_pInstance;
    }
    

5.2. 简单工厂模式

简单工厂模式的主要特点是需要在工厂类中做判断,从而创造相应的产品。当增加新的产品时,就需要修改工厂类。

简单工厂模式

例子:有一家生产处理器核的厂家,它只有一个工厂,能够生产两种型号的处理器核。客户需要什么样的处理器核,一定要显式地告诉生产工厂。

 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
enum CTYPE {COREA, COREB};
class SingleCore{
public:
    virtual void Show() = 0;
};

//单核 A
class SingleCoreA: public SingleCore{
public:
    void Show(){
        cout << "SingleCore A" << endl; 
    }
};

//单核 B
class SingleCoreB: public SingleCore{
public:
    void Show(){
        cout << "SingleCore B" << endl; 
    }
};

// 唯一的工厂,可以生产两种型号的处理器核,在内部判断
class Factory{
public:
    SingleCore* CreateSingleCore(enum CTYPE ctype){
        // 工厂内部判断
        if (ctype == COREA)
            // 生产核 A
            return new SingleCoreA(); 
        else if (ctype == COREB)
            // 生产核 B
            return new SingleCoreB(); 
        else
            return NULL;
    }
};

这样设计的主要缺点之前也提到过,就是要增加新的核类型时,就需要修改工厂类。这就违反了开放封闭原则:软件实体(类、模块、函数)可以扩展,但是不可修改。于是,工厂方法模式出现了。

5.3. 工厂方法模式

工厂方法模式是指定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法模式使一个类的实例化延迟到其子类。

工厂方法模式

例子:这家生产处理器核的厂家赚了不少钱,于是决定再开设一个工厂专门用来生产 B 型号的单核,而原来的工厂专门用来生产 A 型号的单核。这时,客户要做的是找好工厂,比如要 A 型号的核,就找 A 工厂要;否则找 B 工厂要,不再需要告诉工厂具体要什么型号的处理器核了。

 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
class SingleCore{
public:
    virtual void Show() = 0;
};

// 单核 A
class SingleCoreA: public SingleCore{
public:
    void Show(){
        cout << "SingleCore A" << endl; 
    }
};

// 单核 B
class SingleCoreB: public SingleCore{
public:
    void Show(){
        cout << "SingleCore B" << endl; 
    }
};

class Factory{
public:
    virtual SingleCore* CreateSingleCore() = 0;
};

// 生产 A 核的工厂
class FactoryA: public Factory{
public:
    SingleCoreA* CreateSingleCore(){
        return new SingleCoreA();
    }
};

// 生产 B 核的工厂
class FactoryB: public Factory{
public:
    SingleCoreB* CreateSingleCore(){
        return new SingleCoreB(); 
    }
};

工厂方法模式也有缺点,每增加一种产品,就需要增加一个对象的工厂。如果这家公司发展迅速,推出了很多新的处理器核,那么就要开设相应的新工厂。在 C++ 实现中,就是要定义一个个的工厂类。显然,相比简单工厂模式,工厂方法模式需要更多的类定义。

5.4. 抽象工厂模式

抽象工厂模式的定义为提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。

抽象工厂模式

例子:这家公司的技术不断进步,不仅可以生产单核处理器,也能生产多核处理器。现在简单工厂模式和工厂方法模式都鞭长莫及。这家公司还是开设两个工厂,一个专门用来生产 A 型号的单核多核处理器,而另一个工厂专门用来生产 B 型号的单核多核处理器。

 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
// 单核
class SingleCore{
public:
    virtual void Show() = 0;
};

class SingleCoreA: public SingleCore{
public:
    void Show(){
        cout << "Single Core A" << endl; 
    }
};

class SingleCoreB :public SingleCore{
public:
    void Show(){
        cout << "Single Core B" << endl; 
    }
};

// 多核
class MultiCore{
public:
    virtual void Show() = 0;
};

class MultiCoreA : public MultiCore{
public:
    void Show(){
        cout << "Multi Core A" << endl; 
    }
};
class MultiCoreB : public MultiCore{
public:
    void Show(){
        cout << "Multi Core B" << endl; 
    }
};

// 工厂
class CoreFactory{
public:
    virtual SingleCore* CreateSingleCore() = 0;
    virtual MultiCore* CreateMultiCore() = 0;
};

// 工厂 A,专门用来生产 A 型号的处理器
class FactoryA :public CoreFactory{
public:
    SingleCore* CreateSingleCore(){
        return new SingleCoreA(); 
    }
    MultiCore* CreateMultiCore(){
        return new MultiCoreA(); 
    }
};

// 工厂 B,专门用来生产 B 型号的处理器
class FactoryB : public CoreFactory{
public:
    SingleCore* CreateSingleCore(){
        return new SingleCoreB(); 
    }
    MultiCore* CreateMultiCore(){
        return new MultiCoreB(); 
    }
};