Skip to content

构造和析构

我们大家在购买一台电脑或者手机,或者其他的产品,这些产品都有一个初始设置,也就是这些产品对被创建的时候会有一个基础属性值。那么随着我们使用手机和电脑的时间越来越久,那么电脑和手机会慢慢被我们手动创建很多文件数据,某一天我们不用手机或电脑了,那么我们应该将电脑或手机中我们增加的数据删除掉,保护自己的信息数据。

从这样的过程中,我们体会一下,所有的事物在起初的时候都应该有个初始状态,当这个事物完成其使命时,应该及时清除外界作用于上面的一些信息数据。

那么我们C++中OOP思想也是来源于现实,是对现实事物的抽象模拟,具体来说,当我们创建对象的时候,这个对象应该有一个初始状态,当对象销毁之前应该销毁自己创建的一些数据。

初始化和清理

对象的初始化和清理也是两个非常重要的安全问题,一个对象或者变量没有初始时,对其使用后果是未知,同样的使用完一个变量,没有及时清理,也会造成一定的安全问题。C++为了给我们提供这种问题的解决方案,构造函数析构函数,这两个函数将会被编译器自动调用,完成对象初始化和对象清理工作。

无论你是否喜欢,对象的初始化和清理工作是编译器强制我们要做的事情,即使你不提供初始化操作和清理操作,编译器也会给你增加默认的操作,只是这个默认初始化操作不会做任何事,所以编写类就应该顺便提供初始化函数。

为什么初始化操作是自动调用而不是手动调用?既然是必须操作,那么自动调用会更好,如果靠程序员自觉,那么就会存在遗漏初始化的情况出现。

构造和析构函数

构造函数主要作用在于创建对象时为对象的成员属性赋值,构造函数由编译器自动调用,无须手动调用。

析构函数主要用于对象销毁前系统自动调用,执行一些清理工作。

构造函数语法:

构造函数函数名和类名相同,没有返回值,不能有void,但可以有参数。 ClassName(){}

析构函数语法:

析构函数函数名是在类名前面加 ~ 组成,没有返回值、不能有void、不能有参数、不能重载。 ~ClassName(){}

cpp
#include <iostream>
#include <cstring>
using namespace std;

class Person {
public:
    // 构造函数实现
    Person() {
        cout << "构造函数调用!" << endl;
        pName = (char*)malloc(sizeof("John"));
        strcpy(pName, "John");
        mTall = 150;
        mMoney = 100;
    }

    // 析构函数实现
    ~Person() {
        cout << "析构函数调用!" << endl;
        if (pName != NULL) {
            free(pName);
            pName = NULL;
        }
    }

public:
    char* pName;
    int mTall;
    int mMoney;
};

void test() {
    Person p1;
    cout << p1.pName << p1.mTall << p1.mMoney << endl;
}

int main(int argc, char *argv[]) {
    test();

    return 0;
}

程序输出:

shell
构造函数调用!
John150100
析构函数调用!

构造的分类

构造函数的分类

  • 按参数类型:分为无参构造函数和有参构造函数
  • 按类型分类:普通构造函数、拷贝构造函数(复制构造函数)、移动构造函数和委托构造函数
cpp
class Person {
public:
    // 1.默认构造函数
    Person() : _name(""), _age(0) { }
    // 2.有参构造函数
    Person(std::string name, int age = 0) : _name(name), _age(age)  { }
    // 3.拷贝/复制构造函数
    Person(const Person &p) : _name(p._name), _age(p._age)  { }
    // 4.移动构造函数
    Person(const Person &&p) : _name(p._name), _age(p._age)  { }
    // 5.委托构造函数
    Person(int age) : Person("--", age)  { }

private:
    std::string _name;
    int _age;
};

构造的调用方式

对于无参数的默认构造函数来说,调用方式为

cpp
Person person1;
// Person person2(); // 这种方式是错误的

