C++基础知识-(②)面向对象(上)

C语言或C++的数据类型大体上可以分为两类:第一类是内置类型或说是基本类型即int,short,char,float等,他们即是基本类型也是关键字。第二类是自定义类型即用class,struct,enum,union定义出来的类型。

1.面向对象

众所周知C语言是一门面向过程的语言,C++是一门面向对象的语言,那什么是面向过程什么是面向对象呢?下面以一个外卖系统来解释这两个概念之间的区别。

面向过程:更关注具体的实现过程,用户怎么 下单的,商家怎么 接单的,骑手怎么配送的。

面向对象:更关注对象以及对象之间的交互关系,在外卖系统中更关注用户,商家,骑手这三个角色以及这三个角色之间的交互关系:用户下单后该订单给相应的商家,商家接收到订单后向骑手派单,骑手接收到派单后送到相应的用户,形成一个闭环的关系。

2.用户自定义类型

用户自定义类型有结构体,类,枚举。程序员能够通过这三种方式设计并实现自己的数据类型。这里简单说一下结构体,详细的说一下类。

2.1结构体(类的前身)

结构体:是一种将数据和操作分割的用户自定义数据类型。

将数据和操作分割指的是在结构体中只能定义一些不同数据类型的变量但是不能定义函数,我们可以在结构体的外部定义各种函数灵活操作结构体中的各种数据。

结构体的定义和操作:

cpp 复制代码
#include<iostream>
using namespace std;

struct Vector
{
	int sz;        // 元素的数量
	double* elem;  //指向元素的指针
};
//我们可以在结构体的外面定义一系列操作结构体的函数

//初始化结构体的操作
void init_Vector(Vector & v, int s)
{
	v.elem = new double[s];
	v.sz = s;
	/*new运算符从堆中开辟一片内存用来实现持久化存储
	(防止函数调用结束后栈中元素被销毁)*/
}

//向该结构体中存储数据并求和的操作
double cin_sum_Vector(Vector& v, int s)
{
	init_Vector(v, s);
	for (int i = 0; i != s; i++)
	{
		cin >> v.elem[i];
	}

	double sum = 0;
	for (int j = 0; j != s; j++)
	{
		sum += v.elem[j];
	}
	return sum;
}

int main()
{
	Vector v;  //定义了一个Vector类型的变量
	int s = 3;
	cout <<" cin_sum_Vector = " << cin_sum_Vector(v, 3) << endl;
	return 0;
}

结构体成员的访问:

访问struct成员的方式有两种:一种是通过名字或引用使用点运算符来访问,另一种是通过指针使用->来访问。例如以下代码:

cpp 复制代码
void func(Vector v, Vector& rv, Vector* pv)
{
	int i1 = v.sz;
	int i2 = rv.sz;
	int i3 = pv->sz;
}

2.2类

为什么会引入类?

在大多数情况下数据和操作之间建立紧密的联系还是很有必要的,比如我们需要对结构体中定义的数据使用时要具有一致性和保护性,不能让外界随便定义的函数访问结构体中的数据。此时使用结构体定义就不是太妥当了,因此引入了类的概念,类与结构体最大不同就是类中既可以定义数据也可以定义对这些数据的操作函数。

2.2.1类的定义

类:是一种用户自定义的数据类型,用于在程序代码中表示某种概念。

如果我们头脑中或者现实生活中对某种事物有一个概念,都应该设法把它表示为程序中的一个类,从而使一个抽象的概念变为一段具体的代码。(注意:概念是反映事物本质属性的思维形式。------摘录自百度百科)

class是定义类的关键字。类体是{}中的内容。类体中有一系列的成员,成员可以是变量,函数或者类型。类体中定义的变量也称为成员变量,类体中定义的函数也称为成员方法或成员函数 。

类的定义方法有两种:

一是类的声明和成员方法的实现都在同一个.cpp文件中,成员方法在类中后实现默认是内联函数。

cpp 复制代码
class Student
{
	void print_info() 
	{
		cout << _name << endl;
		cout << _age << endl;
		cout << _id << endl;
	}

	char* _name;
	int _age;
	int _id;
};

二是类的声明在头文件(.h后缀的文件)中,成员方法的具体实现在.cpp文件中。(因为具体的工程中代码量较大,将所有的成员函数的实现放在一个类中代码可读性差)

2.2.2类的访问限定符

C++中的访问限定符有public、private、protected ,访问限定符限定的是类外面对类内部成员的访问。其中public修饰的成员是公有的可以在类外面被直接访问,private和protected修饰的成员是私有的在类外面不能被直接访问。访问限定符的作用域是从该访问限定符出现的位置开始到下一个访问限定符出现时为止,如果后面没有访问限定符则作用域到类的作用域的右括号结束。

