对于类声明,还有一些其他工作要做 —— 应为类提供构造方法析构方法

为什么要提供构造函数?


先说结论:由于 C++ 对类的数据进行了隐藏,程序不能直接访问类的数据,需要通过成员函数来访问数据成员,因此需要提供一个成员函数来进行初始化,这个成员函数就是构造函数。也就是说,构造函数就是专门用来初始化类对象的成员函数。


C++ 的目标之一就是使用类对象可以像使用内置类型变量一样。到目前为止,可以像声明内置类型变量那样声明类对象,int a;—> Stock sally;;但是无法像初始化内置类型变量或者结构那样来初始化类对象。也就是说,常规的初始化语法不适用于自定义的类。

  1. // 有效的初始化
  2. int a = 20;
  3. struct Thing {
  4. char *ptr;
  5. int t;
  6. };
  7. // 有效的初始化
  8. Thing b = {"Hello", -1};
  9. class Book {
  10. private:
  11. char* name;
  12. char* author;
  13. double value;
  14. public:
  15. ...
  16. };
  17. // 无效初始化,会报错
  18. Book c = {"C++ Primer Plus", "Stephen Prata", 40.8};

Book 类不能像 int 和 Thing 结构那样初始化的原因在于,Book 类的数据部分是私有访问权限,这意味着程序不能直接访问数据成员。这和我们之前学习的是一致的,程序只能通过成员函数来访问数据成员,因此需要设计一个合适的成员函数,才能成功地将对象初始化,这个成员函数就是构造函数。
PS:如果将数据成员全部设置为公有访问权限,是可以像结构体那样用初始化列表来进行初始化的,但是,这样就违背了类的主要初衷 —— 数据隐藏。而只要有数据成员是私有访问权限,就不能直接通过初始化列表来进行初始化,必须定义构造函数。

class Stock {
private:
    ...
public:
    void acquire(const std::string& co, long n, double pr);
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
}; // 注意类声明以分号结尾

inline void Stock::set_tot() {
    total_value = shares * share_value;
}

Stock 类目前的设计是假设用户在调用任何其他成员函数之前需要调用 acquire(),但是无法将这个假设强加给用户,即用户使用时可能并不会先调用 acquire() 而直接使用其他成员函数。
避开这个问题的方法之一是:在创建对象时,自动对它们进行初始化。为此,C++ 提供了一个特殊的成员函数 —— 构造函数,专门用于构造新对象,并将值赋给它们的数据成员。更准确地说,C++ 已经为构造函数提供了名称、使用语法,而程序员只需要提供定义。

构造函数和普通成员函数有一些差别:

  1. 构造函数的函数名是固定的,与类名一致。
  2. 构造函数没有返回值。虽然没有返回值,但并没有被声明为 void。

    构造函数的声明和定义

    下面将创建 Stock 类的构造函数来代替 acquire() 函数。 ```cpp

    ifndef STOCK1H

    define STOCK1H

include

/*

  • 构造函数和析构函数的练习 / class Stock { private: std::string company; // 公司 long shares; // 持有的股数 double share_value; // 股票实时单价 double total_value; // 持有的股票总价 void set_tot(); public: // 股票初始化, Stock(const std::string& co, long sh = 0, double val = 0); // 购买股票 void buy(long num, double price); // 出售股票 void sell(long num, double price); // 更新股票价格 void update(double price); // 展示股票信息 void show(); }; inline void Stock::set_tot() { total_value = share_value shares; }

    endif // STOCK1H

    ```cpp
    #include "stock1.h"
    Stock::Stock(const std::string &co, long num, double val) {
     std::cout << "这里是 Stock(const std::string &co, long sh = 0, double val = 0.0)" << std::endl;
     company = co;
     if (num < 0) {
         std::cout << "购买数目不能为负,将 " + co + " 股票数目设置为 0." << std::endl;
         shares = 0;
     } else
         shares = num;
     share_value = val;
     set_tot();
    }
    
    上述代码和之前定义的 acquire() 实现是相同的,区别在于,程序声明 Stock 对象时,将自动调用构造函数,而 acquire() 需要手动调用。

    使用构造函数

    C++ 提供了两种使用构造函数的方法 —— 显式调用构造函数和隐式调用构造函数。 ```cpp

    include “stock1.h”

int main(int argc, char* args) { // 显示调用构造函数 Stock s1 = Stock(“可口可乐”, 300, 1.25); Stock s2 = Stock{“茅台”, 540, 3.7}; Stock p1 = new Stock(“比亚迪”, 500, 3.8); // 隐式调用构造函数 Stock s3(“宁德时代”, 1030, 4.8); Stock s4 = {“药明康德”, 680, 2.75};

delete p1;
return 0;

}

```cpp
这里是 Stock(可口可乐, 300, 1.25)
这里是 Stock(茅台, 540, 3.7)
这里是 Stock(比亚迪, 500, 3.8)
这里是 Stock(宁德时代, 1030, 4.8)
这里是 Stock(药明康德, 680, 2.75)

