C++(二)类和对象上篇

1. 类与对象的概念

C语言是面向过程(功能)的语言,注重解决问题的过程、步骤;C++是面向对象的语言,注重对象之间的关系及其交互,面向对象是比面向功能更高级的开发方式,像所熟知的Java,C#,python都是面向对象的语言;其实C++最早的别名是C with classes,主要做的改进就是加入类(class)和对象(object),将现实世界类和对象映射到虚拟计算机系统,比如我们在等待外卖的时候,可以看到地图上的骑手距我们还有多远,骑手是一类对象,用户是一类对象,一个类可以实例化出很多对象,注重对象之间的关系,如下图所示。

其实类的思想是封装,我们来看一下为什么要提出封装的概念;如下图所示,在C语言中变量和函数是分离的,我们提供了接口供使用者调用,但是对于取栈顶元素这个函数,有的人可能感觉就一句代码我直接就写了,还调什么函数啊,然后就出现了下面的代码,因为不知道top究竟是指向栈顶元素还是栈顶元素的下一个位置,不知道底层实现,在这里乱用,很危险。

c 复制代码
int main() {
	ST st;
	STInit(&st);
	int top = st.a[top];
	return 0;
}

1.1 类的定义与实例化

于是C++提出将变量和函数封装到一起,封装的思想是规范的管理;用访问限定符(public, private, protected)来限定类外对类内成员变量、成员函数的访问;一般情况下,变量是私有,用户不能访问,函数是公有,用户可以使用,在这种情况下,用户只能调用已有方法,不能随意访问成员变量(又称属性);类里面可以定义成员变量和成员函数,成员变量一般是私有,成员函数一般是公有,类的定义和使用(实例化)举例如下

cpp 复制代码
//此处class也可为struct
class Stack {
public:
	void Init(int defaultCapacity=4)
	{
		_a = (int*)malloc(sizeof(int) * defaultCapacity);
		_capacity = defaultCapacity);
		_top = 0;
	}
	
	bool Empty()
	{
		return _top == 0;
	}
	
	void Push(int x){
	if (_top == _capacity)
	{
		STDataType* tmp = (int*)realloc(_a, sizeof(int) * _capacity * 2);
		if (tmp == NULL)
		{
			perror("realloc failed");
			return;
		}
		_a = tmp;
		_capacity *= 2;
	}
	_a[_top++] = x;
	}
	
	int Top()
	{
		assert(!Empty());
		return _a[_top - 1];
	}
	
	void Destroy(){
		free(_a);
		_a = NULL;
		_top = 0;
		_capacity = 0;
	}

private:
	int* _a;
	int _top;//或top_,以区分传进来的参数名
	int _capacity;
};
  
int main() {
	Stack st;//类名可以直接做类型
	st.Init();
	return 0;
}

如下图所示,当我们尝试访问类的私有成员变量时就编不过~

我们看到成员变量是上锁的

现阶段private和protected看作等价,但在继承中有不同,一般不用protected;public, private, protected的作用遇到下一个为止;class中默认私有,struct中默认公有

类一般在头文件定义,在类里定义的函数一般是内联(较长或递归就不是内联,决定权在编译器手里),类里较长的函数可以先声明,在另一个文件定义。

同时也引出类域作为整体这个概念,C/C++可以理解为{ }定义的是一个,域会限制访问,变量搜索优先级(如果是在类里):局部域->类域->全局域,一般认为展开的命名空间和全局域等价,命名空间不展开不会主动去命名空间里找,局部域、全局域会影响生命周期

类实例化出对象,对象才开了空间,对象才能存数据,举个例子,类好似设计图,实例化出来的对象好似根据设计图盖出来的一栋又一栋房子;不能用类访问数据,因为成员变量在类里仅仅做了声明,如下图所示

1.2 类与对象大小计算

对象大小计算,也可以用类来计算;
只计算成员变量的大小,不计算成员函数的大小,因为成员函数是所有实例化出的对象都要使用的,每个对象都要存一次函数太浪费了,所以放在公共区域了,每次调用的时候编译器会自己去找;