cpp 复制代码
#include<iostream>
using namespace std;
class Student
{
public:
	void print_info()
	{
		cout << _name << endl;
		cout << _age << endl;
		cout << _id << endl;
	}
public:
	char* _name;
	int _age;
	int _id;
};

int main()
{
	Student s1;
	s1._name =new char[10];
	strcpy(s1._name, "tom");
	s1._age = 18;
	s1._id = 001;
	s1.print_info();
	return 0;
}

注意:

1、成员变量一般都是比较隐私的,一般定义成私有或者保护。

2、C++中struct和class都可以用来声明类,但是class的所有成员如果没有限定符修饰默认限定符都是私有的,struct的所有成员如果没有限定符修饰默认限定符是公有的。

2.2.3类的作用域

类定义了一个作用域,类后面的大括号是这个类的作用域,类的所有成员都定义在类的作用域中。

注意:如果在类的外面定义类的成员,需要使用作用域从操作符::表示定义的成员属于哪一个类。

示例代码:

cpp 复制代码
class Student
{
public:
	void print_info();
	
private:
	int _age;
	int _id;
};

void Student::print_info()
{
	cout << _age << endl;
	cout << _id << endl;
}

2.2.4C语言和C++中的struct区别总结

①C语言中的struct是用来定义结构体的。

②C++中兼容C中的struct定义结构体的用法,同时struct也可以用来定义类。

③C语言中struct加上结构体的名称才代表这个结构体的类型,但是在c++中结构体代表一个类,因此结构体的名称直接代表结构体的类型。由于C++兼容C语言因此在C++中也可以在结构体中通过struct加上结构体的名称代表这个结构体的类型。(直观区别见下面两端代码)

cpp 复制代码
struct C_LinkNode
{
	int _val;
	struct C_LinkNode* _prev;
	struct C_LinkNode* _next;
};
struct C++_LinkNode
{
	int _val;
	struct C++_LinkNode* _prev;/*C++兼容C*/
	C_LinkNode* _next;/*C++中struct代表一个类*/
};

2.2.5封装思想

封装的两个特性:

①将数据(成员变量)和操作(成员方法)放到一起。

②想让外界看到的数据给外界看,不想暴露到外界的数据封装起来。

封装实现方法:访问限定符。(可以将想让外界看到的数据定义成公有的即public,不想让外界看到的数据定义成私有的即private)

注意:一般数据(成员变量)都是不能被外界修改的,但是可以提供一些成员方法(接口)让外界使用来操作类中定义的数据(成员变量)。

2.2.6类的实例化

声明和定义的区别:声明可以认为是一种承诺,承诺要干嘛但是还没做(在内存中还没有开空间),定义就是把这个事落地(在内存中开辟一块空间)。因此类中的成员变量只是声明,类中成员方法的声明是指声明了函数但是函数体中是怎么实现的还没写出来。

类实例化出对象,相当于定义出了类的成员变量,此时会给成员变量在内存中开辟空间。类就像一栋建筑的图纸,类的实例化就是按图纸将楼建出来。(开辟了空间)

cpp 复制代码
class Stack
{
public:
	//成员函数:这里只是声明了成员函数
	void Pop();
	void Push(int x);
	int Size();
private:
	//成员变量:这里也是声明
	int _size;
	int* a;
	int _capacity;
};

int main()
{
	/*s1,s2,s3是类实例化出的对象:定义了成员变量(系统为成员变量开辟了空间)*/
	Stack s1;
	Stack s2;
	Stack s3;

	return 0;
}

类成员函数的定义:两种方法:类内部定义成员函数,类外部定义成员函数。

cpp 复制代码
class Stack
{
public:
	//成员函数:这里只是声明了成员函数
	void Pop();
	void Push(int x)/*类内部定义成员函数*/
	{
		/*...*/
	}
	int Size();
private:
	//成员变量:这里也是声明
	int _size;
	int* a;
	int _capacity;
};

/*类外面定义成员函数*/
void Stack::Pop()
{
	/*...*/
}

int main()
{
	/*s1,s2,s3是类实例化出的对象:定义了成员变量(系统为成员变量开辟了空间)*/
	Stack s1;
	Stack s2;
	Stack s3;

	return 0;
}

2.2.7计算类实例化后对象的大小

计算下面代码中学生类实例化的对象s所占空间大小。

思考:成员函数的函数指针是否占用一定的内存空间?

类实例化后的每个对象只存储成员变量,不存储成员函数,类的成员函数都存放在公共代码段。原因:一个类实例化出若干个对象,每个对象的成员变量都可以存储不同的值,但是调用的成员函数都是同一个函数。如果每个对象都需要存放成员函数那么这些成员函数都是一样的,浪费空间。

如何计算一个类实例化出的对象大小:计算成员变量所占空间之和,并考虑内存对齐规则。因此上面代码中实例化后的s所占的大小是12。(内存对齐规则可以看C语言专栏中的C语言基本语法-自定义类型:结构体&联合体&枚举这篇文章,介绍的非常详细!)

