C++必备知识--类和对象

类的定义

        格式

        class是类的关键字,Stack是类的名字(自己定义的),在{}中定义类的主体。C++中类的出现是为了替代C语言中的结构体,所以类的结构与结构体类似,{}之后需要加分号。类体中的内容称为类的成员,类中(声明)的变量称为类的属性或成员变量,类中的函数称为类的方法或成员函数。

        在C++中也兼容了C语言中结构体的用法,并将 struct 升级成了类,也就是说C++中的 struct 内也可以定义函数,但是一般情况下我们还是优先使用class。除此之外,定义在类里的成员函数默认是inline内联函数。

        为了区分类中的成员变量,我们一般会在成员变量名上加一个特殊标识符,如前置"_"、后置"_"或者以m开头,这些只是习惯,并非硬性规定。

        访问限定符 

        在C++的类中设计了一种封装:将对象的属性和方法(成员变量和成员函数)结合,通过设置成员变量的访问权限,将类的接口选择性地提供给外部用户使用,让对象更为完善。而其选择性就体现在访问限定符。

        访问限定符共有三种:public、private和protected。public 修饰的成员在类外可以直接访问;protected 和 private 修饰的成员在类外不能直接访问(protected 和 private 的区别需要等到学了继承才能体现)。class定义的成员没有被访问限定符修饰的情况下默认为private,struct 则为public。以为一般情况下,成员变量都需要限制,所以我们会使用默认下为private 的class类,需要向外供应的成员函数会放在public之下。

        既然是将类的接口选择性地提供给外部用户使用,那必然要将三种访问限定符混合使用。C++中规定:访问权限的作用域是从当前访问限定符开始到下一个访问限定符结束,如果后面没有访问限定符了,就到类的作用域结束为止。

        类域 

        类域是类定义出的一个新作用域,里面包含的是所有类成员,如果想要在类外定义类中的成员时,需要使用 :: (域作用操作符)指明成员所属的类域。类域同样会影响编译时的查找规则,下方函数定义时如果不指定类域 Stack,那么init函数就会被当做全局函数,而全局函数寻找变量时是找不到 Stack 类中 private 下的成员变量的,所以会报错。如果指定了类域 Stack,在类外定义init函数时,就会到类域中去查找。

        简而言之,在C++中,当在类外定义类成员函数时,编译器会优先使用类中的成员变量,而不是全局变量。这是因为类成员函数的作用域首先是该类的对象范围内的成员变量。如果类中没有对应的成员变量或成员函数,则在类成员函数中使用变量时,编译器会在全局范围中查找该变量。


  实例化

        概念

        实例化是类在物理内存中创建对象的过程,也可称为类的实例化生成对象。在C++中,类是对象的一种抽象描述,限定了类有哪些成员变量和成员函数。类本身只是一个模型或模板,这些成员只是声明,不分配存储空间。只有当用类实例化对象时,才会为这些成员变量分配存储空间。

         类实例化出对象就像现实中使用建筑设计图来建造出房子,类就像设计图,设计图规划了房间的个数、大小及功能,但是并没有实体建筑存在,所以并没有占用空间。用设计图修建出的房子是实体,占用空间,因此实例化出的对象才会占用物理内存,并可以存储数据,实现功能。

#include<iostream>
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
	Date d1;
	Date d2;
	d1.Init(2024, 3, 31);
	d1.Print();
	d2.Init(2024, 7, 5);
	d2.Print();
	return 0;
}

         内存对齐方式

        C++中规定的类成员变量的内存对齐方式与C语言结构体的大致相同(不了解的可以看C语言结构体详解-CSDN博客),但是由于C++的类中引入了成员函数,所以在存储方式上增加了一个规定:如果类中只含有成员函数或为空类时,该类的空间大小为1byte,原因是我们需要表示对象存在过,这里的1字节纯粹是为了占位标识对象存在。

        只含成员函数的类或者空类也会占用至少一个字节的内存空间。这是为了确保每个对象都有一个独特的地址,即使是空类的对象。这种设计使得每个对象在内存中都是可区分的,并且可以使用 sizeof 运算符计算对象的大小,支持对象数组的正确操作。

        正常情况下,成员函数本身确实不直接占用对象的空间,这是因为成员函数在内存中作为代码段的一部分存在,而不是每个对象实例的一部分。成员函数的代码在编译时已经确定,并且在运行时共享使用,不需要为每个对象实例分别存储成员函数的代码。

