C++:类与对象

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

  • C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
  • C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。

可能上面说的有些难理解,这里举个例子:洗衣服

首先洗衣服可以分为几个步骤:你脱下衣服->把衣服放盆里->倒入洗衣粉->叫妈妈->搓衣服->拧干->晒衣服。
这里面的对象有三个:你,妈妈,衣服
剩下的就是过程:脱衣服,倒洗衣粉,搓衣服,拧干,晒衣服。

面向对象的意思就是你看的是三个对象,自己,妈妈和衣服。衣服洗干净是这些对象相互交互完成的,具体怎么完成的就不用去管。
而面向过程是盯着你妈妈洗衣服,看她是怎么搓的怎么晾的。分析出洗衣服的步骤,然后逐步解决。

二.类

1.类的初步认识

在C语言中我们接触过结构体这一概念,比如定义一个日期结构体:

struct date
{
	int _year;
	int _month;
	int _day;
};

int main()
{
	struct date da;
	da._day = 1;
	da._month = 1;
	da._year = 2023;
	return 0;
}

结构体里可以定义各种类型的变量,但是在C++中,结构体进化成了,就是你不但可以定义变量,你还可以定义函数。比如像这样:

struct date
{
	//定义函数(直接在类里面定义的函数会被默认为内联函数)
	void print(int year, int month, int day)
	{
		cout << year << '-' << month << '-' << day << endl;
	}

	//定义变量
	int _year;
	int _month;
	int _day;
};

int main()
{
	struct date da;
	da.print(2023, 1, 1);
	return 0;
}

你可以在这个结构体里面写很多关于日期的函数,调用和变量调用一样。

  1. 在之前学结构体时,结构体的类型是struct + 结构体名称。而类不同,类你直接可以用结构体名称来定义变量,就写上面的日期类,可以这样定义变量:
date da;
  1. 在C++中,一般不习惯用struct表示类,而是用class来表示(注:直接将struct变成class会出现编译错误,具体原因下面会讲)
class date
{
	//定义函数
	void print(int year, int month, int day)
	{
		cout << year << '-' << month << '-' << day << endl;
	}

	//定义变量
	int _year;
	int _month;
	int _day;
};
  1. 用date定义的变量da也可以换一个名字–对象

2.类的定义

class className
{
	// 类体:由成员函数和成员变量组成
};  // 一定要注意后面的分号
  1. class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略
  2. 类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数
  3. 在写类的成员变量是推荐在名字前面加上下划线,后面加也可以。

类有两种定义方式:

  • 成员函数直接定义在类体当中(成员函数如果在类中定义,编译器可能会将其当成内联函数处理):
class date
{
public:
	void print(int year, int month, int day)
	{
		cout << year << '-' << month << '-' << day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};
  • 类声明放在.h文件中,成员函数定义放在.cpp文件中
    在这里插入图片描述
    这里我在.cpp文件中定义函数,但可能你们会发现我在函数名之前加上了student::(student是类的名字)。这是因为这个函数是类里面的函数,而不是全局函数。如果不加student::,编译器是找不到这个函数的声明在哪里,所以会编译报错。

3.类的访问限定符

类的访问限定符分三种:

  1. public(公有)
  2. protected(保护)
  3. private(私有)

访问限定符说明:

  • public修饰的成员在类外可以直接被访问
  • protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
  • 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
  • 如果后面没有访问限定符,作用域就到 } 即类结束。
  • class的默认访问权限为private,struct为public(因为struct要兼容C)

因为class默认的访问权限是私有的,所以在类的初步认识时将struct改为class,编译器会报错,因为私有的东西在类外面是用不了的。

注意:

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

4.类的作用域

类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用::作用域操作符指明成员属于哪个类域。

5.类的实例化

先定义一个类:

