二、类与对象(二)

8 this指针

8.1 this指针的引入

我们先来定义一个日期的类Date

	#include 
using namespace std;
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, 1, 11);
	d2.Init(2022, 1, 12);
	d1.Print();
	d2.Print();
	return 0;
}

对于上面一个类,有这样一个问题:

Date类中有InitPrint两个成员函数,函数体中并没有关于不同对象的区分,那么当d1调用Init函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象的呢?

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

8.2 this指针的特性

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

二、类与对象(二)_第1张图片

  1. this指针

例1:下面程序编译运行的结果是什么?

#include 
using namespace std;
class A
{
public:
	void Print()
	{
		cout << "Print()" << endl;
	}
private:
	int _a;
};
void test1()
{
	A* p = nullptr;//空指针
	p->Print();
}
void test2()
{
	A* p = nullptr;//空指针
	(*p).Print();
}
int main()
{
	test1();
	test2();
	return 0;
}

输出结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从输出结果可以看到,程序正常运行了,这是为什么呢?

这是因为成员函数Print实际上在公共的代码段而并不在对象里面,所以虽然p是一个空指针,但p->Print()在这里并不代表解引用,而是直接去公共区域调用了函数Print(*p).Print()也同理。

如果是这样的话,那能不能不用对象直接调用Print函数呢?

#include 
using namespace std;
class A
{
public:
	void Print()
	{
		cout << "Print()" << endl;
	}
private:
	int _a;
};
int main()
{
	Print();
	return 0;
}

运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以看到,编译器报错了。这是因为Print会受到类域的限制,如果不用对象直接调用Print函数那么编译器将无法找到Print函数。

例2:下面程序编译运行的结果是什么?

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

输出结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从输出结果可以看到,程序崩溃了。这是因为PrintA函数体内部的cout << _a << endl语句等价于cout << this->_a << endl而此时PrintA函数的参数为空指针,那么对空指针进行解引用自然就会发生崩溃了。

9 C语言和C++实现Stack的对比

9.1 C语言实现

#include 
#include 
#include 
typedef int DataType;
typedef struct Stack
{
	DataType* array;
	int capacity;
	int size;
}Stack;

void StackInit(Stack* ps)
{
	assert(ps);
	ps->array = (DataType*)malloc(sizeof(DataType) * 3);
	if (NULL == ps->array)
	{
		assert(0);
		return;
	}
	ps->capacity = 3;
	ps->size = 0;
}

void StackDestroy(Stack* ps)
{
	assert(ps);
	if (ps->array)
	{
		free(ps->array);
		ps->array = NULL;
		ps->capacity = 0;
		ps->size = 0;
	}
}

void CheckCapacity(Stack* ps)
{
	if (ps->size == ps->capacity)
	{
		int newcapacity = ps->capacity * 2;
		DataType* temp = (DataType*)realloc(ps->array,
			newcapacity * sizeof(DataType));
		if (temp == NULL)
		{
			perror("realloc申请空间失败!!!");
			return;
		}
		ps->array = temp;
		ps->capacity = newcapacity;
	}
}

void StackPush(Stack* ps, DataType data)
{
	assert(ps);
	CheckCapacity(ps);
	ps->array[ps->size] = data;
	ps->size++;
}

int StackEmpty(Stack* ps)
{
	assert(ps);
	return 0 == ps->size;
}

void StackPop(Stack* ps)
{
	if (StackEmpty(ps))
		return;
	ps->size--;
}

DataType StackTop(Stack* ps)
{
	assert(!StackEmpty(ps));
	return ps->array[ps->size - 1];
}

int StackSize(Stack* ps)
{
	assert(ps);
	return ps->size;
}

int main()
{
	Stack s;
	StackInit(&s);
	StackPush(&s, 1);
	StackPush(&s, 2);
	StackPush(&s, 3);
	StackPush(&s, 4);
	printf("%d\n", StackTop(&s));
	printf("%d\n", StackSize(&s));
	StackPop(&s);
	StackPop(&s);
	printf("%d\n", StackTop(&s));
	printf("%d\n", StackSize(&s));
	StackDestroy(&s);
	return 0;
}