1.2.1 有成员变量(内存对齐)

规则如下:内存对齐,变量对齐数为min{变量大小,默认对齐数(VS下为8B)},第一个变量存储位置从0开始计算,每个变量存储位置为自身对齐数的整数倍,整个类的大小为所有对齐数中最大值的整数倍。

举例如下

cpp 复制代码
//VS 64x
int main() {
	Stack st;
	cout << "sizeof(Stack)=" << sizeof(Stack) << endl;
	cout << "sizeof(st)=" << sizeof(st) << endl;
	return 0;
}

输出结果如下

cpp 复制代码
sizeof(Stack)=16
sizeof(st)=16

对齐数计算

存储示意图

如果不内存对齐存放,就会导致读写效率下降,性能有所损失,举例如下

cpp 复制代码
//VS 64x
struct A {
	char c;
	int i;
};

如果不内存对齐,如下图,假设机器字长是4B,每次读写32bits,那么读i这个变量需要读两次,因为不能随意读写,只能从c的位置开始读,并且两次读写都有冗余值,最后还要将两次的冗余值舍掉,将留下的部分按顺序拼接。

如果内存对齐,如下图,访问i只需访问一次即可。

本质是用空间换时间,大多数情况下其实空间是充足的,但是嵌入式场景开发、对时间性能要求没那么高的场景下也可以考虑空间换时间,此时默认对齐数调小一些即可。

1.2.2 无成员变量

无成员变量的情况下类的大小为1,起到占位的作用,不开空间,如何区分实例化的不同对象呢?

1.3 this指针

如上图所示,如果是在C中对栈进行操作的话,我们看到&st这个传参很频繁,C++就给出了this指针的概念,我们在用对象去调用类里的成员函数时不需要传对象的地址,并且是不允许我们传参!由编译器来传,this指针就是对象的指针,是形参,与普通参数一样存放在上,VS2022对指针进行优化,对象的地址存放在寄存器rcx中,如下图所示

举例如下

cpp 复制代码
bool Empty()
{
	return _top == 0;//this->_top等价于_top
}
//编译器会对成员函数做的处理,将所有用到的成员变量_x转化为this->_x
bool Empty(Stack* this)
{
	return this->_top = 0;
}

上图的操作等价于

cpp 复制代码
int main() {
	Stack st;
	st.Init();
	st.Push(1);
	st.Push(2);
	st.Push(3);
	st.Push(4);
	st.Destroy();

	return 0;
}

汇编代码如下,我们可以看到橙色框框住的是成员函数的地址;在调用Push的时候用到了对象的地址。

那么就引申出来,方法不是从对象内部进行访问的,我们来看两道题

问题在于p时空指针,使用空指针是运行时错误,编译没问题,1是正常运行,2是运行崩溃,因为首先我们根据上图看到,成员函数的地址和对象地址没有必然关联,访问成员函数不是从对象内部去访问的,1中我们访问Print的时候只是去特定的地址调用Print,做了输出,自然没问题;2中在调用Print时用到了p指向对象的成员变量,这时候要解引用访问,自然崩溃。

cpp 复制代码
// 1.下面程序编译运行结果是? A.编译报错 B、运行崩溃 C、正常运行
class A
{
public:
    void Print()
    {
        cout << "Print()" << endl;
    }
private:
    int _a;
};

int main()
{
    A* p = nullptr;
    p->Print();
    return 0;
}

// 2.下面程序编译运行结果是? A.编译报错 B、运行崩溃 C、正常运行
class A
{
public:
    void PrintA()
    {
        cout<<_a<<endl;
    }
private:
    int _a;
};

int main()
{
    A* p = nullptr;
    p->PrintA();
    return 0;
}

2. 默认成员函数

有些时候我们可能会忘了初始化和销毁,前者导致随机值,运行错误;后者可能导致内存泄漏亦或者比较繁琐,举例如下(用栈判断括号是否匹配)

于是引入默认成员函数,

2.1 定义

2.2 分类