class date
{
public:
	void print(int year, int month, int day)
	{
		cout << year << '-' << month << '-' << day << endl;
	}

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

这个类里面的成员变量是属于定义还是声明呢?答案当然是声明,因为这个类还没有实例化出来,就像是一张房子的施工图纸,而实例化就是定义,也可以理解为根据图纸把房子建出来。

date d1;
date d2;
//定义d1,d2就是实例化出来的对象

一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量。

6.类对象模型

在分析模型之前先做一道题:

class date
{
public:
	void print(int year, int month, int day)
	{
		cout << year << '-' << month << '-' << day << endl;
	}

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

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

这个da的大小是多少?答案应该是:
在这里插入图片描述

看结果,编译器貌似没有把函数的大小算进去。那是因为你用类实例化了很多的对象,每个对象的成员变量都是相互独立的,但是函数不同,即使成员变量不同,但你仍然可以用同一种函数,也就是说,这个函数是公共的。既然函数是公共的,都可以用,那也没必要每实例化一次对象,就为函数开辟一块空间。所以类成员函数是不存在类当中的,而是存在一个公共区域—代码段

清楚了这些,我们再来做一道题目:

// 类中既有成员变量,又有成员函数
class A1 {
public:
	void f1(){}
private:
	int _a;
};

class A2 {
public:
	void f2() {}
};
// 类中什么都没有---空类
class A3
{};

问,这三个类的大小是多少?
类大小的计算和结构体是一样的,都要遵守内存对齐的规则:

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

A1这个类很好计算,就是int的大小–4个字节,那A2,A3的大小呢?刚在说过函数是不算在类大小中的,岂不是A2,A3的大小是0?答案当然不是。对于空类,编译器给了空类一个字节来唯一标识这个类的对象。所以A2,A3的大小都是1个字节。

三.this指针

1.什么是this指针

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

		cout << _year << '/' << _month << '/' << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2;

	d1.print(2022, 2, 14);
	d2.print(2023, 2, 14);
	return 0;
}

这仍然是一个日期类,但是现在有一个问题,实例化的两个对象d1,d2在调用print函数时,这个函数里面的_year,_month,_day分别是那个对象的?这个函数又是怎么知道是哪个对象调用的自己?这就不得不引出关于this指针的这个知识点了。其实编译器在我们调用这个函数之后自动的给我们做了一点小处理。

  1. 在传参的时候,多传了一个参数:调用这个函数对象的地址。
    在这里插入图片描述

  2. 函数内部在使用时也做了处理:
    在这里插入图片描述

函数通过this指针来找到是哪个对象调用了它。

2.this指针的特性

  1. 这些编译器帮我们做的事情–多传1个参数和通过this指针找到对象。这些我们统统不用做,正常写就行
  2. 虽然函数内部的this指针可以不用写,但有些场合,我们可以使用this指针来达到我们希望的效果。
  3. this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
  4. this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递。
  5. this存在栈中

3.this指针的空指针问题

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

		cout << _year << '/' << _month << '/' << _day << endl;
	}
	void f()
	{
		cout << "f()" << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date* d1 = nullptr;

	//1.
	d1->print(2023, 2, 14);
	//2.
	d1->f();
	//3.
	(*d1).f();
	return 0;
}

这三种调用分别是什么样的结果?既然d1是一个空指针,那下面这三种情况是不是都会造成空指针调用的问题?答案是:第一个会崩溃,第二个,第三个会正常运行。那原因是什么呢?

函数是不存在类当中的,所以d1在调用函数的时候是不会去类当中找,即使d1是空指针。所以d1->f()和(*d1).f()都会正常去使用。->和.都是为了说明f()是类成员函数,要去类这个域里面找,而不是去全局域中找。->和.除了说明这个函数是类成员函数,还有一个用途是在传递this指针时将这个空指针传递过去,所以说这里的this指针就是空指针。当然它只是一个空指针,但空指针不能说它是错的。
但d1->printf这个调用不同,他虽然传的this指针也是空,但传过去之后还要进行this->_year = year这个操作,而程序的崩溃也是出现在这个地方。

结论:函数调用时这一块的解引用真正的意义是传递this指针,程序崩不崩溃取决于函数内部是否解引用这个this指针。

四.浅谈封装

相比于C语言,C++的类要比C语言的结构体封装性更强一点,比如C语言去实现一个栈,它的数据和方法是分离的,就是说实现栈的函数不在结构体里面。而C++不同,C++把这些东西都封装在一起,这样就能更好的管理,比如private就是私有的,public就是共有的,这样还能让代码变的更安全。

五.类的默认成员函数

在你写的任何类中,编译器都会默认帮你生成6个成员函数
在这里插入图片描述

1.构造函数

1.1构造函数的概念

构造函数主要用途就是初始化。构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次

因为每创建一个对象的时候都要对其进行一次初始化,这样写可能就变得很麻烦,所以才有了构造函数,每次在创建一个对象时自动帮你调用这个构造函数。

1.2构造函数的用法

首先写一个类:

class Date
{
public:
	void print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

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

int main()
{
	Date d1;
	d1.print();

	return 0;
}

接下来根据构造函数的特点来写:

  1. 函数名与类名相同。
  2. 无返回值。(注意,这里无返回值不用再Date前面加上void)
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。
class Date
{
public:
	//构造函数
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//构造函数可以重载
	Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}
	void print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

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

int main()
{
	Date d1;//这里调用的是无参构造函数,不用加()
	Date d2(2, 2, 2);//这里调用的是有参构造函数,
	//需要你在创建对象时传递参数
	return 0;
}

注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明。

1.3默认构造函数

构造函数如果你不去写的话,它也会默认帮你生成。但是默认生成的会有些缺陷:自定义类型成员会去自动掉它的默认构造函数,内置类型成员则不做任何处理。(注意,有些编译器可能会比较智能,如果不写构造函数,它也会悄悄的帮你把内置类型成员初始化成0)

C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型。

内置类型成员像捡来的孩子一样,后来C++于心不忍,在这一块打了一个补丁:内置类型成员变量在类中声明时可以给默认值。比如像这样:

class Date
{
public:
	void print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

private:
//给成员变量默认值,注意这里不是初始化
	int _year = 1;
	int _month = 1;
	int _day = 1;
};

这里还有一点要注意:无参的构造函数和全缺省的构造函数不能放在一起,像下面这样:

class Date
{
public:
	//无参的构造函数
	Date()
	{
		_year = 1900;
		_month = 1;
		_day = 1;
	}
	//全缺省的构造函数
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

因为此时你要创建一个对象并且没给参数的话,编译器不知道帮你调用哪个构造函数。

最后,还有一个概念要捋清楚:
无参构造函数全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。总的来说不传参的构造函数就是默认构造函数。

2.析构函数

2.1析构函数的概念

析构函数和构造函数类似,构造函数是用来初始化对象的,析构函数是用来做清理的。
析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

比如说你类里面用malloc出一个空间,在对象使用完之后还要手动进行释放,但如果你有析构函数的话,释放这一步就没有必要做了,因为编译器会帮你自动调用析构函数,帮你做好清理工作。

2.2析构函数的用法

析构函数的特性:

  1. 析构函数名是在类名前加上字符 ~。
  2. 无参数无返回值类型。
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构
    函数不能重载
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数

这里先简单定义一个栈:

class Stack
{
public:
	Stack(int capacity = 3)
	{
		_array = (int*)malloc(sizeof(int) * capacity);
		if (NULL == _array)
			exit(-1);
		_capacity = capacity;
		_size = 0;
	}
	void Push(int data)
	{
		_array[_size] = data;
		_size++;
	}
private:
	int* _array;
	int _capacity;
	int _size;
};

析构函数的定义很简单,像这样:

class Stack
{
public:
	Stack(int capacity = 3)
	{
		_array = (int*)malloc(sizeof(int) * capacity);
		if (NULL == _array)
			exit(-1);
		_capacity = capacity;
		_size = 0;
	}
	void Push(int data)
	{
		_array[_size] = data;
		_size++;
	}

	//析构函数
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	int* _array;
	int _capacity;
	int _size;
};
2.3默认析构函数

析构函数和构造函数一样,对自定义类型成员会调用它的默认析构函数,对内置类型成员不做处理。

对于默认析构函数有一个点要注意:
对自定类型成员会调用这个成员的析构函数。像这样:

class Time
{
public:
	~Time()
	{
		cout << "~Time()" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
private:
	// 基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;
	// 自定义类型
	Time _t;
};

int main()
{
	Date d;
	return 0;
}

虽然在Date这个类中没有写Time的析构函数,但是编译器会帮你找到Time类的析构函数。这个调用是Date类自动生成的默认析构函数里面进行的。
因为main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数。main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数。

注意:创建哪个类的对象则调用该类的构造函数,销毁那个类的对象则调用该类的析构函数。如果类中没有申请资源时,析构函数可以不写。

2.4析构函数的顺序

类对象定义出来后都是保存在栈当中的,都遵循一个规律:后进先出,也就是后定义的对象先被析构

class Date
{
public:
	void print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

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

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

在这里析构的顺序就是d3->d2->d1.

3.拷贝构造函数

3.1拷贝构造函数的概念

拷贝构造函数是构造函数的一种重载形式,用来对类对象进行拷贝。同样在用到拷贝构造时,系统会自动帮你调用

3.2拷贝构造函数的用法
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_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 << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

拷贝构造的函数和构造函数的用法一样。但是这里有一个很重要的点要注意:

const Date& d

这个地方一定要用引用。
在解释这样做的原因之前,先要了解一个知识点,自定义类型不能像内置类型那样直接进行拷贝。比如有这样的一个类:
在这里插入图片描述

这个类直接进行拷贝的话s1,s2两个对象的变量a指向的就是同一块空间了,但s1,s2的变量a应该要有两个不同的空间才对。所以直接拷贝(浅拷贝)对自定义类型来讲会出很大问题。
因为这种原因,所以C++规定自定义类型的拷贝必须要调用拷贝构造。这个拷贝构造函数,我们自己就可以去写了。
在第一次调用拷贝构造时,肯定要先传递参数,但是传参这一个步骤需要调用拷贝构造,调用拷贝构造之前要传参…这样会无穷无尽的进行下去,所以为了避免这一点,拷贝构造形参必须是引用,引用是不需要传递参数的,因为引用就是别名,形参实参就是一个东西。

在写完拷贝构造函数之后,使用起来就很简单了,有两种使用方法:

int main()
{
	Date(d1);
	Date d2 = d1; //将d1拷贝给d2
	Date d3(d1);  //将d1拷贝给d3
	return 0;
}
3.3默认生成的拷贝构造

默认生成的拷贝构造和前面的构造/析构函数不一样。