可以看到,在用C语言实现Stack时,Stack相关操作函数有以下共性:

  1. 每个函数的第一个参数都是Stack*
  2. 函数中必须要对第一个参数检测,因为该参数可能会为NULL
  3. 函数中都是通过Stack*参数操作栈的.
  4. 调用时必须传递Stack结构体变量的地址。

结论:C语言中结构体只能定义存放数据的结构,而操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的,而且实现上相对复杂,涉及到大量指针操作,稍不注意可能就会出错。

9.2 C++实现

#include 
#include 
using namespace std;
typedef int DataType;
class Stack
{
public:
	void Init()
	{
		_array = (DataType*)malloc(sizeof(DataType) * 3);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = 3;
		_size = 0;
	}
	void Push(DataType data)
	{
		CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	void Pop()
	{
		if (Empty())
			return;
		_size--;
	}
	DataType Top() { return _array[_size - 1]; }
	int Empty() { return 0 == _size; }
	int Size() { return _size; }
	void Destroy()
	{
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	void CheckCapacity()
	{
		if (_size == _capacity)
		{
			int newcapacity = _capacity * 2;
			DataType* temp = (DataType*)realloc(_array, newcapacity *
				sizeof(DataType));
			if (temp == NULL)
			{
				perror("realloc申请空间失败!!!");
				return;
			}
			_array = temp;
			_capacity = newcapacity;
		}
	}
private:
	DataType* _array;
	int _capacity;
	int _size;
};
int main()
{
	Stack s;
	s.Init();
	s.Push(1);
	s.Push(2);
	s.Push(3);
	s.Push(4);

	printf("%d\n", s.Top());
	printf("%d\n", s.Size());
	s.Pop();
	s.Pop();
	printf("%d\n", s.Top());
	printf("%d\n", s.Size());
	s.Destroy();
	return 0;
}

在C++中,通过类可以将数据以及操作数据的方法进行完美结合,通过访问权限可以控制哪些方法在类外可以被调用,即封装。在使用时就像使用自己的成员一样,更符合人对一件事物的认知。 而且和C语言相比,每个方法不需要传递Stack*的参数,编译器在编译之后会将该参数自动还原,即C++中Stack*参数是编译器维护的,C语言中需要用户自己维护。

10 类的默认成员函数

之前我们说过,如果一个类中什么成员都没有,简称为空类。任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。 默认成员函数指的就是用户没有显式实现,但是编译器会生成的成员函数。

二、类与对象(二)_第2张图片

11 构造函数

11.1 构造函数的概念

我们以下面一个描述日期的类Date为例:

#include 
using namespace std;
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;
	d1.Init(2022, 7, 5);
	d1.Print();
	Date d2;
	d2.Init(2022, 7, 6);
	d2.Print();
	return 0;
}

对于Date类,可以通过公有方法Init给对象设置日期,但如果每次创建对象时都调用该方法设置信息,还是有点麻烦。那能否在对象创建时,就将信息设置进去呢? C++中,引入了构造函数来解决这个问题。

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

需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开辟空间创建对象,而是初始化对象

11.2 构造函数的特性

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载,也就是说构造函数允许对象有多种初始化的方式。

例:

#include 
using namespace std;
class Date
{
public:
    // 1.无参构造函数
    Date()
    {}

    // 2.带参构造函数
    Date(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; // 调用无参构造函数
    Date d2(2015, 1, 1); // 调用带参的构造函数
    Date d3();

    d1.Print();
    d2.Print();
    // 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
    // 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
    //d3.Print(); // warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)
	return 0;
}

输出结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。

例:

#include 
using namespace std;
class Date
{
public:
    
