C++之类和对象

面向对象和面向过程的初步认识

在学习C语言的时候,遇到一个问题的时候我们更关注于的是解决这个问题需要哪些步骤;因此称C语言为面向过程的语言。而面向对象的语言在遇到问题时则不再将重点放在过程上,而是将重点转移到解决这个问题需要的对象上。

面向过程:分析出求解问题的步骤,通过调用函数来逐步解决

在这里插入图片描述

面向对象:将一件事拆分成不同的对象,靠对象之间的交互解决问题

在这里插入图片描述

类的引入

在C语言中“struct”只能用来定义变量,而在C++中“struct”不但可以用来定义变量还可以定义函数。自此结构体升级成了类,在类中定义的变量叫做成员变量,在类中定义的函数称为成员函数或者成员方法,不过一般C++定义类时更喜欢用“class”关键字。

#include<iostream>
using namespace std;

typedef int DataType;
struct Stack
{
	void Init(size_t capacity)
	{
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	void Push(const DataType& data)
	{
		// 扩容
		_array[_size] = data;
		++_size;
	}
	DataType Top()
	{
			return _array[_size - 1];
	}
	void Destroy()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
	DataType* _array;
	size_t _capacity;
	size_t _size;
};
int main()
{
	Stack s;
	s.Init(10);
	s.Push(1);
	s.Push(2);
	s.Push(3);
	cout << s.Top() << endl;
	s.Destroy();
	return 0;
}

在这里插入图片描述

类的定义

定义方法于结构体几乎相同,只是关键字由“struct”替换成了“class”:

class ClassName //关键字接类名
{
	//这里面是类的主体,一般由成员变量和成员函数组成

}; //注意这里一定还要带分号

类的主体一般由成员变量和成员函数组成,但是,是把成员变量放在前面还是把成员函数放在前面这个并没有影响,因为C++认为一个类就是一个整体,并没有什么前后之分。(在C语言中是习惯先定义变量再使用),但是一般我们见到的C++程序都是将成员函数写在成员变量的前面,这是为什么呢?这里借用《高质量C/C++编程》一书来解释:

在这里插入图片描述

类的定义方法有两种,一种是将声明和定义放在一起,还有一种就是将声明和定义分开。

在这里插入图片描述

将声明和定义分离:

在这里插入图片描述

两种定义方法更推荐第二个,因为类中是可以写函数的所以可能导致一个类就会比较长,如果将声明和定义都写在一起想要快速弄清楚一个类的功能就会比较困难。为了方便阅读,更推荐将声明和定义分离。

一个小问题

类中又有变量又有函数,有时候会有变量名和函数参数名相同的情况,这个问题在初始化函数中体现的尤为明显:

在这里插入图片描述

虽然这样并没有什么错误,但是及其不方便阅读,很容易将自己搞晕,要解决这个问题有两个方法:一个是在成员变量前加上域作用限定符,还有一种则是在定义成员变量的时候给其加上一个标识符:

//1.加域作用限定符
class Date
{
public:
	void Init(int year, int month, int day)
	{
		Date::year = year;
		Date::month = month;
		Date::day = day;

	}

private:
	int year;
	int month;
	int day;
	
};

//2.给成员变量加标识符
class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;

	}

private:
	int _year;
	int _month;
	int _day;
	//标识符有多种,并不仅限于这一种表达方式具体还要看公司或者学校要求
};

类的访问限定符及封装

访问限定符

在类和对象阶段主要研究的是C++的封装,而C++实现封装的方法是用类将对象属性和方法结合到一块,让对象更加完善,通过访问权限选择性的将接口提供给外部的用户使用。

在这里插入图片描述

访问限定符说明

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

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

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

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

5.class的默认访问权限为private,struct为public(因为struct要兼容C)

通常不希望成员变量在类外被访问,因此成员变量的访问限定符通常是private

注意: 访问限定符只在编译阶段有用,当数据映射到内存后,就没有任何访问限定符上的差别了。

【面试题】

在C++中class与struct有什么区别?

C++作为C语言的发展产物是兼容C语言的,因此struct关键字到了C++里不但可以继续用来定义结构体甚至还能定义类。但为什么大家更喜欢使用class来定义类呢?因为struct在定义类的时候默认权限是public,而class在定义类的时候默认权限是private。一般来说,并不喜欢在类外可以访问到成员变量。除此之外,在继承和模板参数阶段,struct和class也有一定的区别,这个后续给大家介绍。

封装

面向对象语言有三大特性:封装,继承,多态(并不是说只有三个特性,而是这三大特性比较出名)

在类和对象阶段主要研究的是封装,封装其实就是将数据和操作数据进行有机结合,隐藏对象的属性和实现细节,只对外公开接口来和对象交互。 那么为什么要有封装?封装本质上是一种管理,它可以让用户更方便使用。

比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件

在这里插入图片描述

对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。如果用户使用电脑还需要弄清楚cpu,显卡等内存元件是如何工作的,那计算机的使用门槛也太高了,计算机也就不能方便人们的生活了。从这里也可以看出封装是为了方便用户

除此之外,封装有时候也是一种保护,在C语言实现栈时,取栈顶的元素也是用了函数封装起来的,并没有直接通过访问top变量来获得,这样其实是为了保护top变量的规范。

类的作用域

前面有提到,class创建出来的类默认权限是private,而且在声明类时我们通常会将类的权限设置为私有,这就产生了一个问题就是说定义在类外的函数无法访问成员变量:

在这里插入图片描述

这个问题的解决方法就是使用域访问限定符:

在这里插入图片描述

类的实例化

用类创建对象的的过程称为类的实例化,类是对对象进行描述的是一个模型一样的东西,限定了类有哪些成员并没有分配实际的内存空间来存储它。(声明是不会开空间的,只有定义以后才会开辟空间,可以说类其实就是一次声明,而类的实例化,其实就是类的定义。)

一个类可以实例化出多个对象。类就像设计图纸,而实例化对象就是根据设计图纸建造出来的房子。图纸并不能住人,只有图纸建造出来的房子才可以住人,同时一张图纸是可以建造出多个相同的房子的。

在这里插入图片描述

类对象模型

计算类的大小

类里面又能写变量又能写函数,那么一个类的大小该如何计算呢?

class Person
{
public:
	void Init()
    {
        
    }
private:
	char _name[20];
	char _gender[3];
	int _age;
	
};

int main()
{
	cout << sizeof(Person) << endl;
	return 0;
}

这样一个类的大小是多少呢?如果只有成员变量占空间,那么大小就是28(类也是需要内存对齐的),如果成员函数也要占空间那么就是32(28+4,函数中有多条指令,一般都是存储函数的地址)。事实胜于雄辩,运行一下就能知道结果:

在这里插入图片描述

发现,这个类的大小是28也就是说成员函数并没有占空间。其实在设计类存储时有三个方案:

类对象的存储方式

方式一:类中又放成员变量又放成员函数
在这里插入图片描述

这种就是将成员变量和成员方法都放在类的空间中,这是最容易想到也是最容易理解的方案,但是这样写的话空间的损失就太大了,虽然函数经过编译后变为一道指令存放在代码段中,通常将函数的第一条指令的地址作为函数的地址,类中也存放的是这个地址,不过一个函数四个字节大小,但是每个对象的变量是不同的,如果按照这种方式存储的话,每实例化一个对象就要保存一份代码,空间损失太大。我想你作为用户来说,肯定也是希望一个应用在保证功能的前提下越小越好,所以这种方案没有被采纳。

