C++ 类和对象(二)构造函数、析构函数、拷贝构造函数

前言

        本文将介绍类的6个默认成员函数中的构造函数、析构函数和拷贝构造函数,赋值重载和取地址重载涉及运算符重载的知识,将在下篇讲解。所谓默认成员函数,也就是每个类都有的成员函数,我们可以显式定义这些函数,否则,编译器会自动生成它们。

a94ef638a2ea4f3b900878d09b906f1b.png

目录

前言

1 构造函数

概念

特性

1 函数名与类名相同

2 无返回类型

3 可以重载

4 实例化对象时自动调用

5 默认构造函数

6 合成的默认构造函数

2 析构函数

概念

特性

1 名字为~加类名

2 无参数和返回类型

3 对象生命周期结束时自动调用

4 默认生成的析构函数

3 拷贝构造函数

概念

特性

1 构造函数的一个重载

2 参数只有一个且为引用类型

3 默认生成的拷贝构造函数

4 拷贝构造调用场景


首先定义一个日期类(Date)和一个栈类(Stack),之后将基于它们演示实例,如下:

// 日期类
class Date
{
public:

	void Init(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;
};

// 栈类
typedef int DataType;
class Stack
{
public:

	void Init(size_t capacity = 3)
	{
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}

	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}

    DataType Top()
	{
		if(_size)
			return _array[_size];
	}
	// 其他方法...
	void Destory()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}

private:
	DataType* _array; 
	int _capacity;
	int _size;
};

1 构造函数

概念

        构造函数是一个或几个(函数重载)特殊的函数,它的名字与类名相同,用来初始化类的成员变量。只要类的对象被创建,就会执行(编译器自动调用)构造函数来初始化它,在对象的生命周期内,构造函数只会被执行一次

注意:构造函数的名称虽然叫构造,但它的任务并不是创建对象,而是初始化对象。

特性

1 函数名与类名相同

        日期类已有一个成员函数Init,它的功能就是初始化成员变量。但是每次创建对象后需要显式调用Init函数来初始化,这样太过麻烦。另外,一个对象可以多次调用Init函数为成员变量重新赋值,这并不符合初始化的含义。现在,我们将Init函数改写成构造函数:

class Date
{
public:
    /*void Init(int year, int month, int day)*/
    // 构造函数
	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;
};

栈类的Init函数改成构造函数:

Stack(size_t capacity = 3)
{
	_array = (DataType*)malloc(sizeof(DataType) * capacity);
	if (NULL == _array)
	{
		perror("malloc申请空间失败!!!");
		return;
	}
	_capacity = capacity;
	_size = 0;
}

2 无返回类型

        我们显式定义了日期类的构造函数,没有写返回类型,因为构造函数是特殊的成员函数,它没有返回值,也不需要像其他无返回值函数那样定义成void类型。

注:C语言中没有显示定义类型的函数默认是void类型,C++中则是int类型。

3 可以重载

        构造函数和其它函数一样可以重载,不同的构造函数的参数类型或数量有所区别。例如,我们可以再定义一个无参的构造函数,将对象初始化成1970.1.1:

// 无参构造函数
Date()
{
	_year = 1970;
	_month = 1;
	_day = 1;
}

对于日期类来说,没有必要定义两个不同的构造函数,它们可以通过设置缺省参数进行合并,如下:

Date(int year = 1970, int month = 1, int day = 1)
{
	_year = year;
	_month = month;
	_day = day;
}

4 实例化对象时自动调用

        关于自动调用,有两方面理解:一是实例化一个对象时,编译器就会自动调用它的构造函数,不需要用户显式调用;二是编译器会自动调用合适的构造函数,对于有多个重载的构造函数,会根据实参数量和类型调用最匹配的一个。例如:

class Date
{
public:

    Date()
	{
		_year = 1970;
		_month = 1;
		_day = 1;
	}

	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 d1;            // 调用无参构造函数
	Date d2(2023, 1, 1);// 调用带参构造函数

	d1.Print();
	d2.Print();

	return 0;
}

执行结果:

07e32cfc04674256bbea768eebb52c10.png

注意:通过无参构造函数创建对象时,对象名后面不应该带括号,否则成了函数声明:

Date d1(); // 声明函数d1返回类型是Date

5 默认构造函数

        无参的构造函数和全缺省的构造函数都称为默认构造函数,默认构造函数只能有一个,因为不带参数实例化对象,会造成调用无参构造函数和调用全缺省构造函数的歧义。另外,下面讲的合成的默认构造函数也可以称为默认构造函数,因为只有当类中没有定义构造函数时,编译器才会隐式定义构造函数,也就是说它和其它构造函数不可能同时存在。

6 合成的默认构造函数

        如果类中没有显式定义构造函数,那么编译器会隐式定义一个无参的默认构造函数,它会默认初始化成员变量。

        对于内置类型(C++提供的数据类型:int,char等)的成员变量,将使用编译器提供的默认值初始化,例如:

class Date
{
public:
	// 编译器隐式定义的无参构造函数
	/*Date()
	{

	}*/
	void Print()
	{
		cout << _year << "年" << _month << "月" << _day << "日" << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

int a;

int main()
{
	Date d;
	d.Print();

	cout << a;
	return 0;
}

执行结果:

1ab89e0dd6d4469294d476dedbbd7493.png

可以看到,对于类中的int成员变量,编译器合成的默认构造函数将它们初始化成了随机值,这种方式就如同全局变量a被默认初始化成0一样。当然,不同编译器的初始化值可能不同。

        对于自定义类型成员变量(使用struct/class/union等自定义的类型),编译器合成的默认构造函数会调用该类型的默认构造函数,例如:

class A
{
public:
	A()
	{
		_a = 1;
	}

	int _a;
};

class B
{
public:
	A _aa;
};

B b1; // 调用合成的默认构造函数

B类的成员变量_aa是一个A类的对象,实例化一个B类的对象b1,此时编译器会调用A类的默认构造函数来初始化b1的成员变量_aa,结果就是b1._aa._a的值为1。

如果一个类中的自定义类型成员变量没有默认构造函数,那么编译器将无法初始化该成员,大部分类需要我们自己定义默认构造函数

        内置类型成员变量使用默认的初始化值,自定义类型成员变量也只有编译器合成的默认构造函数时,该变量的成员同样使用默认的初始化值,类合成的默认构造函数似乎并没有工作?如何很好地解决此问题:

C++11为合成的默认构造函数新增了初始化规则,即:内置类型成员变量在类中声明时可以给默认值,合成的默认构造函数会使用这些默认值初始化对象的成员变量例如:

class Date
{
public:

	void Print()
	{
		cout << _year << "年" << _month << "月" << _day << "日" << endl;
	}

private:
	// 声明时给默认值
	int _year = 1970;
	int _month = 1;
	int _day = 1;
};


int main()
{
    Date d;
    d.Print();

    return 0;
}

执行结果:

759451fc035f43bc8c7ecafe9da51c71.png

        以上讲解的都是构造函数通过函数体对成员变量进行初始化,本质上是对它们赋初值。构造函数还有一种初始值列表的用法,实现了真正意义上的初始化工作,该语法将在后续博客讲解。

2 析构函数

概念

         析构函数与构造函数的功能相反,析构函数释放对象申请的空间,并销毁对象的成员变量。对于日期类,它的对象在生命周期结束时,对象的成员变量也就跟着销毁了,它的析构函数没有什么作用;对于栈这样的类,当它的对象生命周期结束时,其动态开辟的内存空间仍然存在,这时析构函数就非常重要。

特性

1 名字为~加类名

       析构函数的名字由字符~接类名构成。在前面的栈类中,已经定义了Destory函数实现清理资源的功能,与Init函数类似,我们需要显式调用它,这个工作通常容易忘记,从而造成资源浪费。现在将Destory函数改写成析构函数:

typedef int DataType;
class Stack
{
public:
    // 构造函数
	Stack(size_t capacity = 3)
	{
        cout << "Stack()" << endl;

		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}

	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}

    DataType Top()
	{
		if(_size)
			return _array[_size];
	}
	// 其他方法...

	/*void Destory()*/
    // 析构函数
	~Stack()
	{
        cout << "~Stack()" << endl;

		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}

private:
	DataType* _array; 
	int _capacity;
	int _size;
};

2 无参数和返回类型

        析构函数和构造函数一样没有返回类型,同时析构函数没有参数,这意味着析构函数不能重载,一个类只能有一个析构函数。

3 对象生命周期结束时自动调用

        无论何时一个对象被销毁,就会自动调用它的析构函数。栈类的构造函数和析构函数都增加了一行输出语句,目的是验证它们是否被调用。例如:

int main()
{
	Stack st;
	st.Push(1);

	int top = st.Top();
	cout << top << endl;

	return 0;
}

执行结果:

4 默认生成的析构函数

        和构造函数一样,如果类中没有显式定义的析构函数,那么编译器会隐式定义一个析构函数。类对象销毁时,它的成员变量也跟着销毁,对于内置类型的成员变量,不需要额外的处理;对于自定义类型的成员变量,默认生成的析构函数则会调用该类型的析构函数,以清理它申请的资源。例如:

class A
{
public:
	A()
	{
		cout << "A()" << endl;

		_a = (int*)malloc(sizeof(int) * 10);
		memset(_a, 0, 10);
	}

	~A()
	{
		cout << "~A()" << endl;

		if (_a)
		{
			free(_a);
			_a = nullptr;
		}
	}
private:
	int* _a;
};

class B
{
public:
	// 默认生成的无参构造函数
	/*B()
	{

	}*/
	// 默认生成的析构函数
	/*~B()
	{

	}*/
private:
	A _aa;
};

int main()
{
	B b;
	return 0;
}

执行结果:

可以看到,B类有一个A类型的成员_aa,实例化一个B类的对象b时,编译器生成的构造函数调用了A类的默认构造函数为_aa初始化,当main函数栈帧结束,b被销毁,编译器默认生成的析构函数调用了A类的析构函数,清理_aa的资源。

3 拷贝构造函数

概念