    // 如果用户显式定义了构造函数,编译器将不再生成
   /* Date(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类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
    // 将Date类中构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再生成
    // 无参构造函数,放开后报错:error C2512: “Date”: 没有合适的默认构造函数可用
    Date d1;
    d1.Print();
    return 0;
}

放开前运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

放开后运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 由于C++把类型分成内置类型(如:int/char等)和自定义类型(如使用class/struct/union等自己定义的类型),而C++的语法又规定编译器生成的默认构造函数不会对内置类型进行处理,也就是说对于内置类型的成员,虽然调用了默认构造函数但是依旧是随机值,而对于自定义类型的成员则会去调用它的默认构造函数。

注意:不传参数就可以调用的构造函数就叫默认构造函数,一般建议每个类都提供一个默认构造函数。

例:

#include 
using namespace std;
class Time
{
public:
	Time()
	{
		cout << "Time()" << endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}
	void Print()
	{
		cout << _hour << "时" << _minute << "分" << _second << "秒" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
public:
	void Print()
	{
		cout << _year << "年" << _month << "月" << _day << "日";
		this->_t.Print();
	}
	/*Date()
	{
		cout << "Date()" << endl;
	}*/
private:
	//基本类型
	int _year;
	int _month;
	int _day;
	//自定义类型
	Time _t;
};
int main()
{
	Date d;
	d.Print();
	return 0;
}

输出结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从输出结果可以看到,编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员函数。

注意:C++11中针对内置类型成员不初始化的缺陷打了补丁,打了补丁后内置类型成员变量在类中声明时可以给默认值。

例:

#define _CRT_SECURE_NO_WARNINGS	1
//构造函数缺陷
#include 
using namespace std;
class Time
{
public:
	Time()
	{
		cout << "Time()" << endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}
	void Print()
	{
		cout << _hour << "时" << _minute << "分" << _second << "秒" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
public:
	void Print()
	{
		cout << _year << "年" << _month << "月" << _day << "日";
		this->_t.Print();
	}
	/*Date()
	{
		cout << "Date()" << endl;
	}*/
private:
	//基本类型型(内置类型)
	int _year = 2023;
	int _month = 10;
	int _day = 3;
	//自定义类型
	Time _t;
};
int main()
{
	Date d;
	d.Print();
	return 0;
}

输出结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。 也就是说,如果既写了无参构造函数又写了全缺省的构造函数,那么编译的时候编译器会报错。

例:

#include 
using namespace std;
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;
};

void Test()
{
	Date d1;
}
int main()
{
	Test();
	return 0;
}

运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

12 析构函数

12.1 析构函数的概念

通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没的呢?

与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的,而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

12.2 析构函数的特性

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

例:

#include 
using namespace std;
typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 3)
	{
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	// 其他方法...
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	int _capacity;
	int _size;
};
void TestStack()
{
	Stack s;
	s.Push(1);
	s.Push(2);
}
int main()
{
	TestStack();
	return 0;
}
  1. 由于内置类型成员的销毁不需要资源清理,是最后由系统直接将其内存回收,所以不需要调用析构函数;而对于自定义类型的成员则需要调用它的析构函数,不过这个自定义类型成员的析构函数不能被直接调用,而是由包含这个自定义类型成员的类的析构函数调用。换言之,如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数即可;而有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。

例:

#include 
using namespace std;
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;
}

输出结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从输出结果可以看到,在main函数中根本没有直接创建Time类的对象,但是最后还是调用了Time类的析构函数,这就是因为main函数中创建了Date类对象d,而d中包含了4个成员变量,其中_year_month, _day三个是内置类型成员,销毁时不需要资源清理,而_tTime类对象,所以在销毁d时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,所以编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,也就是说当Date的对象销毁时,要保证其内部每个自定义对象都能被正确销毁。
总结:创建哪个类的对象则调用该类的析构函数,销毁哪个类的对象则调用该类的析构函数。

13 拷贝构造函数

13.1 拷贝构造函数的概念

在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。

而在C++中,拷贝构造函数就可以实现创建一个与已存在对象一模一样的新对象。

