【C++】类和对象(二)
- 一.构造函数:对象的"出生方式"
-
- 1.为什么需要构造函数?
- 2.构造函数作用
- 3.构造函数特点
-
- [① 函数名与类名相同](#① 函数名与类名相同)
- [② 无返回值(连void也没有)](#② 无返回值(连void也没有))
- [③ 对象实例化创建后自动调用对应的构造函数](#③ 对象实例化创建后自动调用对应的构造函数)
- [④ 可以重载](#④ 可以重载)
- [⑤ 初始化列表](#⑤ 初始化列表)
- 4.构造函数的分类
-
- [① 默认构造函数](#① 默认构造函数)
- ②带参构造函数
- ③拷贝构造函数(下面有详细说明)
- 二.析构函数:对象的"死亡处理"
- 三.拷贝构造函数:对象的"复制"
-
- 1.什么是拷贝构造函数
- 2.特性
-
- [① 参数类型](#① 参数类型)
- [② 重载性质](#② 重载性质)
- [③ 默认行为](#③ 默认行为)
- [④ 深拷贝和浅拷贝(重点)](#④ 深拷贝和浅拷贝(重点))
- 四.赋值运算符重载(=)
-
- 1.什么是运算符重载
-
- [① 写法](#① 写法)
- [② 注意事项](#② 注意事项)
- 2.赋值运算符重载
-
- [① 例子](#① 例子)
- [② 默认赋值运算符重载](#② 默认赋值运算符重载)
一.构造函数:对象的"出生方式"
1.为什么需要构造函数?
在上一篇文章中,我们了解了什么是类,如何定义类,以及神奇的this指针是如何工作的。但这只是面向对象编程的冰山一角。
实际开发过程中,我们可能会遇到这些问题:
① 对象创建时,如何保证数据一定是有效的(如年龄、身高不能为负数)?
② 对象销毁时,如果它申请了堆内存空间,如何防止内存泄漏?
③ 为什么把一个对象赋值给另一个对象时,有时程序会崩溃?
这就涉及到我们今天要讲的有关构造函数等的知识了。
我们首先来看一个例子:
//定义一个简单的学生类,只包含姓名和年龄
class Students
{
public:
void Show() //打印成员变量
{
cout << _name << endl;
cout << _age << endl;
}
private:
string _name;
int _age;
};
int main()
{
Students s1; //定义一个学生对象
s1.Show();
return 0;
}
在这个示例中我们定义了一个学生类,并在类中定义了一个打印成员变量的方法,然后在主函数中声明一个对象,并调用打印函数,运行结果为:

从结果中我们可以看到,姓名是一个空字符串,年龄是一个"脏数据",而"脏数据"产生的原因就是对象在创建后,没有进行初始化。
2.构造函数作用
- 定义:构造函数是一个特殊的成员函数,名字与类名相同,创建类对象时由编译器自动调用,保证每个数据成员都有一个合适的初始值,并且在对象的生命周期内只调用一次。
- 作用:在对象创建时自动调用,用来初始化对象。
3.构造函数特点
① 函数名与类名相同
② 无返回值(连void也没有)
③ 对象实例化创建后自动调用对应的构造函数
④ 可以重载
例如我们给上面的类写一个构造函数:

此时我们在创建对象时,传入对应的学生姓名和年纪,就会自动调用这个构造函数,完成对象的初始化。

⑤ 初始化列表
- 概念:真正对成员变量进行初始化(分配空间并赋值),构造函数函数体内的操作属于赋值。
- 写法:函数名后面跟上冒号,括号内是形参,意思是将形参的值赋给实参

- 限制:1> 初始化列表只能初始化非静态成员变量(静态成员变量需要在类外初始化)。2> 每个成员变量只能在初始化列表出现一次(不能重复初始化,会有歧义)。
- 必须使用初始化列表的成员变量:1> const类型成员变量2> 引用类型成员变量 3> 没有默认构造函数的类类型成员。
- 建议:尽量在初始化列表进行成员变量的初始化而不是在函数体内,使用列表效率通常更高且逻辑更清晰。
- 顺序:成员变量的初始化顺序是取决于它们在类中的声明顺序,而与初始化列表中的顺序无关。
4.构造函数的分类
① 默认构造函数
特点:不需要传递参数就可以创建对象
包括以下三种
class A
{
public:
A() //类中没有对象,或不需要为对象赋初值
{
//构造函数
cout << "调用拷贝构造函数" << endl;
}
};
int main()
{
A a;
return 0;
}
class A
{
public:
A(int x = 10) //类中成员变量有初始值,可以不用传参
{
//构造函数
cout << "调用拷贝构造函数" << endl;
}
private:
int x;
};
int main()
{
A a;
return 0;
}
class A
{
public:
//程序员没有显式写出一个构造函数,系统会自己生成一个默认构造函数
private:
int x;
};
int main()
{
A a;
return 0;
}
②带参构造函数
特点:创建对象时需要传递参数,来初始化对象
例如:
class A
{
public:
A(int x, int y)
:_x(x),
_y(y)
{
}
private:
int _x;
int _y;
};
int main()
{
A a(1, 2);
return 0;
}
或者
class A
{
public:
A(int x, int y = 2) //缺省参数必须放后面
:_x(x),
_y(y)
{
cout << x << " " << y;
}
private:
int _x;
int _y;
};
int main()
{
A a(1);
return 0;
}
③拷贝构造函数(下面有详细说明)
特点:用一个已经存在的同类对象来初始化一个新对象
写法:
class A
{
public:
A(const A& other)//拷贝构造函数
{
x = other.x;
}
A(int a)//带参构造函数
{
x = a;
}
private:
int x;
};
int main()
{
A a(1);
A b(a);//使用对象a来初始化对象b
return 0;
}
二.析构函数:对象的"死亡处理"
1.什么是析构函数
析构函数与构造函数功能相反,它是用于释放资源的,比如分配给对象的内存空间等。
2.析构函数的特性
① 格式
析构函数与构造函数格式相似,只是要在类名前加上" ~ "
例如:
class A
{
public:
//构造函数
A()
{
}
//析构函数
~A()
{
}
};
② 无参数,无返回值
③ 一个类只能有一个析构函数
析构函数不能重载。
④ 对象销毁时自动调用
例如:

main函数结束时,对象a被销毁,调用析构函数。
⑤ 程序员没有显式定义析构函数时,编译器会自己生成一个析构函数。
但是如果对象中涉及到资源管理,程序员必须显式写出析构函数,否则会造成内存泄露。
三.拷贝构造函数:对象的"复制"
1.什么是拷贝构造函数
用一个已经存在的对象去初始化一个新对象
例如:
A a;
A b(a);
或者
A b = a;
2.特性
① 参数类型
拷贝构造函数只有一个参数,并且必须是&(引用)类型,(通常还用const修饰,避免对象被修改)。
写法:
class A
{
A(const A& other)
{
_a = other._a;
}
private:
int _a;
};
② 重载性质
因为拷贝构造函数属于构造函数的一种重载,因此它具有构造函数的所有特性。
③ 默认行为
如果程序员未显式定义拷贝构造函数,系统会自己生成一个默认的。
默认生成的拷贝构造函数会对象内存进行逐字节拷贝,这种行为被称为值拷贝或浅拷贝。
④ 深拷贝和浅拷贝(重点)
- 浅拷贝:
如果类中申请了空间(如动态内存分配new),那么浅拷贝会出现一个问题:指针指向同一块地址空间。

因为浅拷贝是一个字节一个字节地拷贝每个字节的内容,那就意味着会直接拷贝指针所指向的地址给另一个对象,那么在进行析构的时候,两个指针释放同一块地址空间,就会造成内存泄露或程序崩溃。

因此,涉及资源管理的时候,必须显式写出拷贝构造函数进行深拷贝。 - 深拷贝:
深拷贝就是重新申请一块空间,再将指针对象指向的值拷贝给新指针,两个指针并不指向同一块地址,但指向的内容相同。

可以看到这次程序就正常运行了。
四.赋值运算符重载(=)
1.什么是运算符重载
C语言中只有基本数据对象(int,double等)可以使用"+"、"-"、"*"、"/"等运算符进行运算,那如果我们想实现两个类之间的符号运算该怎么办呢?
例如我们想实现日期间隔计算(2026/4/10-2025/2/7=427天),该怎么做呢?
首先来定义一个日期类:
class Date
{
public:
Date(int year, int month, int day)
:_year(year),
_month(month),
_day(day)
{
}
private:
int _year;
int _month;
int _day;
};
此时两个日期类之间相减就会报错:

这时候就需要我们自己重载一下日期类的"-"运算符。
class Date
{
public:
Date(int year, int month, int day)
:_year(year),
_month(month),
_day(day)
{
}
// 判断是否是闰年
bool IsLeapYear(int year)
{
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
// 获取当前日期是这一年的第几天
int GetDayOfYear()
{
int monthDay[13] =
{ 0,31,28,31,30,31,30,31,31,30,31,30,31 };
int days = 0;
for (int i = 1; i < _month; i++)
{
days += monthDay[i];
}
days += _day;
// 闰年并且过了2月
if (IsLeapYear(_year) && _month > 2)
{
days += 1;
}
return days;
}
// 运算符重载(-)
int operator-(Date& d)
{
int days1 = 0;
int days2 = 0;
// 计算当前对象距离1/1/1的总天数
for (int i = 1; i < _year; i++)
{
days1 += IsLeapYear(i) ? 366 : 365;
}
days1 += GetDayOfYear();
// 计算d距离1/1/1的总天数
for (int i = 1; i < d._year; i++)
{
days2 += IsLeapYear(i) ? 366 : 365;
}
days2 += d.GetDayOfYear();
return days1 - days2;
}
private:
int _year;
int _month;
int _day;
};
这时候就能得出正确结果:

① 写法
运算符重载的写法为:
返回值 operator运算符(参数列表)
{ //函数体
}
② 注意事项
- 运算符重载本质就是函数重载
- 不能重载新的运算符
例如 operator@ ,因为@不是C/C++的运算符。 - 重载运算符不要改变其含义
例如在上述日期类中,不要重载"-"运算符,但函数逻辑是两个日期相加。 - 必须有一个类类型操作数
- 成员函数重载时隐藏this
2.赋值运算符重载
当一个已经存在的对象给另一个已经存在的对象赋值时,会调用赋值运算符重载。
① 例子
以C++中String类为例,看看深拷贝赋值运算符如何重载
class String
{
public:
char* str;
String(const char* s = "")
{
str = new char[strlen(s) + 1];
strcpy(str, s);
}
~String()
{
delete[] str;
}
//赋值运算符重载
String& operator=(const String& s)
{
// 防止自赋值
if (this == &s)
return *this;
// 释放旧空间
delete[] str;
// 开新空间
str = new char[strlen(s.str) + 1];
// 拷贝数据
strcpy(str, s.str);
// 返回自身
return *this;
}
};
解释:
Q:为什么参数是&(引用)类型?
A:因为不是引用类型的话,会先调用拷贝构造函数(给形参初始化),非常浪费。
Q:为什么要加const?
A:防止在函数体中将elem对象给修改了。
Q:为什么返回值对象也是引用?
A:减少拷贝,提高效率,并且支持连续赋值。
Q:为什么返回*this
A:*this 代表赋值后的左操作数对象本身,C++ 标准规定赋值表达式的值是左操作数的值。
Q:是否需要检测自己给自己赋值?
A:必须检测。例如 a = a;如果不检测,当类涉及资源管理(如指针)时,先 delete 自己的资源再拷贝,会导致把自己删了再去拷贝自己,程序崩溃。
② 默认赋值运算符重载
如果你不写 operator=,编译器会自动生成一个。
- 默认生成:如果一个类没有显式定义赋值运算符重载,编译器会生成一个默认的。
- 浅拷贝行为:编译器生成的默认赋值运算符重载是按照浅拷贝(按字节序拷贝)方式生成的。
对于内置类型(int, double等),直接拷贝值。
对于自定义类型成员,调用其赋值运算符。
问题:如果类里有指针成员,浅拷贝会导致两个对象的指针指向同一块内存。 - 资源管理时必须显式提供:
如果类中涉及到资源管理(比如开了堆内存 new),用户必须显式提供赋值运算符重载。
否则会造成内存泄漏(原内存没释放)或者运行时崩溃(析构时同一块内存被 delete 两次)。
用户显式实现的通常是深拷贝(重新申请内存,复制内容)。