C++类和对象

对于C++的学习者来说,面向对象的思想可谓是根本,而所需要的工具就是类,我们都认识类,对于一般的基础知识我们也都知道,所以本文会在大体知识的背景下总结介绍很多我们可能忽略的小点。

类的引入

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

但是在C++中,我们更喜欢用类class代替结构体。struct和class有个区别,就是class默认访问权限是private,但struct默认权限是publid(因为struct要兼容C语言)

我们在定义类时,要注意加上类{}后面的分号;

类中的成员叫成员变量,类中的函数被称为成员函数,类中的数据被称之为成员属性或成员变量。

类有两种定义方式:

1.将声明和定义都放在类内。需要注意:若成员函数在类内定义,编译器很可能会将其当成内联函数处理。

2.将声明放在头文件中,定义放在源文件中。这样的话,定义成员函数的时候需要加上类名::用于指明成员属于哪个函数

一般情况下,我们更推荐第二种定义方法。

类的实例化

用类类型创建对象的过程,称之为类的实例化。

1.类是一个类似于模型一样的东西,定义了一个类该有什么东西,但是并没有分配实际的内存空间来存储它。

2.一个类可以实例化多个对象,实例化出的对象将占用实际的物理空间来存储类成员变量。也就是只有实例化出来的对象才能实际存储数据,占用物理空间。

计算类对象的大小

class Person
{
public:
	void ShowInfo()
	{
		cout << _name << "-" << _sex << "-" << _age << endl;
	}
public:
	char* _name;  
	char* _sex;   
	int _age;     
};

我们以上面的Person类举例:

Person类中,既有成员变量又有成员函数,若用类实例化多个对象出来,每个对象的成员变量都是不同的,所以每个对象都需要有一段内存来存储自己的成员变量。但是每个对象都会调用同一段成员函数,若每个对象都拷贝一份成员函数的代码,就会在内存中保存很多段相同的代码,浪费内存空间,所以类的成员函数是放在公共的代码段中的,也就是说类的大小实际上就是类中成员变量的大小之和。

结构体内存对齐的规则

既然类的大小就是类中成员变量大小之和,那么我们假设有个类是这样的:

class x
{
public:  
    char a;
	int b;     
};

若按照上面的结论这个类的大小应该是5,但是我们在vs中测试一下,实际上他的大小是8。这是因为结构体有内存对齐规则

  1. 第一个成员在与结构体变量偏移量为0的地址处,即结构体首地址处,也就是对齐到0处
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。关于对齐数,每个编译器都有一个不一样的默认对齐数,在vs中,默认对齐数是8
  3. 结构体的总大小就是最大对齐数的整数倍(每个成员变量都有一个对齐数,若成员变量大小小于默认对齐数,该变量的对齐数就是它的大小,若成员变量大小大于默认对齐数,该变量的对齐数就是默认对齐数)
  4. 若结构体内嵌套了结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的大小就是所有最大对齐数(包括嵌套结构体的对齐数)的整数倍。

总结来说,类的大小就是类中成员变量的大小之和,计算时还要注意内存对齐。再补充一句,空类的大小是1byte,因为编译器给空类一个字节用来标识这个类,也就是用于占位。

this指针

我们用下面的Date类举例说明:

#include <iostream>
using namespace std;
class Date
{
public:
	void showInfo()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
	void SetDate(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year; 
	int _month; 
	int _day; 
};
int main()
{
	Date d1, d2;
	d1.SetDate(2024, 5, 4);
	d2.SetDate(2024, 5, 5);
	d1.showInfo();
	d2.showInfo();
	return 0;
}

我们在上述代码中分别创建了d1,d2两个对象,那么我们的编译器是怎么区分两者的呢?

C++通过this指针解决上述问题,C++给每个非静态成员函数增加了一个隐藏的this指针,让该指针指向当前对象(即函数运行时调用该函数的对象),在函数体内所有成员变量的操作,都是通过this指针去访问的,只不过不需要用户传递,编译器自动就能完成了。

所以实际上,函数定义的完整形式是:

	void showInfo(Date* this)
	{
		cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
	}
	void SetDate(Date* this, int year, int month, int day)
	{
		this->_year = year;
		this->_month = month;
		this->_day = day;
	}

在调用的时候实际上是这样的:

d1.SetDate(&d1, 2024, 5, 4);
d2.SetDate(&d2, 2024, 5, 5);
d1.showInfo(&d1);
d2.showInfo(&d2);
//需要传入&d参数

this指针的特性

  1. this指针的类型:classname* const
  2. this指针本质上其实是一个成员函数的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象不存储this指针
  3. this指针是成员函数第一个隐含的指针形参,一般情况下由编译器通过ecx寄存器自动传递,不需要用户传递

我们在通过一段特殊代码理解this指针:

#include <iostream>
using namespace std;
class A
{
public:
	void PrintA()
	{
		cout << _a << endl;
	}
	void Show()
	{
		cout << "Show()" << endl;
	}
private:
	int _a;
};
int main()
{
	A* p = nullptr;  
	//p->Show();
	//p->PrintA();
}

p是一个A*类型的空指针,但是我们后面用p调用了Show()和PrintA()成员函数,我们可能会认为在第一次调用Show()函数时程序就会崩溃,其实不然,Show()函数会正常运行,到PrintA()函数的时候才会崩溃。

这是因为:第一句代码并没有对空指针p进行解引用,因为Show函数并没有存到对象中去,而是保存在公共代码段。

但是第二局代码中调用了PrintA()函数,该函数访问了成员变量,所以必须通过对this指针解引用才能访问到,而this指针接受的是空指针,对空指针解引用才会导致程序非法访问的崩溃。

类的六个默认成员函数

假如我们创建一个空类,里面一个成员都没有,但实际上类中会自动生成6个默认成员函数:

  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 赋值运算符重载函数
  5. 取地址操作符重载
  6. const修饰的的取地址操作符重载

下面我们分别进行介绍:

构造函数

1.构造函数的函数名与类名相同

2.构造函数无返回值,这是真的没有返回值,而不是返回值是void

3.构造函数实例化时编译器自动调用对应的构造函数,在用类创建对象的时候,编译器根据使用者传递的参数去调用相应的构造函数

4.构造函数支持重载,也就是说我们可以有多种初始化对象的方式,编译器根据我们调用时传递的参数调用合适的构造函数

5.若类中没有定义构造函数,编译器会自动生成一个无参的默认构造函数

6.我们没写编译器自动创建出来的构造函数,无参的构造函数,全缺省的构造函数,这三种构造函数都被称之为默认构造函数,但默认构造函数只能有一个

我们可能会想,编译器既然会自动生成构造函数,那我们还自己写他干嘛呢?但这种想法很错误,若我们不写构造函数,而是使用编译器自动生成的,那么构造出来的对象的成员都是随机值,例如下面的代码:

#include <iostream>
using namespace std;
class Date
{
public:
	void showInfo()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
	void SetDate(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	d1.showInfo();
	return 0;
}

运行结果为:

编译器自动生成的构造函数机制:

1.对内置类型不做处理

2.对自定义类型,回去调用他们自己的默认构造函数

所以,尽管编译器会自己生成构造函数,但是我们还是要自己写构造函数。

析构函数

析构函数的功能与构造函数完全相反,他负责的是完成对象的销毁,对象在销毁的时候自动调用析构函数,完成类的资源清理工作。对于类对象的销毁,其中的局部变量就已经会随着该对象的销毁而销毁,跟本用不到析构函数,但是对于栈和队列这样的类,其中动态开辟的空间并不会随之销毁,这时候需要我们对其进行空间释放,这才是析构函数的意义所在。

1.析构函数无参数,无返回值,函数名为类名前加上~

2.对象被销毁时,C++编译器会自动调用析构函数,这就大大降低了C语言中堆空间忘记释放的问题的发生

3.与构造函数不同,一个类有且仅有一个析构函数,若我们没有自己定义析构函数,系统会自动生成默认的析构函数