方案二:类中放成员变量,找一块区域存放成员函数,并把这个区域的地址存放到类中,可以通过这个区域找到函数。

]

这种方式在我们看来已经相当不错了,不用再将每个函数的地址都单独存储起来,除了成员变量以外只是多存放了一个地址不过是四个字节而已。但是这种方式在大佬看来还是不够完美,所以还有第三种方法。

方案三:类中只放成员变量,也不放任何地址,将成员函数放到公共代码段,由编译器去查找
在这里插入图片描述

【补充】

有没有想过一个空类的大小是多少?空类的大小是零吗?

class A
{

};

int main()
{
	cout << sizeof(a) << endl;
	return 0;
}

在这里插入图片描述

运行结果表示空类的大小并不是零字节,而是用一个字节的大小来占位。其实这也是可以预料到的,毕竟如果空类是零字节的话,实例化出来的对象就无法分辨了。其实主要原因是,C++有默认的成员函数,就算我们不写编译器也会自动生成,这个后面会提到。

【结构体内存对齐规则】

  1. 第一个成员在与结构体偏移量为0的地址处。
  2. . 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
    注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。VS中默认的对齐数为8
  3. . 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
  4. . 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

【几个问题】

  1. 结构体怎么对齐? 为什么要进行内存对齐?

解答:结构体的对齐规则在前面已经说过了。内存对齐明明会造成空间浪费,那么为什么还存在内存对齐?主要有以下几个原因:

1.平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

2.性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于:为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。所以内存对齐能够提高访问效率。

大家都知道,我们的机器分为32位机器和64位机器,这里的32位和64位其实指的是CPU的位数,而CPU的位数对应着CPU的字长,而字长又决定着CPU读取数据时一次访问多大即空间,即一次读取几个字节,我们以32位机器为例:

在这里插入图片描述

总体来说:结构体的内存对齐是拿空间来换取时间的做法

  1. 如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?

解答:我们可以使用 “#pragma pack(num)” 命令来修改VS中的默认对齐数,使用该命令可以将对齐数修改为0以后的任意值。

  1. 什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景

解答:大小端是机器针对非单字节的一种存储方式,大端存储是将数据的高位存储在内存的低地址处,小端存储是将数据的低位存储在内存的低地址处。测试机器是大端还是小端只需要取第一个字节就可以判断。

this指针

我们已经知道在类的存储方式上编译器选择了方案三,也就是说我们无论实例化多少个对象,这些个对象用的都是同一份函数。那么问题又来了,既然用的是同一个函数,而且我们也并没有将对象的地址传给函数,函数中也并没有区分对象的方法,那为什么却能输出出不同的结果呢?

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

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

int main()
{
	Date d1, d2;
	d1.Init(2022, 10, 28);
	d2.Init(2023, 1, 23);

	d1.Print();
	d2.Print();

	return 0;
}

在这里插入图片描述

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

在这里插入图片描述

也就是说上述代码其实长这样:

class Date
{
public:
	void Init(Date *const this,int year, int month, int day)
	{
		this->_year = year;
		this->_month = month;
		this->_day = day;
	}

