【C++闯关笔记】封装①:类与对象

系列文章目录

从C到C++入门:C++有而C语言没有的基础知识总结-CSDN博客

【C++闯关笔记】封装①:类与对象-CSDN博客

【C++闯关笔记】封装②:友元与模板-CSDN博客


系列前言

为了更好地梳理自己的学习脉络巩固对这些基石概念的理解 ,并希望能为同样踏上C++ OOP学习之旅的朋友们提供一份参考 ,我决定将我的学习过程、思考和实践心得整理成这个博客系列。这并不是专家居高临下的指导,而是一位同路人边走边记的手记。

与君共勉,共同进步。


目录

系列文章目录

系列前言

文章前言

一、类是什么?

1.理解面向过程与面向对象

2.类的引入

3.类的定义

二、类的封装

1.访问限定符

2.封装的定义

3.封装的好处

4.this指针

三、类的六个默认构造函数

1.构造函数

构造函数分类

2.析构函数

3.拷贝构造函数

4.赋值运算符重载

1)运算符重载

2)赋值运算符重载

5.一个Date类示例

补充:什么是匿名对象?

匿名对象的用处

总结



文章前言

C++面向对象编程的三驾马车------封装、继承、多态。

本文主要介绍"封装","封装"是C++面向对象编程的基础,但对于刚接触C++的新人而言,上来直接就讲封装可能不是很友好,所有本文先从"为什么C++被称为面向对象编程,而C语言则被称为面向过程编程了"入手,在理解了面向对象编程之后,再给出类的定义以及访问限定符等内容,最后情调类的六个默认构造函数。


一、类是什么?

1.理解面向过程与面向对象

我们拿洗衣服这个例子来理解面向过程与面向对象的不同:

先站在人类的角度思考整个洗衣服的过程:人将衣服放入洗衣机,倒入洗衣液,启动洗衣机。

这用C语言也很容易描述:各种的对象创建相应的变量存储状态,期间可以用不同的函数按照一定顺序实现,从而解决洗衣服这个任务。这其中主要关注先后过程算法。

C语言的实现方式很符合人类的思考过程,也可以说人们就是按照人类解决事物的方法流程发明了面向过程的C语言:

面向过程

C语言是面向过程的,关注的是过程:分析出求解问题的步骤,通过函数调用逐步解决问题。

C语言似乎已经能解决问题,那还为什么要引入C++呢?------这是作者曾经的疑惑,相信也是不少初学者的困惑。

考虑以下场景:如果我们将上述衣服的种类换了,或者洗衣机改成滚筒洗衣机等操作,这些需要修改具体函数,甚至可能需要更改整个代码的执行逻辑:比如有些衣服要先洗,想要执行什么操作得先执行某操作。于是人们发现C语言得可拓展性不好,稍加改动甚至可能要重构整个程序,这显然是很麻烦的。

为了解决上述问题,也为了符合实际生活中快速更新换代的需求,人们又在C语言的基础上发展了C++:

C++程序由对象 组成。对象是类的实体,对象里面包含了数据(属性)操作这些数据的函数(功能), 通过对象之间交互完成任务。

怎么理解呢?我们还是拿洗衣服举例:

洗衣服这个任务可以拆分成四个关键对象:人、衣服、洗衣机、洗衣液。

这四个对象它们分别有着各自的属性和功能,我们可以抽象提取出它们的属性和功能:

{

人(属性可以有性别、年龄;功能可以是能做的事情);

衣服(属性可以是材质;功能可以是保暖);

洗衣机(属性可以是品牌;功能可以是洗衣、脱水);

洗衣液(属性可以是效果;功能可以是洗衣)。

}

C++就是通过类似上述抽象提取,将任务对象的属性和功能封装 成类(类中包含相应的属性和功能), 再依任务需求实例化出不同对象,通过对象之间 的**交互完成任务。**这种方法就叫做:

面向对象

C++是基于面向对象的,关注的是对象:将一件事情拆分成不同的对象,靠对象之间的交互完成。