  • 它对内置类型也会做处理–内置类型完成对应的浅拷贝(按字节一个一个去拷贝)。
  • 自定义类型,会去掉对应类型的拷贝构造

但是对于内置类型进行浅拷贝会有很大的风险的,这一点在3.2中讲过,就是有可能这个类型是一个指针,指向了一块空间。如果进行了浅拷贝,就相当于新的对象中也拷贝了一个指针来指向这块空间。导致两个不同的对象含有同一块空间。所以这时候我们不能再依赖编译器自动生成的拷贝构造函数,而是要自己去写。

结论:如果你的类中设计到了内存管理的问题,比如malloc,new。这时候需要自己手动去写拷贝构造。

3.4拷贝构造调用场景
  1. 使用已存在的对象来创建新的对象
Date d1;
//下面两个是将d1拷贝给d2,d3。都会调用拷贝构造
Date d2 = d1;
Date d3(d1);
  1. 函数参数类型为类类型对象
//函数类型在这里是类类型对象,在实参传递给形参这一期间
//会先调用拷贝构造。
void fun(Date d)
{
	;
}

class Date
{
	;
};
int main()
{
	Date d;
	fun(d);
	return 0;
}
  1. 函数返回值为类类型对象
//函数的返回值为类类型对象。
//在函数调用结束后,会先将返回值拷贝一份,随后再将拷贝的内容返回
//这里的拷贝就会调用拷贝构造
Date fun(Date d)
{
	;
}

class Date
{
	;
};
int main()
{
	Date d;
	fun(d);
	return 0;
}

因为函数的这两个场景都会调用拷贝构造,所以尽量能用引用的地方就去用引用。

4.赋值重载

4.1.运算符重载

在学习赋值重载之前先要了解运算符重载

1.1运算符重载概念

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

1.2运算符重载的用法

函数原型:返回值类型 operator操作符(参数列表)
函数名字为:关键字operator后面接需要重载的运算符符号

class Date
{
public:
	//得到某月的天数
	int GetMonthDay(int year, int month)
	{
		assert(month > 0 && month < 13);
		int monthArr[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
		if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
			return 29;

		return monthArr[month];
	}

	//得到x天之后的天数
	//+
	//函数重载
	Date operator+(int x)
	{
		Date tmp(*this);

		tmp._day += x;
		while (tmp._day > GetMonthDay(tmp._year, tmp._month))
		{
			tmp._day -= GetMonthDay(tmp._year, tmp._month);
			tmp._month++;

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

		return tmp;
	}
private:
	int _year = 1;
	int _month = 1;
	int _day = 1;
};
Date operator+(int x)
{
	//...
}

这个Date operator+(int x)函数就是所谓的运算符重载。因为自定义类型不像内置类型那样直接对两个对象进行±*/等运算。但我们有时候又会有这些需求。所以C++提供了运算符重载这一个概念。

这个函数虽然看着很别扭,但是你完全可以把它当成普通函数看,operator+就可以当作函数名。调用这个函数就可以这样写:

int main()
{
	Date d1(2023, 2, 15);
	Date d2 = d1.operator+(200);
	return 0;
}

但是会发现这样写还不如定义一个Add函数来的实在。所以你还可以这样调用:

int main()
{
	Date d1(2023, 2, 15);
	Date d2 = d1 + 200;
	//这里d1就当成this传递过去做函数的第一个参数
	//200被当成第二个参数
	return 0;
}

函数的调用直接用+来代替。这样可读性提高了,用起来也方便不少。但运算符重载要注意几个问题:

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

在内置类型中像这样:

int a = 10;
int b = 20;
double x = 1.1;
double y = 2.2;
a = b;
x = y

其中a=b,x=y进行的就是赋值运算,所以在类中,我们也可以设计一个对自定义类型进行赋值的函数:

class Date
{
public:
//赋值重载
void operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}
private:
	int _year;
	int _month;
	int _day;
};

这个函数就是我们自己写的赋值重载函数。但是这样写会有一些缺点,比如像内置类型,它是支持多次赋值的:

a = b = c;

但是我们这里的达不到这样的要求。所以我们还需要改进一下:将函数的返回值写成Date,这样可以将上次函数返回的结果,作为下次赋值的右值:

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

最后还可能会遇到自己给自己赋值的问题,所以函数还需要多加一个条件,以避免不必要的拷贝:

Date& operator=(const Date& d)
	{
		if(this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}

		return *this;
	}

最后,不要忘了赋值重载这个函数也是类的6钟默认成员函数的其中一种。用户没有显式实现时,编译器同样会生成一个默认赋值运算符重载,以值的方式逐字节拷贝
注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。又因为拷贝是浅拷贝,缺点和拷贝构造函数一样。所以在涉及到资源管理的时候,一定要自己手动去写赋值重载。

4.3前置++和后置++

对于运算符重载来说,前置++和后置++比较难处理,因为这两个都是++运算符:

	Date& operator++()
	{
		;
	}

编译器为了好处理,默认是前置++,如果要写后置++,要在括号里多传一个整型,但这个int可以不用去管,它就是启到一个占位符的作用:

	//++Data,默认是前置++
	Date& operator++()
	{
		*this += 1;
		return *this;
	}

	//Data++
	Date operator++(int)
	{
		Date tmp(*this);
		*this += 1;
		return tmp;
	}

调用的时候,直接用就行(注:这里的+=是我提前写好的一个运算符重载):

int main()
{
	Date d1;
	d1++;//后置++
	++d1;//前置++
	return 0;
}
4.4友元函数

在C++用到的,cout,cin两个输入输出说白了就是一个类对象,而<<和>>两个符号则是运算符重载,但是这种用法不支持自定义类型。所以现在我们可以自己用运算符重载写一下。

对于cout的使用我们希望是这样的:

class Date
{
	//...
private:
	int _year;
	int _month;
	int _day;
}
int main
{
	Date d1;
	cout << d1;//cout.operator<<(d1)
	return 0;
}

但是这里有一个小问题,如果要以cout<<d1的形式来写的话,函数的第一个参数是cout,第二个参数是d1,但这里是在Date的类里面写的,函数会默认传递一个this指针,而这个指针指向的是d1,所以要传参数的话,只有这样写才能正常使用:

d1 << cout;//d1.operator<<(cout);

这样写有很别扭,因为正常写都是cout在最左边。所以这里我们需要一点点小改进:将这个函数重载的定义写在类的外面:

ostream& operator<<(ostream& out, const Date d)
{
	out << d._year << "/" << d._month << "/" << d._day << endl;
	//虽然cout不支持自定义类型,但是内置类型还是可以支持的
	return out;
}

这样就无法传递this指针了,传递参数的顺序有我们自己写就好了。(注意:这里传递过去的第一个参数是cout,cout是ostream类定义出的对象)

这里最好将out返回,因为这样就可以做到连续写:

cout << d1 << d2;

难道这就可以了吗?当然不是,类的成员变量默认是私有的,刚才我们把这个函数移除类了,也就是说类里面的成员变量这个函数是用不了的。当然你可以把成员变量改成共有的,但这样改代码的安全性就降低了。所以C++又新加了友元函数的概念。像这样:

class Date
{
public:
	friend ostream& operator<<(ostream& out, Date d);
	//...
private:
	int _year;
	int _month;
	int _day;
}

在类里面进行声明,声明前面加上friend关键字。这样这个函数就可以用类里面的成员变量了。

友元函数总结:

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

一个类里面除了有友元函数,还可以有友元类。

class A
{
friend class B;
;
}
class B
{
;
}

这样子写类B就可以使用类A里的成员。但是A却不能使用B里面的东西,他们之间的关系是单向的。
友元类特点:

  1. 友元关系是单向的,不具有交换性。
    比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。
  2. 友元关系不能传递
    如果C是B的友元, B是A的友元,则不能说明C时A的友元。
  3. 友元关系不能继承。
5.取地址重载

取地址重载其实就是对&这个取地址运算符进行重载,既然是默认成员函数,不写也无所谓。

六.const修饰的成员函数

在使用类的时候可能会出现下面这种情况:

//定义一个类
class A 
{
public:
	void print()
	{
		cout << _num << endl;
	}
private:
	int _num = 10;
};

//一个函数
void fun(const A& a)
{
	a.print();
}

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

这里定义了一个A类和一个对象a,将a传递给函数fun,但是fun的形参是用const修饰的。但是a.print()传递过去的形参是*this,而this的类型默认是A类型,这是不可以被修改的。这也就导致了一个问题:函数a的类型是const A,而传递给函数print后的形参this是const A。这就有了一个权限扩大的问题,但是我们又不能修改this的类型。所以在函数定义的时候可以这样写:

class A 
{
public:
	void print() const
	{
		cout << _num << endl;
	}
private:
	int _num = 10;
};

建议:如果函数内部不改变成员变量的话,最好在后面加上const.

七.初始化列表

7.1初始化列表概念

现在定义一个类:

class B
{
public:
private:
	const int _cb;
};
int main()
{
	B b;
	return 0;
}

问,这样写会不会编译成功呢?显然是不行的。因为类中的构造函数有一个特点:内置类型不做处理,自定义类型调用它的默认构造函数。这个_cb明显是内置类型,所以B类的默认构造函数是不会对他做处理的,但还有一个问题是:const修饰的变量必须对它做初始化。现在有什么办法可以处理这种情况呢?
有的人可能会这样写:

class B
{
public:
private:
	const int _cb = 1;//在声明的时候给值
};

但这只是C++11之后的写法,C++11之前可没这么方便了,而且这不是初始化,这是给变量一个缺省值。所以根据这种情况,C++给出了初始化列表的概念。

7.2初始化列表的用法

构造函数后面,以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。

class B
{
public:
	B()
	//初始化列表
		:_pb(1),
		_cb(1),
		_yb(_pb)
	{
		;
	}
private:
	int _pb;
	const int _cb;
	int& _yb;
};

7.3初始化列表补充

  1. 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
  2. 以下几种情况必须初始化
  • 被const修饰的变量(因为const修饰的变量只有初始化那一次赋值的机会)
  • 引用(和const一样)
  • 自定义类型(这个自定义类型没有默认构造函数)

上面情况前两个可能理解,最后一个解释起来很麻烦,比如像这个类:

class A
{
public:
	A(int a)
	{;}
private:
	int _a;
};

class B
{
public:
private:
	A _aa;
};

int main()
{
	B b;
	return 0;
}

这里B类中的成员变量是自定义成员,系统会自动去调用A的默认构造函数。但是A这个类没有默认构造函数,只有一个自己写的默认构造函数。所以此时编译就会给你报错。
注意:默认构造函数有三种:

  • 你完全没有写构造函数,系统默认生成的
  • 你写了构造函数,但是构造函数参数是全缺省的
  • 你写了构造函数,但是函数形参什么都没有写

所以上面的代码A类中由上面三个的其中一个,系统都不会报错。
但是如果这三个都没有,在B类的初始化列表要怎么写才能弥补错误呢,可以这样:

class A
{
public:
	A(int a)
		:_a(10)
	{
		;
	}
private:
	int _a;
};

class B
{
public:
	B()
		:_aa(1)//在初始化列表中初始化
	{
		;
	}
private:
	A _aa;
};

int main()
{
	B b;
	return 0;
}

只有在初始化列表中传值,系统才会跑去调用A类中你自己写的构造函数,而不是调用A类的默认构造函数。