特例:没有成员变量的类实例化后的对象的大小是1字节。原因:开1个字节不是为了存储数据而是为了占位表示它是存在的,也就是让没有成员变量的类实例化对象时能够编译通过。如下代码所示:

3.this指针

3.1什么是this指针

下面定义了一个日期类:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
private:
	int _year;
	int _month;
	int _day;
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
};

int main()
{
	Date d;
	d.Init(2026, 2, 10);
	d.Print();
	return 0;
}

对于上面代码编译器在背后会做下图的处理:

关于上面代码的一些说明:

①成员函数Init是为了给类中的私有成员变量赋值,相当于我们上面讲的接口。

②成员变量名前面加上下划线的原因是为了区分类中成员变量和给类中成员变量进行赋值的函数Init的形参。

③对于类中定义的成员函数,只要函数中使用了类自身定义的成员变量,在该类实例化的对象调用该成员函数时,编译器就需要在背后进行一些处理,在成员函数的参数中加上指向这个类实例化的对象的指针即this指针,成员函数访问自身成员变量是通过this指针进行访问。

④在main函数中通过实例化的对象调用类中的成员函数时,只要被调用的成员函数中使用了类自身的成员变量,传参时编译器就会再传递一个指向实例化对象的指针即实例化对象d的地址。

通过上面的探讨我们可以得出关于this指针的以下结论:

①this指针的指向问题:哪个实例化后对象调用了对象中的成员函数,this就指向这个实例化的对象。

②this指针只能在成员函数的内部使用,this指针是成员函数第一个隐含的指针形参,在vs编译器中通过ecx寄存器自动传递(this指针使用率高将this指针放在寄存器中提高效率),不需要用户传递。当对象调用成员函数时,将对象的地址作为实参传递给this形参。(对象中不存储this指针)

③this指针指向的地址在成员函数中不能被改变,因此this指针的类型实质上是类的类型* const。

3.2this指针可以为空吗

观察下面两段代码:

4.类的默认成员函数

空类:如果一个类中什么成员都没有,简称为空类。

默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。对于一个空类编译器会默认生成以下6个默认成员函数。

C++11以后还会增加两个默认成员函数,移动构造函数和移动赋值函数。

4.1构造函数

观察下面代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
private:
	int _year;
	int _month;
	int _day;
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
};

int main()
{
	Date d1;
	d1.Init(2026, 2, 10);
	d1.Print();

	Date d2;
	d2.Init(2026, 2, 11);
	d2.Print();
	return 0;
}

对于上面的日期类的代码:可以通过Init公有的成员方法给对象设置日期,但如果每次创建对象时都调用该方法设置信息,未免有点麻烦,那能否在创建对象时,直接将信息设置进去呢?构造函数可以应用到这个场景。

构造函数 是一种特殊的成员函数,用于在创建对象时初始化对象的成员变量。

构造函数 分为无参构造函数有参构造函数

构造函数的特点:

特点①:函数名和类名相同。

特点②:构造函数没有返回值。(不需要写void)

特点③:创建类的实例化对象时由编译器自动调用构造函数以使每个成员变量有一个合适的初始值,并且在对象的整个生命周期中只调用一次。

特点④:构造函数可以进行重载。可以通过给参数或者对参数赋予缺省值来实现重载。

示例代码:

注意:通过无参构造函数创建对象时,对象后面不用加括号,否则就变成了函数声明。比如:Date d3();表示声明了一个函数,这个函数的返回值类型时Date型,该函数无参。

特点⑤:当用户没有显示定义构造函数时,编译器会自动生成一个无参构造函数,这是默认构造函数的一种。如果类中一旦显式定义了一个或多个构造函数,那么编译器就不再默认生成无参构造函数。但是,如果没有定义无参数的构造函数,在创建对象时如果需要调用无参数的构造函数,就会导致编译错误。

**默认构造函数:**如果一个类没有显式地定义任何构造函数,那么编译器会自动生成一个默认构造函数。这个默认构造函数没有参数,它的作用是在创建对象时进行一些基本的初始化操作。

示例代码:

特点⑥:

如果没有自定义构造函数,编译器自动生成无参的构造函数对成员变量会初始化成多少呢?

由上图代码可得:vs编译器默认生成的构造函数对内置类型成员变量没有进行初始化。

特点⑥:编译器默认生成的构造函数,对内置类型的成员变量初始化没有要求,也就是说是否初始化是不确定的,取决于编译器。对于自定义类型的成员变量,编译器会调用这个成员变量的默认构造函数进行初始化。

**内置数据类型:**也称为原生类型或原始类型,是由编程语言的运行时环境直接支持的类型。这些类型通常是语言规范的一部分,不需要开发者额外定义。内置数据类型有:int , float , double , char , bool , 指针 , 引用 , 数组。