13.2 拷贝构造函数的特性

  1. 拷贝构造函数是构造函数的一个重载形式。
  2. 拷贝构造函数只有单个形参,该形参只能是对本类类型对象的引用(一般常用const修饰),而且在用已存在的类类型对象创建新对象时由编译器自动调用,如果使用传值方式进行传参那么编译器会直接报错,因为C++规定自定义类型的传值需要去调用拷贝构造函数,也就是在使用传值方式进行传参的过程中会调用拷贝构造函数,而由于这个拷贝构造函数是以传值方式实现的受C++语法的限制会又调用拷贝构造函数,层层调用最终导致无穷递归调用。

例:

#include 
using namespace std;
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;
    //   cout << "Date(const Date d)" << endl;
    //}
    Date(const Date& d)      // 正确写法
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
        cout << "Date(const Date& d)" << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
int main()
{
    Date d1;
    Date d2(d1);
    return 0;
}

错误写法运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

原因图解:

二、类与对象(二)_第3张图片

正确写法运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 如果没有显式定义拷贝构造函数,那么编译器会生成默认的拷贝构造函数。默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。其中内置类型按照字节方式直接拷贝,而自定义类型则调用其拷贝构造函数完成拷贝

例:

#include 
using namespace std;
class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}
	Time(const Time& t)
	{
		_hour = t._hour;
		_minute = t._minute;
		_second = t._second;
		cout << "Time::Time(const Time&)" << endl;
	}
	void Print()
	{
		cout << _hour << "时" << _minute << "分" << _second << "秒" << endl;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
public:
	void Print()
	{
		cout << _year << "年" << _month << "月" << _day << "日";
		this->_t.Print();
	}
private:
	//基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;
	//自定义类型
	Time _t;
};

int main()
{
	Date d1;
	// 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数
	// 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数
	Date d2(d1);
	d2.Print();
	return 0;
}

输出结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

既然编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝,那么对于所有的类是不是都不需要自己来显式实现呢?我们可以通过下面的类来感受一下:

#include 
using namespace std;
typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		_array = (DataType*)malloc(capacity * sizeof(DataType));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}
		_size = 0;
		_capacity = capacity;
	}
	void Push(const DataType& data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	size_t _size;
	size_t _capacity;
};
int main()
{
	Stack s1;
	s1.Push(1);
	s1.Push(2);
	s1.Push(3);
	s1.Push(4);
	Stack s2(s1);
	return 0;
}

运行结果:

二、类与对象(二)_第4张图片

可以看到,当我们以同样的方式对Stack类的对象s2进行拷贝构造时,程序崩溃了,这是什么原因呢?

二、类与对象(二)_第5张图片

我们可以通过上图帮助我们理解崩溃的原因。在main函数中,s1对象通过调用构造函数创建,而在构造函数中,默认申请了10个元素的空间,然后将1、2、3、4存了进去。

在后续构造s2对象的过程中,由于s2对象使用s1拷贝构造,而Stack类没有显式定义拷贝构造函数,所以编译器会给Stack类生成一份默认的拷贝构造函数,而又因为默认拷贝构造函数是按照值进行拷贝的,也就是说默认拷贝构造函数会将s1中的内容原封不动地拷贝到s2中,所以s1s2指向了同一块内存空间。

当程序退出时,s2s1都要销毁。而根据析构“后进先出”(即后创建的先销毁)的原则,s2将先被销毁,此时s2销毁时调用析构函数已经将0x11223344的空间释放了,但是s1中仍然指向0x11223344这块空间,到s1销毁时,会将0x11223344的空间再释放一次,一块内存空间多次释放,必然会造成程序崩溃。

结论:类中一旦涉及到资源申请时,一定要写拷贝构造函数,否则就是浅拷贝;而类中没有涉及资源申请时,写还是不写拷贝构造函数都可以。

  1. 拷贝构造函数典型调用场景:
    1. 使用已存在对象创建新对象
    2. 函数参数类型为类类型对象
    3. 函数返回值类型为类类型对象

