一. 析构函数

1.1 简介

析构函数执行与构造函数相反的操作:构造函数初始化对象的非 static 数据成员,还可能做一些其它工作。析构函数释放对象使用的资源,并销毁对象的非 static 数据成员。

析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数。对一个给定类,只会有唯一一个析构函数。

#include <cstdio>

class Person{
private:
    int age;

public:
    Person() = default;

    Person(int age)
    :age(age){}

    ~Person(){
        printf("I' destructor.\n");
    }
};

int main(){
    Person you(5);
    return 0;
}

1.2 析构函数什么时候调用

无论何时一个对象被销毁,就会自动调用其析构函数

  1. 变量离开其作用域时被销毁
  2. 当一个对象销毁时,其成员被销毁
  3. 动态分配的对象,delete 时被销毁
  4. 临时对象销毁时

当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。

二. RALL 惯用法

2.1 简介

在有垃圾回收的编程语言里,如 Java、Python、Go,不需要程序员随时注意内存是否泄漏了,而 C++ 则需要程序员细心认真的处理内存,避免内存泄漏,即任何申请的内存都被释放。

那么如何实现呢?C++的解决方法是 Rall 惯用法和构建在其上的智能指针

Rall 全称 Resource acquisition is initialization. 意为资源获取要通过构造函数初始化,然后析构函数负责释放资源

int main(){
    int *pointer = new int{7};
    dosomething(pointer);
    delete pointer;
    return 0;
}

看似没有内存泄漏,但实际这样的代码是很脆弱的

  1. malloc 和 free 或 new 和 delete 可能相距很远,难以看出对应关系
  2. 中间如果有任何异常,如 dosomething 发生了错误,程序提前结束,delete 根本不会执行。
  3. 如果将这段代码复用,搬到其它地方,容易漏掉 free

所以只靠程序员的细致认真,远远不够 !!!

int *pointer = createLinkList();

pointer 这个指针我们到底要不要释放,如果 createLinkList 这个函数是我们写的,我们肯定知道,但是如果不是呢?

因此 Rall 惯用法对于 C++ 程序员来说至关重要,必须熟练掌握

2.2 原理

Rall 惯用法的原理很简单,即利用栈上的对象在离开作用域时,会自动调用析构函数

#include <cstdio>

class MyInt{
private:
    int *int_pointer;

public:
    MyInt(int value){
        this->int_pointer = new int{value};
    }

    ~MyInt(){
        delete this->int_pointer;
    }

public:
    int getValue(void) const{
        return *(this->int_pointer);
    }
};

int main(){
    MyInt my_int(7);
    printf("Value: %d\n", my_int.getValue());
    return 0;
}

三. 三五法则

  1. 如果一个类定义了析构函数,那么必须同时定义或删除拷贝构造函数和拷贝赋值运算符,否则出错
  2. 如果一个类定义了拷贝构造函数,那么必须定义或删除拷贝赋值运算符,否则出错,删除将导致低效
  3. 如果一个类定义了移动构造函数,那么必须定义或删除移动赋值运算符,否则出错,删除将导致低效
  4. 如果一个类定义了拷贝构造函数或拷贝赋值运算符,那么最好同时定义移动构造函数或移动赋值运算符,否则低效

三五法则是前人总结的,避免犯错的经验

四. 拷贝控制

4.1 删除拷贝构造函数和拷贝赋值运算符

#include <stdio.h>

class MyInt{
private:
    int *int_pointer;

public:
    MyInt(int value){
        this->int_pointer = new int{value};
    }

    MyInt(const MyInt &) = delete;
    MyInt &operator=(const MyInt &) = delete;

    ~MyInt(){
        delete this->int_pointer;
    }

public:
    int getValue(void) const{
        return *(this->int_pointer);
    }
};

int main(){
    MyInt my_int(7);
    printf("Value: %d\n", my_int.getValue());
    return 0;
}