对于有参数的构造函数来说,一般有三种方式来调用构造函数

  • 第一种 括号法,最常用
  • 第二种 匿名对象(显式调用构造函数)
  • 第三种 等号法 隐式转换

下面详细来说明这几种方式

第一种 括号法

cpp
Person person01(100);

// 调用拷贝构造函数
Person person02(person01);

第二种 匿名对象

cpp
Person(200);// 匿名对象,没有名字的对象
Person person03 = Person(300);

Person person06(Person(400));
// 等价于 
Person person06 = Person(400);

注意

使用匿名对象初始化判断调用哪一个构造函数,要看匿名对象的参数类型

第三种 等号法

cpp
Person person04 = 100;	
// 隐式转换为
Person person04 = Person(100);

//调用拷贝构造
Person person05 = person04;
// 隐式转换为
Person person05 = Person(person04);

b为A的实例化对象,A a = A(b)A(b)的区别?

A(b) 有变量来接的时候,那么编译器认为他是一个匿名对象,当没有变量来接的时候,编译器认为你 A(b) 等价于 A b

注意

  1. 不要用括号法调用无参构造函数(也就是Person p3();),编译器会认为这行代码是函数声明;

  2. 不能调用拷贝构造函数去初始化匿名对象(例如Teacher t1;之后Teacher(t1);)编译器会认为Teacher t1对象实例化,如果已经有t1了,就会出现重定义的错误,也就是说以下代码不正确;

    点击查看代码
    cpp
    #include <iostream>
    using namespace std;
    
    class Teacher {
    public:
     Teacher() {
         cout << "默认构造函数!" << endl;
     }
     Teacher(const Teacher& teacher) {
         cout << "拷贝构造函数!" << endl;
     }
    public:
     int mAge;
    };
    
    void test() {
     Teacher t1;
     //error C2086:“Teacher t1”: 重定义
     Teacher(t1);  //此时等价于 Teacher t1;
    }
    
    int main(int argc, char *argv[]) {
     test();
    
     return 0;
    }
  3. 匿名对象的特点:当前行执行完毕后,立即释放。

