C++类和对象

这篇博客是对C++中类和对象的详细讲解(写了半个多月的时间,博主已经被榨干),类和对象对于C++是重中之重,学好了类和对象,C++也就有了好的基础,但类和对象的细节实在是太多,这篇博客用将近两万字,只为了清楚的表述其中的细节点,虽然内容很多,但全是我认真总结,希望能对你有所帮助

前言

在C中变量类型分为内置类型和自定义类型,在自定义类型中有一个关键词struct结构体,结构体中能定义不同类型的变量(无论是自定义类型还是内置类型)。而C++是兼容struct的,但C++对struct进行了改进,struct在C++中不仅能定义变量,还能定义函数。

不过C++很少使用struct类型,而是用class类代替struct结构体(它们之间的区别下面会说到)

class定义的类能直接用类名做类型名,不用像struct定义的结构体那样,定义结构体时还要加上struct

struct Student{};// c语言定义结构体
Student{};// C++能省略struct,用名字做类型名
class Student{};
Student{};// C++大多数这样写

类的定义

和struct一样,类只是将struct改成了class

class ClassName
{
	// 类的基本信息		
};// 分号别漏了

ClassName是自定义的类名,class是类的关键字。类的定义有两种方式,一种是在类里面实现类的函数(这样的函数会被编译器当成内联函数),另外一种就是只在类中声明类的函数,其定义在类外实现。

而我更倾向于将类的函数实现在类外(当函数数量很多且函数代码量大时),这样使代码看起来更简洁,但当类的函数不多,且函数短小时就将函数的定义实现在类里面。

类的访问限定符和封装

类的访问限定符限定的是用户访问类中成员的权限。访问限定符有三个:public(共有),private(私有),protected(保护)。public修饰的成员在类外就能访问,private和protect修饰的成员不能在类外被访问。使用方式:在访问限定符后加上冒号: 接着换行写类的成员。举个例子

class Person
{
public:
	void Init(const char* name, int age)
	{
		_name = name;
		_age = age;
	}
	void Print()
	{
		printf("name : %s\nage : %d", _name, _age);
	}
private:
	const char* _name;
	int _age;
};

int main()
{
	Person p;
	p.Init("zhangsan", 18);
	p.Print();
	return 0;
}

(将成员变量定义在任意位置都行,不一定要定义在成员函数的上面。那有个问题,当成员函数访问类的成员变量时,不会出现找不到成员变量的情况吗?因为类在C++中是一个整体,一个新的作用域,当成员函数要访问成员变量时,函数会在整个类中找这个成员变量是否存在,而不是像普通代码一样,只会向上找)

先明白访问限定符的作用范围:从访问限定符开始到下一个访问限定符结束(上面代码public的作用范围:从public开始到private结束),或者从访问限定符开始到类的结束(上面代码中private作用范围:从private开始到类的最后)。在限定符作用范围内定义的成员都是该限定符修饰的。

上面代码定义了一个Person类,类的成员函数有初始化Init,还有打印Print,还有成员对象_name和_age,由于Init和Print是public修饰的,所以在类外(main函数中)也能使用这两个函数。而_name和_age是用private修饰的,在类外则不能访问在这里插入图片描述
可以看出,私有成员不能在类外被访问,这样做可以更好的保护类中的成员,只暴露几个函数接口给外界,使得类中的成员不能被随意修改。

而C++的类中没有写访问限定符,默认成员是私有的。C的结构体则默认成员是公有的。

封装

C++三大特性:封装,继承,多态;这里介绍特性之一的多态:

将数据和实现数据的方法(函数)合理的结合,隐藏了对象的属性和过程的细节,仅对外界公开一些接口(函数),以此实现外界和对象安全的交互。

刚刚说到,用private修饰的成员可以被很好的保护起来,这种保护在C++中叫做封装,而将用public修饰的几个函数提供给外界使用,这些函数接口保证了类中成员能被合理的使用,访问。

总结:封装本质上是一种管理,封装过程,隐藏细节,这样的管理极大的提高了代码的安全性并且实现了代码的低耦合。

类的作用域

当定义一个类时,一个新的作用域也随之被定义,也就是说,两个不同的类可以有相同的成员名,因为类的作用域不同,变量作用域也不同。

而一个函数在类中声明,想在类外定义时,就必须加上类名和作用域解析符::
在这里插入图片描述Init在类中声明了,想在类外定义类的成员函数时,由于出了作用域,成员对象是找不到的,所以程序会报错;而不访问成员变量时,同样是不能通过编译的。在这里插入图片描述
在这里插入图片描述
出现链接错误,说明符号表中没有该函数的地址,在最后的链接中无法找到函数,所以无论怎样,在类外定义函数不加上类名和作用域解析符是错误的。

正确的写法:在函数名前加上类名和域作用限定符::(别加在函数返回类型前面,我第一次就写错了)。这里解释下为什么这个函数能访问类的私有成员,不是说私有成员不能在类外访问吗?道理很简单,加上了类名,这个函数也就是这个类中的函数,访问限定符限制类外的访问,函数在类中(不是类外),不会受到限制,当然能访问私有成员。
在这里插入图片描述

类的实例化

类的储存空间大小

先抛出一个问题,当一个类中只有成员函数时,sizeof求出的大小是多少?空类的大小又是多少?

在这里插入图片描述
答案是1,为什么空类不是0,有成员函数的类大小又为何是1?