创建对象、销毁对象不是函数是系统来完成的,局部变量存在依赖于函数栈帧,变量创建和销毁是在创建、销毁函数栈帧时由系统完成的

2.2.1 构造函数

默认成员函数中构造函数的重载和调用说明如下

cpp 复制代码
class Stack {
public:
	Stack(int defaultCapacity = 4) {
		_a = (int*)malloc(sizeof(int) * defaultCapacity);
		_capacity = 4;
		_top = 0;
	}
	Stack(int* a,int n) {
		_a = a;
		_capacity = n;
		_top = 0;
	}
	
	...
};
  
int main() {
	int a[] = {1, 2, 3, 4, 5};
	Stack st1;//在实例化时自动调用默认构造函数
	Stack st2(a, 5); //用数组a初始化栈
	//Stack st(); 不能这样做这样传参的问题在于无法区分函数声明
	return 0;
}

没有写构造函数,系统自动生成构造函数,使用声明的缺省值对内置变量(指针属于内置类型)进行初始化,如下图所示;但是VS2022没有缺省值的情况下,系统生成的默认构造函数给出的初始化结果也是下面这样的

自定义了拷贝构造函数,没有写构造函数,VS2022不会生成构造函数

构造函数是否需要定义举例说明如下

2.2.2 析构函数

手动释放内存举例如下

cpp 复制代码
~Stack() {
	free(_a);//如果不进行处理,导致_a指向的堆内存 "无人认领",既无法被程序再次使用,也不会被系统回收,最终造成内存泄漏(内存占用越来越多,直到程序结束)
	_a = NULL;
	_top = 0;
	_capacity = 0;
}

2.2.3 拷贝构造函数

是构造函数的一个重载形式

2.2.3.1 传参

传引用,如果传值,会导致无限递归,但是编译器一般都会检查,编不过

我们来分析一下为什么会导致无限递归

接着就是无休无止的Stack st(st1);

以及形参一般都是const类型,我们形参设置,如果传过来的参数只进行读,不进行写,那就一般考虑const修饰,防止一个不留神就把它改了,如下图所示

2.2.3.2 深拷贝与浅拷贝

接下来我们探讨一下浅拷贝与深拷贝,拷贝构造函数一般原则是浅拷贝,内置类型变量只拷贝,自定义变量调用对应的拷贝构造函数

cpp 复制代码
int main() {
	Stack st1;
	Stack st2(st1);

	return 0;
}

我们不定义拷贝构造函数,使用系统生成的拷贝构造函数,此时是浅拷贝,st1._a直接赋给了st2._a,两次析构会出问题,如下图所示

最终要达到的目的是,动态开辟空间的数组内容拷贝过来,但是地址不一致,因为一方面,st2会先被析构,此时释放_a指向的空间,如果st2是浅拷贝,将st1._a赋给st2._a,那么析构st1的时候还会释放_a,访问野指针;其次,如果修改st1,也会影响st2,这不是我们想看到的;而且析构的时候,可能会将st2置为nullptr,VS2022调试监控窗口如下

我们想看到的效果,举例如下

拷贝构造如下

cpp 复制代码
Stack(const Stack& st) {
	//类内可以访问该类的私有变量
	_a = (int*)malloc(sizeof(int) * st._capacity);
	_capacity = st._capacity;
	_top = st._top;
	memcpy(_a, st._a, sizeof(int) * st._capacity);
}

所以拷贝构造一般情况下如果有动态开辟内存,就自行写拷贝构造,如果没有动态开辟,而且包含自定义类型都写了符合需求的拷贝构造,就不需要写拷贝构造,编译器生成就够

因为有些自定义类型拷贝代价较大,比如上面要拷贝数组,所以在传参的时候一般考虑传引用,除非是非法行为,比如返回局部变量的引用;正确性和效率取正确性,方向错了,跑再快有什么用

2.2.2.4 赋值运算符重载

2.2.2.4.1 运算符重载

运算符重载的目的是让自定义类型能够像内置类型一样去使用操作符进行操作;