拷贝构造的调用时机

  • 对象以值传递的方式传给函数参数(旧对象初始化新对象
  • 函数局部对象以值传递的方式从函数返回(vs debug模式下调用一次拷贝构造,qt不调用任何构造)
  • 用一个对象初始化另一个对象
以上三种操作都会调用拷贝构造函数,详细的解释参考代码
cpp
#include <iostream>
using namespace std;

class Person {
public:
    Person() {
        cout << "no param contructor!" << endl;
        mAge = 10;
    }

    Person(int age) {
        cout << "param constructor!" << endl;
        mAge = age;
    }

    Person(const Person& person) {
        cout << "copy constructor!" << endl;
        mAge = person.mAge;
    }

    ~Person() {
        cout << "destructor!" << endl;
    }

public:
    int mAge;
};

//1. 旧对象初始化新对象
void test01() {
    Person p(10);
    Person p1(p);
    Person p2 = Person(p);
    Person p3 = p;// 相当于Person p2 = Person(p);
}

//2. 传递的参数是普通对象,函数参数也是普通对象,传递将会调用拷贝构造
void doBussiness(Person p) {}
void test02() {
    Person p(10);
    doBussiness(p);
}

//3. 函数返回局部对象
Person MyBusiness() {
    Person p(10);
    cout << "局部p:" << (int*)&p << endl;
    return p;
}

void test03() {
    //vs release、qt下没有调用拷贝构造函数
    //vs debug下调用一次拷贝构造函数
    Person p = MyBusiness();
    cout << "局部p:" << (int*)&p << endl;
}

int main(int argc, char *argv[]) {
    cout << "Test01输出:" << endl;
    test01();

    cout << "\nTest02输出:" << endl;
    test02();

    cout << "\nTest03输出:" << endl;
    test03();

    return 0;
}

程序输出:

shell
Test01输出:
param constructor!
copy constructor!
copy constructor!
copy constructor!
destructor!
destructor!
destructor!
destructor!

Test02输出:
param constructor!
copy constructor!
destructor!
destructor!

Test03输出:
param constructor!
局部p:0x7fffa6246b3c
局部p:0x7fffa6246b3c
destructor!

[Test03结果说明:]

编译器存在一种对返回值的优化技术,RVO(Return Value Optimization)。在vs debug模式下并没有进行这种优化,所以函数MyBusiness中创建p对象,调用了一次构造函数,当编译器发现你要返回这个局部的对象时,编译器通过调用拷贝构造生成一个临时Person对象返回,然后调用p的析构函数。

我们从常理来分析的话,这个匿名对象和这个局部的p对象是相同的两个对象,那么如果能直接返回p对象,就会省去一个拷贝构造和一个析构函数的开销,在程序中一个对象的拷贝也是非常耗时的,如果减少这种拷贝和析构的次数,那么从另一个角度来说,也是编译器对程序执行效率上进行了优化。

所以在这里,编译器偷偷帮我们做了一层优化:

当我们这样去调用: Person p = MyBusiness();

编译器偷偷将我们的代码更改为:

cpp
void MyBussiness(Person& _result) {
  _result.X:X(); //调用Person默认拷贝构造函数
  //.....对_result进行处理
  return;
}

int main(int argc, char *argv[]) {
  Person p; //这里只分配空间,不初始化
  MyBussiness(p);
}

构造的调用规则

  • 规则一:默认情况下,C++编译器至少为我们写的类增加3个函数

    • 默认构造函数(无参,函数体为空)

    • 默认析构函数(无参,函数体为空)

    • 默认拷贝构造函数,对类中非静态成员属性简单值拷贝

  • 规则二:如果用户定义拷贝构造函数,C++不会再提供任何默认构造函数

  • 规则三:如果用户定义了普通构造(非拷贝),C++不在提供默认无参构造,但是会提供默认拷贝构造

多个对象构造和析构

类对象作为成员

在类中定义的数据成员一般都是基本的数据类型。但是类中的成员也可以是对象,叫做对象成员

C++中对对象的初始化是非常重要的操作,当创建一个对象的时候,C++编译器必须确保调用了所有子对象的构造函数。如果所有的子对象有默认构造函数,编译器可以自动调用他们。但是如果子对象没有默认的构造函数,或者想指定调用某个构造函数怎么办?

那么是否可以在类的构造函数直接调用子类的属性完成初始化呢?但是如果子类的成员属性是私有的,我们是没有办法访问并完成初始化的。

解决办法非常简单:对于子类调用构造函数,C++为此提供了专门的语法,即

当调用构造函数时,首先按各对象成员在类定义中的顺序(和参数列表的顺序无关)依次调用它们的构造函数,对这些对象初始化,最后再调用本身的函数体。也就是说,先调用对象成员的构造函数,再调用本身的构造函数。

参考代码
cpp
#include <iostream>
using namespace std;

//汽车类
class Car {
public:
    Car() {
        cout << "Car 默认构造函数!" << endl;
        mName = "大众汽车";
    }
    Car(string name) {
        cout << "Car 带参数构造函数!" << endl;
        mName = name;
    }
    ~Car() {
        cout << "Car 析构函数!" << endl;
    }
public:
    string mName;
};

//拖拉机
class Tractor {
public:
    Tractor() {
        cout << "Tractor 默认构造函数!" << endl;
        mName = "爬土坡专用拖拉机";
    }
    Tractor(string name) {
        cout << "Tractor 带参数构造函数!" << endl;
        mName = name;
    }
    ~Tractor() {
        cout << "Tractor 析构函数!" << endl;
    }
public:
    string mName;
};

//人类
class Person {
public:
#if 1
    //类mCar不存在合适的构造函数
    Person(string name) {
        mName = name;
    }
#else
    //初始化列表可以指定调用构造函数
    Person(string carName, string tracName, string name) : mTractor(tracName), mCar(carName), mName(name) {
        cout << "Person 构造函数!" << endl;
    }
#endif
    void GoWorkByCar() {
        cout << mName << "开着" << mCar.mName << "去上班!" << endl;
    }
    void GoWorkByTractor() {
        cout << mName << "开着" << mTractor.mName << "去上班!" << endl;
    }
    ~Person() {
        cout << "Person 析构函数!" << endl;
    }
private:
    string mName;
    Car mCar;
    Tractor mTractor;
};

void test() {
    //Person person("宝马", "东风拖拉机", "赵四");
    Person person("刘能");
    person.GoWorkByCar();
    person.GoWorkByTractor();
}

int main(int argc, char *argv[]) {
    test();

    return 0;
}

程序输出:

shell
Car 默认构造函数!
Tractor 默认构造函数!
刘能开着大众汽车去上班!
刘能开着爬土坡专用拖拉机去上班!
Person 析构函数!
Tractor 析构函数!
Car 析构函数!

初始化列表

构造函数和其他函数不同,除了有名字,参数列表,函数体之外还有初始化列表。

初始化列表简单使用
cpp
#include <iostream>
using namespace std;

class Person {
public:
#if 0
    // 传统方式初始化
    // 在有参构造函数中给成员遍历赋值
    Person(int a, int b, int c) {
        mA = a;
        mB = b;
        mC = c;
    }

    // ‼️‼️
    // 这种方式相当于 int a; a = 10;
    // 是先定义了变量,然后给变量赋初值
#endif

    // ✔️ 使用初始化列表
    // 这种方式相当于 int a = 10;
    // 是在定义变量的时间对其进行初始化

    // 初始化列表方式一
    // 直接在无参构造函数后使用,给成员变量一个默认的值
    Person() : mA(10), mB(20), mC(30) {}

    // 初始化列表方式二 
    // 在有参构造函数后使用,通过参数的值给成员变量初始化
    //Person(int a, int b, int c) : mA(a), mB(b), mC(c) {}

    void PrintPerson() {
        cout << "mA:" << mA << endl;
        cout << "mB:" << mB << endl;
        cout << "mC:" << mC << endl;
    }

private:
    int mA;
    int mB;
    int mC;
};

int main(int argc, char *argv[]) {
    Person p1;
    p1.PrintPerson();

    return 0;
}

程序输出:

shell
mA:10
mB:20
mC:30

注意

  1. 初始化成员列表(参数列表)只能在构造函数使用。
  2. 成员变量初始化的顺序与定义顺序相关,与初始化成员列表中初始化的顺序无关。

explicit关键字

C++提供了关键字explicit,禁止通过构造函数进行的隐式转换(通俗来说就是禁止使用隐式法来构造对象或者初始化对象)。声明为explicit的构造函数不能在隐式转换中使用。

注意

  • explicit用于修饰构造函数,防止隐式转化。
参考代码
cpp
#include <iostream>
using namespace std;

class MyString {
public:
    explicit MyString(int n) {
        cout << "MyString(int n)!" << endl;
    }
    MyString(const char* str) {
        cout << "MyString(const char* str)" << endl;
    }
};

int main(int argc, char *argv[]) {
    // 给字符串赋值?还是初始化?
    // MyString str1 = 1; 

    //括号法初始化对象是允许的
    MyString str2(10);

    //寓意非常明确,给字符串赋值
    MyString str3 = "abcd";
    MyString str4("abcd");

    return 0;
}

程序输出:

shell
MyString(int n)!
MyString(const char* str)
MyString(const char* str)

深拷贝和浅拷贝

浅拷贝

同一类型的对象之间可以赋值,使得两个对象的成员变量的值相同,两个对象仍然是独立的两个对象,这种情况被称为浅拷贝

深拷贝

当类中有指针,并且此指针有动态分配空间,析构函数做了释放处理,往往需要自定义拷贝构造函数,自行给指针动态分配空间,深拷贝

出现问题的原因是p1对象在堆区开辟了内存来存储pName,同时我们也没有在类内部实现自己的拷贝构造函数,然后还在析构函数中释放了p1开辟的内存,最根本的原因是编译器默认提供的拷贝构造函数会逐字节拷贝原对象中的属性(浅拷贝),对于pName属性编译器拷贝的是p1对象中存储pName值的地址;如此一来,p1中pName存放的地址会被拷贝到p2pName中,两个pName存放的地址完全相同!当我们在析构函数中释放内存的时间就会出现重复释放内存的错误(因为对象存储在栈上的特性会先释放p2pName中存放的地址并置空,然后p1pName再释放就出错了)。

解决该问题的方案就是自己实现拷贝构造函数,对于需要开辟到堆区的属性在拷贝构造函数中进行实现就可以了。

cpp
#include <iostream>
using namespace std;
#include <string>

class Person {
public:
    // 有参构造函数
    Person(char *name, int age) {
        pName = (char *) malloc(strlen(name) + 1);
        strcpy(pName, name);
        mAge = age;
    }

    /*
    // 增加拷贝构造函数
    Person(const Person &person) {
        pName = (char *) malloc(strlen(person.pName) + 1);
        strcpy(pName, person.pName);
        mAge = person.mAge;
    }
    */

    // 析构函数
    ~Person() {
        if (pName != NULL) {
            free(pName);
        }
    }

public:
    char *pName;
    int mAge;
};

void test() {
    char name[] = "Edward";
    Person p1(name, 30);
    // 用对象p1初始化对象p2,调用C++提供的默认拷贝构造函数(如果自己有实现拷贝构造函数,就会调用自己实现的拷贝构造【就不会有因浅拷贝而出现重复释放内存的错误了】)
    cout << "姓名:" << p1.pName << " 年龄:" << p1.mAge << endl;
    Person p2 = p1;
    cout << "姓名:" << p2.pName << " 年龄:" << p2.mAge << endl;
}

int main(int argc, char *argv[]) {
    cout << "test开始执行" << endl;
    test();
    cout << "test执行完毕" << endl;
    // 自己实现了拷贝构造函数以后可以正常的运行程序了

    return 0;
}
  • 在Visual Studio中运行以上代码会提示以下错误

    error C2665: “Person::Person”: 没有重载函数可以转换所有参数类型

  • Visual Studio Code中运行结果如下:

    shell
    test开始执行
    姓名:Edward 年龄:30
    姓名:Edward 年龄:30
    05-深拷贝和浅拷贝.out(2761,0x100084580) malloc: *** error for object 0x100306910: pointer being freed was not allocated
    05-深拷贝和浅拷贝.out(2761,0x100084580) malloc: *** set a breakpoint in malloc_error_break to debug

解决的方法也很简单,自己实现一个默认构造函数就可以了

面试题

成员变量的初始化顺序

现在有这样一个程序,问输出的a和b值分别为多少?

cpp
#include <iostream>

class Test {
public:
    Test(int num) : b(num), a(b) {}
    void show() {
        std::cout << "a=" << a << " b=" << b << std::endl;
    }

private:
    int a;
    int b;
};

int main() {
    Test test(10);
    test.show();

    return 0;
}
点击查看答案

输出时,a的值应该是一个无效值,因为并没有对a进行初始化;b的值是10。

你可能有疑惑,明明在初始化列表中先初始化了b,然后用已经初始化了的b的值初始化了a。其实不然,成员变量的初始化顺序与成员列表中初始化的顺序无关,只与成员变量的定义顺序有关。在初始化a时,b还没有被初始化,自然也就无法把b的初始化值给a。