类的实例化:类的声明就像一个模板,限定了类的成员,而一个类被声明时,系统是不会分配内存空间给声明的,只有类被定义时,才会分配内存空间储存类的信息。这个分配内存空间的过程叫做类的实例化

例如下面的Person p就是类的实例化,分配了内存空间
在这里插入图片描述
Person作为一个模板,p是用这个模板实例化出来的一个实体,被分配了内存空间。那内存空间中储存什么信息?是类的成员变量以及成员函数吗?

再做一个实验,将Test1类加上一个int变量在这里插入图片描述
Test1的大小变成了4,而一个int的大小刚好是4,所以类的成员函数不占用内存空间的。得到了这一个结论,就能很好的解释上面的代码。当一个类只有成员函数或者是一个空类时,为了说明这个类是存在的,系统用1字节表示类的大小,这个1字节呢,不是用来存储有效数据的,而是一个占位符,表示这个类存在。

总结一下:由于同一类的实例化对象的成员函数总是一样的,当实例化对象时,保存相同的代码显然是多余的。所以为了不占用更多的储存空间,这些成员函数被储存到一个公共代码区,当要调用这些函数时才去这块代码区去找。而同一类的实例化对象的成员变量总是会有不一样的数据出现,这时就要对这些数据进行存储。所以计算一个类的大小时,只用计算类中成员变量的大小,这个计算规则遵守内存对齐,关于内存对齐可以看我之前写的结构体总结,里面有详细的内存对齐规则说明以及练习题。

this指针

前面提到类的成员函数会放到一个公共代码区,但有一个问题在这里插入图片描述
只传了zhangsan,这个函数是怎么把zhangsan放到p1的_name中的?编译器在这里其实动了点手脚,将类的实体的地址也传给了函数(下面的代码是编译器对代码处理后的结果,但是自己不能这么写)

class Person
{
public:
	void Init(Person* const this, const char* name, int age)
	{
		this->_name = name;
		this->_age = age;
	}
	void Print(Person* const this)
	{
		printf("name : %s\nage : %d", this->_name, this->_age);
	}
private:
	const char* _name;
	int _age;
};

int main()
{
	Person p1;
	Person p2;
	p1.Init(&p1, "zhangsan", 18);
	p1.Print(&p1);

	p2.Init(&p2, "lisi", 20);
	p2.Print(&p1);

	return 0;
}

类的成员函数的形参中其实还隐藏了一个this指针,这个指针只有在编译后才会被加上(这个是编译器要做的事),传参时给了实体的地址,函数就能通过地址该实体进行修改了。

(函数中出现的参数会优先去形参中找,如果形参中没有这个参数,编译器就会去成员变量中找,找到了编译器会为其加上this->,找不到编译器报错。)

this指针不能在形参中显式的写出,但this指针能被函数使用,看一个例子

class Person
{
public:
	void Init(const char* name, int age)
	{
		cout << this << endl;
		this->_name = name;
		this->_age = age;
	}
	void Print()
	{
		cout << this << endl;
		printf("name : %s\nage : %d\n", this->_name, this->_age);
	}
private:
	const char* _name;
	int _age;
};

int main()
{
	Person p1;
	Person p2;
	cout << "p1:" << &p1 << endl;
	cout << "p2:" << &p2 << endl;
	p1.Init("zhangsan", 18);
	p1.Print();

	p2.Init("lisi", 20);
	p2.Print();

	return 0;
}

在这里插入图片描述
首先打印p1和p2的地址,初始化d1时打印该函数的this指针,发现this和p1的地址一样,由此说明this指针作为函数的形参接收对象的地址。

class Person
{
public:
	void Init()
	{
		
	}
	void Print()
	{
	}
//private:
	const char* _name;
	int _age;
};

int main()
{
	Person* p = nullptr;
	p->Init();
	p->_age = 0;
	return 0;
}

p->Init()这行代码是对的吗?p是一个空指针,而对空指针解引用,很明显是错的。但解引用的前提是:需要解引用访问某个对象,而p中有存储Init函数吗?p中只存储成员变量,不存储类的函数。所以这行代码实际上没有解引用。既然没有对空指针解引用,这行代码就能正常运行。而p->_age,_age是类的成员变量,存储在p中,所以这时需要通过解引用来访问p的元素,而p是空指针,对空指针解引用会出现非法访问,导致程序崩溃。

this指针特性

1.this指针的类型为* const类型,const在*的右边表示该指针的内容不能修改(在左边表示不能通过修改该指针去修改该指针指向的对象)
2.this指针只能在成员函数中使用
3.this本质上是成员函数的形参,在对象调用成员函数时,将对象的地址作为实参传给this指针。因此对象中不存储this指针
4.this指针大多存储在栈中,有时因为编译器优化,this指针会存储到寄存器ecx中。

在这里插入图片描述
第一行代码:将p的地址移动到ecx寄存器中,第二行:push,将ecx压栈。所以this指针是在函数调用时生成的,函数调用会开辟栈帧,this栈帧作为形参会被压栈,存到函数栈帧中。

空指针访问类的成员

class Date
{
public:
	void Print()
	{
		cout << this << endl;
	}
	int _year;
};

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

如果p是一个Date类的空指针,可以通过p访问类的Print函数吗?在这里插入图片描述
答案是可以,虽然p是一个空指针,但该指针并不是不存在,而是指向了0地址,如果去访问0地址,程序就会报错,这是规定,但只是指向0地址,不去访问,程序就不会报错。