这种设计有几个主要原因:

        1、成员函数的代码对于所有对象实例都是共享的。无论创建多少个类的实例,成员函数的代码只会在内存中存储一份。这节省了大量的内存空间。

        2、在运行时调用成员函数时,编译器会生成调用指令,使用对象的地址和成员函数的地址来执行调用操作。成员函数的代码在程序加载时被加载到内存中,而不是在每个对象实例中都存储一份。

        3、C++类的成员变量是类对象的状态数据,而成员函数是操作这些状态数据的方法。对象实例需要独立的状态数据,但不需要独立的函数代码。

#include<iostream>
using namespace std;
// 计算⼀下A/B/C实例化的对象是多大?
class A
{
public :
	void Print()
	{
		cout << _ch << endl;
	}
private:
	char _ch;
	int _i;
};
class B
{
public :
	void Print()
	{
		;
	}
};
class C
{

};
int main()
{
	A a;
	B b;
	C c;
	cout << sizeof(a) << endl;
	cout << sizeof(b) << endl;
	cout << sizeof(c) << endl;
	return 0;
}


this指针 

        在C++中,this 指针是一个隐式指针,指向的是调用成员的函数本身。我们来举个例子:Date 类中有 Init 和 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 和 Print 函数时,该函数是如何确定访问对象的呢?这里就引出了 this 指针。

         在C++中,编译器编译后,类的成员函数会默认在形参的第一个位置增加一个与当前类的类型相同的指针,叫做 this 指针。类的成员函数中的访问成员变量操作,本质都是通过this指针访问的,上图中可以看出我们并没有定义 this 指针,但是可以使用 this 指针进行访问操作。但是C++中规定不能在实参和形参中写出 this 指针,只能在函数体内使用。

例题 

1.下面程序编译运行结果是()

A、编译报错         B、运行崩溃          C、正常运行
 

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

分析:我相信很多人第一次做这道题都会选A或者B,首先A是肯定不能选的。因为题目中的p变量是类A的指针类型,只是代表该类的 this 指针被赋值为了 nullptr,所以这道题不会是编译错误,那B对不对呢?答案是不对。在C++中,调用一个成员函数时,可以通过指向对象的指针来调用该函数,即使该指针为空指针(nullptr)。这种情况下,调用的成员函数不能访问任何成员变量,否则会导致未定义行为,从而导致程序崩溃。本题目中并没有访问类中的成员变量,所以可以正常运行。如果将类改为如下代码,那就会程序运行崩溃。

class A
{ 
public:
    void Print()
    {
        cout << "A::Print()" << endl;
        cout << _a << endl;
    }
private:
    int _a;
};

        this 指针在C++中具有多种重要用途,能够增强代码的可读性、灵活性和功能性。通过使用 this 指针,程序员可以更有效地管理对象的成员变量,支持链式调用,操作当前对象的指针,并在复杂的类设计中实现更高级的功能。


C++和C语言实现Stack(栈)的对比

        C++与C语言实现栈的区别在于:在C++中,可以利用类(class)来封装栈的数据和操作,使代码更具有模块化和可重用性。C++中栈的变量和函数都放到了类里面,通过访问限定符进行了限制,不能再随意通过对象来直接修改成员变量,这是C++封装特性的一种体现,这个是最重要的变化。封装的本质是一种更为严格规范的管理,避免了乱访问乱修改的问题。封装的更多知识会在后续章节讲解。

C语言实现栈:数据结构--栈和队列-CSDN博客

        C++中加入了一些相对方便实用的语法,如缺省参数、this 指针,使用自定义类型不在需要 typedef 重定义类型名等等。

        简而言之,C++提供了更多的语言特性,使得实现栈更加简洁和直观,而C语言则需要更多的手动管理和代码书写。

