从C学C++(6)——构造函数和析构函数

发布于:2025-06-28 ⋅ 阅读:(21) ⋅ 点赞:(0)

从C学C++(6)——构造函数和析构函数

若无特殊说明,本博客所执行的C++标准均为C++11.

构造函数与析构函数

构造函数定义

  • 构造函数是特殊的成员函数,当创建类类型的新对象,系统自动会调用构造函数构造函数是为了保证对象的每个数据成员都被正确初始化。

构造函数的特点

  • 函数名和类名完全相同

  • 不能定义构造函数的类型(返回类型)(因为构造函数需要返回对象本身,所以不能指定返回类型),也不能使用void。

  • 通常情况下构造函数应声明为公有函数,否则它不能像其他成员函数那样被显式地调用。

    (特殊情况,例如“单例模式”设计的时候,会将构造函数声明为私有函数,以禁止外界定义一个对象)

  • 构造函数可以有任意类型和任意个数的参数,一个类可以有多个构造函数(构造函数可以重载

析构函数定义和特点

  • 函数名和比类名前面多了一个字符“~”
  • 没有返回类型(和构造函数一样)
  • 没有参数,因此析构函数不能被重载

如果没有定义构造函数和析构函数,默认会生成两个空函数作为构造函数和析构函数。

class Test{
public:
    Test(){
     //默认构造函数就是这样,空的,且没有参数   
    }
    ~Test(){
       //默认析构函数就是这样,空的,且没有参数 
    }
}

构造/析构函数和变量的生存期

当我们定义一个对象的时候,构造函数会被默认调用;当对象的生命周期结束的时候(一般是函数运行结束的时候,对全局对象来说是程序运行结束的时候),会默认调用析构函数进行对象的释放。

当我们new一个对象的时候,构造函数会被默认调用;当delete对象的时候,会默认调用析构函数进行对象的释放(而且通常需要程序员手动调用delete才能释放该对象)。

这里需要注意的是,全局对象的构造是先于main函数执行的(这个我其实存有疑惑,特别是在单片机这样的嵌入式设备的C++编程中,因为单片机的main函数入口前是汇编指令,负责声明对堆栈大小和中断向量表等操作,之后通过指令跳转的main函数入口的,C++的全局变量的构造函数是如何插入到指令跳转到main函数之前的呢?🤔)。

最后,析构函数是可以像普通成员函数一样显示调用的。

转换构造函数和explicit

转换构造函数

拥有单个参数的构造函数被编译器用于隐式类型转换的时候,称为转换构造函数。

因此,类的构造函数只有一个参数是非常危险的,因为编译器可以使用这种构造函数把参数的类型隐式转换为类类型。(可能在我们没有注意到的地方调用了构造函数)。如下:

class Test{
    Test(int a){
        //可以什么也不做
    }
}

int main(void){
    Test t(0); //此时,带一个参数的构造函数,充当的是普通构造函数的功能
    t = 20; 
    // 此时,编译器会默认帮我们找到一个能够用的构造函数(即一个参数的构造函数)进行隐性类型转换;遵循以下步骤:
    // 1. 调用转换构造函数将20这个整数转换成类类型(生成一个临时对象)
    // 2. 临时对象赋值给t对象(调用的是=运算符成员函数)
}

初始化中的=不是赋值运算符

在初始化语句中的等号不是运算符。编译器对这种表示方法有特殊的解释,编译器会将其等价于调用单个参数的构造函数构造对象,而不是赋值运算(调用=重载函数)

因为对编译器来说,Test t = 10; 这样的语句,是对象变量的初始化,直接调用单个参数的构造函数进行初始化就可以了(等价于 Test t(10););

而不需要像 Test t; t = 10; 中一样经过先调用构造函数构造对象,后面那个赋值语句(注意这个不是初始化语句),会分两步,先调用一个参数的构造函数做转换构造函数构造一个临时对象,再调用赋值构造函数(也就是=运算符的重载函数)将临时对象赋值给我们赋值的对象。

这里顺便说明等号运算符一般的重载形式,如下:

Test& Test::operator=(const Test& other){
 //需要的操作
 return *this; //返回对象本身
}

需要注意:

  • 返回对象的引用,一般我们会直接返回 *this , 因为这个重载函数是在 类似a=b 是调用的,其等价于 a = a.operator=(b) 这样的调用,因此,能够很明显函数,=运算符重载函数是需要返回对象自身的(建议返回引用 return *this)
  • 接收参数必须是对象的引用,这里注意到,如果接收参数不是对象的引用的话,而是一个对象的话(参数值传递),因为a=b 等价于 a = a.operator=(b) ; 而值传递的参数决定了,这里需要有一个实参给形参传值的操作,而实参给形参传值的操作也就是 形参=实参 这样的操作,那又会调用operator=(b) 函数,就会形成递归调用,死循环了。

explicit关键字

如果想要避免编译器对类做默认类型转换的话,我们可以给一个形参的构造函数前加上 explicit 这样如果我们使用类似 Test t; t = 10; 这样的操作时,编译器会报错,而不会使用一个参数的(转换)构造函数和等号运算符函数做隐式的类型转换。在这种情况下,对于Test 这个类对象,我们必须使用显式的类型转换。

构造函数和成员初始化

构造函数初始化列表

构造函数后可以使用: 跟一个构造函数初始化列表(成员变量(形参)),用于成员变量的初始化,注意,在构造函数体内执行的成员变量的操作是赋值操作,不是初始化操作。

Clock::Clock(int hour, int minute, int second) 
    : m_hour(hour), m_minute(minute), m_second(second) {//初始化列表是初始化阶段
     // 函数体内是普通计算阶段
     // m_hour = hour;
     // m_minute = minute;  
    // m_second = second; //这部分是赋值操作而不是 初始化操作
     std::cout << "clock initialized: " 
          << m_hour << ":" << m_minute << ":" << m_second << std::endl;
}

分清成员变量的初始化和赋值操作后,由之前的const变量引用变量 必须在初始化时赋初值(后期,const变量不可被赋值更改,而引用变量 更改则是对对象本身进行更改)可知。如果类内成员有const成员引用成员的话,只能在构造函数的初始化列表中完成初始化。

对象成员的初始化

如果一个类中含有其他类的对象作为成员变量(这里成为对象成员),如果我们没有在这个类的初始化列表中提供这个对象成员的初始化的话,那么当这个类的对象定义时,会默认调用这个对象成员没有任何参数的构造函数(即这个对象成员的默认构造函数);如果这个对象成员没有提供这个函数的话,就和我们平时定义一个类对象(这个类的构造函数只有一个有参数的构造函数)而我们定义时没有传入任何参数一样,会报错找不到合适的构造函数。

对象成员的初始化

所以,如果一个类拥有对象成员(且这个对象成员没有不带任何参数的构造函数),在外类对象的初始化的时候必须在初始化列表中给出这个对象成员的初始化,不让会报错找不到对应的构造函数

拷贝构造函数

拷贝构造函数

  • 功能:使用一个已经存在的对象来初始化一个新的同一类型的对象
  • 声明:只有一个参数并且参数为该类对象的引用,形如Clock::Clock(const Clock& other)
  • 如果类中没有说明拷贝构造函数,则系统自动生成一个缺省拷贝构造函数(做逐成员赋值),作为该类的公有成员。

拷贝构造函数调用几种情况

凡是需要用到对象的赋值操作的,都会有拷贝构造函数调用(如函数参数的实参到形参的值传递,函数返回值的赋值等待)

  • 当函数的形参是类的对象,调用函数时,进行形参与实参结合时使用。这时要在内存新建立一个局部对象,并把实参拷贝到新的对象中。理所当然也调用拷贝构造函数。
  • 当函数的返回值是类对象,函数执行完成返回调用者时使用。理由也是要建立一个临时对象中,再返回调用者。为什么不直接用要返回的局部对象呢?因为局部对象在离开建立它的函数时就消亡了,不可能在返回调用函数后继续生存,所以在处理这种情况时,编译系统会在调用函数的表达式中创建一个无名临时对象,该临时对象的生存周期只在函数调用处的表达式中。所谓return对象,实际上是调用拷贝构造函数把该对象的值拷入临时对象。如果返回的是变量,处理过程类似,只是不调用构造函数。

拷贝构造函数的几种情况

深/浅拷贝和禁止拷贝

深/浅拷贝

**对于像成员变量中有在堆上分配的类,一般都需要自己实现拷贝构造函数,因为默认的拷贝构造函数是浅拷贝,仅仅将对成员变量的值拷贝而已。**但成员变量中有在堆上分配的类,每个对象都需要重新分配一个空间,不然,当对象自动销毁时,由于不同对象内的指针指向同一个空间,当前面一个对象销毁后,后一个对象内的指针便成了野指针,无法delete。

#pragma once
#include  <iostream>
#include <cstring>
class Str
{
private:
    char* m_str;
    size_t m_length;
public:
    Str(const char* str = "")
        : m_length(strlen(str)), m_str(new char[m_length + 1])
    {
        strcpy(m_str, str);
        std::cout << "Str(const char* str) called: " << m_str << std::endl;
    }
    Str(const Str& other)
        : m_length(other.m_length), m_str(new char[other.m_length + 1])
    {
        strcpy(m_str, other.m_str);
        std::cout << "Str(const Str& other) called: " << m_str << std::endl;
    }
    ~Str(){
        std::cout << "Str Decon called:" << m_str << std::endl;
        delete[] m_str;
    }

};
class Empty { //在空类中测试禁止拷贝
public:
    Empty() {
        std::cout << "Empty constructor called." << std::endl;
    }
    ~Empty() {
        std::cout << "Empty destructor called." << std::endl;
    }
    // int a; // 如果有成员变量,Empty类的大小会大于1字节,计算方式与结构体一样
private: //需要将拷贝构造函数和赋值运算符声明为私有,以禁止拷贝
    Empty(const Empty&); // 禁止拷贝构造函数
    Empty& operator=(const Empty&); // 禁止赋值运算符
};

禁止拷贝

通过将拷贝构造函数与=运算符声明为私有,并且不提供它们的实现 ,可以使得,Str s2(S1); ,Str s2 = s1; , Str s2; s2 = s1 这样的语句直接在编译时报错,帮助我们实现只有单个对象禁止拷贝的功能。需要注意上面三个语句编译器所寻找的函数有一些不同。

  1. Str s2(S1); 找只有一个参数,且参数是Str 对象的构造函数,其实就是拷贝构造函数(这是我们人为对这个重载函数给的名字罢了)。
  2. Str s2 = s1; 找只有一个参数,且参数是Str 对象的构造函数,其实就是拷贝构造函数(这是我们人为对这个重载函数给的名字罢了),注意这里是初始化,所以是找拷贝构造函数,不是找=的重载函数。
  3. Str s2; s2 = s1 先找不带任何参数的构造函数,第二个语句找=运算符的重载函数 operator=(const Str& other)

空类默认产生的成员

一个类(类型是没有大小的,这里类的大小指的是这个类对象的大小,像sizeof(int) 得到的是int 变量的一个实例的大小一样) 的大小只取决于其中的非static成员大小(计算规则和struct一样),与以下都无关:

  • static成员(其实这些成员相当于全局变量,只是语法上编译器层面把他归类在类中,需要通过类域访问)
  • 成员函数(不论static还是一般成员函数,包括各种构造析构函数,这些函数都是放在程序的代码段,他们和类的大小没有关系,那这些成员函数怎么区分对象的?依靠我们之前说过的隐藏参数this指针)

如果一个类没有任何成员,是一个空的类,为了标识它的存在,编译器会给它分配一个字节的空间。

测试代码和结果:

深浅拷贝和空类


网站公告

今日签到

点亮在社区的每一天
去签到