之前说过,类的成员函数不属于任何一个对象,成员函数放在一个公共代码区,通过p调用Print,本质是将p指向的地址传给函数,函数用this指针接收,但是Print函数没有解引用这个地址(没有去访问),程序就不会报错。在这里插入图片描述
如果Print函数去访问了p指向的空间,就是对p进行解引用,则程序报错,非法访问。

类的6个默认成员函数

刚刚说一个空类的大小为1字节,但空类没有成员函数,也没有成员变量。虽然表面上没有成员函数,但编译器会默认生成6个成员函数,这里介绍一些重要的成员函数

构造函数

也许是翻译问题,构造函数不是字面意思,去构造或是创建一个对象,其准确的名称应该是“初始化”函数,构造函数的主要功能是将类的成员变量赋初值。

构造函数特性

1.函数名与类名相同

假设有一个日期类,名字是Date,则该类的构造函数的函数名也是Date

2.构造函数没有返回值

构造函数是进行初始化的函数,一个进行初始化的函数当然不需要返回值了。而普通函数可能有返回值可能没有,所以普通函数中才用上了void返回类型来表示该函数没有返回值。而构造函数是一个特殊的函数,它的功能决定了它一定没有返回值,既然一定没有返回值,C++就做的干脆了些,连void都不用写,这样的函数看着也更加简洁,同时也能一眼看出这是个构造函数。

3.构造函数可以重载

先写一个日期类,成员变量有年,月,日。手动给该类写构造函数,一个构造函数无参,什么都不干,一个有参,接收输入的年月日,并将其赋值给成员变量。这两个构造函数构成函数重载。