自定义类型一般都无法直接使用运算符进行操作,可以理解为结构体无法直接比大小,假如我们在类内写下面的函数来比大小,但是问题是可能有命名不规范问题,写成fun1, fun2等无法很好区分,举例如下

cpp 复制代码
bool less1(const Date& d1, const Date& d2) {
	if (d1._year < d2._year)
		return true;
	else if (d1._year == d2._year && d1._month < d2._month)
		return true;
	else if (d1._year == d2._year && d1._month == d2._month && d1._day < d2._day)
		return true;
	else
		return false;
}

bool greater1(const Date& d1, const Date& d2) {
	if (d1._year > d2._year)
		return true;
	else if (d1._year == d2._year && d1._month > d2._month)
		return true;
	else if (d1._year == d2._year && d1._month == d2._month && d1._day > d2._day)
		return true;
	else
		return false;
}

提出运算符重载,运算符重载操作数等于形参数,假设我们先在全局定义,在类外操作数等于形参数(在类外访问类内private变量的问题,可以先将类内变量置为public用于测试)

注意:

  1. 不能通过重载构建新的运算符,只能重载已有的操作符
  2. 运算符重载,如果是赋值,必须写在类内,因为是默认成员函数,如果不写在类内,类会自动生成,和全局定义的区分不开;其它运算符重载在全局和类内都可以,但是要考虑private.
  3. 操作数至少有一个是自定义类型,如果都是内置类型,要改变编译器自身的操作数原则吗,不可以
  4. 五个不能重载:.* sizeof :: ?: .(笔试常考查,用*做干扰选项)
cpp 复制代码
bool operator<(const Date& d1, const Date& d2){
	if (d1._year < d2._year)
		return true;
	else if (d1._year == d2._year && d1._month < d2._month)
		return true;
	else if (d1._year == d2._year && d1._month == d2._month && d1._day < d2._day)
		return true;
	else
		return false;
}

int main() {
	Date d1(2026, 1, 10);
	Date d2(2025, 12, 31);
	operator<(d1,d2); //等价于(d1<d2),因为<<优先级高于<,所以加括号
	d1 < d2;
	return 0;
}

汇编代码operator<(d1,d2)等价于d1<d2,如下图所示

但是在类内有一个隐性参数就是this指针,所以我们手动传的形参数要比操作数少一个,在类内定义

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

bool Date::operator<(const Date& d) const {
	if (_year < d._year)
		return true;
	else if (_year == d._year && _month < d._month)
		return true;
	else if (_year == d._year && _month == d._month && _day < d._day)
		return true;
	else
		return false;
}

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

bool Date::operator>(const Date& d)const {//复用的思想,适用于所有自定义类型
	return !(*this < d || *this == d);
}

bool Date::operator>=(const Date& d) const {
	return !(*this < d);
}

bool Date::operator<=(const Date& d)const {
	return *this < d || *this == d;
}

bool Date::operator!=(const Date& d) const{
	return !(*this == d);
}

汇编代码d1.operator<(d2)等价于d1<d2,如下图所示

2.2.2.4.2 赋值运算符重载

如果是系统生成的赋值运算符重载,处理规则和拷贝构造函数有些类似,内置变量浅拷贝,自定义变量调用对应的赋值运算符重载函数;

所以赋值运算符重载一般情况下如果有动态开辟内存,就自行写赋值运算符重载,如果没有动态开辟,而且包含自定义类型都写了符合需求的赋值运算符重载,就不需要写赋值运算符重载,编译器生成就够

举例如下,下面代码的效果和系统生成的效果一致

cpp 复制代码
Date& operator=(const Date d)const {//注意这个地方返回的是Date&,因为有些时候可能用到连=
	_year = d._year;
	_month = d._month;
	_day = d._day;
	return *this;
}

int main() {
	int i,j, k = 1;
	i = j = k;//j=k,把k的值赋给j,并且这个赋值表达式的返回值是j,接着把这个返回值赋给i

	Date d1(2026, 12, 31);
	Date d2(2025, 12, 31);
	Date d3;
	d2 = d3 =d1;

	return 0;
}