        拷贝和赋值是一个意思,当以拷贝的方式初始化一个对象时,就会调用拷贝构造函数。拷贝构造函数的参数是自身类类型的引用,一般用const修饰

特性

1 构造函数的一个重载

        拷贝构造函数也是一个构造函数,除了参数的特殊性,其余形式与构造函数相同。接下来定义日期类的拷贝构造函数:

// 日期类的拷贝构造函数
Date(Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

调用拷贝构造初始化对象:

int main()
{
	Date d1(2023, 9, 1);
	Date d2(d1); // 调用拷贝构造
	Date d3 = d2;

	d1.Print();
	d2.Print();
	d3.Print();

	return 0;
}

执行结果:

实例化对象d2时,参数并不是3个具体的int类型,而是已经存在的Date类的对象d1,那么就会调用拷贝构造函数,函数体内的功能就是用d1的成员变量赋值给d2的成员变量,从而初始化d2。

注意:Date d3 = d2;也会调用拷贝构造函数,因为其本质也是用已存在的同类型的对象初始化新的对象。

2 参数只有一个且为引用类型

        我们知道类的成员函数都有一个隐含的this指针形参,拷贝构造只有一个参数指的是它只有一个显式的参数,并且该参数必须是该类的同类型的引用。

        对于自定义类型的拷贝,也就是将一个对象的所有成员变量拷贝给另一个对象,那么拷贝构造函数的参数可以是类类型而不是引用吗?先将对象拷贝给函数参数,再用形参这个局部变量拷贝给this对象是否可行:

答案是不可行的,因为传参会调用拷贝构造,调用拷贝构造又要传参...结果就会无穷递归,如下图所示:

3 默认生成的拷贝构造函数

        拷贝构造也是默认成员函数,如果类中没有显式定义,则编译器会默认定义一个拷贝构造函数。虽然拷贝构造函数也是构造函数,但是只要没有显式定义拷贝构造函数,即使已经定义了构造函数,也会生成默认拷贝构造函数。可以认为拷贝构造函数是特殊的构造函数,它们之间互不影响。

        默认的拷贝构造函数的作用是按内存储存将一个对象拷贝给新的对象,称之为浅拷贝,对于日期类,我们可以不定义拷贝构造函数,默认的拷贝构造函数执行的功能是相同的。像栈这样的类我们就需要自己定义拷贝构造函数。

        首先分析栈类使用默认的拷贝构造函数的缺陷:

typedef int DataType;
class Stack
{
public:
	// 默认生成的拷贝构造函数功能如下:
	/*Stack(Stack& st)
	{
		_array = st._array;
		_capacity = st._capacity;
		_size = st._size;
	}*/

	Stack(size_t capacity = 3)
	{
		cout << "Stack()" << endl;

		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}

	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}

	DataType Top()
	{
		if (_size)
			return _array[_size - 1];
	}
	// 其他方法...

	~Stack()
	{
		cout << "~Stack()" << endl;

		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}

private:
	DataType* _array;
	int _capacity;
	int _size;
};

先定义一个对象st1,再用st1拷贝构造对象st2:

int main()
{
	Stack st1;
	Stack st2(st1);

	return 0;
}

执行结果:

可以看到析构函数被调用了两次,然后程序就崩溃了,原因是st2是st1通过浅拷贝构造的,它们的成员_array指向同一块内存空间,其中一个对象调用析构函数free了_array它的空间归还给了系统,另一个对象的_array就成了野指针,再去调用析构函数时free野指针引发程序崩溃。

所以对于栈类,我们必须自己定义拷贝构造函数,为对象的_array申请一块与为其拷贝的对象的_array大小相同的内存空间,这样称之为深拷贝

定义栈类的拷贝构造函数:

// 拷贝构造函数
Stack(Stack& st)
{
	_array = (DataType*)malloc(sizeof(DataType) * st._capacity);
	if (NULL == _array)
	{
		perror("malloc申请空间失败!!!");
		return;
	}

	_capacity = st._capacity;
	_size = st._size;
}

总结:如果类的成员没有涉及资源申请,拷贝构造函数是否显式定义都可以;否则,需要我们自己定义拷贝构造函数,避免浅拷贝。

4 拷贝构造调用场景

1 使用已存在对象创建新对象时需要调用拷贝构造函数。

2 函数参数为类类型时,需要调用拷贝构造函数创建临时对象。

3 函数返回类型为类类型时,调用拷贝构造为接收的对象赋值。

为了提高程序效率,减少拷贝构造函数的调用,函数参数应尽量使用引用类型,函数返回值根据实际情况,也尽量选择引用类型。

如果本文内容对你有帮助,可以点赞收藏,感谢支持,期待你的关注。

下篇预告:C++ 类和对象(三)运算符重载、const成员函数

猜你喜欢

转载自blog.csdn.net/2301_79391308/article/details/132580068