class Date
{
public:
	// 无参构造函数
	Date()
	{}
	// 带参构造函数
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

int main()
{
	Date d1;// 调用无参构造函数去创建对象
	Date d2(2022, 5, 17);// 调用有参构造函数去创建对象
	return 0;
}

调用构造函数后d1,d2里的数据。由于无参构造函数什么都不干,所以d1中的值都是随机值。
在这里插入图片描述

而main函数中的Data d1怎么就调用无参构造函数了?这行代码连函数调用符()都没有,看起来就是定义了一个日期对象d1啊。其实C++语法规定,这行代码就该怎样写,调用无参构造函数初始化对象时不用加上(),如果加上,则代码变成Date d1(),这行代码是调用函数吗?很显然这是一行声明函数的代码,d1是函数名,该函数无参,返回值是Date日期类。所以加上函数调用符不是在调用函数而是在声明函数,无法达到我们的目的,因此要调用无参构造函数千万不能加()。

4.对象实例化时,编译器自动调用构造函数,并且在该对象的生命周期内,构造函数只调用一次

通过他调试观察对象在初始化时会自动调用构造函数
在这里插入图片描述
程序运行下一步就调用了构造函数
在这里插入图片描述

5.如果类中没有显式的构造函数,编译器会自动生成一个默认构造函数,该函数无参

当屏蔽显示构造函数,再去实例化一个日期对象时,编译器就会生成一个默认构造函数,并且去调用该函数,但通过观察d1的值发现,生成的默认构造函数并不会修改对象的值(里面的值还是随机值)。
在这里插入图片描述
在这里插入图片描述
这里有一个问题,默认构造函数不对数据进行处理,那为什么还用调用它?这个调用有没有意义?

先看结论:对于内置类型,编译器生成的默认构造函数不会对数据进行处理;对于自定义类型默认构造函数会去调用自定义类型默认构造函数(这里注意区分默认构造函数和构造函数,具体看第6点)。

例如现在有一个Queue类,其成员变量有一个属于Stack类的st1和st2

class Stack
{
private:
	int* _data;
	int _top;
	int _capacity;
};

class Queue
{
private:
	Stack st1;
	Stack st2;
};

int main()
{
	Queue q;
	return 0;
}

此时创建一个Queue类的变量q,由于没有显式定义的构造函数,所以编译器会自动生成默认构造函数,又因为Queue中有两个Stack类,属于自定义类型,所以默认构造函数会去调用Stack的默认构造函数。要是Stack的默认构造函数也是编译器生成的,那么q这个变量里存储的还是随机值。

结论:当一个类中有内置类型时,最好显式地写上构造函数(只要有就写),而当一个类中有自定义类型时(这里是只有),可以不用写这个类的构造函数,编译器会自动调用默认构造函数,这个默认构造函数会去调用你为内置类型显式定义的构造函数。

Stack中有三个私有成员,如果Queue的构造函数由我们显式定义,赋值Stack的私有成员时很显然程序会报错。所以对于有自定义类型的类,有时是不能初始化自定义类型的。

6. 默认构造函数有三种:我们不写,编译器默认生成的;我们写的无参数的;我们写的全缺省的

简单说默认构造函数的特点就是:不用传参数就可以调用的

但默认构造函数只能存在一个。这就有一个问题,当我们写了无参的构造函数和全缺省的构造函数,在最后程序运行时,程序是要调用无参的还是全缺省的?

class Date
{
public:
	// 无参构造函数
	Date()
	{
		_year = 0;
		_month = 0;
		_day = 0;
	}
	// 全缺省构造函数
	Date(int year = 2022, int month = 5, int day = 19)
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

int main()
{
	// 定义d1的日期类对象,d1会去自动调用构造函数
	Date d1;
	// d2日期类对象就能正确的调用
	Date d2(2022, 1);
	return 0;
}

在这里插入图片描述
很明显,无参函数和全缺省函数是不能构成重载的,因为调用函数时传的参数都是无参的,实例化d1时程序该调用哪个函数?这里出现了歧义(重载的调用不明确)。而实例化d2时,由于d2给了参数,所以程序知道d2要调用的构造函数是有参数的,这时没有歧义。

为了防止歧义的出现,定义构造函数时尽量不要定义一个无参构造和一个全缺省构造。推荐只定义一个全缺省的函数
在这里插入图片描述
因为使用全缺省时,可以实现只对部分成员变量赋值,剩下的成员变量则使用缺省值,这样操作还是很方便的。
在这里插入图片描述
当Stack类的构造函数是显式定义的,在创建Queue类的变量时,由于Queue没有显式定义的构造函数,编译器生成一个默认构造函数,该默认构造函数会去调用Stack的默认构造函数,而Stack已经显式定义了一个构造函数,编译器不会为Stack生成一个默认构造函数,Stack也没有全缺省的和无参构造函数,所以Stack没有默认构造函数。因此这里的Queue去调用Stack的默认构造函数时由于找不到默认构造函数程序会出错。
在这里插入图片描述

7.C++11的补丁

最后 C++11为构造函数新增了一条语法,比如Queue这样的类中除了自定义类型还有内置类型,通常是让编译器自己生成默认构造函数去调用Stack的默认构造函数,这样虽然对st1和st2进行了初始化,_size的值却是随机值,C++11可以这样用
在这里插入图片描述
在这里插入图片描述
这个size = 10并不是定义,而是给默认构造函数的声明,默认构造函数去调用Stack的默认构造函数时,还会根据这个声明来初始化_size。

析构函数

析构函数不是完成对象的销毁,与构造函数相反,析构函数完成对象的清理工作:释放申请的资源。

比如一个日期类是否要手动写一个析构函数?日期类的成员变量都是局部变量,在函数调用完后自动销毁,没有写析构的必要。如果是链表类或者栈类,有动态开辟内存空间的类呢,就需要进行资源的清理。

析构函数的特性

析构函数名为类名前加上~,如Date类的析构函数名为 ~Date
一个类只有一个析构函数,若无显式定义,析构函数由编译器默认生成
析构函数无参且无返回值
析构函数在对象要销毁时由编译器自动调用

class Stack
{
public:
	Stack(int capacity = 10)
	{
		_data = (int*)malloc(sizeof(int) * capacity);
		_top = 0;
		_capacity = capacity;
	}
	~Stack()// 析构函数
	{
		cout << "~Stack()" << endl;
		free(_data);
	}
private:
	int* _data;
	int _top;
	int _capacity;
};

class Queue// 含有两个自定义变量的类Queue
{
private:
	Stack st1;
	Stack st2;
};

int main()
{
	Stack st1;
	Stack st2;
	return 0;
}

这段代码中,程序会先调用st1的构造函数再去调用st2的构造函数,当两者的生命周期快结束时,那么谁的析构函数先调用呢?由于st1和st2是存储在栈中的,栈满足先进后出的特点,所以st2的析构先调用,st1的后调用。

而析构函数要做的事就是释放内存空间,像上面的Stack一样,构造函数为_data申请了堆空间,而析构函数要做的就是释放申请的堆空间。

还有一点,和构造函数一样,若类中没有析构函数的显式定义,则编译器会生成一个默认析构函数并且调用它。

对于内置类型,析构函数并不会释放申请的内存,由系统自动回收内置类型的内存;对于自定义类型,不写析构函数,编译器生成的默认析构函数会去调用该类型的析构函数(这里又和构造函数不一样,构造函数对于自定义类型生成的默认构造函数会去调用它的默认构造函数,注意构造是默认构造调用默认构造,而析构是默认析构调用析构)

假设类中有一个指针,该指针可能是new的,也可能是malloc,还可能是一个文件指针,编译器若处理内置类型,释放指针指向的空间。首先new和malloc得到的指针要用delect和free释放,编译器要怎么知道指针该用什么释放。
在这里插入图片描述
当把主函数修改成上面那样,创建q对象时,由于Queue类没有显式定义构造函数,编译器生成一个默认构造函数,该默认构造函数去调用Stack的默认构造函数。当main函数结束时,q的生命周期也要结束,此时程序调用Queue的析构函数,由于Queue没有显式定义析构函数,编译器生成一个默认析构函数,该析构函数调用Stack的析构函数
在这里插入图片描述
q中的st1和st2分别析构一次,st2后入栈,所以st2先析构,再析构st1。

学习这几个函数时只要把握两个方面:

1.基本的语法规则:函数名,返回值,是否有参数,什么时候会调用
2.我们不写编译器默认生成的函数都干了些什么事

拷贝构造函数

拷贝构造作为构造函数的一种重载形式,本质上是一种构造函数。定义语法和构造函数一样。

要怎么使用:比如定义了一个Date d1,想用d1来初始化d2,首先是要定义d2,这样写:Date d2,然后加上括号表示调用构造函数,括号里是要被拷贝的对象,Date d2(d1),可以看下面的main函数

class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 19)// 日期类的构造
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(Date d)// 日期类的拷贝构造(但这样写是错的)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

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

这里要注意:拷贝构造函数的参数有且只有一个,并且该参数必须是对本类类型变量的引用,否则会出现无穷递归调用。

像上面那样写拷贝构造就是错的。解释:先理解一个点,当函数形参是自定义类型时,给函数传参,形参会去复制实参的值进行拷贝,但形参如果是自定义类型呢?语法规定这个拷贝工作要交给自己做。所以当函数的形参是自定义类型时,形参为了拷贝实参会去调用该实参类型的拷贝构造函数。

上面的代码中 Date(Date d)是一个拷贝构造函数,然而该函数的形参是一个Date类对象,要生成形参d就要对实参进行拷贝,拷贝就会调用Date类的拷贝构造函数,但是Date类的拷贝构造函数却是它自己,自己去调用自己,但是没有停下来的条件,会导致程序不断的调用拷贝构造函数,陷入死循环。

因此拷贝构造函数的形参必须是该类型的引用

正确的写法:Date(Date& d),形参作为实参引用时,不会拷贝实参,就不会出现无线递归的问题

浅拷贝导致的问题
class Stack
{
public:
	Stack(int capacity = 10)
	{
		_data = (int*)malloc(sizeof(int) * capacity);
		_top = 0;
		_capacity = capacity;
	}