#include<iostream>
#include<assert.h>
using namespace std;
typedef int STDataType;
class Stack
{
public :
	// 成员函数
	void Init(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		} 
		_capacity = n;
		_top = 0;
	}
	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity *sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			}
			_a = tmp;
			_capacity = newcapacity;
		} 
		_a[_top++] = x;
	} 
	void Pop()
	{
		assert(_top > 0);
		--_top;
	} 
	bool Empty()
	{
		return _top == 0;
	} 
	int Top()
	{
		assert(_top > 0);
		return _a[_top - 1];
	} 
	void Destroy()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	// 成员变量
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};
int main()
{
	Stack s;
	s.Init();
	s.Push(1);
	s.Push(2);
	s.Push(3);
	s.Push(4);
	while (!s.Empty())
	{
		printf("%d\n", s.Top());
		s.Pop();
	} 
	s.Destroy();
	return 0;
}

类的默认成员函数

        默认成员函数是指当用户没有显式实现时,编译器会自动生成的成员函数。在C++98标准中,每个类都有六个默认的成员函数,这些函数在没有明确定义时由编译器自动生成。

  • 初始化和清理:构造函数完成初始化工作,析构函数完成清理工作。
  • 拷贝复制:拷贝构造是使用同类对象初始化创建对象,赋值重载主要是把一个对象赋值给另一个对象。
  • 取地址重载:主要是普通对象和const对象取地址,这两个很少会自己实现。

在C++11中又增加了两个默认成员函数:移动构造和移动赋值,这里先跳过,以后会讲解。

默认成员函数很重要也很复杂,所以我们从两方面去学习:

  1. 我们不明确定义时,编译器默认生成的函数行为是什么,是否能满足我们的需求。
  2. 编译器默认生成的函数不满足我们的需求时,我们如何自己实现。

构造函数

        构造函数是一种特殊的成员函数,虽然名为构造函数,但它的主要功能是对象实例化时初始化对象,而非开空间创建对象,因为我们使用的类对象在栈帧创建时,空间就开好了。构造函数的的出现目的是为了替代我们在写C语言时总是需要初始化结构体的 Init 函数,并且构造函数自动调用的特点完美地替代了需要手动初始化的 Init 函数。

构造函数的特点:

  1. 函数名与类名相同。
  2. 没有返回值。(C++规定,无需给返回值,也不需要写void)
  3. 对象实例化时,系统会自动调用对应的构造函数。
  4. 构造函数可以重载。
  5. 如果类中没有明确定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户明确定义构造函数,编译器将不再生成。
  6. 无参构造函数、全缺省构造函数、编译器自动生成的构造函数都被称为默认构造函数,这三个函数有且只有一个可以存在,不能共存。无参构造函数和全缺省构造函数属于可构成重载,但存在调用歧义,因此不能共存。很多人会认为只有编译器自动生成的构造函数才是默认构造函数,这是不对的,实际上只要不传实参就可以调用的构造函数,都叫默认构造函数。
  7. 编译器自动生成的构造函数对内置类型的成员变量的初始化没有要求,也就是说其是否初始化时不确定的,看编译器。对于自定义类型的成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量没有默认构造函数,那么就会报错,我们要初始化这个成员变量需要用到初始化列表才能解决,我们后续会讲解。