例:

#include 
using namespace std;
class Date
{
public:
	Date(int year, int minute, int day)
	{
		cout << "Date(int,int,int):" << this << endl;
	}
	Date(const Date& d)
	{
		cout << "Date(const Date& d):" << this << endl;
	}
	~Date()
	{
		cout << "~Date():" << this << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
Date Test(Date d)
{
	Date temp(d);
	return temp;
}
int main()
{
	Date d1(2022, 1, 13);
	Test(d1);
	return 0;
}

输出结果:

二、类与对象(二)_第6张图片

程序解读:

二、类与对象(二)_第7张图片

总结:为了提高程序效率,一般对象传参时,尽量使用引用类型;返回时根据实际场景,能用引用尽量使用引用。

14 赋值运算符重载

14.1 运算符重载

C++为了增强代码的可读性引入了运算符重载,让自定义类型对象也可以使用运算符。

运算符重载是具有特殊函数名的函数,也具有其返回值类型、函数名以及参数列表,其返回值类型与参数列表与普通的函数类似。

函数名为:operator + 需要重载的运算符符号

函数原型:返回值类型 + operator +(参数列表)

注意:

  1. 不能通过连接其他符号来创建新的操作符,比如operator@
  2. 重载操作符必须有一个类类型参数。
  3. 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义。
  4. 作为类成员函数重载时,其形参看起来比实际操作数数目少1,但是成员函数里还隐藏了一个this参数。
  5. 特别注意:.*::sizeof?:.这5个运算符不能重载,这个经常在笔试选择题中出现。

例:用全局的operator==实现判断Date类相等:

//全局的operator==
#include 
using namespace std;
class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
    {
        _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;
}
void Test()
{
    Date d1(2023, 9, 27);
    Date d2(2023, 9, 27);
    Date d3(2023, 9, 27);
    Date d4(2023, 9, 26);
    cout << (d1 == d2) << endl;//d1 == d2会被转换成operator==(d1,d2)
    cout << (d3 == d4) << endl;
}

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

成员变量为私有时运行结果:二、类与对象(二)_第8张图片

成员变量为公有时运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里会发现运算符重载成全局的就需要成员变量是公有的,但如果这样的话封装性就无法得到保证了。这里其实可以用我们后面学习的友元解决,或者干脆重载为成员函数。

例:

//重载为成员函数
#include 
using namespace std;
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }

    // bool operator==(Date* this, const Date& d2)
    // 这里需要注意的是,成员函数都有一个默认的隐藏参数,即左操作数是this,指向调用函数的对象
    bool operator==(const Date & d2)
    {
        return _year == d2._year
            && _month == d2._month
            && _day == d2._day;
    }
private:
    int _year;
    int _month;
    int _day;
};
void Test()
{
    Date d1(2023, 9, 27);
    Date d2(2023, 9, 27);
    Date d3(2023, 9, 27);
    Date d4(2023, 9, 26);
    cout << (d1 == d2) << endl;
    cout << (d3 == d4) << endl;
}
int main()
{
    Test();
    return 0;
}

运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

14.1.1 运算符重载的复用

刚才我们实现了判断Date类相等的函数operator==,那当我们还想实现诸如operator>operator<operator>=这样逻辑相似的函数时,如果每一个函数都要单独写一段代码进行实现,那未免也太麻烦了,有没有什么简化的方法呢?

这里我们就可以通过对运算符重载的复用来实现,还是以Date类为例,要实现所有的比较关系的话,我们实际上只需在实现operator==的基础上,再实现一个operator<或者operator>即可:

	bool operator==(const Date & d2)
	{
        return _year == d2._year
            && _month == d2._month
            && _day == d2._day;
    }
    bool operator<(const Date& d)
    {
        return _year < d._year
            || (_year == d._year && _month < d._month)
            || (_year == d._year && _month == d._month && _day < d._day);  
    }
    bool operator<=(const Date& d)
    {
        return *this < d || *this == d;
    }
    bool operator>(const Date& d)
    {
        return !(*this <= d);
    }
    bool operator>=(const Date& d)
    {
        return !(*this < d);
    }
    bool operator!=(const Date& d)
    {
        return !(*this == d);
    }