拷贝构造和赋值的区别,写=不一定是赋值,从定义出发

  1. 拷贝构造是用一个变量初始化另一个变量,因为拷贝构造就是构造的一种函数重载,构造就是用来初始化的;
  2. 而赋值是将一个变量的值拷贝给一个已经存在的变量;

下面是拷贝构造,不是赋值

cpp 复制代码
int main() {
	Date d1;
	
	Date d2;
	d2 = d1;//等价于Date d2(d1);

	return 0;
}
2.2.2.4.3 日期实例运算符重载
2.2.2.4.3.1 日期+/-天数 日期-日期

运算符重载,以实际意义为中心,日期加日期没有意义,但日期+天数、日期-天数、日期-日期有意义;

cpp 复制代码
int Date::GetMonthDay(int year, int month) {
	static int days[] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };//优化1:因为后续GetMonthDay会被频繁调用,所以此处构造局部静态数组
	if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0))//如果&&前后判断颠倒,先判闰年,结果month不是2,没意义还消耗时间,所以先判month是否为2
		return 29;
	return days[month];
}

Date& Date::operator+=(int day) {
	if (day < 0)
		return *this -= -day;//综合考虑day的情况,如果d<0,就转化为-= -day;如果d>0,要处理的非法日期就是>该月份天数
	_day += day;
	while (_day > GetMonthDay(_year, _month)) {
		_day -= GetMonthDay(_year, _month);
		_month++;
		if (_month == 13) {
			_year++;
			_month = 1;
		}
	}
	return *this;
}

下面const修饰的是this指向的变量,因为涉及到权限的问题,如果此处没有const,如果是const Date类型的d进行+,其指针没办法赋给* this,因为*this是Date,权限放大,权限可以平移、缩小,不能放大,所以此处用const,无论是const Date还是Date都可以接收,而且符合规则,防止误改;一般不需修改的都要加const,防止权限扩大以及误改;

这个const放末尾,因为this是不可以显式传参的,只能这样做来标明

const Date* p, Date const* p,不能修改的是p内容(相当于const修饰*p,p是指针, * p就是指向的内容);Date * const p不能修改的是p本身(const修饰指针p,指针不能改)

但是this指针的类型是Date* const this,当所指向的变量也加const,this的类型就是const Date* const this

cpp 复制代码
Date Date::operator+(int day)const {
	Date tmp = *this;
	tmp += day;
	return tmp;
}

Date& Date::operator++() {//前置 ++i
	*this += 1;
	return *this;
}

Date Date::operator++(int) {//后置 i++
	Date tmp(*this);
	*this += 1;
	return tmp;
}

Date& Date::operator--() {//前置 --i
	*this -= 1;
	return *this;
}

Date Date::operator--(int) {//后置 i--
	Date tmp(*this);
	*this -= 1;
	return tmp;
}

Date& Date::operator-=(int day) {
	if (day < 0) {
		return *this += -day;
	}
	_day -= day;
	while (_day <= 0) {
		--_month;
		if(_month==0){
			_year--;
			_month = 12;
		}
		_day += GetMonthDay(_year, _month);
	}
	return *this;
}

Date Date::operator-(int day)const {//日期-天数
	Date tmp(*this);
	tmp -= day;
	return tmp;
}

int Date::operator-(const Date& d)const {//日期-日期
	Date max = *this;
	Date min = d;
	int flag = 1;
	if (*this < d) {
		max = d;
		min = *this;
		flag = -1;
	}
	int n = 0;
	while (min !=max) {//比<的代价小一些
		n++;
		min++;
	}
	return n * flag;
}

使用函数重载来实现相同运算符不同功能的重载,学以致用~

我们来看拷贝消耗,如下图所示,更推荐先实现+=,再复用+=实现+

日期-日期,可以先通过对对齐年,再对齐月、日;比如2023/3/1-2020/1/1,先将2020/1/1调整到2023/1/1,中间需要特殊处理的是闰年;然后计算2023/1/1到2023/3/1相差的天数,正的+,负的-;但是上面不使用这种方法,一方面上面的代码简单易读,而且这种计算在computer面前就是小case.