补充:C++把类型分为了内置类型(C语言中就已定义的基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如:int/char/double/指针等;自定义类型就是我们使用class/struct等关键字自由定义的类型。

代码说明:

#include<iostream>
using namespace std;
class Date
{
public :
	// 1.无参构造函数
	Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	} 
	// 2.带参构造函数
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// 3.全缺省构造函数:1、3只能存在一个
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	// 如果留下三个构造中的第二个带参构造,第⼀个和第三个注释掉。
	// 编译报错:error C2512: “Date”: 没有合适的默认构造函数可用。
    //原因是Date d1;是无参实例化对象,自动调用构造函数时,没找到参数。
    // 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则编译器无法。
    //语法是为了区分Date d1();是函数声明还是实例化对象,C++规定如此。
	Date d1;                 // 调用默认构造函数
	Date d2(2025, 1, 1);     // 调用带参的构造函数
	// warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意⽤变量定义的?)。
	Date d3();
	d1.Print();
	d2.Print();
	return 0;
}

        在学习数据结构时,数据结构--栈和队列-CSDN博客,我们使用C语言实现过两个栈模拟队列的实现 。在C++的类中,这个结构完美契合了构造函数的第七条特点,我们无需写任何函数就能完成初始化,因为对于自定义类型的成员变量,要求调用这个成员变量的默认构造函数初始化。我们在MyQueue中没有写构造函数,那就会去调用两个成员变量的构造函数,这样就完美地完成了初始化。

#include<iostream>
using namespace std;

typedef int STDataType;
class Stack
{
public :
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
		{
			perror("malloc fail!");
			return;
		} 
		_capacity = n;
		_top = 0;
	} 
	// ...
private:
	STDataType * _a;
	size_t _capacity;
	size_t _top;
};

// 两个Stack实现队列
class MyQueue
{
public :
	//编译器默认生成MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化

private:
	Stack pushst;
	Stack popst;
};

int main()
{
	MyQueue mq;
	return 0;
}

析构函数

        析构函数也是一种特殊的成员函数,且功能与构造函数相反,C++规定类对象在销毁时会自动调用析构函数,完成类对象中资源的清理释放工作。因为类中的局部对象是存在于栈帧中的,当函数结束时,这些局部对象会自动销毁,其占用的内存会被释放,因此不需要手动销毁局部对象。析构函数的出现目的是为了替代我们使用C语言实现栈等数据结构时实现的 Destroy 函数。当然,我们写析构函数是因为我们向内存申请了空间资源,如果向 Date 这种日期类,我们并没有资源需要释放的情况下就不需要析构函数。

析构函数的特点:

  1. 析构函数的函数名是在类名前加上字符 ~ 。
  2. 析构函数无参数无返回值。(与构造函数类似,也不需要加void)
  3. 一个类只能有一个析构函数。如果没有明确定义,系统会自动生成默认的析构函数。
  4. 对象生命周期结束时,系统会自动调用析构函数。
  5. 在没有明确定义析构函数的类中,编译器会自动生成析构函数,该函数对内置类型的成员变量不做处理,对自定义类型的成员变量会调用它的析构函数(与构造函数相似)。
  6. 在有明确定义析构函数的类中,依旧会调用自定义成员变量的析构函数,也就是说自定义类型的成员变量始终会自动调用它自己的析构函数。
  7. 如果类中没有申请空间资源,析构函数可以不写,直接使用编译器自动生成的析构函数即可(如Date类)。如果类中有申请空间资源,一定要自己写析构函数,避免造成空间资源的泄漏(如Stack)。
  8. C++规定,一个局部域如果存在多个类对象,那么后定义的先析构。

代码说明:

        下面的代码就是析构函数在使用两个栈实现队列的结构中的应用,我们无需写析构函数就能完成资源清理工作,因为对于自定义类型的成员变量,要求调用这个成员变量的默认析构函数完成清理工作。我们在MyQueue中没有写析构函数,那就会去调用两个成员变量的析构函数,这样就完美地完成了资源清理工作。

#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public :
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		} 
		_capacity = n;
		_top = 0;
	} 
	//明确定义的析构函数
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public :
	//编译器默认生成MyQueue的析构函数调用了Stack的析构,释放的Stack内部的资源
	// 显式写析构,也会自动调用Stack的析构
	//~MyQueue()
	//{
	//	//……;
	//}
private:
	//类成员变量
	Stack pushst;
	Stack popst;
};
int main()
{
	Stack st;
	MyQueue mq;
	return 0;
}

拷贝构造函数

        拷贝构造函数是构造函数中的一种特殊形式,如果一个构造函数的第一个参数是对当前类的类型对象的引用,且剩余的其他参数都有缺省值,则称此构造函数为拷贝构造函数或复制构造函数。