  • 对于内置类型,编译器自动生成的析构函数做浅拷贝(值拷贝)
  • 对于自定义类型,编译器自动生成的析构函数会去调用他们自己的析构函数

4.先构造的对象后析构,后构造的对象先析构

因为对象是定义在函数中的,函数调用建立栈帧,栈帧中对象的构造与析构符合栈先进后出的原则。

拷贝构造函数

这是构造函数中特殊的一种,它的参数就是该类型对象的引用(并且一般这个参数会用const修饰),目的是用以创建的类对象拷贝构造出一个新对象,我们还以Date类为例:

#include <iostream>
using namespace std;
class Date
{
public:
	Date(int year,int month,int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	
	void showInfo()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2024, 5, 5);
	Date d2(d1);//调用拷贝构造函数
	d2.showInfo();
	return 0;
}

对于拷贝我们最需要注意的就是,传参的时候要使用引用传参,我们看如果不使用引用传参会导致什么:

因为当我们进行传值传参的时候,在传参过程中又会构造出一个临时变量对象进行传参,而这个临时变量对象又是通过调用拷贝构造函数构造出来的,这样一来又会调用拷贝构造函数,进而导致无穷递归。

所以我们的拷贝构造函数的参数都是要使用引用传参,而且既然是拷贝构造,那么我们根本不想要更改它,所以一般我们还会加上const。

深浅拷贝问题

若我们没有自己定义拷贝构造函数,则编译器自动生成:

  • 对于内置类型,编译器自动生成的析构函数做浅拷贝(值拷贝)
  • 对于自定义类型,编译器自动生成的析构函数会去调用他们自己的析构函数

例如我们上面的Date类,可以使用系统自动生成的拷贝构造函数去进行拷贝构造,上面的代码中Date d2(d1)中就是调用自动产生的拷贝构造函数将d1的代码完全拷贝了一份给d2,浅拷贝对于类似于Date类型完全够了。

但是某些场景下只实现浅拷贝就不够了,例如我们的栈:

class Stack
{
public:
	Stack(int capacity =4 )
	{
		_a = (int*)malloc(sizeof(int)*capacity);
		if (_a == nullptr)
		{
			perror("malloc fail");
			exit(-1);
		}

		_top = 0;
		_capacity = capacity;
	}
	void print()
	{
		printf("%p\n", _a);
		printf("%d\n", _top);
		printf("%d\n", _capacity);
	}
	~Stack()
	{
		cout << "~Stack()" << endl;

		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	int* _a  ;
	int _top ;
	int _capacity ;
};
int main()
{
    Stack s1;
    Stack s2(s1);
    return 0;
}

对于上面栈类,如果我们使用默认的构造函数就会出访问非法空间的问题,这是因为编译器自动生成的拷贝构造函数是浅拷贝,即:将s1的内容完全复制给s2,是一个字节一个字节复制给他的。

这样出现的问题是:s1创建的时候在堆上进行了动态的开辟了空间,而后_a指针指向的是这块空间,但是后面将s1的内容完全复制给s2,也就是说把s1中_a指针的内容也复制给了s2中_a指针,这样就会产生下图的样子:

也就是s1和s2同时指向了一块空间,也就是说s1和s2的其中一个做了什么操作都会影响到另一个。而且最后还会产生访问非法内存的问题,因为s1和s2都要执行析构函数,s2执行析构函数free掉后自己的内存空间后,s1又会执行析构函数,但是这会它再free的时候,就会出错了,因为他们俩指向的是同一块内存,s2已经将这块内存还给系统了,s1再想访问这块内存程序自然会崩溃。

在这种情况下,我们就需要深拷贝,使用下面的代码:

	Stack(const Stack& s)
	{
		cout << "Stack(const Stack& s)" << endl;	
		_a = (int*)malloc(sizeof(int)*s._capacity);
		if (_a == nullptr)
		{
			perror("malloc fail");
			exit(-1);
		}
			memcpy(_a, s._a, sizeof(int)*s._top);
			_top = s._top;
			_capacity = s._capacity;
	}

这就是深拷贝的示意图:

赋值运算符重载函数

运算符重载

C++为了增加代码的可读性引入了运算符重载,其实运算符重载是具有特殊函数名的函数,其目的是让自定义类型像内置类型一样可以直接使用运算符进行操作。

运算符重载形式为:返回值 operator 运算符(参数1,参数2,……)

注意事项:

  1. 不能连接其他符号创造新的运算符,例如:operator#
  2. 重载运算符必须是用自定义类型做操作数,例如:枚举,类。不能是内置类型
  3. 作为类成员的重载函数,函数默认第一个形参是this
  4. 有五个运算符不能重载:sizeof::*.

我们在这写==运算符的重载作为例子:

我们可以将运算符重载函数作为类的一个成员函数,函数此时第一个形参默认是this指针:

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

其实上面我们在调用==重载符号的时候,实际上我们是通过d1调用的,我们在判断两个类是否相等的时候,还可以这样写d1.operator==(d2);

我们也可以将运算符重载函数放在类外,但是此时就得把要访问的成员变量全部设为public权限的,或者将函数设为友元函数,否则在类外无法访问;而且类外函数没有this指针,所以此时函数要设置两个参数。

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	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;
}

下面我们进行赋值运算符重载的操作:

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date& operator=(const Date& d)
	{
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		return *this;
	}
private:
	int _year;
	int _month;
	int _day;
};

重载赋值运算符的注意事项:

1.参数最好设置为引用,用const修饰

赋值运算符第一个形参默认是this指针,第二个形参是赋值运算符的右操作数。如果是传值调用的话,我们会多调用一次拷贝构造函数,所以我们最好把它设置为引用传参。

而且对于赋值的右操作数来说,我们也不会更改他,所以建议加上const修饰

2.函数的返回值用引用返回,返回值为*this

假如我们只需要类似与d1=d2形式的赋值,我们没有必要设置返回值,但是我们有的时候会涉及类似于d1=d2=d3形式的拷贝,为了支持这样的连续赋值,就需要为函数设置一个返回值。返回值必然是赋值操作符的左操作数,所以我们只能返回*this。和传参的道理一样,为了不必要的拷贝,我们最好还是用引用返回。

3.检查是否自己给自己赋值

若出现自己给自己赋值的情况出现,我们是不需要做任何操作的,所以在赋值前我们先if判断一下。

4.若一个类用户没有自己定义赋值运算符重载,编译器也会自动生成,完成对象按字节序的值拷贝

编译器自动生成的赋值运算符重载也支持连续赋值。但是它的赋值是对象按字节序的值拷贝,如d1=d2,编译器会将d2所占内存空间的值完全拷贝到d1所占内存空间中,类似于memcpy的作用。

	Date d1(2024, 5, 5);//1
	Date d2(2024, 5, 6);//2
	Date d2 = d1;//3
    Date d3 = d1;//4

我们还要注意上面第3,4行代码的区别,第3行代码是用的是赋值运算符重载,但是第4行代码用的是拷贝构造函数。我们要注意区分两者:

赋值运算符重载函数:在两个对象都已经存在的情况下,将一个对象赋值给另一个对象

拷贝构造函数:用一个已经存在的对象去构造初始化另一个即将创建的对象。

const成员

const修饰类的成员变量

我们将const修饰的类成员函数称之为const成员函数,但是const实际修饰的是成员函数中的隐藏参数----this指针,也就是表明该成员函数中this指向的对象进行修改,也就是不能对调用的对象进行修改。如下面的代码就可以防止不小心修改了对象:

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

若我们在该成员函数内对对象进行了修改,就会报错。

结合我们之前介绍的权限放大和缩小的知识,我们需要考虑几个问题:

1.const对象可以调用非const成员函数吗?

不可以,对象被const修饰,成员函数没有被const修饰,也就是说成员函数的this指针没有被const修饰,但是调用函数的对象被const修饰了,属于权限的放大。
2.非const对象可以调用const成员函数吗?

可以,对象没被const修饰,成员函数被const修饰,也就是说成员函数的this指针被const修饰了,但是调用函数的对象没被const修饰了,属于权限的缩小。
3.const成员函数内可以调用其他的非const成员函数吗?

不可以,const内部的this指针被const修饰了,调用其他非const成员函数,也就是会被一个没被const修饰的this指针接收,属于权限的放大。
4.非const成员函数内可以调用其他的const成员函数吗?

可以,非const成员函数内的this指针没有被const修饰,调用其他const成员函数,也就是会被一个被const修饰的this指针接收,属于权限的缩小。

取地址及const取地址操作符重载

class Date
{
public:
	Date* operator&()
	{
		return this;
	}
	const Date* operator&()const
	{
		return this;
	}
private:
	int _year;
	int _month;
	int _day;
};

//这两个默认成员函数我们一般不自己定义,使用编译器自动生成的即可
  • 30
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值