[C++] 类和对象 _ 剖析构造、析构与拷贝


一、构造函数

构造函数是特殊的成员函数,它在创建对象时自动调用。其主要作用是初始化对象的成员变量(不是开辟空间)。构造函数的名字必须与类名相同,且没有返回类型(即使是void也不行)。

在C++中,构造函数是专门用于初始化对象的方法。当创建类的新实例时,构造函数会自动被调用。通过构造函数,我们可以确保对象在创建时就被赋予合适的初始状态。下面我将详细解释如何使用构造函数进行初始化操作,并以Date类为例进行说明。

创建一个Date类:

cpp 复制代码
class Date 
{  
public:  
    // 成员函数...  
private:  
    int _year;  
    int _month;  
    int _day;  
};

构造函数的特征

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。

无参构造

无参构造函数允许我们创建Date对象而不提供任何参数。但是,需要注意的是,如果我们不在无参构造函数中初始化成员变量,那么这些变量的初始值将是未定义的,这可能会导致程序出错。
Date d1; // 调用无参构造函数

cpp 复制代码
class Date 
{  
public:  
    // 1. 无参构造函数  
    Date() 
    {  
        // 在这里可以添加一些初始化代码,例如设置默认日期  
        // 例如:_year = 2000; _month = 1; _day = 1;  
    }  
  
    // 其他成员函数...  
  
private:  
    int _year;  
    int _month;  
    int _day;  
};

带参构造

带参构造可以和无参构造函数重载,因为在之后调用的时候不会受影响,可以与之后讲解的全缺省构造函数和无参构造函数之间的不能函数重载的进行区别。

带参构造函数可以在对对象进行初始化的时候进行传参,传参的数值会直接进行初始化对象中的成员变量。
Date date2(2023, 3, 15); // 调用带参构造函数创建对象,并初始化日期为2023年3月15日

cpp 复制代码
class Date 
{  
public:  
    // 1. 无参构造函数  
    Date() 
    {  
        // ...  
    }  
  
    // 2. 带参构造函数  
    Date(int year, int month, int day) 
    {  
        _year = year;  
        _month = month;  
        _day = day;  
    }  
  
    // 其他成员函数...  
  
private:  
    int _year;  
    int _month;  
    int _day;  
};

在这个带参构造函数中,我们通过参数year、month和day来初始化_year、_month和_day成员变量。这样,我们就可以在创建Date对象时直接指定日期了。

注意区别创造对象的格式

cpp 复制代码
Date d1; // 调用无参构造函数
Date d2(2015, 1, 1); // 调用带参的构造函数

默认无参构造函数

参考代码:

cpp 复制代码
class Date
{
public:
/*
// 如果用户显式定义了构造函数,编译器将不再生成
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
*/
    void Print()
    {
    cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
int main()
{
    // 将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函
    数
    // 将Date类中构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再
    生成
    // 无参构造函数,放开后报错:error C2512: "Date": 没有合适的默认构造函数可用
    Date d1;
    return 0;
}

在C++中,如果你没有为类显式定义任何构造函数,编译器会为你自动生成一个默认的无参构造函数。这个默认构造函数不会执行任何操作,也不会初始化类的成员变量。这意味着,如果你的类Date没有显式定义任何构造函数,那么你可以创建一个Date对象而不提供任何参数,编译器会为你调用这个默认构造函数。

然而,一旦你为类显式定义了任何构造函数(无论是带参还是无参),编译器就不会再自动生成默认构造函数了。因此,如果你屏蔽了Date类中的带参构造函数,编译器会为你生成一个默认构造函数,所以你可以直接这样创建对象:

cpp 复制代码
Date d1;

但是,当你放开带参构造函数时,由于你已经显式定义了至少一个构造函数,编译器就不会再为你生成默认构造函数了。因此,在尝试这样创建对象时,编译器会报错,因为它找不到一个合适的默认构造函数来调用。错误信息表明编译器找不到一个可以调用的构造函数,因为没有默认构造函数可用。

不显式定义构造函数(系统默认生成)

请注意:

默认构造函数只对自定义类型进行初始化,内置类型不做处理。

但是自定义类型的最终还是要对自定义类型中的内置类型进行初始化,所以要在类创建的时候就做好处理。

问题的解决方式

问题描述:
显式定义构造函数的影响:一旦你为类显式定义了至少一个构造函数(无论带参还是不带参),编译器就不会再自动生成默认构造函数。这意味着如果你想要创建类的对象而不提供任何参数,你必须自己定义一个无参构造函数,否则编译器会报错,因为它找不到一个合适的构造函数来调用。

显式定义的无参构造函数
cpp 复制代码
class Date 
{
public:
	// 显式定义的无参构造函数  
	Date() 
	{
		_year = 0;
		_month = 0;
		_day = 0;
	}

	// 其他成员函数...  

private:
	int _year;
	int _month;
	int _day;
};
带参构造函数
cpp 复制代码
// 带参构造函数  
    Date(int year, int month, int day) 
    {  
        _year = year;  
        _month = month;  
        _day = day;  
    }

全缺省参数的构造函数

C++11 😗*内置类型成员变量在类中声明时可以给默认值。 **

使用全缺省参数即可解决5.2问题,在该小节中主要对全缺省参数的构造函数进行详细讲解。

全缺省参数的构造函数结构类似于以下代码:

cpp 复制代码
Date(int year = 1900, int month = 1, int day = 1)  
{  
    _year = year;  
    _month = month;  
    _day = day;  
}

特点:会在参数列表中进行类似于赋值的操作

这个构造函数接受三个参数,并且每个参数都有一个默认值。这意味着,在创建Date对象时,你可以选择性地提供这些参数。如果你没有为任何一个参数提供值,那么它们将使用默认值(即1900年1月1日)。

可以思考以下代码在创建对象的时候会不会编译通过:

cpp 复制代码
class Date
{
public:
	Date()
	{
		_year = 1900;
		_month = 1;
		_day = 1;
	}
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

结论是:无法通过。

原因是:

语法可以存在、调用存在歧义。

无参构造和全缺省存在歧义,当使用不传参创建对象Date d;的时候编译器无法抉择选择构造函数。

推荐使用全缺省参数的构造函数。

二、析构函数

析构函数是一种特殊的成员函数,它在对象的生命周期结束时自动被调用。其主要职责是执行与对象销毁相关的清理操作,如释放动态分配的内存、关闭文件等。

对象在销毁时会自动调用析构函数,完成对象中资源的清理工作

特性

  1. 析构函数名是在类名前面加上" ~ "
  2. 无参数和返回值

~Stack() { }

  1. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构

函数不能重载

  1. 对象生命周期结束时,C++编译系统系统自动调用析构函数

用栈来理解析构函数

cpp 复制代码
typedef int DataType;
class Stack
{
public:
    Stack(size_t capacity = 3)
    {
        _array = (DataType*)malloc(sizeof(DataType) * capacity);
        if (nullptr == _array)
        {
            perror("malloc申请空间失败!!!");
            return;
        }
        _capacity = capacity;
        _size = 0;
    }
    void Push(DataType data)
    {
        if (_size == _capacity)
        {
            // 扩展数组大小
            _capacity *= 2;
            _array = (DataType*)realloc(_array, sizeof(DataType) * _capacity);
            if (nullptr == _array)
            {
                perror("realloc扩展空间失败!!!");
                return;
            }
        }
        _array[_size] = data;
        _size++;
    }
    // 其他方法...
    ~Stack()
    {
        if (_array)
        {
            free(_array);
            _array = nullptr;
            _capacity = 0;
            _size = 0;
        }
    }
private:
    DataType* _array;
    size_t _capacity;
    size_t _size;
};
void TestStack()
{
    Stack s;
    s.Push(1);
    s.Push(2);
}

int main() 
{
    TestStack();
    return 0;
}

析构函数的析构过程解析

当正确使用析构函数后就不用担心程序中有内存泄漏的情况了,因为在每次该对象生命周期结束后都会自动调用析构函数,流程如下:

①准备出生命周期

②出生命周期,进入析构函数

③析构函数执行完毕,对象销毁

编译器自动生成构造函数

特性
  1. 内置类型不做处理
  2. 自定义类型会去调用它的析构函数

以Leetcode 用栈实现队列该题为例:https://leetcode.cn/problems/implement-queue-using-stacks/description/ ,讲解编译器自动生成的构造函数的特性。

该题思路为:将一个栈当作输入栈,用于压入 push 传入的数据;另一个栈当作输出栈,用于 pop 和 peek操作。

将流程简化为:

cpp 复制代码
class MyQueue
{
private:
	Stack _pushst;
	Stack _popst;
};

该类中成员变量只有两个自定义类型Stack,所以在析构自定义类型的时候会去调用Stack类的析构函数

cpp 复制代码
~Stack()
{
    if (_array)
    {
        free(_array);
        _array = nullptr;
        _capacity = 0;
        _size = 0;
    }
}

从而将Stack类中的动态申请的资源给释放掉,以避免内存泄漏。

结论
  1. 自定义类的销毁的最终还是需要将动态申请的资源清理,所以一般情况下,有动态申请资源,就需要写析构函数释放资源,因为编译器自动生成的析构函数最终还是无法释放动态申请的资源,只是深入的去调用当前类中自定义类型的析构函数。
  2. 没有懂太申请的资源,不需要写析构函数
  3. 需要释放资源的成员都是自定义类型,不用写析构。

三、拷贝构造函数

什么是拷贝构造?

拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用.

特性

  1. 拷贝构造函数是构造函数的一个重载形式。
  2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,
    因为会引发无穷递归调用。

如何定义和使用拷贝构造函数

定义

浅拷贝

浅拷贝只是简单地复制对象的成员变量值,包括指针成员的地址,而不是复制指针所指向的内容。这可能会导致多个对象共享同一个内存地址,当一个对象修改了内存中的内容时,其他对象也会受到影响。

cpp 复制代码
ShallowCopy(const ShallowCopy& other)
{
    data = other.data;
}
深拷贝

深拷贝则是在拷贝对象时,复制指针所指向的内容,而不是简单地复制地址。这样每个对象都拥有自己的内存空间,互相之间不会受到影响。

cpp 复制代码
DeepCopy(const DeepCopy& other) 
{
    data = new int;
    *data = *(other.data);
}

拷贝构造函数的使用

代码

以深拷贝为例写一个完整的拷贝构造函数的使用代码:

cpp 复制代码
#include <iostream>

class DeepCopy 
{
private:
    int *data;
public:
    // 构造函数
    DeepCopy(int value) {
        data = new int;
        *data = value;
    }

    // 拷贝构造函数(深拷贝)
    DeepCopy(const DeepCopy& other) {
        data = new int;
        *data = *(other.data);
    }

    // 获取数据的函数
    int getData() const {
        return *data;
    }

    // 设置数据的函数
    void setData(int value) {
        *data = value;
    }

    // 析构函数
    ~DeepCopy() {
        delete data;
    }
};

int main() 
{
    DeepCopy obj1(10);
    DeepCopy obj2 = obj1;

    // 修改obj1的数据
    obj1.setData(20);

    std::cout << "obj1的数据:" << obj1.getData() << std::endl;
    std::cout << "obj2的数据:" << obj2.getData() << std::endl;

    return 0;
}
注意:防止无限循环
cpp 复制代码
#include <iostream>

class MyClass 
{
private:
    int data;
public:
    // 拷贝构造函数
    MyClass(const MyClass other) 
    {
        // 构造信息
    }
};

int main() 
{
    MyClass obj;
    MyClass newObj = obj; // 这里会调用拷贝构造函数

    return 0;
}

当在main函数中进行拷贝构造的时候调用的拷贝构造函数是:

cpp 复制代码
MyClass(const MyClass other) 
{
    // 构造信息
}

在使用该拷贝构造函数进行拷贝构造的时候就会出现无限循环拷贝,因为形参为MyClass other而不是MyClass& other,为什么出现这样的情况呢?

可以思考。在main函数中拷贝传参的时候 MyClass newObj = obj相当于将obj作为参数传入拷贝构造函数,其在main中对应格式为类 = 类所以调用了拷贝构造。而在拷贝构造函数中呢,也相当于类(形参) = 类(实参),这样不也相当于拷贝构造吗?所以也会进行调用拷贝构造函数,如此下来,就陷入了拷贝构造函数的无限循环调用。

所以我们在使用拷贝构造函数的时候要注意避免陷入无限循环:

  1. 形参使用引用方式
  2. 不在拷贝构造内进行拷贝构造

默认拷贝构造函数

当你没有显式地为类定义一个拷贝构造函数时,C++编译器会自动生成一个默认的拷贝构造函数。默认的拷贝构造函数执行的是浅拷贝,即简单地将每个成员变量的值从原始对象复制到新对象中。

在一些情况下默认的拷贝构造函数会有危害:

当类中存在指针成员时,编译器默认的拷贝构造函数只会复制指针的值,而不会复制指针所指向的内容。这就意味着,如果两个对象共享同一个资源,例如动态分配的内存,那么在其中一个对象销毁时,会释放相同的内存地址,导致另一个对象访问到无效的内存。这种情况下,就需要我们自己来手动编写拷贝构造函数来执行深拷贝,以确保每个对象都有自己的资源副本。

所以当类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请

时,则拷贝构造函数是一定要写的,否则就是浅拷贝。

函数返回值类型为类类型对象

可以思考如下代码:

cpp 复制代码
// 1.
Stack& func()
{
	Stack st;
	return st;
}

// 2. 
Stack func()
{
	Stack st;
	return st;
}

// 3. 
Stack& func()
{
	static Stack st;
	return st;
}

分析①

cpp 复制代码
// 1.
Stack& func()
{
	Stack st;
	return st;
}

该程序的结果是:崩溃

该函数返回值使用类引用进行返回,在函数中用直接创建了一个对象然后进行返回。

为什么会崩溃呢?

在函数中创建了一个对象并进行返回,但是在函数结束后也就出了st的域,所以会调用Stack的析构函数对st进行析构,从而导致之前返回的那个值变为了析构后的结果,然后在返回的那个值出了它的域之后又会进行一次析构,这时候析构的就是已经析构过的对象了,所以会进行崩溃。

分析②

cpp 复制代码
// 2. 
Stack func()
{
	Stack st;
	return st;
}

②与①进行对比,没有返回对象的引用,所以程序可以正常运行,

这个函数返回一个Stack对象。在函数结束时,局部对象st会被销毁,但返回的是一个副本,因此不会直接导致访问无效内存的问题。

后面的操作取决于该类的拷贝构造函数。

分析③

cpp 复制代码
// 3. 
Stack& func()
{
	static Stack st;
	return st;
}

这个函数返回一个静态局部对象的引用。静态局部对象在函数结束时不会被销毁,因此返回的引用仍然是有效的。


相关推荐
励志成为嵌入式工程师3 小时前
c语言简单编程练习9
c语言·开发语言·算法·vim
捕鲸叉4 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer4 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq4 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
hikktn5 小时前
如何在 Rust 中实现内存安全:与 C/C++ 的对比分析
c语言·安全·rust
青花瓷5 小时前
C++__XCode工程中Debug版本库向Release版本库的切换
c++·xcode
观音山保我别报错5 小时前
C语言扫雷小游戏
c语言·开发语言·算法
幺零九零零7 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
捕鲸叉7 小时前
MVC(Model-View-Controller)模式概述
开发语言·c++·设计模式
Dola_Pan8 小时前
C++算法和竞赛:哈希算法、动态规划DP算法、贪心算法、博弈算法
c++·算法·哈希算法