同时定义拷贝构造函数和拷贝赋值运算符,这两个函数的写法不一样,类的行为也不一样,具体分为行为像值的类和行为像指针的类

4.2 行为像值的类

对于行为像值的类,每个对象都应该拥有一份自己的资源

class MyInt{
private:
    int *int_pointer;

public:
    MyInt(int value){
        this->int_pointer = new int{value};
    }

    MyInt(const MyInt &my_int)
    :int_pointer(new int{*my_int.int_pointer}){}

    MyInt &operator=(const MyInt &my_int){
        int *new_int_pointer = new int{*my_int.int_pointer};
        delete this->int_pointer;
        this->int_pointer = new_int_pointer;                                                                                                                          
        return *this;
    }

    ~MyInt(){
        delete this->int_pointer;
    }

public:
    int getValue(void) const{
        return *(this->int_pointer);
    }
};

int main(){
    MyInt my_int(7);
    MyInt she_int(1);
    MyInt you_int(she_int);
    printf("Value: %d\n", you_int.getValue());

    you_int = my_int;
    printf("Value: %d\n", you_int.getValue());
    return 0;
}

编写赋值运算符时,一定要注意两点

  1. 如果将一个类赋予它自身,赋值运算符必须能正常工作
  2. 大多数赋值运算符组合了析构函数和拷贝构造函数的工作

当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从局部对象拷贝到左侧运算对象的成员中了

MyInt &operator=(const MyInt &my_int){
        delete this->int_pointer;
        this->int_pointer = new int{*my_int.int_pointer};                                                                                                                        
        return *this;
    }

如果 my_int 和 int_pointer 是同一个对象,delete 会释放掉 my_int 和 int_pointer 指向的 int, 接下来就会访问一个指向无效内存的指针,其行为和结果是未定义的

4.3 行为像指针的类

对于行为像指针的类,每个对象应该共享一份资源。此时,需要使用引用计数

引用计数的工作方式如下:

  1. 除了初始化对象,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享资源。当我们创建一个对象时,只有一个对象共享资源,此时将计数器初始化为 1
  2. 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出当前对象的资源又被一个新对象共享。
  3. 析构函数递减计数器,指出共享资源的对象少了一个。如果计数器变为 0,则析构函数释放资源
  4. 拷贝赋值运算符递增右侧对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0,意味着它的共享资源没有对象了,拷贝赋值运算符就必须销毁对象

计数器应该放在哪里?

可以将计数器放在动态内存中,当创建一个对象时,分配一个新的计数器;当拷贝或赋值对象时,我们拷贝指向计数器的指针,这样前后两个对象都会指向相同的计数器。

class MyInt{
private:
    int *int_pointer;
    int *use_count;

public:
    MyInt(int value)
    :int_pointer(new int{value}), use_count(new int{1}){}

    MyInt(const MyInt &my_int)
    :int_pointer(my_int.int_pointer), use_count(my_int.use_count){
        ++(*this->use_count);
    }

    MyInt &operator=(const MyInt &my_int){
        ++(*my_int.use_count);
        --(*this->use_count);
        if(*this->use_count == 0){
            delete this->int_pointer;
            delete this->use_count;
        }
        this->int_pointer = my_int.int_pointer;
        this->use_count = my_int.use_count;
        return *this;
    }

    ~MyInt(){
        --(*this->use_count);
        if(*this->use_count == 0){
            delete this->int_pointer;
            delete this->use_count;
        }
    }

public:
    int getValue(void) const{
        return *this->int_pointer;
    }

    int getUseCount(void) const{
        return *this->use_count;
    }
};

int main(){
    MyInt my_int(7);
    MyInt she_int(1);
    MyInt you_int(my_int);
    you_int = she_int;
    return 0;
}

作业

给你两个二进制字符串 a 和 b ,以二进制字符串的形式返回它们的和。

示例 1:

输入:a = “11”, b = “1”

输出:“100”

示例 2:

输入:a = “1010”, b = “1011”

输出:“10101”