小白第一次理解C++面向对象的逻辑可能有些困难,但不必担心随着日后编程的逐渐深入,对于面向对象这一概念的理解会越发清晰,所以大可不必焦虑。但类的实现其实很简单,读者可以将类比作一种特殊的"结构体",只不过这种结构体里面能定义函数。

2.类的引入

C语言结构体中只能定义变量,但在C++中,结构体内不仅可以定义变量,也可以定义函数。

cpp 复制代码
struct Node
{
	int _val;
	struct Node* _next;

	void Init()
	{
		_val = 0;
		_next = NULL;
	}
};

在实际过程中,C++更喜欢用class来代替struct:

cpp 复制代码
class Node
{
	int _val;
	struct Node* _next;

	void Init()
	{
		_val = 0;
		_next = nullptr;
	}
};

所以,刚接触C++的新人朋友如果还是对类的概念模糊,那么大可将类当作"大号结构体"来看待和使用,在后面的学习中再逐渐完善对类的认识。

3.类的定义

cpp 复制代码
class ClassName//类名任取
{
 // 类体:由成员函数和成员变量组成
};  
// 一定要注意后面的分号

①class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分的分号不能省略;

②类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的功能方法或者成员函数。

类的定义方法分为两种:

①直接在类内中定义:声明和定义全部放在类体中。

cpp 复制代码
class Date
{
public:
	void display()
	{
		cout << _year << _month << _day;
	}
	
private:
	int _year;
	int _month;
	int _day;
};

②类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名::

cpp 复制代码
class Date
{
public:
	void display()
	{
	}

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

void Date::display()
{
	cout << _year << _month << _day;
}

一般情况下,实际编程都使用第二种定义。


二、类的封装

1.访问限定符

注意到上述定义C++类中有两个在C语言中没有的关键字:public和private,它们都被称为访问限定符。

实际上C++中的访问限定符有三个:

访问限定符说明

  1. public修饰的成员在类外可以直接被访问

  2. protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)

  3. 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止

  4. 如果后面没有访问限定符,作用域就到}即类结束。

  5. class的默认访问权限为private,struct为public。

简而言之,C++将类的实现代码通过访问限定符划分为两部分:一部分对外放开,运行外部访问;

另一部分私有,外部不能访问仅供内部成员专访。

2.封装的定义

通过上文的描述与铺垫,读者可能猜到了C++的封装与访问限定符有关,下面给出封装的定义:

封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开相应接口来和对象进行交互。

为什么要封装?

**封装本质上是一种管理,让用户更方便使用。**就比如普通人使用手机电脑一样,将内部实现细节隐藏起来,不用知道它们的具体组成,仅仅对外提供开关机、鼠标以 及键盘等,仅需让用户知道如何与之交互就可以了。

同样的,C++通过封装将数据以及操作数据的方法进行结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。

3.封装的好处

分析封装的好处,换句话说就是分析C++相较于C语言的优点。

①可拓展性

通过上述分析,我们已经知道相较于C语言,C++有个优点是可拓展性强------如果任务对象发生了变化,C++只需要更改相应的类的属性(成员变量)或方法(成员函数),整体框架不用更改。

②数据和操作有机结合

C++的封装还解决了C语言数据和操作分离的问题 。在C语言中,数据是被操作的属于被动 的,操作它们的函数相较于数据而言是**外部的与数据无关的,**数据和操作它们的逻辑没有内在联系。而C++中的类,则完全解决了这个问题。

有读者可能会问:数据和操作的结合很重要吗?

举个简单的例子,用C语言实现的数据结构如栈(【数据结构】栈_栈 数据结构-CSDN博客),由于数据与操作分离所导致结果之一就是:程序员需要付出更多的精力关联每个数据和函数的关系。比如栈的每个执行函数都需要外部传入的指针,并且内部需要维护这些指针,如果指针传错或者其他问题就会导致整个程序出问题。而如果用C++实现栈,那么每一个栈对象在执行相关函数时都是调用自己的成员函数,这天然的关联了每个数据与相应函数(实际用到了this指针,下文提及),不再需要程序员关心。

③增强数据安全性