其中 Stock 指针 p1 是将构造函数与 new 一起使用。这条语句创建了一个 Stock 对象,将其初始化为参数提供的值,并将该对象的地址赋给 p1 指针。在这种情况下,对象没有名称,但是可以通过 p1 指针来管理该对象。

构造方法的使用方式和普通成员函数是不同的,一般来说使用对象调用方法都会用 . 运算符,但无法使用对象来调用构造函数,因为在构造函数构造出对象之前,对象是不存在的。因此,构造函数是用来创建对象,而不能通过对象来调用。

特殊的构造函数

1. 默认构造函数

默认构造函数是指在没有提供显式初始值时,用来创建对象的构造函数。也就是说,是用于下面这种声明的构造函数:

Stock sally; // 使用默认构造函数

如果没有提供任何构造函数,则 C++ 将自动提供默认构造函数,它是默认构造函数的隐式版本,不做任何工作。

默认构造函数没有任何参数

需要注意的:当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数。而一旦为类提供了构造函数,程序员必须为它提供默认构造函数。如果程序员提供了非默认构造函数,但没提供默认构造函数(例如上面的 Stock 类),则下面的声明将出错:

Stock s5; // 报错,原因是未提供默认构造函数

image.png
这样做的原因可能是想要禁止创建未初始化的对象。然而,如果想要创建对象,并且不进行显式初始化,则必须定义一个可以不接受任何参数的默认构造函数。

定义默认构造方法有两种:
第一种方法,重载一个没有任何参数的 Stock 构造方法:

class Stock {
private:
    ...
public:
    Stock();
}

Stock::Stock() {
    ...
}

第二种方法,给已有构造函数的所有参数都提供默认值:

class Stock {
private:
    ...
public:
    Stock(const std::string& co = "no name", int num = 0, double value = 0.0);
}