	void Print()
	{
		cout << this->_year << "-" << this->_month << "-" << this->_day << " " << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

但是 this 指针参数以及对象的地址都是由编译器自动传递的,当用户主动传递时编译器会报错;不过在成员函数内部我们是可以显示的去使用 this 指针的:
在这里插入图片描述

this指针的特性

this指针有如下一些特性:

1.this 指针只能在 “成员函数” 的内部使用;

2.this 指针使用 const 修饰,且 const 位于指针*的后面;即 this 本身不能被修改,但可以修改其指向的对象 (我们可以通过 this 指针修改成员变量的值,但不能让 this 指向其他对象)

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

4.this 指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过建立“成员函数”的函数栈帧时压栈传递,不需要用户主动传递。(注:由于this指针在成员函数中需要被频繁调用,所以VS对其进行了优化,由编译器通过ecx寄存器传递)

相关面试题

1.this指针存在哪里?

解答:this 指针作为函数形参,存在于函数的栈帧中,而函数栈帧在栈区上开辟空间,所以 this 指针存在于栈区上;不过VS这个编译器对 this 指针进行了优化,使用 ecx 寄存器保存 this 指针

2.this指针可以为空吗 ?

解答:this指针作为参数传递时是可以为空的,但是如果成员函数用到了this指针,可能会造成空指针的解引用。

3.下面两段代码的运行结果分别是什么?

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

int main()
{
	A* p = nullptr;
	p->Print();
	return 0;
}
//***********************************//
class A  //代码2
{
public:
	void PrintA()
	{
		cout << _a << endl;
	}
private:
	int _a;
};

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

解答:代码1可以正常运行,因为虽然我们用空指针A访问了成员函数Print,但是由于成员函数并不存在于对象中,而是存在于代码段中,所以编译器并不会通过类对象p去访问成员函数,即并不会对p进行解引用。而this指针作为参数传递时是允许为空的,在成员函数里也没有对this指针进行解引用。

在这里插入图片描述

代码2运行崩溃,因为在成员函数中对this指针进行解引用了,而this指针是一个空指针。
在这里插入图片描述

默认成员函数

如果类中什么成员也不写,就称之为空类,空类中真的什么都没有吗?其实并不是,任何类在什么都不写的情况下编译器会自动生成六个默认成员函数。(默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数 )

在这里插入图片描述

下面将对这六个默认成员函数进行讲解。

构造函数

基础知识

构造函数是一个特殊的成员函数,名字与类名相同并且不需要写返回类型。构造函数并不需要用户自己调用,而是在创建类类型对象后由编译器自动调用,并且在对象生命周期内只能调用一次。(注意:构造函数虽然叫构造,但它并不是用来给对象开辟空间的,而是在对象实例化以后,给对象初始化用的,相当于Init函数)。

【构造函数的特性】

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载也支持缺省参数
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成;
  6. 构造函数对内置类型不处理,对自定义类型调用自定义类型自身的默认构造;
  7. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个

以日期类来讲解构造函数的一些特性:

class Date
{
public:
	Date()//无参构造函数
	{

	}

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

int main()
{
	
	Date d1;
	//d1.Print();

	Date d2(1949, 1, 03);
	d2.Print();

	return 0;
}

在这里插入图片描述

编译成功,也就是说构造函数是支持重载和缺省参数的。但是有一点需要注意的是,当构造函数是无参时,对象后面不要跟括号,否则会产生二义性,也就是说编译器无法确定这个是函数声明还是无参的构造函数。

在这里插入图片描述

虽然说构造函数支持重载,但一般只需要显示定义一个全缺省的构造函数即可(选择缺省是因为这个比较灵活有多种调用方式)。

自动生成

构造函数第五点特性提到,如果没有显示定义构造函数,编译器就会自动生成一个无参的默认构造函数。

在这里插入图片描述

可以看到,我们不写编译器确实会有一个构造函数来初始化,不过这个初始化出来的数太随机值了,看起来就像乱码一样。这是为什么?这就要用构造函数的第六个特性来解释了;

选择处理

C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型 ,**构造函数对内置类型并不处理,而面对自定义类型则会调用自定义类型的构造函数。**解下来看这样一段代码:

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

		cout << "Stack 构造" << endl;
	}

	void Push(int x)
	{
		_a[_top++] = x;
	}

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

class MyQueue
{
public:
	void Push()
	{
		_pushST.Push(2);
	}
private:
	Stack _pushST;
	Stack _popST;
};

class Date
{
public:
	
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << " " << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	
	Date d1;

	d1.Print();

	//Date d2(1949, 1, 03);
	//d2.Print();

	Stack st1;
	MyQueue q;


	return 0;
}

日期类都是内置类型,不处理:

在这里插入图片描述

Stack栈都是内置类型,但我写了构造函数,所以可以将栈初始化我想要的结果:

在这里插入图片描述

MyQueue虽然没写构造函数,但是MyQueue都是自定义类型,会去调用Stack的构造函数,而我写了Stack的构造函数:

在这里插入图片描述

其实MyQueue不写构造函数,然后Stack也不写构造函数,最后MyQueue得到的还是随机值,因为最后还是全部都是由内置类型组成的。只要有内置类型就是要写构造函数的。 这样很麻烦,所以到了C++11的时候,大佬们针对这个问题又打了一个补丁,也就是说在声明内置类型的时候可以给一个缺省值。
在这里插入图片描述

这个缺省值功能可以说十分强大,甚至可以给定一块空间:
在这里插入图片描述

但是这里有一点要注意就是,虽然调用了malloc看起来像是开辟了空间,但其实没有,前面就提到了,类并不会开辟空间相当于一个函数的声明而已,只有在实例化对象的时候才会开辟空间。这里的malloc只是相当于我在设计图纸上标注了某个房间的面积是多大,但是在建造出这个房间之前,这个房间并不会占用任何实际的空间。

默认构造

构造函数的第七个特性是:无参的构造函数和全缺省的构造函数都称之为默认构造函数,并且默认构造函数只能有一个。构造函数虽然可以重载,但是无参和全缺省是不能构成重载的,因为在调用的时候这两种函数都可以不传参会产生二义性。

在这里插入图片描述

如果我们写了构造函数,并且不是默认构造函数,那就必须要传参数

在这里插入图片描述

析构函数

基础知识

在C语言中很容易被忘记将开辟的动态内存空间进行释放,但是如果不释放就会造成内存泄漏的问题。而C++引入了析构函数作为默认成员函数,它会在程序结束时由编译器自动调用完成资源的释放(与构造函数并不是开辟空间类似,析构函数并不是销毁对象,销毁对象是由编译器来进行的),与构造函数正好相反。

当变量的生命周期结束时变量就被销毁,所以位于函数中的局部对象在函数调用完成时销毁,位于全局的对象在main函数调用完成时销毁;另外,后定义的对象会被先销毁

析构函数的特性如下:

1.析构函数名是在类名前加上字符 ~ (表示与构造函数功能相反);;

2.无参数无返回值;

3.一个类只能有一个析构函数,若未显式定义,系统会自动生成默认的析构函数;(注意:析构函数不能重载)

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

5.析构函数对内置类型不处理,对自定义类型调用它自身的析构函数

在这里插入图片描述

可以看到,虽然我没有调用析构函数,但是编译器在main函数结束时自动调用完成了资源的清理。

选择处理

对于没有资源申请的类可以不用显示定义析构函数,编译器自动生成的析构函数就够用,但是对于有资源申请的类就必须要显示定义析构函数否则会造成资源损失。
在这里插入图片描述
在这里插入图片描述

如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date 类;有资源申请时要写,否则会造成资源泄漏,比如Stack类;如果类中有自定义类型,编译器会去调用自定义类型的析构函数。

拷贝构造

基础知识

复制是我们经常使用到的操作,如果想复制一个类的话该怎么办?C++对于这个问题的解决方案是提供了一种叫做拷贝构造的成员函数。

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

拷贝构造也是特殊的成员函数,其特征如下:

1.拷贝构造函数是构造函数的一个重载形式,当我们使用拷贝构造实例化对象时,编译器不再调用构造函数;

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

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

4.默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数;

引用做参数

拷贝构造的第二个特性说拷贝构造函数的参数必须是引用,否则会有无穷递归的现象产生,这是因为传值传参本身就是一次拷贝(传值传参是建立一个临时变量,然后将值拷贝给这个临时变量),而拷贝类就需要调用拷贝构造,也就是说如果传值的话编译器会陷入一个”调用拷贝构造需要先传值,传值需要拷贝,拷贝需要调用拷贝构造 “这样一个死循环。

在这里插入图片描述

如果是用引用做参数的话,形参作为实参的别名,可以说是实参的本身也就不需要拷贝实参了,就可以避免无穷递归的发生。此外拷贝构造并不需要改变拷贝方,为了防止下面意外的发生,建议用const修饰参数:

在这里插入图片描述

深浅拷贝

默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数。这种浅拷贝对于要动态开辟空间的自定义类型来说并不够用:

在这里插入图片描述

可以看到浅拷贝虽然将变量st1拷贝给st2了,但是它们两个指向的是同一块空间,这样在向其中任意一个变量种插入元素时都会影响到另外一个变量,而且还会有一个隐藏问题就是在free释放空间时,同一块空间被释放了两次,程序将执行失败:

在这里插入图片描述

正确的拷贝应该是为st2单独开辟一块空间,并将st1中的所有数据拷贝到st2中:

在这里插入图片描述

对于没有申请资源举动的类来说,编译器生成的默认拷贝构造函数已经够用,就没有必要再显示定义拷贝构造函数了,比如日期类:

在这里插入图片描述

可以看到,我没有写拷贝构造,但是编译器生成的默认拷贝构造将d1变量成功拷贝给了d2。

总结

如果类中没有资源申请,则不需要手动实现拷贝构造,直接使用编译器自动生成的即可;如果类中有资源申请,就需要自己定义拷贝构造函数,否则就可能出现浅拷贝以及同一块空间被析构多次的情况;

其实,拷贝构造和函数析构函数在资源管理方面有很大的相似性,可以理解为需要写析构函数就需要写拷贝构造,不需要写析构函数就不需要写拷贝构造;

拷贝构造的经典使用场景:

使用已存在对象创建新对象;
函数参数类型为类类型对象;
函数返回值类型为类类型对象;

运算符重载

基础知识

对于C/C++编译器来说,它知道内置类型的运算规则,比如整形+整形、指针+整形、浮点型+整形;但是它不知道自定义类型的运算规则,比如日期+天数 、日期直接比较大小、日期-日期;我们要进行这些操作就只能去定义对于的函数,比如AddDay、SubDay;但是这些函数的可读性始终是没有 + - > < 这些符号的可读性高的,而且不同程序员给定的函数名称也不一样相同;所以为了提高代码的可读性,C++引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。运算符重载的函数名为关键字operator后面接需要重载的运算符符号 ,函数原型为:返回值类型 operator操作符(参数列表)

有关运算符重载有以下几点是需要我们注意的:

1.不能通过连接其他符号来创建新的操作符:比如operator@
2.重载操作符必须有一个类类型参数
3.用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
4.作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this

5." . * " " :: " " sizeof " " ? " " : " . 注意以上5个运算符不能重载。这个经常在笔试选择题中出现

日期类运算符重载的实现

再日常生活中我们经常会对日期进行计算,所以后面我将用日期类来进行讲解,首先这里放上正确的+=重载形式:

class Date  //全是内置类型,只要写构造函数
{
public:
	Date(int year = 2022, int month = 2, int day = 17)
	{
		_year = year;
		_month = month;
		_day = day;
	}


	int GetMonthDay(int year,int month)
	{
		//要获得每个月的天数,使用数组是最方便的办法

		static int day[13] = { 0,31,28,30,30,31,30,31,31,30,31,30,31 };

		//对于二月这个特殊的月份而言,如果是闰年则是29天
		if (month == 2 && (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)))
		{
			return 29;
		}
		return day[month];
	}

	Date& operator+=(int day)//最好用引用返回
	{
		//如果当前天数加上day超过本月该有的天数,则月份加一
		_day += day;
		while (_day > GetMonthDay(_year, _month))
		{
			_day -= GetMonthDay(_year, _month);
			_month++;

			if (_month == 13)
			{
				_year++;
				_month = 1;
			}
		}
        return *this;
	}

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

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

int main()
{
	Date d1;
	d1 += 30;
	d1.Print();
	return 0;
	
}

在这里插入图片描述

2022年不是闰年,所以2月17日的三十天后是3月19日。在数组前面加了static将数组变成了静态的是为了提高效率,数组作为函数内的变量在函数栈帧销毁以后就会被销毁,在函数调用时随着函数栈帧的建立才会建立,也就是说每次函数调用的时候都要重新建立数组。但是如果加上一个statci关键字将数组从栈区改到静态区,就只会初始化一次,只有第一次函数调用的时候需要建立数组,此后每次函数调用都不用重新建立数组。 此外还可以发现两件事,首先我把这个运算符重载写在类中而不是类外,其次我只传了一个参数。接下来解答这两个问题:


前面讲访问限定符的时候就有提到过,C++并不希望能在类外访问到类的成员数据,所以类的成员变量一般都是私有的,在类外是无法访问的,总不能因为需要写运算符重载就将成员变量的权限改成公有吧,那就因小失大了,所以最好的办法就是讲运算符重载写在类里面,否则是无法访问到成员变量的:
在这里插入图片描述


那么为什么我在写运算符重载的时候只传了一个参数,而且是需要加的天数而不对象呢?因为这个“+=”本来也就是只要两个参数。在前面有说,C++给每个非静态的函数加了一个隐藏的this指针,这个指针就代表了对象本身,所以说只需要传一个参数就够了。如果你非要传两个参数,那也可以无非就是程序错误而已嘛:

在这里插入图片描述

前后置++重载

这里有必要提一下++运算符的实现,++运算符是一个单操作运算符,也就是说只需要一个参数,也就是说只用隐含的this指针就可以了,但是这样的话有一个问题就产生了,那就是怎么区分前后置++呢?C++为了解决这个问题,有一个约定**后置++多一个整形参数,这个参数没有实际意义,只是用于区分。**下面实现一下前后置++的重载:

	//前置++不用写参数,返回++后的值
	Date& operator++()
	{
		*this += 1;
		return *this;
	}

	//后置++需要多写一个整形类型的参数作为区分

	Date operator++(int i)
	{
		//返回的是++前的值,所以需要使用到拷贝构造
		Date ret(*this);
		*this += 1;
		return ret;//这个ret是一个临时变量,出了函数以后就不存在了,所以只能传值返回
	}

在这里插入图片描述

常见的运算符重载

常见的运算符重载有:operator+ (+)、operator- (-)、operator* (*)、operator/(/)、operator+= (+=)、operator-= (-=)、operator== (==)、operator= (=)、operator> (>)、operator< (<)、operator>= (>=)、operator<= (<=)、operator!= (!=)、operator++ (++)、operator-- (–)等;

其中,对于 operator++ 和 operator-- 来说有一些不一样的地方 –因为 ++ 和 – -分为前置和后置,二者虽然都能让变量自增1,但是它们的返回值不同;但是由于 ++ 和 – -只有一个操作数,且这个操作数还会由编译器自动传递;所以正常的 operator++ 和 operator-- 并不能对二者进行区分;最终,C++规定:后置++/–重载时多增加一个int类型的参数,此参数在调用函数时不传递,由编译器自动传递;

其次,重载函数中的 operator= 就是默认成员函数之一 – 赋值重载函数;

注:由于运算符重载函数很多,情况也比较复杂,所以我们将运算符重载的详细细节 (比如引用做返回值、引用做参数、函数的复用、对特殊情况的处理等知识) 放在 Date 类的实现中去介绍;

赋值重载

基础知识

**赋值重载函数也是C++默认的六个成员函数中的一个,是运算符重载的一种形式,它的作用是在两个已经存在的函数间赋值。**有以下特性:

  1. 赋值重载的格式规范;
  2. 赋值运算符只能重载成类的成员函数不能重载成全局函数;
  3. 若未显式定义,编译器会生成默认的赋值重载函数;
  4. 默认的赋值重载函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的赋值重载函数;

日期类赋值重载的实现

赋值重载一般使用引用做参数(这里其实可以使用传值传参,但是传值传参要拷贝临时变量,所以为了提高效率还是使用引用做参数),并用const修饰(是为了防止写错顺序将数据篡改)。在返回值方面也是使用传引用返回,这样也是为了提高效率(毕竟传值返回的话也是需要一次临时变量的拷贝,虽然VS对此做了优化,在传值返回时如果变量较小就使用寄存器返回,但是标准中是有一次临时变量的拷贝)。

此外在赋值重载的时候,有时候会出现给自己赋值的情况,要检查防止这种情况的出现。用户在调用成员函数时有可能发生下面这种情况:Date d1; Date& d2 = d1; d1 = d2; 这种情况对于只需要浅拷贝的对象来说并没有什么大碍,但对于有资源申请,需要进行深拷贝的对象来说就会发生不可控的事情。在《Effective C++》一书中对赋值重载的自我赋值是这样说的:

img

下面实现Date的赋值重载函数:

class Date
{
public:
	Date(int year = 2023, int month = 1, int day = 17)
	{
		如果只是这样写的话,就算是非法日期也会输出,建议这里还要检查以下日期的合法性
		//_year = year;
		//_month = month;
		//_day = day;

		if ((_year >= 1) && (_month >= 1 && _month <= 12) && (_day >= 1 && _day <= GetMonthDay(_year, _month)))
		{
			_year = year;
			_month = month;
			_day = day;
		}
		else
		{
			cout << "日期非法" << endl;
		}
	}

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

	int GetMonthDay(int year,int month)
	{
		static int arrDay[13] = { 0,31,28,30,30,31,30,31,31,30,31,30,31 };
		if ((month==2)&&(year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)))
		{
			return 29;
		}
		return arrDay[month];
	}

	Date& operator=(const Date& d)
	{
		//要先判断一下是否是自我赋值
		if (*this == d)//要使用“==”首先要运算符重载
		{
			return *this;
		}
		
		//不是自我赋值再进行下一步
		_year = d._year;
		_month = d._month;
		_day = d._day;

		return *this;
	}

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

在这里插入图片描述

这里我还想解释一下,为什么要有返回值。因为在进行赋值操作的时候我们经常会连续赋值如(a=b=c)这样的操作,其实这个连续赋值是从右往左进行的,也就是说其实是先将c赋值给b,在将b=c这个赋值表达式的返回值赋值给a,所以还是需要返回值。

重载限制

**赋值运算符只能重载成类的成员函数而不能重载成全局函数。**因为赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。这一特性在《C++ primer》中也有解释:

在这里插入图片描述
在这里插入图片描述

深浅拷贝

用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。逐字节拷贝,这和拷贝构造一样因此也存在和拷贝构造一样的问题就是对于有资源申请的类来说,编译器默认生成的赋值重载是不够用的。这里以栈举例:

在这里插入图片描述

可以看到这里没写赋值重载函数直接报错了,所以其实只要写了析构函数的就要写拷贝构造也就是要写赋值重载。否则只是浅拷贝,在程序结束时同一块空间会被析构两次,和拷贝构造那里是同一个问题,此外还会有内存泄漏的问题存在,因为st1拷贝st2以后,st1原来的那块空间就没人能找到了:

在这里插入图片描述

Stack的赋值重载:

Stack& operator=(const Stack& st)
	{
		free(_a);
		_a = (int*)malloc(sizeof(int) * st._capacity);
		if (_a == NULL)
		{
			perror("malloc fail\n");
			exit(-1);
		}
		//拷贝
		memcpy(_a, st._a, sizeof(int) * st._capacity);
		_top = st._top;
		_capacity = st._capacity;
	}

在这里插入图片描述

深拷贝栈st1需要有一块和st2相同大小的空间,或许有人会疑惑为什么一定要先释放然后重新开辟,而不能使用realloc来改变大小。其实是可以使用realloc的,只是并不建议这样做,因为你不知道到底是st1空间大还是st2空间大,如果是缩容,那么就需要重新找一块空间来给st1使用,那么这样的代价是比较大的。如果不缩容,那又可能造成空间的浪费。所以还不如直接重新建立一块新的空间来使用。

前面在实现日期类函数重载的时候有考虑到一个自我赋值的问题,那么栈是否也需要考虑这个问题呢?下面来看一个示例:

在这里插入图片描述

这是为什么?其实并不难理解,因为在赋值重载时为了防止内存泄漏的问题会首先将原空间释放再申请新的空间,此时新的空间未被初始化,里面全是随机值。

在这里插入图片描述

所以在赋值重载的时候一定要检查是否是自我赋值,正确的Stack赋值重载函数如下:

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

		_top = 0;
		_capacity = capacity;
	}