**自定义数据类型:**是根据需要定义的类型。它们可以基于内置类型来构建更复杂的数据结构。比如我们使用class或者struct等关键字自己定义的类型。自定义数据类型有:结构体(struct),类(class),枚举(enum),联合(union),接口(interface),泛型(generics)。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Stack
{
public:
	Stack(int n = 4)
	{
		_p = (int*)malloc(sizeof(int) * n);
		if (_p == NULL)
		{
			perror("malloc");
			return;
		}
		_capacity = n;
		_top = 0;
	}
private:
	int* _p;
	int _capacity;
	int _top;
};

class MyQueue
{
public:

private:
	Stack pushst;
	Stack popst;
};

int main()
{
	MyQueue mq;
	return 0;
}

从监视窗口中可以得出结论:对于自定义类型的成员变量,编译器会调用这个成员变量的默认构造函数进行初始化。

注意:C++11中针对内置类型成员不初始化的缺陷,又打了补丁,即内置类型成员变量在类中声明时可以给默认值。示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Stack
{
public:
	Stack(int n = 4)
	{
		_p = (int*)malloc(sizeof(int) * n);
		if (_p == NULL)
		{
			perror("malloc");
			return;
		}
	}
private:
	int* _p;
	/*内置类型成员变量在类中声明时可以给默认值。*/
	int _capacity = 4;
	int _top = 0;
};

class MyQueue
{
public:

private:
	Stack pushst;
	Stack popst;
};

int main()
{
	MyQueue mq;
	return 0;
}

特点⑦:无参的构造函数和全缺省的构造函数都称为默认构造函数,默认构造函数只能有一个。 注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,但是这三种函数有且只有一个存在,不能同时存在。

无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。因为当创建对象不传实参时,编译器不知道调用的是无参构造函数还是全缺省构造函数。

4.2析构函数

通过构造函数,我们知道一个对象是怎么来的,那一个对象又是怎么没的呢?

析构函数:析构函数用于在对象生命周期结束时执行清理工作。它与构造函数相对应,构造函数用于初始化对象,而析构函数用于销毁对象。

析构函数的特点:

特点①:析构函数的名称与类名相同,前面加上波浪线(~)。

特点②:析构函数没有返回值类型,也没有参数。(这里跟构造类似,也不需要加void)

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

特点④:析构函数不能重载。

特点⑤:析构函数被自动调用的时机: 当对象超出其作用域(如函数结束、局部变量被销毁)、使用 delete 运算符释放动态分配的对象或者程序结束时,析构函数会自动被调用。

对象超出作用域(函数结束)被调用:

程序结束时被调用:

特点⑥:析构函数不能被继承,但可以被覆盖。

特点⑦:跟构造函数类似,我们不显式的写析构函数,编译器自动生成的析构函数对内置类型成员不做处理(编译器会自动清理),自定类型成员一定会调用它们自己的析构函数。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Stack
{
public:
	Stack(int n = 4)
	{
		_p = (int*)malloc(sizeof(int) * n);
		if (_p == NULL)
		{
			perror("malloc");
			return;
		}
		_capacity = n;
		_top = 0;
	}
	~Stack()
	{
		cout << "~Stack();" << endl;
	}
private:
	int* _p;
	int _capacity;
	int _top;
};

class MyQueue
{
public:
	~MyQueue()
	{
		cout << "~MyQueue();" << endl;
	}
private:
	Stack pushst;
	Stack popst;
};

int main()
{
	MyQueue mq;
	return 0;
}

注意:自定义类型成员无论什么情况都会自动调用析构函数。

特点⑧:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数;如果默认生成的析构就可以用,也就不需要显示写析构,如MyQueue;但是有资源申请时,一定要自己写析构,否则会造成资源泄漏,如Stack。

cpp 复制代码
#include<iostream>
using namespace std;

class Stack
{
public:
	Stack(int n = 4)
	{
		_p = (int*)malloc(sizeof(int) * n);
		if (_p == NULL)
		{
			perror("malloc");
			return;
		}
		_capacity = n;
		_top = 0;
	}
	~Stack()
	{
		free(_p);
		_p = nullptr;
		_capacity = 0;
		_top = 0;
		cout << "~Stack();" << endl;
	}
private:
	int* _p;
	int _capacity;
	int _top;
};

class MyQueue
{
public:

private:
	Stack pushst;
	Stack popst;
};

int main()
{
	MyQueue mq;
	return 0;
}

当对象st的生命周期结束时自动调用析构函数,因为Stack对象中有资源申请,所以一定要自己写析构函数,将申请的资源释放掉(使用free函数进行释放),再将指针_a指向空(nullptr)。