可以看到,上面的代码只具体实现了operator==operator<,其他的关系直接通过这两个函数的复用就实现了,以operator>为例,operator>就是通过复用operator<=,然后对它的判断结果进行取反来进行实现的。

实际上,上面这一套判断逻辑,对所有的类均适用

14.2 赋值运算符重载

以往赋值运算符=只能在内置类型之间使用,而如果要让自定义类型也能通过=进行赋值,就需要对赋值运算符进行重载。

有了刚才实现运算符重载的经验,那我们实现赋值运算符的重载实际上也没有什么难度。

例:

#include 
using namespace std;
class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void operator=(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;
};
void Test()
{
    Date d1(2023, 9, 27);
    Date d2;
    d1.Print();
    d2.Print();
    d2 = d1;
    d1.Print();
    d2.Print();
}
int main()
{
    Test();
    return 0;
}

运行结果:

二、类与对象(二)_第9张图片

可以看到,我们设计的赋值运算符重载实现了它的功能,但实际上当前设计的还是存在缺陷的,比较突出的一点就是它不支持连续赋值,因为它的返回类型是void

要实现连续赋值,那么它应该返回当前被赋值的对象,也就是返回左操作数的值。除此之外,我们还应该考虑到自己给自己赋值的情况,尤其在需要深拷贝时,会降低程序运行的效率,所以遇到这种情况时,我们直接返回即可。那么对于刚才的operator=函数我们可以进行如下改造:

#include 
using namespace std;
class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
   /* void operator=(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }*/
    bool operator==(const Date& d2)
    {
        return _year == d2._year
            && _month == d2._month
            && _day == d2._day;
    }
    bool operator!=(const Date& d)
    {
        return !(*this == d);
    }
    Date& operator=(const Date& d)//支持连续赋值的重载赋值运算符
    {
        if (this != &d)//地址不一样时才赋值
        {
            _year = d._year;
            _month = d._month;
            _day = d._day;
            return *this;
        } 
    }
    void Print()
    {
        cout << _year << "年" << _month << "月" << _day << "日" << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
void Test()
{
    Date d1(2023, 9, 27);
    Date d2;
    Date d3;
    d1.Print();
    d2.Print(); 
    d3.Print();
    d3 = d2 = d1;
    d1.Print();
    d2.Print();
    d3.Print();
}
int main()
{
    Test();
    return 0;
}

输出结果:

二、类与对象(二)_第10张图片

可以看到,改造后的operator=函数就支持连续赋值了。

需要注意的是,赋值运算符只能重载成类的成员函数而不能重载成全局函数,原因在于赋值运算符如果不显式实现,那么编译器就会生成一个默认的赋值运算符重载,此时如果用户再在类外自己实现一个全局的赋值运算符重载,那么就和编译器在类中生成的默认赋值运算符重载冲突了,所以赋值运算符重载只能是类的成员函数

例:

#include 
using namespace std;
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	int _year;
	int _month;
	int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
	if (&left != &right)
	{
		left._year = right._year;
		left._month = right._month;
		left._day = right._day;
	}
	return left;
}
void Test()
{
	Date d1(2023, 9, 27);
	Date d2;
	Date d3;
	d3 = d2 = d1;
}
int main()
{
	Test();
	return 0;
}

运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里还需要注意的是,由编译器生成的默认赋值运算符重载,是以值的方式逐字节拷贝,也就是说,对于内置类型成员变量是直接赋值的,但是对于自定义类型成员变量则需要调用对应类的赋值运算符重载才能完成赋值。

例:

#include 
using namespace std; 
class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}
	//Time& operator=(const Time& t)  //
	//{
	//	if (this != &t)
	//	{
	//		_hour = t._hour;
	//		_minute = t._minute;
	//		_second = t._second;
	//	}
	//	return *this;
	//}
	void Print()
	{
		cout << _hour << "时" << _minute << "分" << _second << "秒" << endl;
	}
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 Print()
	{
		cout << _year << "年" << _month << "月" << _day << "日";
		this->_t.Print();
	}
private:
	// 基本类型(内置类型)
	int _year;
	int _month;
	int _day;
	// 自定义类型
	Time _t;
};
int main()
{
	Date d1;
	Date d2(2023, 10, 4);
	d1.Print();
	d2.Print();
	d1 = d2;
	d1.Print();
	d2.Print();
	return 0;
}

运行结果:

二、类与对象(二)_第11张图片

所以,虽然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,但是对于一些涉及到资源管理的类,则必须要自己实现赋值运算符的重载,否则会出现无法预料的结果。

例:

#include 
using namespace std;
typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		_array = (DataType*)malloc(capacity * sizeof(DataType));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}
		_size = 0;
		_capacity = capacity;
	}
	void Push(const DataType& data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	size_t _size;
	size_t _capacity;
};
int main()
{
	Stack s1;
	s1.Push(1);
	s1.Push(2);
	s1.Push(3);
	s1.Push(4);
	Stack s2;
	s2 = s1;
	return 0;
}

运行结果:

二、类与对象(二)_第12张图片

可以看到,当我们以同样的方式对Stack类的对象s2进行拷贝构造时,程序崩溃了,原因就在于Stack类中涉及到了资源管理,而Stack的赋值运算符重载又是依靠编译器实现的。

图解:

二、类与对象(二)_第13张图片

结论:

  1. 赋值运算符重载格式:
    1. 参数类型:const T&,传递引用可以提高传参效率。
    2. 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值。
    3. 检测是否自己给自己赋值。
    4. 返回*this:要复合连续赋值的含义。
  2. 赋值运算符只能重载成类的成员函数而不能重载成全局函数。
  3. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
  4. 如果类中未涉及到资源管理,那么赋值运算符是否实现都可以;一旦涉及到资源管理则必须要自行实现。
14.2.1 赋值运算符重载和拷贝构造之间的辨析

我们通过下面这段代码来感受一下赋值运算符重载和拷贝构造的区别:

#include 
using namespace std;
class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    bool operator==(const Date& d2)
    {
        return _year == d2._year
            && _month == d2._month
            && _day == d2._day;
    }
    bool operator!=(const Date& d)
    {
        return !(*this == d);
    }
    Date& operator=(const Date& d)//支持连续赋值的重载赋值运算符
    {
        if (this != &d)//地址不一样时才赋值
        {
            _year = d._year;
            _month = d._month;
            _day = d._day;
            return *this;
        } 
    }
    void Print()
    {
        cout << _year << "年" << _month << "月" << _day << "日" << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
void Test()
{
    Date d1(2023, 9, 27);
    Date d2 = d1;//拷贝构造
    d1.Print();
    d2.Print();
    Date d3;
    d3 = d1;//赋值重载
    cout << "--------------------------------" << endl;
    d1.Print();
    d2.Print();
    d3.Print();
}
int main()
{
    Test();
    return 0;
}

运行结果:

二、类与对象(二)_第14张图片

由于赋值重载是在两个已经定义好的对象之间进行的,虽然Date d2 = d1;这条语句中用了赋值重载运算符=,但是这条语句的意思是用d1来初始化d2,也就是用一个已经定义好的对象来初始化一个正在定义的对象,所以Date d2 = d1;这条语句实际上是拷贝构造,而d3 = d1;这条语句才是赋值重载。

14.3 前置++和后置++重载

前置++和后置++的重载之所以要单独拎出来讲,是因为它们和运算符+-相比,有需要注意的地方。

由于前置++和后置++都是一元运算符,为了让前置++与后置++能正确重载,C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递。

例:

#include 
using namespace std;
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// 前置++:返回+1之后的结果
	// 注意:this指向的对象函数结束后不会销毁,故以引用方式返回提高效率
	Date& operator++()
	{
		_day += 1;
		return *this;
	}
    //后置++:返回+1之前的结果
	// 注意:后置++是先使用后+1,因此需要返回+1之前的旧值,故需在实现时需要先将this保存一份,然后给this + 1
	// 而temp是临时对象,因此只能以值的方式返回,不能返回引用
	Date operator++(int)
	{
		Date temp(*this);
		_day += 1;
		return temp;
	}
	void Print()
	{
		cout << _year << "年" << _month << "月" << _day << "日" << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d;
	Date d1(2022, 1, 13);
	d = d1++;
	d.Print();
	d1.Print();
	cout << "-------------------------" << endl;
	d = ++d1;
	d.Print();
	d1.Print();
	return 0;
}

运行结果:

二、类与对象(二)_第15张图片

15 const成员函数

在引出const函数之前,我们先来看下面这种情况:

#include 
using namespace std;
class A
{
public:
	void Print()
	{
		cout << _a << endl;
	}
private:
	int _a = 10;
};
int main()
{
	A aa;
    //const A aa;
	aa.Print();
	return 0;
}

const修饰aa前的运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

const修饰aa后的运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以看到,当对象aa没有被const修饰时,它能够顺利运行,但是当aaconst修饰后再运行编译器就报错了。

之所以会报错,是因为这里涉及到一个权限被放大的问题。

在这个例子中,当我们把aa传进Print函数时,本质上传的是aa的地址,aa在没有被const修饰前,&aa的类型为A*,而Print的隐藏参数this的类型为A* const,也就是说当把aa传进Print函数后this的指向是不能被改变的,这是个权限缩小的过程,所以编译器允许;而当aaconst修饰后,&aa的类型就成了const A*,也就是说这个时候aa是不能被修改的,但传进Print函数后却反而可以被修改了,这个过程就把this的权限放大了,而这是不被编译器所允许的。

又由于this是隐藏的参数,我们没有办法进行修改,所以我们只能对函数用const进行修饰,那么我们就将const修饰的成员函数称为const成员函数,这个const实际修饰的是成员函数隐藏的this指针,修饰后this的类型就变成了const A*

虽然我们实际情况下很少在定义的时候用const修饰变量,但是像下面的情况却并不少见:

#include 
using namespace std;
class A
{
public:
	void Print() const
	{
		cout << _a << endl;
	}
private:
	int _a = 10;
};
void Func(const A& x)
{
	x.Print();
}
int main()
{
	A aa;
	Func(aa);
	return 0;
}

const修饰Print函数前的运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

const修饰Print函数后的运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以看到,当我们把对象传给某个参数被const修饰的函数,而这个函数的内部所调用的函数却没有被const修饰时,就容易出错。

因此,只要函数内部不对成员变量进行改变一般都建议用const修饰一下,加上之后const对象和普通对象都可以调用。

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

对于这两个操作符一般不需要重载,使用编译器生成的默认取地址的重载即可。

例:

#include 
using namespace std;
class A
{
public:
	/*A* operator&()
	{
		cout << "My &:";
		return this;
	}
	const A* operator&() const
	{
		cout << "My const&:";
		return this;
	}*/
private:
	int _a = 10;
};
int main()
{
	A aa;
	const A bb;

	cout << &aa << endl;
	cout << &bb << endl;
	return 0;
}

使用编译器默认生成的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

使用自己写的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

只有特殊情况,才需要重载,比如想让别人获取到指定的内容:

#include 
using namespace std;
class A
{
public:
	A* operator&()
	{
		return nullptr;//拒绝取地址
	}
	const A* operator&() const
	{
		return nullptr;//拒绝取地址
	}
private:
	int _a = 10;
};
int main()
{
	A aa;
	const A bb;

	cout << &aa << endl;
	cout << &bb << endl;
	return 0;
}

运行结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

你可能感兴趣的:(C++笔记,c++,笔记)