	~Stack()
	{
		free(_a);
		_a = NULL;
		_top = _capacity = 0;
	}

	Stack(Stack&st)
	{
		_a = (int*)malloc(sizeof(int) * 4);
		_top = st._top;
		_capacity = st._capacity;
	}
	void Push(int x)
	{
		_a[_top++] = x;
	}

	bool operator==(const Stack& st)
	{
		return _a == st._a && _top == st._top && _capacity == st._capacity;
	}

	Stack& operator=(const Stack& st)
	{
		if (*this == st)
		{
			return *this;
		}
		free(_a);
		_a = (int*)malloc(sizeof(int) * st._capacity);
		if (_a == NULL)
		{
			perror("malloc fail\n");
			exit(-1);
		}
		//拷贝
		memcpy(_a, st._a, sizeof(int) * st._capacity);
		_top = st._top;
		_capacity = st._capacity;
	}

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

在这里插入图片描述

总结

自动生成的赋值重载函数对成员变量的处理规则和析构函数一样 – 对内置类型以字节方式按值拷贝,对自定义类型调用其自身的赋值重载函数;我们可以理解为:需要写析构函数的类就需要写赋值重载函数,不需要写析构函数的类就不需要写赋值重载函数。

日期类的实现

其实这个没什么好说的,无非就算根据我们前面所讲的运算符重载以及赋值重载等知识来实现日期之间的加减等功能。相当于是对之前知识的巩固与练习,这里就不多赘述了,直接放上代码:

class Date
{
public:
	Date(int year = 2023, int month = 1, int day = 17)
	{
		如果只是这样写的话,就算是非法日期也会输出,建议这里还要检查以下日期的合法性
		//_year = year;
		//_month = month;
		//_day = day;

		if ((year >= 1) && (month >= 1 && month <= 12) && (day >= 1 && day <= GetMonthDay(year, month)))
		{
			_year = year;
			_month = month;
			_day = day;
		}
		else
		{
			cout << "日期非法" << endl;
		}
	}