  1. 尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化。
  2. 成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
class A
{
public:
	A(int a)
		:_a1(a)
		, _a2(_a1)
	{}
	void Print() {
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a2;
	int _a1;
};
int main() {
	A aa(1);
	aa.Print();
}

问:这题会输出什么?
有的人可能会认为结果是1,1。因为_a1被初始化成1,_a2被初始化为_a1也是1。但事实却和你想的不一样。

因为_a2先声明,所以_a2先被初始化,但此时_a1还没有被初始化为1,还是个随机值,所以此时_a2被初始化为随机值。而接下来正常初始化_a1为1.所以结果是1,随机值。

八.explicit关键字

构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值的构造函数,还具有类型转换的作用。

class A
{
public:
	A(int a)
		:_a1(a)
	{}
private:
	int _a1;
};
int main() {
	//当构造函数中只有一个参数时,下面两种形式都是可以的
	A aa(1);    //正常的调用构造函数
	A aa1 = 1;  //发生了隐式类型转换
}

这里的隐式类型转换的过程是:
先创建一个A类型的临时变量,将1的值赋值给这个临时变量(用1构造出一个临时变量),然后由这个临时变量通过拷贝构造赋值给aa1这个对象。

也就是说这里用到了构造函数和拷贝构造函数两种。但是有些新版的编译器会对此做些优化,将拷贝构造这一部分省略掉了。

虽然这种隐式转换可以用,但是代码的可读性就变差了,如果你不想发生隐式转换这件事,可以在构造函数前面加上explicit关键字。

class A
{
public:
	explicit A(int a)
		:_a1(a)
	{}
private:
	int _a1;
};
int main() {
	A aa(1);
	A aa1 = 1;
}

在C++11中,还支持多参数的隐式类型转换:

class A
{
public:
	explicit A(int a, int b)
		:_a1(a)
		,_a2(b)
	{}
private:
	int _a1;
	int _a2;
};
int main() {
	A aa(1, 2);    //正常调用构造函数
	A aa1 = { 1, 2 }; //隐式类型转换,多参数用{}括起来
}

九.类中的static成员

类中可以定义静态成员变量的:

class A
{
public:
	A(){count++;}
	A(A& aa)
		:_a(aa._a)
		{count++;}
private:
	static int count; //静态成员变量的声明
	int _a = 0;
};

但是静态成员变量的初始化不能这样写:

static int count = 1;

因为这里只是声明,让其等于1是给了它一个缺省值。但静态成员变量给不了它缺省值,其它非静态成员变量可以给缺省值是因为,在对象实例化时,编译器会先去初始化列表对这些变量进行初始化,如果有的非静态成员不在初始化列表中才会给这个变量赋缺省值。又因为静态成员变量是在静态区的,不是某个对象所特有的,是所有对象公用一个,所有静态成员变量是不能在初始列表中初始化的,同样也不能给它缺省值。

所以静态成员变量要在类外面进行初始化:

class A
{
public:
	A(){count++;}
	A(A& aa)
		:_a(aa._a)
		{count++;}
private:
	static int count; //静态成员变量的声明
	int _a = 0;
};
int A::count = 0; //静态成员变量的定义和初始化

既然可以定义静态成员变量,那我们如何使用它呢?可以尝试写一个函数:

class A
{
public:
	A()
	{
		count++;
	}
	A(A& aa)
		:_a(aa._a)
	{
		count++;
	}

	int GetCount()
	{
		return count;
	}
private:
	static int count;
	int _a = 0;
};
int A::count = 0;

在使用的时候直接调用就行:

int main()
{
	A a1;
	A a2;
	A a3;
	cout << a3.GetCount() << endl;
	return 0;
}

可以通过一个对象来访问这个函数,但如果没有对象又该怎么访问呢?

class A
{
public:
	A()
	{
		count++;
	}
	A(A& aa)
		:_a(aa._a)
	{
		count++;
	}

	int GetCount()
	{
		return count;
	}
private:
	static int count;
	int _a = 0;
};
int A::count = 0;

void fun()
{
	A a1;
	A a2;
	A a3;
}
int main()
{
	fun();
	cout << a3.GetCount() << endl;//err
	return 0;
}

对象都是在fun函数里定义的,main函数里肯定用不了。所以我们可以在这个函数前面加上static:

class A
{
public:
	A()
	{
		count++;
	}
	A(A& aa)
		:_a(aa._a)
	{
		count++;
	}

	static int GetCount()//静态成员函数
	{
		return count;
	}
private:
	static int count;
	int _a = 0;
};
int A::count = 0;

加上static之后就可以变成静态成员函数。就可以这样使用:

void fun()
{
	A a1;
	A a2;
	A a3;
}
int main()
{
	fun();
	cout << A::GetCount() << endl;//err
	return 0;
}

静态成员函数有一个缺点:没有this指针,也就是说,他不能访问类中的静态成员。它只能访问静态成员变量。

总结:

  1. 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
  2. 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
  3. 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问
  4. 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
  5. 静态成员也是类的成员,受public、protected、private 访问限定符的限制

十.匿名对象

class A
{
public:
	A(int i = 0)
	{
		_a = i;
	}
private:
	int _a = 0;
};

int main()
{
	A a;
	A b(1);
	return 0;
}

这时候创建对象可以有两种方式,但是我们不能这样创建对象:

A a();

因为这样写编译器不知道是函数声明还是创建对象。但是还可以这样写:

A();

上面这个就叫做匿名对象。它的特点是:

  1. 没有名字
  2. 生命周期只是它所在的这一行
  3. 匿名对象具有常性

十一.内部类

在一个类里面可以定义其它类:

class A
{
public:
	///
	class B
	{
	public:
		void f(const A& a)
		{
			cout << a._a << " " << a._b << endl;
		}
	private:
		int _c = 100;
		int _d = 200;
	};
	///
private:
	int _a = 10;
	int _b = 20;
};

上面B就是在A类里面定义的一个类。
内部类有两个特点:

  1. 内部类天生就是外部类的友元类。
  2. 内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名。
  3. 内部类可以定义在外部类的public、protected、private都是可以的。
  4. 用sizeof计算外部类的大小时,和内部类是没有关系的。

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

在传参和传返回值的过程中,一般编译器会做一些优化,减少对象的拷贝。一般是在一个表达式中同时做了构造和拷贝构造,那编译器就会将拷贝构造优化掉。(注意:一般只有比较新的编译器会做这些事情)

  1. 传值传参
class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "构造函数" << endl;
	}
	A(const A& a)
	{
		_a = a._a;
		cout << "拷贝构造" << endl;
	}
private:
	int _a = 1;
};
void func1(A a)
{
	;
}

int main()
{
	A aa1 = 1; //1给临时变量是调用构造函数,临时变量拷贝给aa1调用拷贝构造。优化-->只有构造过程没有拷贝构造
	func1(aa1);//A aa1 = 1是构造,这里的传参是拷贝构造,但是这两个不在一个表达式里,所以不会被优化
	cout << "-----------------------" << endl;
	func1(2);//2传给形参和aa1那一块相同,构造函数+拷贝构造-->优化为构造
	cout << "-----------------------" << endl;
	func1(A(3));//这里先构造一个匿名对象A(3)再拷贝构造给func1,所以也会被优化为构造
	return 0;
}
  1. 传引用传参
class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "构造函数" << endl;
	}
	A(const A& a)
	{
		_a = a._a;
		cout << "拷贝构造" << endl;
	}
private:
	int _a = 1;
};

void func2(const A& a)
{
	;
}

int main()
{
	A aa1 = 1; //1给临时变量是调用构造函数,临时变量拷贝给aa1调用拷贝构造。优化-->只有构造过程没有拷贝构造
	cout << "-----------------------" << endl;
	func2(aa1);//形参就是aa1的别名,这里没有构造也没有拷贝构造
	cout << "-----------------------" << endl;
	func2(2);//构造一个临时变量,是形参a的别名
	cout << "-----------------------" << endl;
	func2(A(3));//同样构造一个匿名对象,是形参a的别名
	cout << "-----------------------" << endl;
	return 0;
}
class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "构造函数" << endl;
	}
	A(const A& a)
	{
		_a = a._a;
		cout << "拷贝构造" << endl;
	}
private:
	int _a = 1;
};

A func3()
{
	A aa;
	return aa;
}

int main()
{
	func3(); //A aa是进行构造的一个操作,return会先构造一个临时变量,在拷贝构造给这个变量是构造+拷贝构造-->优化为构造。
	cout << "-----------------------" << endl;
	A aa1 = func3();//整个func3()调用的过程是构造+拷贝构造,返回的值赋值给aa1又是一个拷贝构造-->构造+拷贝构造
	return 0;
}

这里稍微旧一点的编译器像VS2017它是优化为构造+拷贝构造,但是我这个VS2022优化的比较彻底,是优化的只剩构造。

A func4()
{
	return A();
}

int main()
{
	func4(); //这里构造,拷贝构造都是在一个表达式里,所以直接优化为构造
	cout << "-----------------------" << endl;
	A aa1 = func4();//同样是只有一个表达式,返回的过程是构造+拷贝构造,赋值过程是拷贝构造。构造+拷贝构造+拷贝构造-->优化为构造
	return 0;
}
class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "构造函数" << endl;
	}
	A(const A& a)
	{
		_a = a._a;
		cout << "拷贝构造" << endl;
	}
	A& operator=(const A& aa)
	{
		cout << "赋值" << endl;
		if (this != &aa)
		{
			_a = aa._a;
		}
		return *this;
	}
private:
	int _a = 1;
};

A func5()
{
	A aa;
	return aa;
}

int main()
{
	A aa1 = func5();//A aa是构造,返回值赋给aa1是拷贝构造
	cout << "-----------------------" << endl;
	A aa2;//构造
	aa2 = func5();//A aa是构造,返回过程是构造加拷贝,但这里赋值给aa2是真正的赋值而不是拷贝构造,而赋值不能被优化
	return 0;
}

注意看这里分割线上下两种写法效果是一样的,但是上面要更好些。
在这里插入图片描述

总结:

  1. 返回值尽量用拷贝构造方式接收,不用赋值接收,像上面第5点的那样。
  2. 函数返回时尽量用匿名对象返回,像上面第4点
  3. 尽量使用const&传参
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值