	Stack(Stack& st)// 拷贝构造函数
	{
		_data = st._data;
		_capacity = st._capacity;
		_top = st._top;
	}

	~Stack()
	{
		free(_data);
	}
private:
	int* _data;
	int _top;
	int _capacity;
};

int main()
{
	Stack st1;// 定义一个Stack类的对象st1
	Stack st2(st1);// 用st1拷贝构造一个st2
	return 0;
}

上面的拷贝构造函数只是简单的将st1的数据赋值到了st2中,但Stack类中有一个_data指针,指向在堆中开辟的空间。所以这样拷贝就会有一个问题,st1的_data值和st1相同,即两者指向同一块内存空间。很明显,两者共用一块内存空间会导致许多的问题,如数据的存储会相互影响,但更严重的问题是:st1和st2在销毁前调用析构函数会使_data释放两次,第二次释放,即释放一个已经被释放的指针,导致程序崩溃。
在这里插入图片描述
所以拷贝构造函数要视情况使用浅拷贝和深拷贝,要是将拷贝工作交给编译器器,编译器无法判断什么情况下要用浅拷贝什么情况要用深拷贝,所以这就是为什么语法规定当形参是自定义类型时,传参要用拷贝构造,而不是简单的值拷贝。

还有一点:对于内置类型,不写拷贝构造编译器生成的默认拷贝构造是浅拷贝,对于自定义类型,不写拷贝构造编译器生成的默认拷贝构造函数会自动调用自定义类型的拷贝构造(不是默认的拷贝构造)。

综上所述:要直接管理类中的资源时,需要自己写拷贝构造函数,间接管理类中的资源时不需要写拷贝构造,使用编译器生成的浅拷贝就够用了。

class Stack
{
public:
	Stack(int capacity = 10)
	{
		_data = (int*)malloc(sizeof(int) * capacity);
		_top = 0;
		_capacity = capacity;
	}

	Stack(Stack& st)// 拷贝构造函数
	{
		...//假设这里实现了深拷贝 
	}

	~Stack()
	{
		free(_data);
	}
private:
	int* _data;
	int _top;
	int _capacity;
};

class Queue
{
private:
	Stack st1;
	Stack st2;
};

int main()
{
	Queue q;
	return 0;
}

就像上面的例子,Queue是自定义类型,里面有两个Stack类的自定义成员,Stack会申请堆中的资源,属于直接管理资源,拷贝构造要自己实现(深拷贝)。而Queue属于间接管理资源,编译器生成的默认拷贝构造函数会去调用Stack的拷贝构造函数,Stack的拷贝构造已经实现好了,所以不用自己写Queue的拷贝构造。

(拷贝构造函数更规范的写法:Stack(const Stack& st),因为拷贝构造函数不修改被拷贝对象的数据,所以用const修饰引用的对象,表示其不能被修改)

不那么重要的默认成员函数

还有个默认函数,这里只需要稍微提一下,就是取地址和取const地址的函数重载

class Date
{
public:
	Date* operator&()// 普通取地址的存在
	{
		return this;
	}
	const Date* operator&() const// 取const地址的重载
	{
		return this;
	}
private:
	int _year;
	int _month;
	int _day;
};

void Func(const Date& d)
{
	cout << &d << endl;// 调用const取地址的重载
}

int main()
{
	Date d;
	cout << &d << endl;// 调用普通取地址的重载
	Func(d);
	return 0;
}

这两个&重载基本不需要自己写,大多数情况下是用编译器默认生成的函数。

运算符重载

对于一个日期类对象d,能不能实现d + 100这种操作呢?由于编译器只支持操作数为内置类型的运算,d为自定义类型,所以d + 100是不能被编译器识别的。要实现这样的操作,就需要重新定义+操作符,用一个函数定义这个操作符的行为,这个函数叫做运算符重载。

和函数重载不同,函数重载指的是参数不同,函数名相同的几个函数构成重载,运算符重载是重新定义这个运算符的意义与功能,这里要注意别混淆。

使用:返回值operator运算符(参数列表),比如我想知道两个日期是否相等,这时就需要重载==这个运算符。

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

//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(2022, 5, 20);
	Date d2(2022, 5, 20);// 两个相等的日期

	if (d1 == d2)// 这是一种重载后的使用方式
	{
		cout << "==" << endl;
	}
	if (operator==(d1, d2))// 这种写法等价于上面的
	{
		cout << "==" << endl;
	}
	return 0;
}