2.2.2.4.3.2 cout, cin重载

之前我们提到过cout是可以自动识别类型的,查C++的标准库可以看到是函数重载实现的,如下图;

现在我们考虑实现一个自定义类型的cout,如果是下面这样,每次调用是d.Print();

cpp 复制代码
void Date::Print() {
	cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
cpp 复制代码
//类内声明
ostream& operator<<(ostream& out);

//类外定义
ostream& Date::operator<<(ostream& out){
	cout << _year  << "年" << _month << "月" << _day << "日" << endl;
	return out;
}

int main() {
	Date d1(2023,2,4);
	d1 << cout;//因为默认第一个参数是this指针,所以只能这样调用

	return 0;
}

为了使自定义类型使用cout, cin如内置类型一般,需要在全局进行定义,

cpp 复制代码
//类内友元,从而在类外可以访问private变量
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);

//全局声明
ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);

//全局定义
ostream& operator<<(ostream& out, const Date& d){
	cout << d._year  << "年" << d._month << "月" << d._day << "日" << endl;
	return out;//返回值是out,适用于连着打印的场景,比如cout << d1 << d2; cout << d1的返回值作为d2调用的输入
}

istream& operator>>(istream& in, Date& d) {
	in >> d._year >> d._month >> d._day;
	return cin;
}

int main() {
	Date d1(2023,2,4);
	cout << d1;

	return 0;
}

优化

cpp 复制代码
//日期实例一定要注意非法日期问题,有两种情况生成一个日期,一个是初始化,一个是流插入,我们优化这两块如下
Date::Date(int year, int month, int day) {
	if (month > 0 && month <= 12 && day>0 && day <= GetMonthDay(year, month)) {
		_year = year;
		_month = month;
		_day = day;
	}
	else {
		cout << "非法日期" << endl;
		assert(false);
	}	
}

istream& operator>>(istream& in, Date& d) {
	int year, month, day;
	in >> year >> month >> day;
	if (month > 0 && month <= 12 && day > 0 && day <= d.GetMonthDay(year, month)) {//类外用对象调类内函数,此处如果没有对象,要调用GetMonthDay可以加static把其改为静态函数 
		d._year = year;
		d._month = month;
		d._day = day;
	}
	else {
		cout << "非法日期" << endl;
		assert(false);
	}
	return cin;
}

静态函数的优化

cpp 复制代码
//类内声明为静态
static int GetMonthDay(int year,int month);

istream& operator>>(istream& in, Date& d) {
	int year, month, day;
	in >> year >> month >> day;
	if (month > 0 && month <= 12 && day > 0 && day <= Date::GetMonthDay(year, month)) {//类外通过域作用限定符直接调
		d._year = year;
		d._month = month;
		d._day = day;
	}
	else {
		cout << "非法日期" << endl;
		assert(false);
	}
	return cin;
}
相关推荐
fqbqrr8 小时前
2601C++,编译时连接两个串指针
c++
superman超哥8 小时前
双端迭代器(DoubleEndedIterator):Rust双向遍历的优雅实现
开发语言·后端·rust·双端迭代器·rust双向遍历
嵌入式进阶行者8 小时前
【算法】TLV格式解析实例:华为OD机考双机位A卷 - TLV解析 Ⅱ
数据结构·c++·算法
OC溥哥9998 小时前
Paper MinecraftV3.0重大更新(下界更新)我的世界C++2D版本隆重推出,拷贝即玩!
java·c++·算法
Jayden_Ruan8 小时前
C++蛇形方阵
开发语言·c++·算法
星火开发设计8 小时前
C++ map 全面解析与实战指南
java·数据结构·c++·学习·算法·map·知识
老鱼说AI8 小时前
现代计算机系统1.2:程序的生命周期从 C/C++ 到 Rust
c语言·c++·算法
仰泳的熊猫8 小时前
题目1099:校门外的树
数据结构·c++·算法·蓝桥杯
心.c9 小时前
如何基于 RAG 技术,搭建一个专属的智能 Agent 平台
开发语言·前端·vue.js