	int GetMonthDay(int year,int month)
	{
		static int arrDay[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
		if ((month==2)&&(year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)))
		{
			return 29;
		}
		return arrDay[month];
	}

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

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

	Date& operator=(const Date& d)
	{
		//要先判断一下是否是自我赋值
		if (*this == d)
		{
			return *this;
		}
		
		//不是自我赋值再进行下一步
		_year = d._year;
		_month = d._month;
		_day = d._day;

		return *this;
	}

	Date& operator+=(int day)
	{
		//如果当前天数加上day超过本月该有的天数,则月份加一
		_day += day;
		while (_day > GetMonthDay(_year, _month))
		{
			_day -= GetMonthDay(_year, _month);
			_month++;

			if (_month == 13)
			{
				++_year;
				_month = 1;
			}

		}
		return *this;
	}
	bool operator<(const Date& d)
	{
		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;
		}
		return false;
	}

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

	Date& operator-=(int day)//这里是日期和天数之间的减法
	{
		//这里先写日期和天数之间的相减,日期减天数,核心是借位
		_day -= day;
		while (_day <= 0)
		{
			//本月天数不够,就向之前的月份借,首先月份减减
			_month--;
			
			//这里处理一些特殊情况,比如_month=0;
			if (_month == 0)
			{
				--_year;
				_month = 12;
			}
			_day += GetMonthDay(_year, _month);
		}
		return *this;
	}

	int operator-(const Date& d)
	{
		//两个日期之间相减,这里有一个很巧妙的办法,就是复用++,将小的日期++n次,直到和日期较大的相等
		//这里首先假设左值的日期大于右值
		Date max = *this;
		Date min = d;
		int flag = 1;//这里设立flag是为了判断是左边日期大于右边,还是右边大于左边

		if (*this < d)
		{
			max = d;
			min = *this;
			flag = -1;
		}
		int n = 0;
		while (min != max)
		{
			++n;
			++min;
		}
		
		return n*flag;//如果返回值是负数,说明是当前日期小于比较日期
	}
	
	//前置++不用写参数,返回++后的值
	Date& operator++()
	{
		*this += 1;
		return *this;
	}

	//后置++需要多写一个整形类型的参数作为区分

	Date operator++(int i)
	{
		//返回的是++前的值,所以需要使用到拷贝构造
		Date ret(*this);
		*this += 1;
		return ret;//这个ret是一个临时变量,出了函数以后就不存在了,所以只能传值返回
	}