特点⑨:对于一个局部域的实例化的多个对象,C++规定后定义的对象先调用析构函数。

cpp 复制代码
#include<iostream>
using namespace std;

class Stack
{
public:
	Stack(int n = 4)
	{
		_p = (int*)malloc(sizeof(int) * n);
		if (_p == NULL)
		{
			perror("malloc");
			return;
		}
		_capacity = n;
		_top = 0;
	}
	void Print()
	{
		cout << "Stack.Print()" << endl;
	}
	~Stack()
	{
		cout << this->_p << " ~Stack();" << endl;
		free(_p);
		_p = nullptr;
		_capacity = 0;
		_top = 0;
	}
private:
	int* _p;
	int _capacity;
	int _top;
};

int main()
{
	Stack st1;
	st1.Print();

	Stack st2;
	st2.Print();
	return 0;
}

4.3拷贝构造函数

在创建对象时,能否创建一个和已经存在的对象一模一样的新对象呢?

拷贝构造函数用于创建一个新的对象,这个新的对象是另一个和它同类型对象的副本(拷贝)。

拷贝构造函数的特点

特点①:拷贝构造函数的第一个参数必须是自身类类型对象的引用,如果不使用传引用方式而使用传值方式编译器直接报错,因为会引发无穷递归调用。此外拷贝构造函数参数中通常用 const 修饰引用,原因是为了防止拷贝构造函数修改原始对象。

使用传值方式引发无穷递归调用的原因:

使用传引用的方式赋值的过程:

拷贝构造函数的参数需要使用const修饰的原因:

使用const修饰拷贝构造函数的参数可以让拷贝构造函数处理更广泛的情况,当一个对象是非常量修饰时也可以使用常量的形式来引用,但是当一个对象是被const修饰时,就只能使用常量的形式来引用,如果使用非常量的形式来引用时就会造成权限放大,导致编译错误。综上所述,当使用const修饰拷贝构造函数的参数时,就可以顺利地处理这种情况,实现基于常量引用对象的拷贝操作。

特点②: 拷贝构造函数是构造函数的一个重载形式。

特点③:拷贝构造函数也可以有多个参数,但是第一个参数必须是类类型对象的引用,后面的参数也必须有缺省值。

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date()
	{
		_year = 2026;
		_month = 2;
		_day = 11;
	}
	Date(const Date& d, int i = 10)
	{
		_year = d._year;
		_month = d._month;
		_day = i;/*副本和原始的有一些差别时应用*/
	}

	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

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

int main()
{
	Date d1;
	Date d2(d1);
	d1.Print();
	d2.Print();
	return 0;
}

特点④:如果未显式定义拷贝构造函数,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝(也就是浅拷贝:一个字节一个字节的拷贝),对自定义类型成员变量会调用它自己的拷贝构造函数。

**浅拷贝(Shallow Copy)**是对象拷贝的一种形式,它指的是仅仅复制对象的外层数据,而不复制对象内部指向的动态分配的内存。浅拷贝只复制对象本身,而不复制对象内部的指针指向的数据。如果类的成员变量全是内置类型则使用浅拷贝就足够了。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 0, int month = 0, int day = 0)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

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

int main()
{
	Date d1(2026,2,11);
	Date d2(d1);
	d1.Print();
	d2.Print();
	return 0;
}

调用编译器自动生成的拷贝构造函数对Date类的内置类型成员变量完成了浅拷贝。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 0, int month = 0, int day = 0)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << " ";
	}

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

class Time
{
public:
	Time(const Date& d,int hour, int minute, int second)
	{
		_d = d;
		_hour = hour;
		_minute = minute;
		_second = second;
	}
	void Print()
	{
		_d.Print();
		cout << _hour << ":" << _minute << ":" << _second << endl;
	}
private:
	Date _d;
	int _hour;
	int _minute;
	int _second;
};

int main()
{
	Date d1(2026,2,11);
	Date d2(d1);
	
	Time t1(d1, 5, 2, 0);
	Time t2 = t1;

	t1.Print();
	t2.Print();
	return 0;
}

上面代码中自定义类型成员变量_d自动调用了它的拷贝构造函数。

特点⑤:类里面如果涉及资源申请的成员变量需要使用深拷贝。

**深拷贝(Deep Copy)**是对象拷贝的一种形式,它涉及到复制对象及其内部所有指向的动态分配的内存,也就是说:不仅复制对象的外层数据,而且复制对象内部指向的动态分配的内存中的数据到另一块新开辟的内存中。与浅拷贝不同,深拷贝确保新对象和原始对象完全独立,它们拥有自己的内存副本,互不影响。

示例代码:使用编译器自动生成的拷贝构造函数(浅拷贝):

cpp 复制代码
#include<iostream>
using namespace std;