在C语言中,任何函数都能直接修改数据,这可能会导致某些安全隐患。而C++中,由于访问限定符的存在,只有类内的成员函数才能访问这些数据,防止了外部修改某种重要数据的可能。

④管理简单

用C语言实现的代码会随着整体系统功能的丰富导致:全局变量和在各函数间传递的数据结构会增多,比如malloc的空间,在庞大的系统中就容易被重复释放或者申请,状态容易失控。而C++的实现逻辑靠对象之间的交互,每个数据都有明确的所属,每个操作都只针对属于该对象自己的变量进行操作,因此管理起来较C++简单不少。

4.this指针

在C++类中我们可以编写众多成员函数,然后通过实例化后的对象调用它们。可有一个问题:假设我们实例化了多个对象,那么这些成员函数是怎么知道是哪个对象在调用它们的呢?

C++通过引入this指针解决该问题:

**C++编译器给每个"非静态的成员函数"增加了一个隐藏的指针参数,并且让该指针指向当前函数运行时调用该函数的对象,在函数体中所有"成员变量" 的操作,都是通过该指针去访问。**只不过this指针对用户是透明的,即用户不需要处理,编译器自动会完成。

比如成员函数display:

cpp 复制代码
	void display()
	{
		cout << _year << _month << _day;
	}

在实际运行时是:

cpp 复制代码
	void display(Date*this)//隐藏的this指针
	{
		cout << this->_year << this->_month << this->_day;
	}

this指针特性

  1. this指针的类型:类类型* const this,即成员函数中,不能改变this指针的指向;

  2. 只能在成员函数的内部使用;

  3. this指针本质上是"成员函数"的形参,当对象调用成员函数时,将对象地址作为实参传递给 this形参。所以对象中不存储this指针;

  4. this指针是"成员函数"第一个隐含的指针形参,一般情况由编译器自动传递,不需要用户传递。

this指针存储在哪呢?

this指针本身不是一个存储在对象内部的独立变量 。它是一个 运行时由编译器自动生成并传递的临时值,其存储位置由调用约定决定,通常在寄存器中。

this指针可以为空吗?

**理论上可以,但最好不要,因为可能发生未定义的事情:程序崩溃或者正常运行亦或者其他诡异的行为。**当通过一个类类型的空指针调用成员函数时,this指针就为空。


三、类的六个默认构造函数

cpp 复制代码
class Person {};

如果一个类中什么成员都没有,那么称为空类。但空类中真的什么都没有吗?其实不是的,任何类在什么都不写时,编译器会自动生成6个默认成员函数。

后两个编译器自动生成的默认成员函数(取地址操作符重载和const重载),它们的功能在一般情况下已经够正常使用了,但是前四个默认构造函数则需要我们深入了解它们的概念和特性

1.构造函数

什么是构造函数?

构造函数的概念:

构造函数是一个比较特殊的成员函数,它的函数名与类名相同,创建类对象时由编译器自动调用,以保证每个数据成员都有初始值,并且在对象整个生命周期内只调用一次。

注意:构造函数名为构造,但实际上干的初始化的活,并非申请空间。

构造函数的特性:

  1. 函数名与类名相同。

  2. 无返回值。

  3. 对象实例化时编译器自动调用对应的构造函数。

  4. 构造函数可以重载。

不清楚什么是重载?没关系: 从C到C++入门:C++有而C语言没有的基础知识总结-CSDN博客

为什么说构造函数是默认成员函数?

如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦 用户显式定义编译器将不再生成。

既然编译器会自动生成默认构造函数,我们还需要自定义构造函数吗?

答案是肯定的。构造函数的作用就是为对象初始化,相反如果没有自定义构造函数,那么就没有初始化功能,实例化出来对象中的值全是随机数。

构造函数分类

首先明确构造函数本质上依旧是个函数,该函数的功能就是初始化。

①无参构造函数

无参构造函数,构造函数中没有参数,但仍可以在函数体中添加一些语句,当该类实例化对象时便会执行函数体中语句。不能进行初始化赋值,如:

cpp 复制代码
class Date
{
public:
	Date()
	{
		cout << "Date Init";
	}
private:
	int _year;
	int _month;
	int _day;
};