Stock::Stock(const std::string& co = "no name", int num = 0, double value = 0.0) { {
    ...
}

PS:只能有一个默认构造方法,因此不要同时采用这两种方式

实际上,通常应初始化所有的对象,以确保所有成员一开始就有已知的合理值。因此,用户定义的默认构造函数通常给所有成员提供隐式初始值,而编译器自动提供的默认构造函数则是什么也不做。
PS:在设计类时,通常应提供对所有类成员做隐式初始化的默认构造函数

2. 复制构造函数

什么是复制构造函数?

还有一类比较常用的特殊构造函数 —— 复制构造函数。复制构造函数只有一个参数,参数的类型是本类的引用。可以是 const 引用,也可以是非 const 引用,不过一般都是设置为 const 引用。
例如,Stock 类的复制构造函数如下所示:

class Stock {
private:
    ...
public:
    Stock(const Stock&); // 复制构造函数
};

默认复制构造函数

和默认构造函数类似,如果类的设计者不定义复制构造函数,编辑器会自动生成一个复制构造函数,这个编辑器自动生成的复制构造函数称为默认复制构造函数。编辑器自动生成的构造函数的作用其实就是从源对象到目标对象逐个字节的复制,这种复制也称为浅拷贝。

class Stock {
    ...
public:
    Stock();
    Stock(const std::string& co, long num = 0, double val = 0.0);
}

int main() {
    Stock s1 = Stock("可口可乐", 300, 1.25);
    // 显式复制构造函数
    Stock s7 = Stock(s1);
    // 隐式调用复制构造函数
    Stock s8 = s1; 
}

可以看到,在 Stock 类中,并没有定义参数是 Stock& 的构造函数,但依旧可以使用 Stock(s1) 这个构造函数来为 s7 初始化就是因为编译器提供了默认复制构造函数。

一般情况下,我们完全不需要定义复制构造函数,但是,如果类的成员中使用了动态内存分配的情况时,就需要我们定义复制构造函数完成深拷贝。关于类中的动态内存分配的知识会单独拿出来讲解的。

复制构造函数的使用

使用类对象初始化同类的 Stock 对象时调用。主要场景就是声明并初始化类对象,以及调用参数列表或返回为类对象的函数时创建临时对象。

首先,我们在 Stock 类的声明中定义复制构造函数。

// stock1.h
#ifndef STOCK1_H_
#define STOCK1_H_
#include <iostream>
/*
 * 构造函数和析构函数的练习
 */
class Stock {
private:
    std::string company; // 公司
    long shares; // 持有的股数
    double share_value; // 股票实时单价
    double total_value; // 持有的股票总价
    void set_tot();
public:
    Stock();
    Stock(const Stock &);// 复制构造函数
    Stock(const std::string& co, long num = 0, double val = 0.0);
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};
inline void Stock::set_tot() {
    total_value = share_value * shares;
}
#endif // STOCK1_H_
// stock1.cpp
// 这里只附上复制构造函数的定义,其他函数的实现就不重复了
Stock::Stock(const Stock & s) {
    std::cout << "这里是 Stock 复制构造函数" << std::endl;
    company = s.company;
    shares = s.shares;
    share_value = s.share_value;
    set_tot();
}
// use_stock1.cpp
Stock s1 = Stock("可口可乐", 300, 1.25);
Stock s2 = Stock{"茅台", 540, 3.7};
Stock s7 = Stock(s1);
s7.show();
std::cout << std::endl;
Stock s8 = s1;
s8.show();
std::cout << std::endl;
s8 = s2; // 赋值运算符,和赋值构造函数无关
s8.show();
这里是 Stock(可口可乐, 300, 1.25)
这里是 Stock(茅台, 540, 3.7)
这里是 Stock 复制构造函数
Company: 可口可乐
Shares: 300
Share Price: $1.25
Total Worth: $375

这里是 Stock 复制构造函数
Company: 可口可乐
Shares: 300
Share Price: $1.25
Total Worth: $375

Company: 茅台
Shares: 540
Share Price: $3.7
Total Worth: $1998

需要注意的是,Stock s8 = s1依旧是调用了复制构造函数去初始化的 s8,而后面的s8 = s2是赋值语句,和初始化无关,不涉及构造函数,因此不会使用复制构造函数。

接下来,演示复制构造函数比较隐晦的使用 —— 按值传递的函数调用。

Stock f1(Stock t1) {
    std::cout << "f1()" << std::endl;
    return t1;
}

Stock& f2(Stock &t2) {
    std::cout << "f2()" << std::endl;
    return t2;
}
int main(int argc, char** args) {
    Stock s1 = Stock("可口可乐", 300, 1.25);
    Stock s2 = Stock{"茅台", 540, 3.7};
    f1(s1);
    std::cout << std::endl;
    f2(s1);
}
这里是 Stock 复制构造函数
f1()
这里是 Stock 复制构造函数

f2()

对于 f1(),第一句 “这里是 Stock 复制构造函数” 是将实参 s1 按值传递给形参 t1 时调用复制构造函数时输出的;第二句 “这里是 Stock 复制构造函数” 是将形参 t1 作为返回值调用复制构造函数生成一个临时的 Stock 变量时输出的。

对于 f2() 由于参数和返回值都采用引用传递,因此不必创建临时变量,这也是推荐使用引用传递参数的原因 —— 减少创建临时变量造成的开销。

析构函数

名词解释:析构其实是翻译来的,它的英文是 destructor,销毁、毁灭的意思,destructor 是 constructor(构造) 的反义词。从名字就能看出来,构造函数和析构函数是一对小冤家,构造函数是创建对象时调用的,而析构函数则是销毁对象时调用的。

用构造函数创建对象后,程序负责跟踪对象,直到其过期为止。对象过期时,程序将自动调用一个特殊的成员函数 —— 析构函数。析构函数完成对象销毁的清理工作。

事实上,析构函数的清理工作主要是针对动态分配内存的释放。由于 Stock 类并没有使用动态内存分配,因此 Stock 类的析构函数实际上并没有需要完成的任务。在这种情况下,只需要让编译器生成一个什么都不需要做的默认析构函数即可。我们前面的 Stock 类声明中就是这样做的。然而,了解如何声明和定义析构函数是绝对必要的。

和构造函数一样,析构函数也很特殊:函数名是在类名前面加上~,没有返回值类型。但是与构造函数不同的是析构函数没有参数。因此,Stock 类的析构函数必须为 ~Stock()。

由于 Stock 类没有使用动态内存分配,因此 ~Stock() 并不承担任何工作,可以将其编写为不执行任何操作的函数:

Stock::~Stock() {}

但是因为析构函数是在对象被销毁的时候自动被调用的,在代码中是不会有析构函数被调用的痕迹的,因此为了能够看出析构函数何时被调用,在析构函数中输出一句话:

Stock::~Stock() {
    std::cout << "Stock 析构函数, Company: " << company << std::endl;
}

什么时候调用析构函数?这由编译器决定,通常不会在代码中显式调用析构函数。编译器调用构造函数的时间和内存模型相关。

  • 如果创建的是自动存储的类对象,则其析构函数将在程序执行完代码块(类对象在其中定义)时自动被调用;
  • 如果创建的是静态存储的类对象,则其析构函数将在程序结束时自动被调用;
  • 如果创建的是动态存储的类对象,即通过 new 创建的类对象,则其析构函数将在使用 delete 释放内存时被调用;
  • 如果创建的是临时对象来完成特定的操作,程序将在结束对该临时对象的使用时自动调用其析构函数。

在类对象过期时析构函数将被自动调用,因此必须有一个析构函数。在程序员没有提供析构函数的时候,函数将隐式的声明一个默认析构函数,并在发现导致类对象被删除的代码之后,提供默认析构函数的定义。

代码演示

修改 Stock 类的代码,为了更直观的看到构造方法和析构方法在什么时候调用,在构造函数和析构函数的实现中输出一些语句。

// stock2.h
#ifndef STOCK2_H_
#define STOCK2_H_
#include <iostream>
class Stock {
private:
    std::string company; // 公司
    long shares; // 持有的股数
    double share_value; // 股票实时单价
    double total_value; // 持有的股票总价
    void set_tot() { total_value = share_value * shares; }

public:
    Stock();
    Stock(const Stock &);
    Stock(const std::string com, long num = 0, double price = 0.0);
    ~Stock();

    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};
#endif // STOCK2_H_
// stock2.cpp
#include "stock2.h"
Stock::Stock() {
    std::cout << "This is Default Constructor Function.(默认构造函数)\n";
    company = "No Company";
    shares = 0;
    share_value = 0.0;
    set_tot();
    std::cout << "Default Constructor Function END." << std::endl;
}

Stock::Stock(const Stock & stock) {
    std::cout << "This is Copy Constructor Function.(复制构造函数)\n";
    company = stock.company;
    shares = stock.shares;
    share_value = stock.share_value;
    set_tot();
    std::cout << "Copy Constructor Function END." << std::endl;
}

Stock::Stock(const std::string com, long num, double price) {
    std::cout << "This is a Constructor Function —— Stock(const std::string, long, double)\n";
    company = com;
    if (num < 0) {
        std::cout << "Number is invalid. Number is set 0.\n";
        shares = 0;
    } else
        shares = num;
    share_value = price;
    set_tot();
    std::cout << "Constructor Function END." << std::endl;
}

Stock::~Stock() {
    std::cout << "Destructor Function. Company is " << company << std::endl << std::endl;
}

void Stock::buy(long num, double price) {
    if (num < 0) {
        std::cout << "Number is invalid.Buy Failed.\n";
        return;
    }
    shares += num;
    share_value = price;
    set_tot();
}

void Stock::sell(long num, double price) {
    if (num < 0) {
        std::cout << "Number is invalid. Sell Failed.\n";
        return;
    }
    if (num > shares) {
        std::cout << "Shares isn't enough. Sell Failed.\n";
        return;
    }

    shares -= num;
    share_value = price;
    set_tot();
}

void Stock::update(double price) {
    share_value = price;
    set_tot();
}

void Stock::show() {
    std::cout << "Company: " << company << std::endl
              << "Shares: " << shares << std::endl
              << "Share Price: $" << share_value << std::endl
              << "Total Worth: $" << total_value <<std::endl;
}
// use_stock2.cpp
#include "stock2.h"

Stock s1 = Stock("Stock1", 30, 55.5);
static Stock s2 = Stock("Stock2", 40, 40.3);
int main() {
    using std::cout;
    using std::endl;

    cout << endl << "Show s1 Information: " << endl;
    s1.show();
    cout << endl << "Show s2 Information: " << endl;
    s2.show();
    {
        cout << endl << "Create s3: " << endl;
        Stock s3 = Stock("Stock 3", 20, 10.4);
        cout << "Show s3 Information: " << endl;
        s3.show();
        cout << endl << "Create s4: " << endl;
        static Stock s4 = Stock("Stock 4", 30, 20.4);
        cout << "Show s4 Information: " << endl;
        s4.show();
        cout << endl << "Create s5: " << endl;
        Stock s5 = s1;
        cout << "Show s5 Information: " << endl;
        s5.show();
        cout << "Assign s2 to s5: " << endl;
        s5 = s2;
        cout << "Show s5 Information: " << endl;
        s5.show();
        cout << "Assign s5: " << endl;
        s5 = Stock("Stock 5", 23, 6);
        cout << "Show s5 Information: " << endl;
        s5.show();
    }
    cout << "Main END.\n";
    return 0;
}
This is a Constructor Function —— Stock(const std::string, long, double)
Constructor Function END.
This is a Constructor Function —— Stock(const std::string, long, double)
Constructor Function END.

Show s1 Information: 
Company: Stock1
Shares: 30
Share Price: $55.5
Total Worth: $1665

Show s2 Information: 
Company: Stock2
Shares: 40
Share Price: $40.3
Total Worth: $1612

Create s3: 
This is a Constructor Function —— Stock(const std::string, long, double)
Constructor Function END.
Show s3 Information: 
Company: Stock 3
Shares: 20
Share Price: $10.4
Total Worth: $208

Create s4: 
This is a Constructor Function —— Stock(const std::string, long, double)
Constructor Function END.
Show s4 Information: 
Company: Stock 4
Shares: 30
Share Price: $20.4
Total Worth: $612

Create s5: 
This is Copy Constructor Function.(复制构造函数)
Copy Constructor Function END.
Show s5 Information: 
Company: Stock1
Shares: 30
Share Price: $55.5
Total Worth: $1665
Assign s2 to s5: 
Show s5 Information: 
Company: Stock2
Shares: 40
Share Price: $40.3
Total Worth: $1612
Assign s5: 
This is a Constructor Function —— Stock(const std::string, long, double)
Constructor Function END.
Destructor Function. Company is Stock 5

Show s5 Information: 
Company: Stock 5
Shares: 23
Share Price: $6
Total Worth: $138
Destructor Function. Company is Stock 5

Destructor Function. Company is Stock 3

Main END.
Destructor Function. Company is Stock 4

Destructor Function. Company is Stock2

Destructor Function. Company is Stock1

s1、s2、s3、s4 构造函数没什么可说的,s5 是利用复制构造函数创建的,之后将 s2 赋值给 s5,此时是用的赋值运算符,并不是构造函数(因为此时是给 s5 赋值,并不是初始化 s5)。

接下来有一个需要注意的地方:观察 Log,会发现s5 = Stock("Stock 5", 23, 6);会调用一次构造函数,以及一次析构函数 —— Log 50-53 行。这是因为s5 = Stock("Stock 5", 23, 6);这条语句其实相当于Stock tmp = Stock("Stock 5", 23, 6);s5 = tmp;这两条语句的结合。这里会先用构造函数创建一个临时对象,然后将这个临时变量赋值给 s5,这里依旧是用的赋值运算符,而不是复制构造函数,构造函数只有在对象初始化时才会用到,一旦对象创建成功就不会再调用了。当临时变量成功的赋值给 s5 之后,会被删除,这也是 Log 53 行调用一次析构函数的原因 —— 临时对象被销毁。

最后,来看一下类对象的释放顺序:s5 s3 s4 s2 s1。s3 和 s5 都是自动变量,在退出代码块的时候被销毁,因此 s3 和 s5 在 Main END 这条 Log 之前调用析构函数。s1、s2、s4 都是静态变量,在程序结束之后销毁,因此在 Main END 之后调用析构函数。

C++11 列表初始化

C++11 之后,可以将列表初始化语句用于类对象的初始化。

Stock s6 = {"Stock 6", 32, 4.9};

列表初始化会调用对应的构造函数进行初始化。

总结

I. 为什么需要构造函数和析构函数?
C++ 希望能够像初始化变量一样初始化类对象。如果类的数据都是 public 权限,则可以直接用初始化列表来初始化类对象,但这样违背了数据隐藏原则。因此,为了实现初始化含有 private 数据的类的对象,C++ 提供了构造函数来完成类对象的初始化。构造函数在创建对象时被调用。

有的类可能初始化的时候会为数据申请一些资源,例如,动态内存分配(会单独拿出来讲),在对象销毁的时候,如果不回收分配出去的内存就会造成内存泄漏,因此 C++ 提供了析构函数来销毁类对象。析构函数在销毁对象时被调用。

II. 构造函数和析构函数的函数原型比较特殊

  1. 没有返回值类型。
  2. 构造函数和析构函数的函数名都很特殊。构造函数的函数名和类名一致,析构函数的函数名是在类名前面加上了 ~。

III. 当程序员没有定义构造函数、析构函数时,编译器会提供无参的默认构造函数、默认复制构造函数、默认析构函数。

IV. 构造函数可以有参数,允许重载构造函数,所以一个类可以有多个构造函数;析构函数没有参数,因此不存在特征标不同的情况,无法重载析构函数,所以一个类只有一个析构函数。

V. 如果程序员定义了一个带参构造函数,则编译器不会自动生成一个无参的默认构造函数。

VI. 复制构造函数、重载赋值运算符、析构函数的工作和类内的动态内存分配相关,因此都需要和动态内存一起学习;virtual 修饰的析构函数和继承相关知识关联,因此将放在类的继承哪里学习。

V. 注意区分复制构造函数和赋值运算符的区别。