	//前后置--与前后置++是同一个道理这里就不在实现了

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

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

取地址以及const取地址重载

const成员函数

我们将const修饰的成员函数称之为const成员函数const修饰类的成员函数时,其实是修饰隐藏的this指针,表明该成员函数不能对类的任何成员进行修改。

在这里插入图片描述
在这里插入图片描述

可以看到,这里我定义了一个const类型的只读日期类,甚至连打印都做不到,这是为什么?因为类的成员函数第一个参数是隐藏的this指针,而日期类this指针的完整类型是:Date const*this,这个const修饰的是this指针本身,也就是说this指针指向的值是可以修改的。不可修改的只读变量作为参数传给可以修改的指针,这样就有权限放大的问题。但是this指针又是隐藏的参数,我们不能显示的去写。为了解决这个问题,C++规定可以在函数名的最后写一个const用于修饰隐藏的this指针:

在这里插入图片描述

建议对于不用修改对象的函数都加上const(也就是不用修改this指针代表的对象),这样无论传来的参数是不是const类型的都可以使用,因为权限可以平移和缩小,但是不能放大。

最后,我们来做几个思考题:

const对象可以调用非const成员函数吗?-- 不可以,权限扩大;
非const对象可以调用const成员函数吗?-- 可以,权限缩小;
const成员函数内可以调用其它的非const成员函数吗?-- 不可以,权限扩大;
非const成员函数内可以调用其它的const成员函数吗?-- 可以,权限缩小;

取地址重载

取地址重载也是C++默认的六大成员函数之一,是运算符重载的一种:

Date* operator&()
		{
			return this;
		}

在这里插入图片描述

const取地址重载

const 取地址重载也是C++的默认六个成员函数之一,它是取地址重载的重载函数,其作用是返回 const 对象的地址:

	const Date* operator&()const
		{
			return this;
		}

在这里插入图片描述

其实这两个默认成员函数很少让我们自己实现,除了某些特定场景不允许获得该对象的地址,任何取地址行为都直接返回空以外,想不到别的应用场景了。


初始化列表

基础知识

通过前面我们已经知道,在创建对象时编译器会自动调用构造函数对对象的各个变量赋一个合适的初值:

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

上述函数调用之后,对象已经有了一个合适的初始值,但是这并不能称为对对象中的成员变量进行初始化,构造函数中的语句只能称之为赋初值,不能称为初始化,因为初始化只能初始化一次,而构造函数体内能多次赋值

此外前面也有说过类中只是成员变量的声明并没有定义,并不会占用内存空间,只有当实例化出对象以后才会占用内存空间,而实例化对象时是整个对象一起定义的,那么类中的成员变量又是在哪单独定义的呢?

C++类对象中的成员变量通过初始化列表定义和初始化初始化列表以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式

class Date
{
Date(int year=2023,int month=2,int day=17)
			:_year(year)
			,_month(month)
			,_day(day)
		{}
private:
		int _year;
		int _month;
		int _day;
};

在这里插入图片描述

特性

初始化列表有以下几个特性:

1.初始化列表是成员初始化的地方,所以每个变量(无论是内置类型还是自定义类型)都会一定会走一次初始化列表,无论我是否显示写了初始化列表,所以每个成员都只能在初始化列表中出现一次:

在这里插入图片描述

就像世界上很多事只有第一次才让人充满感触一样,要牢记初始化只能有一次

2.如果我显示写了初始化列表,那么编译器就会调用我显示写的;否则对于内置类型编译器会使用随机值来初始化,对于自定义类型的话编译器就会去调用它的默认构造函数,如果没有默认构造函数编译器就会报错:

在这里插入图片描述

可以看到对于内置类型_a当我不在初始化列表中初始化它,编译器就会用一个随机值来初始化,而我不在初始化列表中初始化,编译器就会自己去找自定义类型自己的默认构造函数,当自定义类型既没有在初始化列表中显示定义又没有默认构造函数时就会报错:

在这里插入图片描述

3.如果类中包含以下成员就必须要显示定义在初始化列表中:

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

**引用是给变量取别名,一旦它成为了某一个变量的别名就不能再成为另一个变量的别名,也就是说它只有一次初始化的机会并且必须在定义的时候初始化,const作为只读常量,也是必须要在定义的时候就初始化,并且只能初始化一次。**前面说了构造函数只是赋值并不是初始化,真正的初始化只有在初始化列表中,所以引用成员变量和const成员变量都必须显示的写在初始化列表中。

img

image-20221014152923817


此外构造函数的初始化列表是可以和函数体的赋值一起使用的,这样的使用方法在有资源申请的类会十分好用,这里以Stack为例:

class Stack
{
public:
	Stack(int capacity=4)
		:_top(0)
		,_capacity(capacity)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a == NULL)
		{
			perror("malloc fail\n");
			exit(-1);
		}
	}

	~Stack()
	{
		free(_a);
		_a = NULL;
		_top = _capacity = 0;
	}


	void Push(int x)
	{
		_a[_top++] = x;
	}


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

在这里插入图片描述

4.尽量使用初始化列表,因为无论我们是否显示定义初始化列表,成员变量都会走一次初始化列表。


5.成员变量在类中声明的顺序就算初始化的顺序,也就是说初始化看的不是初始化列表中显示定义的顺序而是看类的声明顺序:

class A
{
public:

	A(int a=1)
		:_a2(a)
		,_a1(_a2)
	{}
	void Print()
	{
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a1;
	int _a2;
};

int main()
{
	A _aa;
	_aa.Print();
	return 0;
}

在这里插入图片描述

可以看到我首先声明的是_a1,但是在显示写初始化列表时是将 _a2的初始化写在前面,如果是按照声明顺序初始化的话,就应该是先初始化 _a1,也就是说此时 _a2的值还是一个随机值,而我用 _a2的值初始化 _a1得到的也就是一个随机值。输出结果表示 _a1的值确实是一个随机值,也就是说初始化列表的顺序只与类的声明顺序有关。


隐式类型转换

基础知识

隐式类型转换是指两个不同类型的变量在进行运算时(包括赋值),编译器会自动将其中一个变量的类型转换成另外一个变量的类型。比如:

int main()
{
	int a = 0;
	double b = a;
	const double& rb = a;
    const int& c = 1;
}

如上,为什么int类型可以赋值给double呢?就是因为存在隐式的类型转换。这个赋值并不是将a直接赋值给b的,而是根据b的类型产生了一个临时变量,将a的值赋给临时变量,再由临时变量赋值给b。

对于rb来说也是一样的,只不过对于引用和指针来说要考虑权限的缩小和放大的问题,而产生的临时变量具有常性,所以对于rb变量要加上const修饰。

最后一个也是大同小异,对于整形数据1来说要先产生一个临时变量将1赋值给临时变量,最后由临时变量赋值给c,又由于临时变量具有常性,所以要加const修饰。

构造函数的隐式类型转换

在C++98中,单参数的构造函数也支持隐式函数重载,这里说的单参数是指只需要传一个参数的函数,包括单参数,全缺省和半缺省。

class Date
{
public:
	Date(int year=2022, int month = 2, int day = 10)
		:_year(year)
		,_month(month)
		,_day(day)
	{
		cout << "构造函数" << endl;
	}

	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
		cout << "拷贝构造" << endl;
	}
	
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2(d1);
	Date d3 = 2022;

	return 0;
}

在这里插入图片描述

可以看到对于日期类我们不但可以使用日期类来拷贝构造和构造来初始化,同样可以运用赋值一个整形来初始化。日期类和整形两种不同的类型直接可以赋值,正是隐式类型转换的原因。

这里还要讲一下d3,从上图可以发现将一个整形类型的数赋值给一个日期类似乎只是调用了一个构造函数,但真是这样吗?

