【C++】类和对象(二)

【C++】类和对象(二)

一.构造函数:对象的"出生方式"

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 两次)。
    用户显式实现的通常是深拷贝(重新申请内存,复制内容)。
相关推荐
2401_878454531 小时前
js的复习(一)
开发语言·javascript·ecmascript
等故意1 小时前
C# 工业视觉上位机开发心得分享
开发语言·数码相机·c#·视觉检测
广师大-Wzx1 小时前
JavaWeb:后端部分
java·开发语言·spring·servlet·tomcat·maven·mybatis
机器学习之心1 小时前
基于CPO-VMD冠豪猪优化优化变分模态分解与最小包络熵的自适应变分模态分解方法,附MATLAB代码
开发语言·matlab·cpo-vmd·冠豪猪优化优化变分模态分解
广东王多鱼1 小时前
一个人 + Claude = 全栈开发团队:从零构建 AI 自动化开发系统的技术实现
后端·vibecoding
用户2160719532951 小时前
AQS、ReentrantLock详解
后端
lly2024061 小时前
Font Awesome 文件类型图标
开发语言
Rust研习社1 小时前
Rust Clippy 实用指南:写出更优雅、安全的 Rust 代码
后端·rust·编程语言
wefg11 小时前
一些零散的算法
c++·算法