class Stack
{
public:
	Stack(int n = 4)
	{
		_p = (int*)malloc(sizeof(int) * n);
		if (_p == nullptr)
		{
			perror("malloc");
			return;
		}
		_capacity = n;
		_top = 0;
	}
	void Print()
	{
		cout << _p << endl;
	}
private:
	int* _p;
	int _capacity;
	int _top;
};
int main()
{
	Stack s1;
	Stack s2 = s1;
	s1.Print();
	s2.Print();
	return 0;
}

注意:对象s2的_p指针对s1的_p指针进行的是浅拷贝。s1._p和s2._p两个指针指向了同一块内存空间地址,当对两个栈插入数据时,就会造成错误。当s1和s2的析构函数被调用时,因为后定义的先析构,所以s2._p指向的内存空间先被释放掉了,再调用s1的析构函数时,会导致同一块内存被释放两次,这会引发程序崩溃。

如果对象中涉及资源申请的成员变量,需要自定义拷贝构造函数实现深拷贝:为目标对象中的指针成员分配新的内存,并将源对象指针所指向的数据复制到新分配的内存中。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Stack
{
public:
	Stack()
	{

	}
	Stack(int n)
	{
		_p = (int*)malloc(sizeof(int) * n);
		if (_p == nullptr)
		{
			perror("malloc");
			return;
		}
		_capacity = n;
		_top = 0;
	}
	Stack(const Stack& st)
	{
		_p = (int*)malloc(st._capacity * sizeof(int));
		if (_p == nullptr)
		{
			perror("malloc");
			return;
		}
		memcpy(_p, st._p, st._top * sizeof(int));
		_capacity = st._capacity;
		_top = st._top;
	}
	void Push(int x)
	{
		if (_top == _capacity)
		{
			int _newcapacity = _capacity * 2;
			int* _newp = (int*)realloc(_p, _newcapacity * sizeof(int));
			if (_newp == nullptr)
			{
				perror("realloc");
				return;
			}
			_p = _newp;
			_capacity = _newcapacity;
		}
		_p[_top++] = x;
	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		_p = nullptr;
		_capacity = 0;
		_top = 0;
	}

private:
	int* _p;
	int _capacity;
	int _top;
};

class MyQueue
{
public:

private:
	Stack pushst;
	Stack popst;
};

int main()
{
	Stack st1(4);
	st1.Push(1);
	st1.Push(2);
	st1.Push(3);
	st1.Push(4);
	st1.Push(5);

	Stack st2 = st1;

	MyQueue mq1;
	MyQueue mq2 = mq1;

	return 0;
}

上面代码的调试结果:

根据调试结果可得st1._p和st2._p指向两个不同的内存空间,当调用它们的析构函数时,就避免了同一块空间被释放两次的风险。

注意:上面代码的MyQueue mq1;会自动调用MyQueue的默认构造函数,但是对于自定义类型成员会直接调用自定义成员的默认构造函数即Stack(){}。此外,MyQueue mq2 = mq1;MyQueue调用它自动生成的拷贝构造,对于其中自定义成员变量pushst和popst,会调用他们自己的拷贝构造,进调用Stack拷贝构造完成pushst和popst的拷贝,只要Stack拷贝构造函数自己实现了深拷贝,就不会出错。

技巧:如果一个类显示实现了析构并释放资源,那么就需要显示写拷贝构造函数实现自定义的深拷贝,否则就不需要。

特点⑥:类的实例化对象作为函数的返回值返回时:

①如果用传值方式返回,会先在函数内部创建一个临时对象,这个临时对象可能是要返回的对象通过拷贝构造函数创建的,然后再将这个临时对象拷贝到main函数中的接收对象中。

②如果用传引用方式返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,此时的引用相当于一个野引用(类似野指针)。传引用方式返回对象类型的数据可以减少拷贝,但要确保返回的对象在当前函数结束后还在,才能用引用返回。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date() {}
	Date(int year = 2026, int month = 2, int day = 12)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

Date& func()
{
	Date d1(2026, 2, 13);
	return d1;
}

int main()
{
	Date d2 = func();
	return 0;
}

func函数以传引用的方式返回一个函数中局部对象的引用,由于引用必须绑定到一个已经存在的对象上,所以这种返回值是局部对象的引用是不安全的,因为当func函数结束时,局部对象d1的生命周期就结束了,返回的引用指向了一个未定义的值。

返回值优化机制RVO:

如果使用传值方式返回,编译器在很多情况下会进行 RVO 操作。RVO 的目的是避免这些不必要的拷贝操作。编译器会直接在main函数中d2的内存位置上构造func函数中的d2对象,这样就跳过了中间的临时对象创建和拷贝过程。

特点⑦:拷贝构造函数通常在以下情况下被调用:

1、函数参数类型为类类型对象。

2、函数返回值类型为类类型对象。

3、当一个对象需要被初始化为另一个已经存在的对象时。