其实不是这样的,前面有提到对于赋值操作的时候其实并不是直接赋值而是要通过一个临时变量做中转的。也就是说要先产生一个日期类临时变量将这个整形赋值给这个日期类的临时变量,产生日期类临时变量的时候需要调用一次拷贝构造吧。将临时变量赋值给d3的时候又要调用一次d3的构造函数,所以这个过程其实是拷贝构造+构造得到的,不过编译器做了优化跳过了拷贝构造的过程。但是如果你使用的是一些较老的编译器就可能没有优化。

**C++11又对这个语法进行了拓展,支持多参数的构造函数,**只是在传递多参数时需要使用一个花括号

在这里插入图片描述

explicit 关键字

这个关键字用于修饰构造函数,可以禁止隐式类型转换的行为:

class Date
{
public:
	explicit Date(int year = 2022, int month = 10, int day = 10)
		:_year(year)
		, _month(month)
		, _day(day)
	{
		cout << " 构造" << endl;
	};

	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
		cout << " 拷贝构造" << endl;
	}

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

在这里插入图片描述

类型转换的意义

隐式类型转换其实是有风险的,一个double赋值给int就会直接丢失小数部位的数据。因此也就有了explicit关键字的存在,那么既然有风险我们不直接禁止呢?因为隐式类型转换在很多时候可以方便我们:

int main()
{
	string s1("hello");
	push_back(s1);

	push_back("hello");
}

在上述代码的情况下,如果有隐式类型的转换,我们在插入s1时就不必要先构造一个string,而是可以直接使用hello做参数,其实类似这样的情况还非常多,以后你就会发现了

static成员

基础知识

声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化 ,下面看一个面试题:

面试题:实现一个类,计算程序中创建出了多少个类对象。

class A
{
public:
    A() { ++_scount; }
    A(const A& t) { ++_scount; }
    ~A() { --_scount; }
    static int GetACount() { return _scount; }
private:
	static int _scount;
};
int A::_scount = 0;
void TestA()
{
    cout << A::GetACount() << endl;
    A a1, a2;
    A a3(a1);
    cout << A::GetACount() << endl;
}

img

我们知道要创建一个类对象就一定会调用构造函数,所以构造函数被调用的次数就是创建类对象的个数。虽然使用全局变量也可以达到这个目的,但是并不建议使用全局变量,因为全局变量随处都可以修改,而使用static作为类的成员变量的话会受到类域的限制所以相对会更安全。

static成员变量

静态成员变量指的是用static修饰的成员变量,具有以下特性:

  • 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区;
  • 静态成员变量必须在类外定义,定义时不添加 static 关键字,类中只是声明;
  • 静态成员变量的访问受类域与访问限定符的约束;

因为静态成员变量不是属于任一类对象的而是存在于静态区由所有对象共享的所以不能将静态成员变量写入初始化列表:

在这里插入图片描述

写道初始化列表中的成员变量在每个成员被实例化时就会定义并且初始化,不能将静态成员写入初始化列表就从侧面表明静态成员并不是属于某一个对象的。

此外类中只是声明,又不能在初始化列表中定义静态成员,那么静态成员应该在哪定义呢?静态成员需要在全局定义并且要加上类访问限定符,此时不用再加static关键字:

在这里插入图片描述

一般来说在类外是无法访问到类中的成员变量的,不只是因为类域的原因再一个就是因为访问限定符的阻碍,但静态变量在定义的时候只需要标明域就可以打破访问限定符的限制,这是一个特例需要我们记住。

其实静态成员变量除了在定义的时候可以无视访问限定符以外,其他时候和普通成员变量没什么区别:

在这里插入图片描述

在有了静态成员变量后,统计对象创建的个数时就可以使用静态成员变量了,但是此时又面临类访问限定符限制的问题,为了解决这个问题,C++给出了静态成员函数来解决

static成员函数

静态成员函数是指用static关键字来修饰的函数,有如下特性:

  • 静态成员函数没有隐藏的this指针,不能访问任何非静态成员;
  • 静态成员也是类的成员,同样受类域和访问限定符的约束;

静态成员函数最特别的地方在于它没有那个隐藏的this指针,所以我们在调用的时候不用传对象的地址,因此可以直接使用域限定符直接调用,而不需要创建对象,就能直接访问到类里面的静态成员变量。但是相应的没了this指针就不能访问到非静态成员变量和成员函数。因为非静态成员变量需要实例化以后才有实体

class A
{
public:
	A(int i = 0)
		:_i(i)
	{
		_n++;
	}

	A(const A& a)
	{
		_i = a._i;
		_n++;
	}

	static int GetN()
	{
		return _n;
	}

private:
	int _i;
	static int _n;
};

int A::_n = 0;

在这里插入图片描述

可以看到这里有红色波浪线,但是程序却正确运行了,其实编译器给的这个红色波浪线的警告就像某些人很准的第六感一样,并不是每次都能带你走向正确的,要以输出列表为准,因为输出列表给的报错是最正确的,同时要从第一个错误开始解决。

虽然静态成员函数无法调用非静态成员变量,但是非静态成员函数可以调用静态成员变量

最后来做一道题来巩固一下静态成员的知识:求1+2+3+…+n

class Sum
{
public:
    Sum()
    {
        //每构造一次_i++一次,再用ret累加i
        _i++;
        _ret+=_i;
    }

    static int GetRet()
    {
        return _ret;
    }
private:
   static int _i;
   static int _ret;
};
int Sum::_i=0;
int Sum::_ret=0;



class Solution {
public:
    int Sum_Solution(int n) {
        
        //在这里定义n个对象即可,每定义一个对象就要调用一次构造函数
        Sum arr[n];
        return Sum::GetRet();
    }
};

友元

引入

在C++中我们经常使用cout和cin配合流提取<<及流插入>>来使用,因为它们可以自动识别内置类型。其实它们之所以可以做到自动识别内置类型是因为函数重载和运算符重载

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

可以看到,cin 和 cout 分别是 istream 和 ostream 类的两个全局对象,而 istream 类中对流提取运算符 >> 进行了运算符重载,osteam 中对流插入运算符 << 进行了运算符重载,所以 cin 和 cout 对象能够完成数据的输入输出;同时,istream 和 ostream 在进行运算符重载时还进行了函数重载,所以其能够自动识别数据类型;

为了方便自定义类型的输入输出,我们也可以重载一下<<和>>

class Date
{
public:
	Date()
		:_year(2023)
		,_month(2)
		,_day(17)
	{}
	//因为类对象是隐藏的this指针,所以不用再显示的传对象
	istream& operator>>(istream& in)
	{
		in >> _year;
		in >>_month;
		in >> _day;
		return in;
	}

	ostream& operator<<(ostream& out)const
	{
		out << _year << " " << _month << " " << _day << endl;
		return out;
	}
private:
	int _year;
	int _month;
	int _day;
};

在这里插入图片描述

我们重载了以后编译器还是说没有域这些操作数匹配的运算符,为什么?因为我们在类中定义的这个函数,所以第一个参数默认为隐藏的this指针,也就是左参数必须是类对象,因此这个运算符应该要这样使用:
在这里插入图片描述

可以看到这样使用就没有任何问题了,但是这样的可读性不高,重载这个运算符又显得没有意义了(运算符重载就是为了提高可读性)。或许你会说将这个运算符重载写成全局函数,但是其实这样也是不行的,因为要受到类访问限定符的限制。为了解决这个问题呢C++就提出了一个叫友元的东西,友元又分为友元函数和友元类;

友元函数

**友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。 **

class Date
{
	//友元声明,可以放在类的任意位置