拷贝构造的特点:

  1. 拷贝构造函数是构造函数的一种重载。
  2. 拷贝构造函数的第一个参数必须是对当前类的类型对象的引用,使用传值传参的方式编译器会报错,因为在语法逻辑上传值传参会引发无穷递归调用。
  3. C++规定自定义类型的类对象在进行拷贝行为时必须调用拷贝构造,所以自定义类型传值传参和传值返回都会调用拷贝构造完成。
  4. 若没有明确定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型的成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型的成员变量会调用它的拷贝构造函数。
  5. 对于没有申请空间资源的全内置类型的类成员变量,编译器自动生成的拷贝构造就可以满足拷贝需求,所以我们无需手动定义拷贝构造函数。对于有申请空间资源的全内置类型的类成员变量,编译器自动生成的拷贝构造函数不能满足我们的要求,所以我们需要自己实现深拷贝(对申请的空间资源也进行拷贝),明确定义拷贝构造函数。而对于像两个栈实现队列这样的内部是全自定义类型的类成员变量,编译器自动生成的拷贝构造函数会自动调用栈的拷贝构造,因此我们无需手动定义 MyQueue 拷贝构造函数,只需要手动定义好自定义类型 Stack 的拷贝构造函数即可满足我们的需求。简而言之,如果一个类中明确定义了需要释放空间资源的析构函数,那么这个类就需要手动实现拷贝构造函数。
  6. 传值返回会产生一个临时对象来调用拷贝构造函数,传引用返回,返回的是返回对象的别名,不会产生拷贝行为。但是如果返回的对象在当前函数的局部域中时,一旦函数生命周期结束,函数中定义的局部变量都会销毁,那么使用传引用返回就有问题了,这时返回的引用相当于一个野引用,类似野指针。传引用返回虽然可以减少拷贝行为,提高效率,但是一定要确保返回对象的生命周期。

上述特点会在代码中一 一说明:

#include<iostream>
using namespace std;

class Date
{
public:
	//构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//1.拷贝构造:构造函数的重载
	//2.传参不可用传值传参,应该用传引用传参,传地址传参也可以,但是它不属于拷贝构造。
    //传值传参
	Date(Date d)
	{
		//编译报错:error C2652: “Date”: 非法的复制构造函数: 第⼀个参数不应是“Date”
		//原因见下图
        _year = d._year;
		_month = d._month;
		_day = d._month;
	}
	//传引用传参
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._month;
	}
	//传地址传参:非拷贝构造,因为传的实参是&d1,无法和上面的函数构成重载。
	Date(Date* d)
	{
		_year = d->_year;
		_month = d->_month;
		_day = d->_day;
	}
private:
	int _year;
	int _month;
	int _day;
};

#include<iostream>
using namespace std;

class Date
{
public:
	//构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//拷贝构造:构造函数的重载
	//传引用传参
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._month;
	}
    //传地址传参:非拷贝构造,因为传的实参是&d1,无法和上面的函数构成重载。
	Date(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 Func1(Date d)
{
	cout << &d << endl;
	d.print();
}

void Func2(const Date& d)
{
	cout << &d << endl;
	d.print();//这里会报错,因为const修饰的d对象是不可以访问的。
	//这里以后讲解决方法,暂且搁置。
}
int main()
{
	//对象实例化并给实参
	Date d1(2024, 7, 5);
	//3.这里的d1属于传值传参,传给Func中的形参d,形参d会先进行拷贝构造,然后将d自己的参数传入函数。
	Func1(d1);
	//3.这里的d1属于传引用传参,形参d是实参d1的别名,因此不会进行拷贝构造,直接传入函数。
	Func2(d1);

    //拷贝构造的两种实参写法
    Date d2(d1);
    Date d3 = d1;
    //传地址拷贝不属于拷贝构造
    Date d4(&d1);
	return 0;
}

#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public :
	//构造函数
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		} 
		_capacity = n;
		_top = 0;
	}
	//拷贝构造
	Stack(const Stack& st)
	{
		// 需要对_a指向资源创建同样大的资源再拷贝值
		_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败!!!");
			return;
		} 
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_top = st._top;
		_capacity = st._capacity;
	}
	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity *sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			} 
			_a = tmp;
			_capacity = newcapacity;
		} 
		_a[_top++] = x;
	} 
	//析构函数
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public :
	//