但operator==(d1, d2)这行代码比起d1 == d2,像是一个函数调用,难以体现这是两个操作数作比较,所以不推荐这种写法,用d1 == d2不仅简洁还提高了程序的可读性。

但有一个问题,可以看到我屏蔽了Date类中的private,将成员变量设为公有,但这也违法了C++的封装特性,使外界访问成员变量。如果成员变量是私有的,运算符重载函数却在类外定义,这时就不能访问类的私有成员,运算符无法重载。所以更适合的写法是将运算符重载写在类里面。

但类里面的每个函数都隐藏了一个形参:this指针。比如==运算符有两个操作数,但由于this指针的存在重载函数的形参只要有一个,另一个为隐藏的this。

(由于this存在,==的操作数应该为1个,2个操作数程序会报错)
在这里插入图片描述
正确的写法:

class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 19)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	bool operator==(const Date& d)// ==的重载
	{
		return this->_year == d._year
			&& this->_month == d._month
			&& this->_day == d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2022, 5, 20);
	Date d2(2022, 5, 20);

	if (d1 == d2)
	{
		cout << "==" << endl;
	}
	//if (operator==(d1, d2))// error
	if (d1.operator==(d2))// 相应的这种写法也要改变,调用d1的operator==函数,要与d1进行比较的对象是d2,所以括号里是d2
	{
		cout << "==" << endl;
	}
	return 0;
}

运算符重载函数的特性

重载的运算符必须是有意义的,比如日期+日期没有意义不需要重载
重载函数形参个数比操作数少1。比如++只有一个操作数,其重载函数没有形参,因为形参中隐藏了this指针
.* 、::、size of、?:、.这五个运算符语法规定不能重载(有的运算符真没见过…)

赋值运算符重载

class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 19)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	Date& operator=(const Date& d)// 关于细节点看下面的解释
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
		
		return *this;
	}

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

上面的赋值运算符重载函数:首先是将形参d的数据赋值给this指针指向对象,这没什么好说的。但平常的赋值运算符支持连等,即i = j = k,并且赋值运算符的优先级是从右向左,先将k赋值给j,再把j的值赋值给i。所以赋值函数返回值是左操作数的值。而函数中只有左操作数的地址,this指针,所以返回值是对this的解引用,但返回值是Date的话在返回时会去调用一次构造拷贝函数,显然是麻烦的,所以返回值应该是Date的引用。

关于更多的运算符重载可以看我写的日期类的实现,里面详细介绍了重载运算符的实现。

赋值和拷贝构造

了解了赋值运算符的重载,现在来看这一段

class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 26)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2022, 5, 26);
	Date d2(d1);// 1
	Date d3;
	d3 = d1;// 2
	return 0;
}

代码中的1和2有什么区别?1是拷贝构造,2是对象的赋值。拷贝构造和赋值又有什么区别? 用存在的对象去初始化不存在的对象叫拷贝构造,用存在的对象去初始化存在的对象叫赋值。首先d2是不存在的,在创建d2对象时,用d1的值去初始化d2,这样就叫拷贝构造,而d3是之前已经创建的,将d1的值赋值到d3中,这样叫赋值。

初始化列表

初始化列表的意义

在这里插入图片描述
有一个问题:这些成员定义了吗?如果没有定义那这些成员什么时候定义?因为类只是一个声明,其中的成员也是声明,并不是定义。所谓定义是指开辟了内存空间并分配给它,很显然,声明一个类时,并不会分配内存空间个这些变量。

这些变量真正的定义是在类的对象创建时,比如Date d1,创建了一个Date类的d1对象,此时d1的所有成员变量都有一块自己的内存空间,但还未赋初始值,因此构造函数被引入C++,构造函数将成员变量初始化,也就是赋初值。但总有一些变量不能在定义之后赋值,比如const修饰的常变量为只读属性,定义过后不能写入。引用,引用在定义时必须有一个明确的引用实体。

刚才说的,Date d1是为Date类中的所有成员变量开辟内存空间,如果Date类有一个引用或者const修饰的常变量,在定义时必须赋初值,而构造函数的赋初值是在变量定义后赋初值,想要在变量定义时就被赋初值,就要用到初始化列表。
用法

int valut = 10;
class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 26, int test = 1)
	: _test(test)
	, _valur(value)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
private:
	int _year;
	int _month;
	int _day;
	const int _test;
	int& _value;
};

在构造函数后加上冒号,用括号里的变量的值去初始化括号前变量的值(所以value是一个全局变量的引用,如果是一个局部变量的引用,出了构造函数,可能会导致非法访问的问题)。

总结一下就是:Date类的对象d1定义的过程:先去调用Date类的初始化列表(初始化列表是每个类都有的,就算没有显式的写出,因为初始化列表是变量定义的地方,一个变量要先定义才能使用是吧。初始化列表干的事就是为变量开辟内存空间),在类中的成员变量定义之后(调用完初始化列表之后)才会去调用构造函数,

除了const和引用要在初始化列表初始化,没有默认构造函数的自定义变量也要在初始化列表初始化

class ATest
{
public:
	ATest(int num)
	{
		_num = num;
	}
private:
	int _num;
};


class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 26, int num = 10)
		:_a(num)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date& operator=(const Date& d)// 赋值运算的重载
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;

		return *this;
	}
private:
	int _year;
	int _month;
	int _day;
	ATest _a;
};