	friend istream& operator>>(istream& in, Date& d);
	friend ostream& operator<<(ostream& out,const Date& d);
public:
	Date()
		:_year(2023)
		,_month(2)
		,_day(17)
	{}
	
	
private:
	int _year;
	int _month;
	int _day;
};
inline istream& operator>>(istream& in,Date&d)
{
	in >> d._year;
	in >> d._month;
	in >> d._day;
	return in;
}

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

在这里插入图片描述

因为这两函数使用频率相当的高,且函数体内容较短,所以将其设置为内联函数,可以提高效率。同时为了可以连续这两个运算符我们需要返回值,又因为cin和cout是全局变量,所以可以使用传引用返回。

总结

  • 友元函数可访问类的私有和保护成员,但不是类的成员函数;
  • 友元函数不能用 const 修饰;
  • 友元函数可以在类定义的任何地方声明,不受类访问限定符限制;
  • 一个函数可以是多个类的友元函数;
  • 友元函数的调用与普通函数的调用原理相同;

友元类

友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员 (不受访问限定符的限制)。

class Time
{
	friend class Date; // 声明日期类为时间类的友元类,则在日期类中就直接访问Time类
中的私有成员变量
public:
	Time(int hour = 0, int minute = 0, int second = 0)
	: _hour(hour)
	, _minute(minute)
	, _second(second)
{}
private:
	int _hour;
	int _minute;
	int _second;
};
	class Date
	{
	public:
		Date(int year = 1900, int month = 1, int day = 1)
		: _year(year)
		, _month(month)
		, _day(day)
	{}
	void SetTimeOfDate(int hour, int minute, int second)
	{
		// 直接访问时间类私有的成员变量
		_t._hour = hour;
		_t._minute = minute;
		_t._second = second;
	}
	private:
	int _year;
	int _month;
	int _day;
	Time _t;
};

在这里插入图片描述

这就好像你把别人带到你的家里,他可以看到你家里的任何东西,但是他没把你带到他家去所以你不知道他家里有什么。

友元类有如下特点:

1.友元关系是单向的,不具有交换性;比如上述 Time 类和 Date 类,在 Time 类中声明 Date 类为其友元类,那么可以在 Date 类中直接访问 Time 类的私有成员变量,但想在 Time 类中访问 Date 类中私有的成员变量则不行;

2.友元关系不能传递;如果C是B的友元, B是A的友元,则不能说明C是A的友元;

3.友元关系不能继承,继承的相关知识我们到C++进阶再详细学习

内部类

如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。

class A
{
private:
    static int k;
    int h;
public:
    class B // B天生就是A的友元
    {
    public:
        void foo(const A& a)
        {
            cout << k << endl;//OK
            cout << a.h << endl;//OK
        }
    };
};
int A::k = 1;
int main()
{
    A::B b;
    b.foo(A());
    return 0;
}

内部类有如下特性:

1.内部类天生就是外部类的友元,所以内部类可以通过外部类的对象参数来访问外部类中的所有成员;但外部类不是内部类的友元

2.内部类定义在外部类的 public、protected、private 处都是可以的,但是内部类实例化对象时要受到外部类的类域和访问限定符的限制;

3.内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名;

4.内部类是一个独立的类,它不属于外部类,所以 sizeof (外部类) == 外部类;

5.内部类在C++中很少被使用,在Java中使用频繁,所以大家只需要了解有这个东西即可

匿名对象

所谓匿名对象就是在定义时不给它取名字,这样的对象生命周期只有定义那一行,因为没有名字所以无法被别人使用,一旦出了那一行就没有人能记得它了。除了生命周期和普通类对象不同以外,其他都是一样的。

class A
{
public:
	A()
		:_a(10)
	{
		cout << "构造函数" << endl;
	}

	~A()
	{
		cout << "析构函数" << endl;
	}
private:
	int _a;
};

int main()
{
	A();
	return 0;
}

在这里插入图片描述

有时候我们需要调用一个类的成员函数,但是调用一个类的成员函数就必须要有一个类的对象,如果只是为了调用这个函数,那么就可以使用匿名对象,不但减少了代码量,也减少了内存占用。

拷贝对象时编译器的一些优化

在传参和传返回值的过程中,编译器会做一些优化减少拷贝的次数,在如下场景中特别有用:

class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}
	A(const A& aa)
		:_a(aa._a)
	{
		cout << "A(const A& aa)" << endl;
	}
	A& operator=(const A& aa)
	{
		cout << "A& operator=(const A& aa)" << endl;
		if (this != &aa)
		{
			_a = aa._a;
		}
		return *this;
	}
	~A()
	{
		cout << "~A()" << endl;
	}
private:
	int _a;
};
void f1(A aa)
{}
A f2()
{
	A aa;
	return aa;
}

**优化场景一:传参隐式类型转换,连续构造+拷贝构造->优化为直接构造 **

在这里插入图片描述

可以看到这里只调用了一次构造函数,但是根据前面说的隐式类型转换我们可以知道中间有个临时变量的产生,需要先构造这个临时变量,再将这个临时变量拷贝构造aa,但编译器经过优化以后直接成了将1去构造aa;

优化场景二:匿名对象 – 构造+拷贝构造 --> 直接构造

在这里插入图片描述

这个与场景一类似,本来是先用2来构造一个匿名对象,然后使用这个匿名对象来拷贝构造aa,经过编译器优化后变为直接使用2去构造aa;

优化场景三:传值返回 – 构造+拷贝构造+拷贝构造 --> 直接构造

在这里插入图片描述
在这里插入图片描述

f2 函数返回的是局部的匿名对象,所以编译器会先用匿名对象去拷贝构造一个临时对象,然后再用临时对象来拷贝构造aa2,而编译器优化后变为直接使用无参来构造aa2;即构造+拷贝构造+拷贝构造优化为直接构造。

再次理解类和对象

现实生活中的实体计算机并不认识,计算机只认识二进制格式的数据。如果想要让计算机认识现实生活中的实体,用户必须通过某种面向对象的语言,对实体进行描述,然后通过编写程序,创建对象后计算机才可以认识。比如想要让计算机认识洗衣机,就需要:

  1. 用户先要对现实中洗衣机实体进行抽象—即在人为思想层面对洗衣机进行认识,洗衣机有什么属性,有那些功能,即对洗衣机进行抽象认知的一个过程
  2. 经过1之后,在人的头脑中已经对洗衣机有了一个清醒的认识,只不过此时计算机还不清楚,想要让计算机识别人想象中的洗衣机,就需要人通过某种面相对象的语言(比如:C++、Java、Python等)将洗衣机用类来进行描述,并输入到计算机中
  3. 经过2之后,在计算机中就有了一个洗衣机类,但是洗衣机类只是站在计算机的角度对洗衣机对象进行描述的,通过洗衣机类,可以实例化出一个个具体的洗衣机对象,此时计算机才能知道洗衣机是什么东西。
  4. 用户就可以借助计算机中洗衣机对象,来模拟现实中的洗衣机实体了

在类和对象阶段,大家一定要体会到,类是对某一类实体(对象)来进行描述的,描述该对象具有那些属性,那些方法,描述完成后就形成了一种新的自定义类型,才用该自定义类型就可以实例化具体的对象

在这里插入图片描述

如果你能看到这里,那我必须要给你一个大大的赞👍👍

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

别动我的饭

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值