private:
	Stack pushst;
	Stack popst;
};
int main()
{
	Stack st1;
	st1.Push(1);
	st1.Push(2);
	//5. Stack 如果不明确定义实现拷贝构造,⽤自动生成的拷贝构造完成浅拷贝会导致st1和st2⾥面的_a指针指向同⼀块资源,析构时会析构两次,从而程序崩溃。
	Stack st2(st1);

	MyQueue mq1;
	//5. MyQueue自动生成的拷贝构造,会自动调用Stack拷贝构造完成pushst/popst的拷贝,只要Stack拷贝构造自己实现了深拷贝,就不会有问题。
	MyQueue mq2(mq1);
	return 0;
}

赋值运算符重载

        运算符重载

        我们知道内置类型的对象是支持通过运算符来比较的,但是自定义类型的变量内容复杂,并不支持通过运算符来进行比较,但是日常生活中我们经常会对于复杂的对象进行比较,如超市商品的价格、日期等。为了能更好的支持复杂对象的比较,C++规定,当类类型的对象使用运算符时,必须转换成调用对应运算符的重载,若没有对应运算符的重载,则会编译报错。换言之,当类类型的对象使用运算符时,我们需要通过运算符重载的形式为其指定新的含义。

        运算符重载是具有特殊名字的函数,它的名字由 operator 和要定义的运算符共同构成。运算符重载函数与其他函数一样,都有返回类型、参数列表和函数体等结构。运算符重载函数的参数个数由该运算符作用于的运算对象的数量决定,且参数顺序与该运算符的运算对象之间的相对位置有关。

#include<iostream>
using namespace std;
class Date
{
public :
	//构造函数
	Date(int year = 1, 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;
} 
int main()
{
	Date d1(2024, 7, 5);
	Date d2(2024, 7, 6);
	// 运算符重载函数可以显式调用
	operator==(d1, d2);
	// 编译器会转换成 operator==(d1, d2);
	d1 == d2;
	return 0;
}

        如果各位写出来的代码和上面一样,那就会发现编译报错。原因是我们在全局域定义的运算符重载函数是无法访问类域中的私有成员变量的,解决方案有以下3种:

  1. 将成员变量放在 public 公有区域。这样可以解决上面的问题,但是我们如果将成员变量公有化可能会导致类中的数据被轻易修改,在大型项目中会造成巨大后果,所以并不推荐。
  2. 提供get成员函数返回成员变量的值。这种方法在 Java 中经常使用。
  3. 将运算符重载函数放在类中,重载为成员函数。这种是C++最常用的。
  4. 友元函数:这个下面会讲。

        我们来看看第三种方法:

#include<iostream>
using namespace std;
class Date
{
public :
	//构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//运算符重载
	bool operator==(const Date& d2)
	{
        //this 指针可默认不加
		return this->_year == d2._year
			&& _month == d2._month
			&& _day == d2._day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2024, 7, 5);
	Date d2(2024, 7, 6);
	// 运算符重载函数可以显式调用
	d1.operator==(d2);
	// 编译器会转换成 d1.operator==(d2);
	d1 == d2;
	return 0;
}

        在C++中,运算符重载函数可以是成员函数也可以是非成员函数。如果运算符重载函数是成员函数,那么它的第一个运算对象会默认传递给隐式的 this 指针,因此成员函数形式的运算符重载函数会比运算符需要的操作数少一个参数。但运算符重载至少有一个类类型的参数,且不能通过运算符重载来改变内置类型对象的含义,因此一个类需要重载哪些运算符是看哪些运算符重载之后有意义,比如 Date 类重载operator-可以表示两个日期相减,即天数,但 operator+ 表示两个日期相加,没有任何意义。