②全缺省构造函数

全缺省构造函数,函数有形参但没有缺省值,可以初始化赋值:通过在函数体中将传入的形参赋值给对象中的成员变量,如

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

③半/全缺省构造函数

半/全缺省构造函数,函数有形参且有缺省值,可以初始化赋值,如果实例化对象时没有传入数据,会自动将缺省值赋值给成员变量。

(半缺省构造函数指只有一部分形参有缺省值)

cpp 复制代码
class Date
{
public:
	Date()
	{
		cout << "Date Init";
	}

	Date(int year = 2025, int month = 8, int day = 20)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。

解释:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。

④初始化列表

实际编写类时,常用带初始化列表的构造函数,且有些成员变量只能通过初始化列表初始化:

引用成员变量,const成员变量,自定义类型成员(且该类没有默认构造函数时)

成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后 次序无关,初始化列表语法:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。如下所示

cpp 复制代码
class Date
{
public:

	Date(int year = 2025, int month = 8, int day = 20):_year(year),_month(month),_day(day)
	{
	}

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

2.析构函数

通过前面构造函数的介绍,我们知道一个对象是怎么创建的,那么一个对象又是怎么清理的呢?

析构函数概念

析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

析构函数特性

析构函数是特殊的成员函数,若不显式定义编译器会生成默认析构函数。

  1. 析构函数名是在类名前加上字符 ~。

  2. 无参数无返回值类型。

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

注意:析构函数不能重载

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

析构函数示例:

cpp 复制代码
class Date
{
public:
	Date()
	{
		cout << "Date Init";
	}

	~Date()
	{
		//如果类中没有申请资源时,析构函数甚至可以不写
	}

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

什么情况下必须定义析构函数?

如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date类;但有资源申请时,一定要写并且释放申请的空间资源,否则会造成资源泄漏!

3.拷贝构造函数

当希望将某个对象赋值给另一个对象时,就会用到拷贝构造函数。

拷贝构造函数概念

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

拷贝构造函数特征

  1. 拷贝构造函数是构造函数的一个重载形式。

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

  3. 若未显式定义,编译器会生成默认的拷贝构造函数。

默认的拷贝构造函数对象,按内存存储的字节序完成拷贝(可以理解为,将内存中某区域的数据原封不动的拷贝一份给其他对象),这种拷贝叫做浅拷贝。在某些情况下浅拷贝是不够用的。

拷贝构造函数示例

cpp 复制代码
    Date(const Date& d)
    {
        cout << "Date(const Date& d):" << endl;
    }

拷贝构造函数的典型使用场景

1.使用已存在对象创建新对象;

cpp 复制代码
Date a(2020,1,1);
Date b(a);

2.函数形参为类类型对象;

3.函数返回值类型为类类型对象;

什么时候需要自定义深拷贝?

如果类中没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,因为浅拷贝没有为新对象实际申请一个空间,而是将上一个对象中的堆空间地址拷贝给另一个对象,换句话说,这会造成两个对象共用一片堆空间的错误,同时析构时这块空间也会因释放两次而导致程序崩溃。

4.赋值运算符重载

1)运算符重载

运算符重载,即运算符重载函数,是具有特殊函数名的函数。

函数语法

函数名字为:关键字operator后面接需要重载的运算符符号。

函数原型:返回值类型 operator操作符(参数列表)

示例

cpp 复制代码
bool operator==(const Date& d1, const Date& d2)
{}

细节注意

1.重载操作符必须有一个类类型参数;

2.用于内置类型(如int)的运算符,其含义不能改变。如:内置的整型+,不能改变其含义;

3.作为类成员函数重载时,其形参看起来比非成员函数的形参数目少1,因为成员函数的第一个参数为隐藏的this;

4." * :: sizeof ?: . " 注意以上5个运算符不能重载。

2)赋值运算符重载

赋值运算符重载格式

1.参数类型:const T&,传递引用可以提高传参效率

2.返回值类型:T&,返回引用可以提高返回的效率。有返回值目的是为了支持连续赋值,如:a = b = 1;

3.预防自己给自己赋值;

4.返回*this------连续赋值。

示例:以下为类成员函数

cpp 复制代码
	Date& operator=(const Date& d)
	{
		if (&d != this)
		{
			this->_year = d._year;
			this->_month = d._month;
			this->_day = d._day;
			return *this;
		}
		return *this;
	}

赋值运算符只能重载成类的成员函数不能重载成全局函数

赋值运算符如果不显式实现,编译器会生成一个默认赋值运算符函数。若此时用户再在类外实现 一个全局的赋值运算符重载,就与类中生成的默认赋值运算符重载冲突了,**故赋值运算符重载只能是类的成员函数。**默认赋值运算符依旧采用浅拷贝方式:以值的方式逐字节拷贝。

一般情况下编译器生成的默认赋值运算符是够用的,赋值运算符是否实现都可以;但如果涉及堆上的空间或其他资源时,就需要自定义实现赋值运算符。

5.一个Date类示例

cpp 复制代码
class Date
{
public:

	Date(int year = 2025, int month = 8, int day = 20):_year(year),_month(month),_day(day)
	{
		cout << "Date" << endl;
	}

	Date(const Date& d)
	{
		cout << "const Date& d" << endl;
		this->_year = d._year;
		this->_month = d._month;
		this->_day = d._day;
	}

	~Date()
	{
		//如果类中没有申请资源时,析构函数甚至可以不写
		cout << "~Date" << endl;
	}

	Date& operator=(const Date& d)
	{
		cout << "operator=" << endl;
		if (&d != this)
		{
			this->_year = d._year;
			this->_month = d._month;
			this->_day = d._day;
			return *this;
		}
		return *this;
	}

	void Display()
	{
		cout << "year:" << _year << " month:" << _month << " day:" << _day << endl;
	}

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

一个类中成员数量,随该类的属性与功能的增删而增删,上述代码只为帮助读者综合文章内容并在脑海建立起一个具体类的模型。

补充:什么是匿名对象?

正常实例化对象时,我们采用如定义内置类型变量那样定义对象,如

cpp 复制代码
int a = 1;
Date d(2025,8,22);

其实C++还支持定义匿名对象 (匿名对象也叫临时对象)。

匿名对象 指的是那些没有绑定任何命名的对象 。它是在表达式求值过程中"临时"创建,通常在其所在的完整表达式结束后就被销毁,即生命周期只有那一行。

cpp 复制代码
Date(2025,8,20);

//或者直接调用类的构造函数
Date::Date();

匿名对象的用处

①作为函数参数(最常用的场景)

这是匿名对象最经典实用的场景:当你需要一个对象作为参数传递给函数,但这个对象只使用一次时,直接传递一个匿名对象可以使代码非常简洁。如

cpp 复制代码
void pro(const Date& s)
{
    //操作
}

int main() {
    // 传统繁琐的方式:先创建有名对象,再传递
    Date temp(1,1,1);
    pro(temp);
    
    // 直接传递匿名对象
    pro(Date(1,1,1)); // 创建一个Date匿名对象并传入
    
    return 0;
}

②作为函数返回值

在函数返回时,现代编译器常对匿名对象有特殊优化,返回匿名对象通常比返回一个有名对象效率更高。

cpp 复制代码
MyClass cre() 
{
    return Date(2000,2,2); // 直接返回匿名对象,编译器会优化,避免拷贝
}

③在链式调用或表达式中使用

匿名对象可以无缝嵌入复杂的表达式之中。

cpp 复制代码
// 假设有一个Logger类
class Logger 
{
public:
    Logger(const std::string& pre) {...}

    Logger& log(const std::string& message) 
    {
        std::cout << message << std::endl;
        return *this;
    }
};

// 在表达式中使用匿名Logger对象,注意log函数返回的是*this所以可以连续调用
Logger("Error: ").log("File not found").log("404");

总结

本文从"面向过程"与"面向对象"的区别入手,先是给出了什么是"类",紧接着介绍封装的好处,之后还介绍了类的六个默认成员函数中经常用到的四种函数,最后补充了匿名对象的概念与用法。

整理不易,希望本文能帮到你。

读完点赞,手留余香。