比如声明了一个ATest类,这个类的构造函数不是无参,不是全缺省,不是我们不写构造函数编译器自动生成的,所以ATest的构造函数不是默认构造函数,而Date类中有一个ATest类的对象_a,当定义一个Date类的对象时,需要通过初始化列表定义该对象(初始化列表是对象定义的地方,如果没有显式地在初始化列表中定义_a,编译器会自动定义_a,同时调用_a的默认构造函数,而_a没有默认构造函数,程序报错),此时就需要用初始化列表定义_a,并传参给 _a的构造函数
在这里插入图片描述
在这里插入图片描述

explicit关键字

class ATest
{
public:
	ATest(int num)
	{
		_num = num;
	}
private:
	int _num;
};


int main()
{
	ATest a = 10;
	return 0;
}

以上main函数中的ATest a = 10,存在隐式类型转换,表示先用10构造一个ATest类对象,再用该对象拷贝构造一个a。
在这里插入图片描述
但用explicit修饰构造函数后,表示禁止这种隐式类型转换

成员初始化顺序

class Test
{
public:
	Test(int x = 1)
		:_x(x)
		,_y(_x)
	{}
	void Print()
	{
		cout << _x << endl << _y << endl;
	}
private:
	int _y;
	int _x;
};

int main()
{
	Test t;
	t.Print();
	return 0;
}

以上程序输出结果是什么?两个1吗?
在这里插入图片描述
_x被初始化成1,_y没有被初始化,原因是:初始化列表中变量的定义顺序是类中成员声明的顺序,Test类中_y先声明,则初始化列表先创建_y,此时的_x是一个随机数,_y也是一个随机数,_x后声明,初始化列表后定义_x,则_x的值是1

静态成员

静态成员变量

假设我写了一段程序,要统计一个类的构造函数调用了多少次。那么可以创建一个全局变量count,在构造函数中写上++count。这种做法虽然可行,但全局变量破坏了程序的封装性,也让程序变得不安全,因为修改全局变量很容易。所以C++很少使用全局变量,要想统计一个类的构造函数调用了几次,可以创建一个静态的成员变量。

class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 26)
	{
		_count++;
		_year = year;
		_month = month;
		_day = day;
	}
	static int GetCount()
	{
		return _count;
	}
private:
	int _year;
	int _month;
	int _day;
	static int _count;// 静态变量的声明
};

int Date::_count = 0;

int main()
{
	Date d1;
	cout << d1.GetCount() << endl;
	cout << Date::GetCount() << endl;
	return 0;
}

但在类中声明了静态成员变量还不能使用,这里理解一个点:静态成员变量不属于该类的任何一个对象(因此sizeof(对象名),不会将静态成员变量的大小算入其中),但属于所有该类的对象,是属于整个类的,生命周期是整个程序(在main函数创建栈帧之前,静态变量已经创建好了,然后根据main函数中的初始化语句进行初始化),作用域是该类,存储的区域是静态区。因此构造函数不会为静态成员变量赋初值,初始化列表也不会为静态成员变量开辟空间。

要为静态成员开辟空间,需要我们手动定义

int Date::_count = 0;

如何访问静态成员变量?因为上面声明的静态成员变量是私有的,不能在类外直接访问,所以需要在类中定义一个公有函数,该公有函数返回_count的值。

class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 26)
	{
		_count++;
		_year = year;
		_month = month;
		_day = day;
	}
	static int GetCount()
	{
		return _count;
	}
private:
	int _year;
	int _month;
	int _day;
	static int _count;// 静态变量的声明
};

int Date::_count = 0;

int main()
{
	Date d1;
	cout << d1.GetCount() << endl;
	cout << Date::GetCount() << endl;
	return 0;
}

在这里插入图片描述

静态成员函数

以此引出静态成员函数,在定义函数前加上static,将函数定义成静态的,在访问该函数时,可以通过普通的对象访问,也能通过类来访问。
在这里插入图片描述
静态成员函数特性:没有this指针,由于this指针指向的是一个对象,而静态成员函数是所有该类对象共享的,不属于某个特定的对象,所以静态成员函数没有this指针,以此不能通过静态成员函数访问非静态成员,包括非静态成员变量和非静态成员函数

总结

静态成员由所有类对象共享,不属于某个特定的对象
静态成员变量在类中声明,在类外定义,定义时不加static
静态成员不仅能用类对象访问还能用类名访问
静态成员函数没有this指针,不能访问非静态成员
静态成员也有三种访问限定符,静态成员函数能具有返回值

友元

友元分为友元类和友元函数,友元为我们提供了便利,但同时友元也破坏了程序的封装性,所以友元要尽量少用。

友元函数

友元函数能直接访问一个类的私有成员,但不属于该类,在日期的实现中,友元函数在重载cout运算符时被应用,可以去看下这篇博客。

友元函数的声明可以放在类的如何一个地方,不过正常都放在类的最开始处(声明很简单,只要在函数名前加上friend)
在这里插入图片描述