        运算符重载之后的优先级和结合性不会发生改变,与对应的内置类型运算符保持一致。并且运算符重载只能对内置类型中已有的运算符进行重载,不能通过链接语法中没有的符号来创建新的运算符,如 operator@ 。在C++中存在五个不能重载的运算符:" .* "、" :: "、" sizeof "、" ?: "、" . ",在面试的选择中常考。

        ++运算符分为前置++和后置++,运算符重载是无法很好地区分。C++规定,后置++运算符重载时,增加一个 int 形参,与前置++构成函数重载,方便区分。在重载输入输出运算符<<和>>时,需要重载为全局函数,因为重载为成员函数,this 指针默认抢占了第一个形参的位置,从而导致语法和使用上的不便。具体来说,输入输出运算符的左操作数通常是 ostream 或 istream 对象,而右操作数是用户自定义的类对象。如果将这些运算符定义为成员函数,那么左操作数会成为隐式的 this 指针,这不符合惯用的使用方式。因此,通常将 << 和 >> 重载为非成员的全局函数。

        赋值运算符重载 

        赋值运算符重载是我们要学的第4个默认成员函数,他的功能是完成两个已经存在的对象之间的拷贝赋值,我们来对比拷贝构造来记忆,拷贝构造是将已经存在的对象拷贝给新创建的对象进行初始化,两者之间的区别在于第二个对象的状态是否已经初始化。

赋值运算符重载的特点

  1. 赋值运算符重载是一个运算符重载函数,C++规定该函数必须重载为成员函数,也就是说至少将该函数的声明放在类中。如果赋值运算符重载的参数写成传值的形式,则在传给形参的时候会存在拷贝行为,因此我们通常都写成 const 的类类型的引用。
  2. 为了支持连续赋值的行为,C++规定赋值重载运算符需要有返回值,且建议将返回值写成当前类类型的引用,这样可以减少拷贝提高效率。
  3. 没有明确定义赋值运算符重载时,编译器会自动生成一个默认的赋值运算符重载,自动生成的赋值运算符重载函数的行为与自动生成的构造函数类似,对内置类型的成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型的成员变量则会调用它的赋值运算符重载。
  4. 对于成员变量全为内置类型,且没有申请空间资源的类,编译器自动生成的赋值运算符重载就可以满足需求,所以我们无需手动实现赋值运算符重载,如之前写过的 Date 日期类。对于成员变量全为内置类型,但是有申请空间资源的类,编译器自动生成的赋值运算符重载并不能满足我们的需求,所以我们需要手动实现深拷贝(对指向的空间资源也进行拷贝),如Stack 类。对于成员变量中主要是自定义类型,且当前类中(不包括自定义类型的成员变量中申请的空间资源)没有申请空间资源的类,编译器自动生成的赋值运算符重载会调用自定义类型成员的赋值运算符重载,因此不需要我们手动实现,如MyQueue这样的类型内部主要为自定义类型 Stack 成员的类。简而言之,如果一个类中明确定义了析构函数并释放了空间资源,那么他就需要手动写赋值运算符重载函数。

下面我们通过代码一 一说明:

#include<iostream>
using namespace std;
class Date
{
public :
	//构造函数
	Date(int year = 1, 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;
    }
    //赋值运算符重载:完成两个已经存在的对象之间的拷贝赋值
    Date& operator=(const Date& d)
    {
    	_year = d._year;
    	_month = d._month;
    	_day = d._day;
    	// d1 = d2表达式的返回对象应该为d1,也就是*this
        //这样就可以支持连续赋值:d3 = d2 = d1;
    	return *this;
    }
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2024, 7, 5);
	Date d2(2024, 7, 6);
	// 运算符重载函数可以显式调用
	d1.operator=(d2);
	// 编译器会转换成 d1.operator=(d2);
	d2 = d1;

	//这里要区别于拷贝构造
	Date d3 = d1;
	Date d4(d1);
	return 0;
}

  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值