C++类和对象的构造函数、析构函数、拷贝构造、初始化列表详解
- 引言
- 一、构造函数
- 1.1、数据初始化和清理
- 1.2、构造函数概述
- 1.3、构造函数的定义
- 1.4、提供构造函数的影响
- 二、析构函数
- 三、拷贝构造函数
- 3.1、拷贝构造的定义
- 3.2、拷贝构造、无参构造、有参构造 三者的关系
- 3.3、拷贝构造的调用形式
- 3.4、拷贝构造的深拷贝和浅拷贝
- 四、初始化列表
- 4.1、对象成员
- 4.2、初始化列表的使用
- 五、explicit关键字——防止构造函数隐式转换
- 六、类的对象数组
- 七、动态对象的创建
- 7.1、c语言的方式创建动态对象
- 7.2、new创建动态对象
- 7.3、delete释放动态对象
- 7.4、动态对象数组
- 总结
引言
💡 作者简介:专注于C/C++高性能程序设计和开发,理论与代码实践结合,让世界没有难学的技术。包括C/C++、Linux、MySQL、Redis、TCP/IP、协程、网络编程等。
👉
🎖️ CSDN实力新星,社区专家博主
👉
🔔 专栏介绍:从零到c++精通的学习之路。内容包括C++基础编程、中级编程、高级编程;掌握各个知识点。
👉
🔔 专栏地址:C++从零开始到精通
👉
🔔 博客主页:https://blog.csdn.net/Long_xu
🔔 上一篇:【027】C++类和对象的基本概念
一、构造函数
1.1、数据初始化和清理
当实例化一个对象的时候,这个对象应该有一个初始化状态,当对象销毁之前应该销毁自己创建的数据。对象的初始化和清理是非常重要的安全问题,一个对象或变量没有初始化时,对其使用的后果是未知的;同样的,使用完一个对象或变量,没有及时清理,也会造成一定的安全问题。
因此,C++提供了构造函数和析构函数,这两个函数被编译器自动调用,完成对象初始化和对象清理工作。对象的初始化和清理工作是编译器强制要求去做的,即使没有提供初始化操作和清理操作,编译器也会自动添加默认的操作,只是默认的初始化操作不会做任何事。所以,编写类应该顺便提供初始化函数。
初始化操作是必须的,由编译器自动调用,开发人员只需要提供初始化函数。
1.2、构造函数概述
C++构造函数是一种特殊的成员函数,用于初始化类的对象。它们具有与类名称相同的名称,并且不返回任何值(包括void)。它们可以带有参数或不带参数,根据需要进行重载。
当创建一个类对象时,构造函数会被自动调用。如果没有定义构造函数,则编译器会提供一个默认的构造函数。默认构造函数不执行任何操作,但确保对象被正确初始化。
在构造函数中,可以对数据成员进行初始化、分配内存空间和执行其他必要的操作
类实例化对象的时候,系统自动调用构造函数,完成对象的初始化。
如果用户不提供构造函数,编译器会自动添加一个默认的构造函数(空函数)。
1.3、构造函数的定义
构造函数名和类名相同,没有返回值(连void都不能有),可以有参数(即可以重载);权限为public。
实例化对象时先给对象开辟空间,然后调用构造函数进行初始化。
格式:
类名()
{
// 初始化操作
}
类名(参数列表)
{
// 初始化操作
}
示例:
#include <iostream>
#include <string.h>
class Person {
public:
char name[32];
int age;
// 构造函数1
Person() {
name = "";
age = 0;
}
// 构造函数2
Person(const char *n, int a) {
strcpy(name,n);
age = a;
}
};
int main() {
// 使用默认构造函数创建对象p1,隐式调用无参构造函数
Person p1;
// 显式调用无参构造函数
Person p2=Person();
// 使用第二个构造函数创建对象p3,隐式调用有参构造函数
Person p3("Tom", 25);
// 显式调用有参构造函数
Person p4=Person("Lion",18);
// 匿名对象,当前语句结束立即释放
Person();
Person("Long",20);
return 0;
}
1.4、提供构造函数的影响
- 如果用户不提供任何构造函数,编译器默认提供一个空的无参构造。
- 如果用户定义了构造函数(不管是有参还是无参),编译器不再提供默认的构造函数。
#include <iostream>
#include <string.h>
class Person {
public:
char name[32];
int age;
// 构造函数
Person(const char *n, int a) {
strcpy(name,n);
age = a;
}
};
int main() {
//Person obj;//error,没有无参构造函数
Person obj("Lion",18);// OK,提供了有参构造
return 0;
}
二、析构函数
析构函数的定义:函数名和类名称相同,在函数名前面添加~
符号,没有返回值,不能重载。
~类名()
{
// 释放内存操作
}
当对象生命周期结束时,系统自动调用析构函数。先调用析构函数,再释放对象的空间。
示例:
#include <iostream>
using namespace std;
class Data{
public:
int a;
public:
Data()
{
a=100;
cout<<"无参构造函数"<<endl;
}
Data(int p)
{
a=p;
cout<<"有参构造函数"<<a<<endl;
}
~Data()
{
cout<<"析构函数"<<a<<endl;
}
};
int main()
{
Data ob01(200);
{
Data ob02(300);
}
Data ob03(400);
return 0;
}
注意:构造和析构的顺序是向反的,先构造的后析构。
一般情况下,空的析构函数就足够;但是,如果一个类中有指针成员,这个类必须写析构函数,释放指针成员所指向的空间。
示例:
#include <iostream>
#include <string.h>
using namespace std;
class Data{
public:
int a;
char *name;
public:
Data()
{
a=100;
cout<<"无参构造函数"<<endl;
}
Data(int p,const char *str)
{
a=p;
name=new char[strlen(str)+1];
strcpy(name,str);
cout<<"有参构造函数"<<a<<", "<<name<<endl;
}
~Data()
{
cout<<"析构函数"<<a<<endl;
if(name!=NULL)
delete[] name;
}
};
int main()
{
Data ob01(200,"Lion");
cout<<ob01.name<<endl;
return 0;
}
三、拷贝构造函数
拷贝构造本质上是构造函数,它是一种特殊的构造函数。
拷贝构造的调用时机:旧对象初始化新对象才会调用拷贝构造。
如果用户不提供拷贝构造,编译器会自动提供一个默认的拷贝构造,完成赋值操作(浅拷贝)。
3.1、拷贝构造的定义
在C++中,拷贝构造函数是一种特殊的构造函数,它用于创建一个新对象,并将其初始化为已经存在的对象的副本。
当函数有当前类的常引用时就是拷贝构造。
格式:
类名(const 类名 &参数名)
{
// 相关操作
}
注意:
- 一旦实现了拷贝构造函数,必须完成赋值操作。
- 一般情况下,使用系统默认的拷贝构造(浅拷贝)就足够了;只有要完成深拷贝的情况下才需要我们实现拷贝构造函数。
示例:
#include <iostream>
using namespace std;
class Data{
public:
int a;
public:
Data()
{
a=100;
cout<<"无参构造函数"<<endl;
}
Data(int p)
{
a=p;
cout<<"有参构造函数"<<a<<endl;
}
// 拷贝构造
Data(const Data &ob)
{
a=ob.a;
}
~Data()
{
cout<<"析构函数"<<a<<endl;
}
};
int main()
{
Data ob01(200);
Data ob02=ob01;
return 0;
}
3.2、拷贝构造、无参构造、有参构造 三者的关系
- 用户定义了拷贝构造或者无参构造,系统不会自动生成默认的无参构造函数,即会屏蔽无参构造。
- 用户定义了有参构造或者无参构造,不会影响系统自动生成默认的拷贝构造。
示例一:实现了拷贝构造,编译器不自动生成默认的构造函数。
#include <iostream>
using namespace std;
class Data{
public:
int a;
public:
// 拷贝构造
Data(const Data &ob)
{
a=ob.a;
}
~Data()
{
cout<<"析构函数"<<a<<endl;
}
};
int main()
{
Data ob01;// error,找不到构造函数
return 0;
}
示例二:实现了有参构造,编译器不自动生成默认的无参构造函数。
#include <iostream>
using namespace std;
class Data{
public:
int a;
public:
Data(int p)
{
a=p;
cout<<"有参构造函数"<<a<<endl;
}
~Data()
{
cout<<"析构函数"<<a<<endl;
}
};
int main()
{
Data ob01;//error,找不到无参构造函数
return 0;
}
示例三:实现了有参构造或无参构造函数,不影响编译器自动生成默认的拷贝构造函数。
#include <iostream>
using namespace std;
class Data{
public:
int a;
public:
Data()
{
a=100;
cout<<"无参构造函数"<<endl;
}
Data(int p)
{
a=p;
cout<<"有参构造函数"<<a<<endl;
}
~Data()
{
cout<<"析构函数"<<a<<endl;
}
};
int main()
{
Data ob01(200);
Data ob02=ob01;// OK
return 0;
}
3.3、拷贝构造的调用形式
假设存在 Data 这个类。
class Data{
public:
int a;
public:
Data()
{
a=100;
cout<<"无参构造函数"<<endl;
}
Data(int p)
{
a=p;
cout<<"有参构造函数"<<a<<endl;
}
Data(const Data &ob)
{
a=ob.a;
cout<<"拷贝构造函数"<<endl;
}
~Data()
{
cout<<"析构函数"<<a<<endl;
}
};
(1)旧对象给新对象初始化,调用拷贝构造。
Data ob01(200);
Data ob02=ob01;// 调用拷贝构造
(2)给对象取别名,不会调用拷贝构造。
Data ob01(200);
Data &ob02=ob01;// 不会调用拷贝构造
(3)普通对象作为函数参数,调用函数时会发生拷贝构造。
void func(Data ob)
{
}
int main()
{
Data ob01(100);// 有参构造
func(ob01);// 拷贝构造
return 0;
}
(4)函数返回普通对象,可能会发生拷贝构造(依赖于编译器:在windows的visual studio下会发生拷贝构造,在linux下不会发生拷贝构造)。
Data getObj()
{
Data ob1(100);
return ob1;
}
int main()
{
Data ob2=getObj();
}
Windows的过程:先创建一个匿名对象,然后调用拷贝构造给匿名对象赋值,ob1释放,ob2接管匿名对象。
Linux下的过程:进行了优化,避免了拷贝构造,提高效率。不创建匿名对象,ob2直接接管ob1。
3.4、拷贝构造的深拷贝和浅拷贝
拷贝构造函数可以执行浅拷贝和深拷贝。
浅拷贝:当使用浅拷贝时,只复制指针或引用而不是整个对象。这意味着新对象与原始对象共享相同的内存位置,因此对其中一个对象所做的更改也会影响另一个对象。如果原始对象被删除,则新对象也会失效。
深拷贝:当使用深拷贝时,会复制整个对象及其所有内容,包括指针所指向的数据。这意味着每个对象都有自己独立的内存位置,因此对其中一个对象所做的更改不会影响另一个对象。如果原始对象被删除,则新对象仍然有效。
默认的拷贝构造都是浅拷贝。如果类中没有指针成员,不用实现拷贝构造和析构函数。
如果类中有指针成员,且指向堆区空间,必须实现拷贝构造函数完成深拷贝。
例如:
#include <string.h>
class Person {
public:
char* name;
int age;
Person(int num,const char *str)
{
age=num;
name = new char[strlen(str)+1];
strcpy(name,str);
}
// 拷贝构造函数
Person(const Person& p) {
name = new char[strlen(p.name)+1];
strcpy(name,p.name);
age = p.age;
}
~Person()
{
if(name!=NULL)
delete [] name;
}
};
int main() {
// 创建Person1
Person person1(18,"Tom");
// 使用深拷贝创建Person2
Person person2 = person1;
// 打印person1和person2的信息
cout << "Person1: " << *(person1.name) << ", " << person1.age << endl;
cout << "Person2: " << *(person2.name) << ", " << person2.age << endl;
delete person1.name;
delete person2.name;
return 0;
}
输出结果:
Person1: Tom, 18
Person2: Tom, 18
使用深拷贝创建的新对象与原始对象是独立的,修改原始对象不会影响新对象。而且可以避免重复释放。
四、初始化列表
4.1、对象成员
在类中定义的数据成员一般都是基本的数据类型。但是类中的成员也可以是对象,叫做对象成员。
实例化类时,先调用对象成员的构造函数,再调用本身的构造函数。析构函数和构造函数的调用顺序相反;先构造,后析构。
注意:类会自动调用对象成员的无参构造。
示例:
#include <iostream>
using namespace std;
class A{
public:
int mA;
public:
A()
{
cout<<"A 无参构造"<<endl;
}
A(int num)
{
mA=num;
cout<<"A 有参构造"<<endl;
}
~A()
{
cout<<"A 析构函数"<<endl;
}
};
class B{
public:
int mB;
A mA;
public:
B()
{
cout<<"B 无参构造"<<endl;
}
B(int num)
{
mB=num;
cout<<"B 有参构造"<<endl;
}
~B()
{
cout<<"B 析构函数"<<endl;
}
};
int main()
{
{
B ob01;
}
cout<<"-------------------"<<endl;
{
B ob02(100);
}
return 0;
}
输出:
A 无参构造
B 无参构造
B 析构函数
A 析构函数
-------------------
A 无参构造
B 有参构造
B 析构函数
A 析构函数
4.2、初始化列表的使用
类想调用对象成员的有参构造,必须使用初始化列表。
格式:
B类的有参构造函数(参数列表):A类的对象名(参数列表)
{
// 有参构造函数体
}
示例:
#include <iostream>
using namespace std;
class A{
public:
int mA;
public:
A()
{
cout<<"A 无参构造"<<endl;
}
A(int num)
{
mA=num;
cout<<"A 有参构造"<<endl;
}
~A()
{
cout<<"A 析构函数"<<endl;
}
};
class B{
public:
int mB;
A mA;
public:
B()
{
cout<<"B 无参构造"<<endl;
}
B(int num)
{
mA.mA=num;
mB=num;
cout<<"B 有参构造"<<endl;
}
// 初始化列表
B(int a,int b):mA(a)
{
mB=b;
cout<<"B 有参构造: "<<mB<<endl;
}
~B()
{
cout<<"B 析构函数"<<endl;
}
};
int main()
{
{
B ob01;
}
cout<<"-------------------"<<endl;
{
B ob02(100);
}
cout<<"-------------------"<<endl;
{
B ob03(200,300);
}
return 0;
}
输出:
A 无参构造
B 无参构造
B 析构函数
A 析构函数
-------------------
A 无参构造
B 有参构造
B 析构函数
A 析构函数
-------------------
A 有参构造
B 有参构造:300
B 析构函数
A 析构函数
五、explicit关键字——防止构造函数隐式转换
C++提供了关键字explicit 禁止通过构造函数进行的隐式转换;声明为explicit的构造函数不能在隐式转换中使用。
explicit用于修饰构造函数构造函数,防止隐式转换;是针对单参数的构造函数(或者除了第一个参数外其余参数都有默认值的多参数构造)而言的。
示例:
#include <iostream>
using namespace std;
class MyString{
public:
explicit MyString(int n)
{
cout<< "MyString(int): " << n <<endl;
}
MyString(char *str)
{
cout<< "MyString(char *): " << str <<endl;
}
};
int main()
{
// 语义不明,给字符串赋值还是初始化?
// MyString str=100;// 本质是调用MyString(int n)初始化
MyString str(100);
// 寓意明确,字符串赋值
MyString str01="abc";
MyString str02("abc");
return 0;
}
六、类的对象数组
对象数组本质是数组,数组的每个元素是对象。
- 对象数组的每个元素都会自动调用构造函数和析构函数。
- 对象数组不初始化,每个元素调用无参构造。
- 对象数组的初始化必须显式使用有参构造,逐个元素初始化。
示例:
#include <iostream>
using namespace std;
class Data {
public:
int mA;
public:
Data()
{
cout << "Data 无参构造" << endl;
}
Data(int a)
{
mA = a;
cout << "Data 有参构造:" << mA << endl;
}
~Data()
{
cout << "Data 析构函数" << endl;
}
};
int main()
{
// 对象数组的每个元素都会自动调用构造函数和析构函数。
{
Data data[5];
// 对象数组的初始化必须显式使用有参构造,逐个元素初始化。
Data data2[5] = { Data(100),Data(200), Data(300), Data(400), Data(500) };
for (int i = 0; i < 5; i++)
{
cout << data2[i].mA << " ";
}
cout << endl;
}
return 0;
}
输出:
Data 无参构造
Data 无参构造
Data 无参构造
Data 无参构造
Data 无参构造
Data 有参构造:100
Data 有参构造:200
Data 有参构造:300
Data 有参构造:400
Data 有参构造:500
100 200 300 400 500
Data 析构函数
Data 析构函数
Data 析构函数
Data 析构函数
Data 析构函数
Data 析构函数
Data 析构函数
Data 析构函数
Data 析构函数
Data 析构函数
七、动态对象的创建
当创建数组的时候,需要提前知道数组的长度,然后编译器分配预定长度的数组空间;在使用数组时会有这样的问题,数组也许空间太大了,造成空间浪费,数组也许空间不足,不足以存储所有数据;如果能根据需要分配空间是最好的,因此动态就意味着不确定性。
c语言的malloc和free函数可以在运行时从堆中分配存储单元,然而这些函数在C++中不能很好的允许,因为它们不能帮我们完成对象的初始化和释放工作。
7.1、c语言的方式创建动态对象
当创建一个C++对象时会发生两个动作:
- 为对象分配内存。
- 调用构造函数来初始化对象内存。
第一步能保证实现,第二步是C++强迫这么做的,因为使用未初始化的对象可能会造成程序错误。C语言动态分配内存方法是为了在运行时动态分配内存,C语言的标准库提供了malloc、calloc、realloc函数动态分配内存,提供free函数释放内存;这些函数是有效的,但是是原始的,需要程序员小心使用。
C语言使用动态内存分配函数创建一个类的示例:
class Person{
public:
int mAge;
char *mName;
public:
Person()
{
mAge=20;
mName=(char*)malloc(32);
strcpy(mName,"Lion");
}
void init()
{
mAge=20;
mName=(char*)malloc(32);
strcpy(mName,"Lion");
}
void clean()
{
if(mName!=NULL)
free(mName);
}
~Person()
{
if(mName!=NULL)
free(mName);
}
};
int main()
{
Person* person=(Person*)malloc(sizeof(Person));
if(person==NULL)
return -1;
// 需要调用初始化函数
person->init();
//......
// 需要调用清理函数
persion->clean();
// 释放对象
free(person);
return 0;
}
C语言风格创建对象的问题:
- 必须确定对象的长度。
- malloc返回void指针,C++不允许将void赋值给其他任何指针,必须强转。
- malloc可能申请内存失败,所有必须判断返回值来确定内存分配成功。
- 用户使用对象之前必须记住对他初始化,构造函数不能显式调用初始化(构造函数由编译器自动调用),用户可能忘记调用初始化函数。
C语言的动态内存分配函数太复杂,容易令人混淆,C++中推荐使用new和delete。
7.2、new创建动态对象
C++中解决动态内存分配的方案是把创建一个对象所需的操作都结合在一个new的运算符中;当用new创建一个对象时,它就在堆中为对象分配内存并调用构造函数完成初始化。
比如:
Person person=new Person;
new操作符能确定在调用构造函数初始化之前内存分配是成功的,所以不用显式确定调用是否成功。现在在堆中创建对象的过程变得简单了,只需要一个简单的表达式,它带有内置的长度计算、类型转换和安全检查。这样在堆中创建一个对象和在栈中创建一个对象一样简单。
7.3、delete释放动态对象
new表达式的反面是delete表达式。delete先调用析构函数,再释放对象内存。
#include <iostream>
using namespace std;
class Person{
public:
int mAge;
char *mName;
public:
Person()
{
mAge=20;
mName=new char[32];
strcpy(mName,"Lion");
cout<<"无参构造"<<endl;
}
Person(int num,const char *name)
{
cout<<"有参构造"<<endl;
mAge=num;
mName=new char[32];
strcpy(mName,name);
}
void show()
{
cout<<mName<<" : "<<mAge<<endl;
}
~Person()
{
cout<<"析构函数"<<endl;
if(mName!=NULL)
{
delete [] mName;
mName=NULL;
}
}
};
int main()
{
Person *person=new Person;//无参构造
Person *person2=new Person(20,"Long");//有参构造
person->show();
person2->show();
delete person;
delete person2;
}
7.4、动态对象数组
当创建一个对象数组时,必须为每个数组元素调用构造函数(默认是调用无参构造),除了在栈上可以聚合初始化,必须提供一个默认的构造函数。
#include <iostream>
using namespace std;
class Person{
public:
int mAge;
char *mName;
public:
Person()
{
mAge=20;
mName=new char[32];
strcpy(mName,"Lion");
cout<<"无参构造"<<endl;
}
Person(int num,const char *name)
{
cout<<"有参构造"<<endl;
mAge=num;
mName=new char[32];
strcpy(mName,name);
}
void show()
{
cout<<mName<<" : "<<mAge<<endl;
}
~Person()
{
cout<<"析构函数"<<endl;
if(mName!=NULL)
{
delete [] mName;
mName=NULL;
}
}
};
int main()
{
Person person[]={Person(100,"Lion"),Person(200,"Tom"),Person(300,"Long")};
cout<<person[1].mName<<endl;
Person *person2=new Person[20];//无参构造
delete person2;
}
总结
-
构造函数:构造函数是一种特殊的成员函数,用于在创建对象时初始化对象的成员变量。C++中,每个类都至少有一个构造函数,默认情况下,如果没有显式定义构造函数,编译器会自动生成一个默认构造函数。
-
析构函数:析构函数与构造函数相对应,用于在销毁对象时释放对象占用的资源。它们通常被用来删除动态分配的内存、关闭文件和释放锁等操作。与构造函数不同的是,析构函数没有参数。
-
拷贝构造:拷贝构造是一种特殊的构造函数,它通过复制已有对象创建新的对象。当使用赋值运算符或将一个对象作为实参传递给另一个对象时会调用拷贝构造。默认情况下,编译器会生成一个浅拷贝的拷贝构造函数。如果需要实现深拷贝,则需要手动编写拷贝构造函数。
-
初始化列表:初始化列表用于在创建对象时初始化其成员变量。它可以更高效地初始化非静态常量数据成员和引用类型数据成员,并且可以避免某些未定义行为和难以调试的错误。初始化列表由冒号(:)开始,并由逗号(,)分隔多个成员变量及其初始值组成。
构造函数、析构函数和拷贝构造都是类的成员函数,用于初始化对象、释放资源和复制对象。初始化列表可以更高效地初始化数据成员,并避免某些错误。在实际编程中,需要根据具体情况选择适当的方式来实现。