为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。

5.赋值运算符重载

5.1运算符重载

当运算符被用于类实例化出的对象时,C++允许通过运算符重载 的形式指定新的含义。C++规定类实例化出的对象使用运算符时,必须转换成调用对应运算符重载 ,如果没有对应的运算符重载,则会编译报错。

运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字,参数列表和函数体。其返回值类型、参数列表、函数体与普通的函数类似。但是函数名是由operator和后面要定义的运算符共同构成。

示例代码:下面代码演示了==运算符重载的一般形式:

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

运算符重载的特性:

特性①:重载运算符函数的参数个数和该运算符作用的运算对象的数量一样多。比如:一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。

特性②:如果一个重载运算符函数是成员函数,则它的第一个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	bool operator==(const Date& d2)
	{
		return 
			_year == d2._year &&
			_month == d2._month &&
			_day == d2._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

特性③:不能通过连接语法中没有的符号来创建新的操作符:比如operator@。

特性④:运算符重载后,其优先级和结合性与对应的内置类型运算符保持一致。比如:对于内置类型运算符来说,* 的优先级比 + 的优先级高(a+b*c),当运算符重载以后,其优先级和结合性依然与内置类型运算符保持一致。

特性⑤:.* :: sizeof ?: .以上五个运算符不能进行运算符重载。

.* 表示成员指针运算符 ,它用于访问类成员指针所指向的成员变量或成员函数

扩展:成员指针运算符 .* 和 ->*

成员指针是指向一个类中某个成员的指针而不是对象中成员的特定实例。成员指针并不是真正的指针,它只是成员在对象中的偏移量,如果你有一个对象和一个指向该对象成员的指针,你可以使用 .* 来解引用并访问该成员。示例代码:

在上面程序中,创建了两个成员指针 dp 和 fp 。其中 dp 指向了成员变量 sum ,fp 指向了函数 func() 。需要注意指针的声明语法:在声明中使用了作用域解析运算符来指定指针指向的成员属于那个类。当使用对象或对象引用来访问对象的成员时,必须使用 .* 运算符,如程序中的 c.*fp 和 c.*dp 这种用法。如果使用指向对象的指针来访问对象的成员,那么必须使用

->* 运算符,如下程序示例:

上面程序中,变量d是指向 MyClass 类型对象的指针,所以应该使用->*运算符来访问sum和func()。成员指针是为了处理特殊情况而设计,在一般程序设计中通常不需要用到他们。

:: 是作用域解析运算符,用于指定名称(如变量、函数或类成员)在全局作用域或命名空间中。例如,std::vector 使用 :: 来指定 vector 是 std 命名空间的一部分。

?: 这是一个三目运算符,其语法格式为表达式1?表达式2:表达式3。先计算表达式1的值,如果表达式1的值为真(非零),则整个条件运算符的值为表达式2的值;如果表达式1的值为假(零),则整个条件运算符的值为表达式3的值。

特性⑥:如果一个运算符重载函数放到全局中实现,但是类中的成员变量是私有的,不能在类外部访问,下面是几种解决这个问题的方法:

1.直接将成员变量的访问权限设置为公有。

2.在类中提供get()成员函数(相当于一个接口)来访问成员变量,在类的外部通过调用对应的成员函数来访问成员变量。

3.使用友元函数。在类中声明该运算符重载函数是友元函数,这样该运算符重载函数就可以在类的外部访问私有的成员变量。示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 0, int month = 0, int day = 0)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	friend bool operator==(const Date& d1, const Date& d2);

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

bool operator==(const Date& d1, const Date& d2)
{
	return
		d1._year == d2._year &&
		d1._month == d2._month &&
		d1._day == d2._day;
}

int main()
{
	Date d1(2026, 2, 14);
	Date d2(2026, 2, 13);
	operator==(d1, d2); //运算符重载函数可以显示调用
	d1 == d2; //编译器会转换成 operator==(d1, d2);
	return 0;
}

4.和之前一样将运算符重载函数重载为成员函数。

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 0, int month = 0, int day = 0)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	bool operator==(const Date& d2)
	{
		return
			_year == d2._year &&
			_month == d2._month &&
			_day == d2._day;
	}

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

int main()
{
	Date d1(2026, 2, 14);
	Date d2(2026, 2, 13);
	d1.operator==(d2); //运算符重载函数显示调用
	d1 == d2; //编译器会转换成 d1.operator==(d2);
	return 0;
}

特点⑦:在重载++运算符的时候,分为前置++和后置++,运算符重载函数名都是operator++,无法很好地区分。因此C++规定,在后置++重载时,需要增加一个int形参,跟前置++构成函数重载,便于区分。示例代码:

cpp 复制代码
class Date
{
public:
	Date(int year = 0, int month = 0, int day = 0)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date& operator+(int x)
	{
		return *this + x;
	}
	Date& operator++()
	{
		*this =*this + 1;
		return *this;
	}
	Date& operator++(int x)
	{
		Date tmp(*this);
		*this =*this + 1;
		return *this;
	}

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

5.2赋值运算符重载

赋值运算符重载是一个默认成员函数,用于完成两个已存在的对象直接的拷贝赋值。这里需要注意和拷贝构造的区别,拷贝构造用于一个对象拷贝初始化给另一个要创建的对象。

示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year = 0, int month = 0, int day = 0)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	void operator=(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

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

int main()
{
	Date d1(2026, 2, 14);
	Date d2(2026, 2, 13);
	d1 = d2;
	d1.Print();
	return 0;
}

使用赋值运算符重载进行连续赋值的方法:在赋值运算符重载函数最后设置返回值以支持连续赋值。示例代码:

赋值运算符重载的特性:

特点①:有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景。

特点②:没有显式实现时,编译器会生成一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。

特点③:如果类中的成员变量没有涉及资源申请,则编译器自动生成的默认赋值运算符重载就可以实现。但是如果类中的成员变量涉及资源申请,编译器自动生成的赋值运算符重载完成的浅拷贝不符合我们的需求(防止不同类的成员变量指向同一块内存空间),所以需要我们显示实现该类的赋值运算符重载。

技巧:如果一个类显示实现了析构并释放资源,那么它就需要显示写赋值运算符重载,否则就不需要。

5.3流运算符重载

流运算符重载允许用户自定义类型能够像内置类型一样使用这些流运算符进行输入和输出操作。

在 C++ 中,流运算符是<<和>>,通常用于标准输入输出流对象(如cin和cout),<<运算符用于输出,被称为插入运算符;>>运算符用于输入,被称为提取运算符。

在重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,调用时就变成了 对象<<cout,不符合使用习惯和可读性。重载为全局函数时只需把ostream/istream放到第一个形参位置就可以了,第二个形参为类类型对象。当重载为全局函数时,因为类的成员变量是私有的,不能在类的外部直接访问类对象的成员变量,所以通常将流运算符重载函数声明为类的友元函数,这样可以直接访问类的私有成员。如果不使用友元函数,需要提供公共的访问函数来获取类的成员数据。

示例代码:

注意:

输出流运算符(<<)重载函数的第二个形参Date类对象用const修饰,但是输入流运算符(>>)重载函数的第二个形参Date类对象能用const修饰的原因:

在输出操作中,我们只是从Date类对象中获取数据进行输出,并不需要修改Date类对象的内容。使用const可以确保在函数内部不会意外地修改Date类对象,同时也允许传递const和非const的Date类对象给这个重载函数。

但是在输入操作中,我们需要将从输入流中读取的数据存储到Date类对象中,这必然涉及到对Date类对象的修改,如果将其声明为const,那么在函数内部就无法对Date类对象进行赋值等修改操作,导致无法实现从输入流读取数据并填充Date对象的功能。
最后本文主要和大家说再见了,这篇文章作者花费大量时间根据自己的思考编写,主要介绍了C++中的数据类型、面向对象概念和类的基本特性。内容涵盖:1)数据类型分为内置类型和自定义类型;2)面向过程与面向对象的区别;3)结构体与类的区别,类的封装思想;4)类的默认成员函数(构造函数、析构函数、拷贝构造函数等);5)运算符重载,包括赋值运算符和流运算符的重载。重点阐述了类的封装特性、this指针原理、深浅拷贝区别,以及如何通过成员函数和运算符重载实现类的功能。

相关推荐
三水彡彡彡彡2 小时前
深入理解指针:常量、函数与数组
c++·学习
你好!蒋韦杰-(烟雨平生)2 小时前
Opengl模拟水面
c++·游戏·3d
Rhystt2 小时前
代码随想录第二十六天|669. 修剪二叉搜索树、108.将有序数组转换为二叉搜索树、538.把二叉搜索树转换为累加树
数据结构·c++·算法·leetcode
不染尘.2 小时前
字符串哈希
开发语言·数据结构·c++·算法·哈希算法
今儿敲了吗2 小时前
25| 丢手绢
数据结构·c++·笔记·学习·算法
卷卷的小趴菜学编程2 小时前
项目篇----C++ AI大模型接入SDK->API获取与测试
c++·ai·api·apifox·deepseek
浅念-2 小时前
C++ STL stack、queue 与容器适配器详解
开发语言·c++·经验分享·笔记·学习·面试
0 0 03 小时前
CCF-CSP 32-2 因子化简(prime)【C++】考点:素数因子分解(试除法)
开发语言·数据结构·c++·算法
仰泳的熊猫3 小时前
题目1545:蓝桥杯算法提高VIP-现代诗如蚯蚓
数据结构·c++·算法·蓝桥杯