// 重载<<运算符,使输出日期
class Date
{
	friend std::ostream& operator<<(std::ostream& out, const Date& d);
public:
	Date(int year = 2022, int month = 5, int day = 26)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

std::ostream& operator<<(std::ostream& out, const Date& d)
{
	out << d._year << "-" << d._month << "-" << d._day;
	return out;
}

int main()
{
	Date d1;
	cout << d1 << endl;
	return 0;
}

在这里插入图片描述
特性

1.友元函数能访问类的私有成员
2.友元函数不受访问限定符的限制,可以在类的任何地方声明,但通常声明在第一行
3.一个函数可以是多个类的友元

// 一个函数可以是多个函数的友元演示
class Date;// 先声明Date类,不然Time的Func友元声明会报错

class Time
{
	friend void Func(const Date& d, const Time& t);
public:
	Time(int hour = 12, int minute = 11)
	{
		_hour = hour;
		_minute = minute;
	}
private:
	int _hour;
	int _minute;
};

class Date
{
	friend void Func(const Date& d, const Time& t);
public:
	Date(int year = 2022, int month = 5, int day = 26)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

void Func(const Date& d, const Time& t)
{
	cout << d._year << "-" << t._hour << endl;
}

int main()
{
	Date d;
	Time t;
	Func(d, t);
	return 0;
}

假设有这样的需求:上面的Func需要访问两个类的私有成员,这时就需要将Func声明成Date和Time的友元函数,使Func函数能访问这两个类的私有成员。

友元类

若将Date声明成Time的友元类,则Date的所有函数可以访问Time的私有成员,可以理解为Date类的所有函数都是Time的友元函数。

class Time
{
	friend class Date;// Date是Time的友元,则Date可以访问Time的私有函数
public:

private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
public:
	Date(int year = 2022, int month = 5, int day = 26)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// 该函数对Time类对象的私有成员直接进行修改
	void SetTime(Time& t, int hour, int minute, int second)
	{
		t._hour = hour;
		t._minute = minute;
		t._second = second;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d;
	Time t;
	d.SetTime(t, 1, 2, 3);
	return 0;
}

总结

友元的访问关系:我想访问你的私有成员,我就要成为你的友元。且友元是单向的,若A是B的友元,A能访问B的私有,B不能访问A的私有;友元不能传递,A是B的友元,A能访问B的私有,C是A的友元,C能访问A的私有,但C不能访问B的私有,友元不具有传递性。
在这里插入图片描述

内部类

内部类的概念及特性

一个类定义在另一个类中,前者就是一个内部类,后者为外部类。内部类是一个独立的类,它不属于外部类,外部类不能访问内部类的任何对象
在这里插入图片描述
内部类中有一个公有函数Print,不能通过外部类的对象去调用Print在这里插入图片描述
这是因为内部类是一个独立的类,并不是外部类的成员,内部类的成员也不属于外部类。所以sizeof(外部类)=外部类的大小,无内部类无关。内部类只是外部类的天生友元,所以内部类可以通过外部类的对象访问外部类的所有成员。

并且内部类可以定义在外部类的任何地方,但受访问限符private,public,protect的限制。当内部类是public时,作用域被限制在外部类中,所以定义内部类对象要写外部类名::内部类名。如果内部类是private的,则不能在外部类外创建内部类的对象,即函数或其他函数中,只能在外部类里面创建内部类对象,此时的内部类是外部类专属的。

class Outside
{
public:
	Outside(int o = 10)
	{
		_o = o;
	}
	class Inside
	{
	public:
		void Print(Outside& out)// 内部类是外部类的友元,可以访问外部类的成员
		{
			cout << out._o << endl;
		}
	private:
		int _i;
	};
private:
	int _o;
};

int main()
{
	Outside out;
	Outside::Inside in;// 不能写成Inside in
	in.Print(out);
}

在这里插入图片描述
如果内部类是private保护的
在这里插入图片描述
外部(main函数)无法访问内部类,程序报错
在这里插入图片描述

编译器的优化

最后看一段程序

class Date
{
public:
	Date()
	{
		cout << "Date()" << endl;// 构造函数打印该语句
	}
	Date(const Date& d)
	{
		cout << "Date(cosnt Date& d)" << endl;// 拷贝构造函数时打印该语句
	}
private:
	int _year;
};


Date f(Date x)// 形参x接收实参,会调用一次拷贝构造
{
	Date y = x;// 用存在的对象去初始化不存在的对象,也会调用拷贝构造
	Date z = y;// 同理
	return z;// 返回z时,由于z出了函数就进行销毁,所以返回z前要进行拷贝构造,将z的数据拷贝到一个临时变量中,返回的是该临时变量
}


int main()
{
	f(Date());
	// Date();// 匿名对象
	return 0;
}

(在类名后加上一对括号,表示创建一个该类的匿名对象,匿名对象的性质和临时变量相似,具有临时性,匿名对象被const修饰,因此当匿名对象作为函数形参时,不能用引用做形参,若用引用做形参需要加const)
在这里插入图片描述
当不用const修饰形参,程序报错
在这里插入图片描述
问:上面的程序调用了几次构造函数?如果只是单纯的调用f函数,程序会调用四次构造函数在这里插入图片描述
如果用一个对象去接收函数的返回值,就应该多一次拷贝构造(函数返回临时变量,将临时变量的值赋值给接收对象)
在这里插入图片描述
结果却还是4次,原因是编译器在这里进行了优化,f返回临时变量,调用了构造函数,将临时变量赋值给ret调用了拷贝构造,有的编译器对于:构造+拷贝构造会进行优化,优化成一次拷贝构造函数,直接将z的值拷贝构造给ret(将两次构造优化成一次构造)。

对于这样连续构造的情况,现在的编译器大多会进行优化。
在这里插入图片描述
如果是Date ret = f(f(d),编译器会优化成7次拷贝构造在这里插入图片描述

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值