🌈座右铭🌈:人的一生这么长、你凭什么用短短的几年去衡量自己的一生!
目录
? ? ? ? 这篇文章主要为大家讲解C++当中的运算符重载的问题,学习这篇文章需要对C++的this指针足够的了解,链接我已经为大家放到了文章的开头如果又需要的话请查收。我将全方位地为大家讲解运算符重载,一次性解决各位初学者的所有疑问。
1、基本概念
????????当我们谈到运算符重载时,我们实际上是在讨论如何重新定义 C++ 中的某个运算符,使其适用于用户自定义的类或数据类型。运算符重载通过定义特殊的成员函数来实现,这些成员函数以 operator 关键字开头,后接需要重载的运算符符号。
????????下面是一个运算符重载函数的通用格式:
返回值类型 operator运算符(参数列表) { // 运算符的实现代码 }
? ? ? ? 接下来我来用代码为大家演示一下什么叫做运算符重载:
class MyClass { private: double value; public: MyClass(double value) :value(value) { } MyClass operator + (const MyClass& other)const { return MyClass(value + other.value); } MyClass operator - (const MyClass& other)const { return MyClass(value - other.value); } MyClass operator * (const MyClass& other)const { return MyClass(value * other.value); } MyClass operator / (const MyClass& other)const { if (other.value != 0) { return MyClass(value / other.value); } else { cerr << "Error: Division by zero:" << endl; return MyClass(0); } } MyClass& operator++(){ ++this->value; return *this; } MyClass& operator++(int) { MyClass tmp(*this); ++this->value; return tmp; } double getValue() { return this->value; } }; int main() { MyClass obj1(2.0); MyClass obj2(4.0); MyClass obj3 = obj1 + obj2; MyClass obj4 = ++obj1; cout << obj3.getValue() << endl; cout << obj4.getValue() << endl; return 0; }
? ? ? ? 再上面的代码当中我们重载了加法运算符、减法运算符等等,通过这样的方式我们定义了让两个对象当中的值相加的过程,运算符重载是C++当中一种灵活的方式,让用户自定义类型的对象能够使用类似于内置类型的语法进行操作。
2、为什么要引入运算符重载
????????1、自然语法:
????????运算符重载使得用户自定义的类型能够使用类似于内置类型的语法进行操作。例如,通过重载加法运算符,你可以使用object1 + object2的形式进行对象相加,这样的语法更接近我们日常的数学表达方式,使代码更易读。
? ? ? ? 2、代码简洁性:
????????运算符重载可以简化代码,使其更紧凑而易于理解。通过自定义运算符的行为,你可以隐藏底层实现细节,使代码更具表达力。
? ? ? ? 3、类的抽象性:
????????运算符重载有助于创建更抽象的类,使其更符合问题领域的模型。例如,通过重载比较运算符,你可以定义自定义类对象之间的比较规则,使得类在各种情境下都能够直观地比较。
? ? ? ? 4、标准库兼容
? ? ? ? 运算符重载使得用户自定义类型能够与标准库当中的算法和容器协同工作,例如如果你的类支持小于运算符的重载、那么对象就可以用于STL当中的排序算法当中,这就是标准库兼容,虽然运算符重载有着很多的优势,但是如果过度使用也会导致代码的混乱和不容易理解,因此在进行运算符重载的时候建议谨慎选择。
????????参数类型为 const T& 表示传递的参数是一个对常量类型 T 的引用。这有两个主要好处
? ? ? ? 1、效率提升
????????避免拷贝构造函数调用:通过使用引用而不是直接传递对象,可以避免不必要的拷贝构造函数的调用。如果使用非引用的方式传递参数,会导致传递的对象被复制一份,调用拷贝构造函数,增加了额外的开销,特别是对于大型对象或者自定义类型来说,这样的开销是不必要的。通过传递引用,可以直接操作原始对象,提高了传参的效率。
? ? ? ? 2、避免修改输入参数
????????使用 const 修饰:参数类型中的 `const` 关键字确保在函数内部不能修改传递的对象。这是通过将对象声明为常量引用来实现的。如果在函数内部尝试修改这个引用所引用的对象,编译器会报错。这样的设计有助于保护传递的对象不被意外地修改,提高了代码的健壮性和可维护性。
????????
void processData(const std::string& input) { // 不能修改 input,只能读取其中的数据 // ... }
????????`processData` 函数接受一个 `std::string` 类型的常量引用作为参数。这确保了在函数内部不能修改传递的字符串,而且通过引用的方式传递参数,也避免了不必要的字符串拷贝。
????????返回类型为 T& 表示返回的是对类型 T 的引用。这也有两个主要优势:
? ? ? ? 1、效率提升:
????????避免返回时的拷贝:返回引用而不是对象本身避免了在函数返回时发生不必要的拷贝构造函数调用。如果函数返回对象本身而不是引用,那么在返回时需要创建一个副本,调用拷贝构造函数,这可能会导致性能开销。通过返回引用,可以直接返回原始对象,提高了效率。
? ? ? ? 2、支持连续赋值:
????????允许链式赋值操作:返回引用允许进行连续赋值操作,例如 `a = b = c`。这是因为返回的是对象的引用,而不是对象本身,所以可以在赋值操作中继续引用相同的对象。这种语法糖提高了代码的简洁性和可读性。
class MyClass { private: int value; public: MyClass(int value) :value(value) { } MyClass& setValue(int num) { this->value += num; return *this; } int getValue() { return this->value; } }; int main() { MyClass obj1(10); obj1.setValue(2).setValue(5).setValue(7); cout << obj1.getValue() << endl; return 0; }
? ? ? ? 程序的运行结果如下:因为当我们返回一个对象的引用的时候,返回的是一个对象的别名我们就可以继续使用这个对象连续的进行同样的操作,这就叫做对象的链式调用,如果有小伙伴关于this指针这部分的内容有不理解的话,可以看我之前写过的一篇this指针的专题文章,那里面有对于this指针的详细介绍。
? ? ? ? ?1、为自身赋值
????????在赋值操作符重载中,检测自己给自己赋值是为了防止资源泄漏或不一致的状态。通常,你会看到类似以下的代码:
MyClass& MyClass::operator=(const MyClass& other) { // 检测是否自己给自己赋值 if (this != &other) { // 执行赋值操作 // ... } return *this; }
????????在赋值操作符重载中检测自己给自己赋值是一种防御性编程的做法,目的是避免可能导致资源泄漏或不一致状态的情况发生。如果大家对于这段话不理解的话,我用代码来解释,以下是一个常见的检测自赋值的代码模式:
#include <iostream> class Example { public: int* data; size_t size; // 构造函数 Example(size_t s) : size(s) { data = new int[size]; } // 析构函数 ~Example() { delete[] data; } // 赋值操作符重载 Example& operator=(const Example& other) { // 检测自赋值 if (this != &other) { // 进行赋值操作 delete[] data; // 释放原有资源 size = other.size; data = new int[size]; std::copy(other.data, other.data + size, data); // 复制数据 } return *this; } }; int main() { Example obj1(3); Example obj2(5); obj1 = obj2; // 赋值操作 return 0; }
? ? ? ? 大家尤其注意一下这段代码:
// 赋值操作符重载 Example& operator=(const Example& other) { // 检测自赋值 if (this != &other) { // 进行赋值操作 delete[] data; // 释放原有资源 size = other.size; data = new int[size]; std::copy(other.data, other.data + size, data); // 复制数据 } return *this; }
? ? ? ? 大家可以想象一下如果我在主函数当中进行了这样的操作:
int main() { MyClass obj1(20); obj1 = obj1; return 0; }
? ? ? ? 如果我进行了这样的操作,而且这个对象单中含有data这样的指针成员,我不进行任何的判断就执行了这样的代码:
delete[] data; // 释放原有资源 size = other.size; data = new int[size]; std::copy(other.data, other.data + size, data); // 复制数据
? ? ? ? 大家可以想象一下会有什么样的后果,一个对象因为自己给自己赋值然后不分青红皂白地就把自己的数据给删除了,这样会导致非常严重的后果,这个对象的数据会丢失,资源造成泄漏,所以我们在进行运算符重载的时候一定要判断一下,不能够直接删除数据。
? ? ? ? 2、为自身赋值的后果
????????在上述例子中,`operator=` 被重载以处理 `Example` 类型对象的赋值操作。在赋值之前,首先检测了是否是自赋值,即 `this != &other`。如果是自赋值,就不进行释放和拷贝的操作,以避免释放正在使用的资源,并保持对象的一致性。 这种检测自赋值的做法在处理动态分配的资源(比如堆内存)时尤为重要。如果不进行自赋值检测,可能导致在释放原有资源之前就将其覆盖,从而导致资源泄漏或者出现不一致的状态。通过检测自赋值,可以确保赋值操作的安全性和一致性。 如果在赋值操作符重载中不进行自赋值检测,可能会导致以下危害:
????????1. 资源泄漏:?
????????假设你有一个包含动态分配内存的类,如果没有检测自赋值并在赋值前释放资源,那么在自赋值的情况下就会导致原有的资源丢失,无法释放,从而发生内存泄漏。
????????2. 不一致的状态:
????????如果在进行自赋值时不检测,可能会导致对象的状态处于不一致的状态。例如,在拷贝数据之前删除原有数据,这样会导致拷贝时访问无效的内存,导致未定义行为。
????????3. 程序崩溃或不稳定:
????????不进行自赋值检测可能导致程序崩溃或不稳定的行为。在自赋值情况下,如果不小心释放了正在使用的资源,可能导致悬挂指针或无效内存访问,最终导致程序崩溃。
#include <iostream> class Example { public: int* data; size_t size; Example(size_t s) : size(s) { data = new int[size]; } ~Example() { delete[] data; } Example& operator=(const Example& other) { // 没有自赋值检测 // 可能导致资源泄漏和不一致的状态 delete[] data; // 错误:没有检测自赋值 size = other.size; data = new int[size]; std::copy(other.data, other.data + size, data); return *this; } }; int main() { Example obj(3); obj = obj; // 自赋值 return 0; }
? ? ? ? 代码我为大家放到了这里,有兴趣的话可以自己去验证一下会发生什么。编译虽然不会报错但是程序已经出现了巨大的安全问题。
? ? ? ? 1、*this的作用:
????????return *this 的目的是支持连续赋值。在连续赋值中,每个赋值表达式的返回值都是被赋值的对象的引用。例如,a = b = c,首先 b = c 返回 b 的引用,然后 a = b 返回 a 的引用。这种返回自身引用的方式允许多个赋值操作可以串联在一起。
? ? ? ? 我不知道大家是否还记得我刚刚在对象的链式调用当中提过这个知识点,代码如下,在实现链式调用的过程当中一方面返回值是引用而且return后面的语句就是*this。
class MyClass { private: int value; public: MyClass(int value) :value(value) { } MyClass& setValue(int num) { this->value += num; return *this; } int getValue() { return this->value; } }; int main() { MyClass obj1(10); obj1.setValue(2).setValue(5).setValue(7); cout << obj1.getValue() << endl; return 0; }
? ? ? ? this指针指向的是这个对象本身,可以说是对象在内存当中的地址,那么*this就是对这个对象进行解引用,返回的也就是这个对象本身。和链式调用相互配合。
? ? ? ? 2、*this与引用返回
? ? ? ? 各位小伙伴这部分的内容非常重要,也困扰了我很长的时间,今天我一次性给大家讲清楚。
- 新对象 vs. 原对象:如果返回新创建的对象而不是引用,那么每次调用函数时都会生成一个新的对象。这意味着每次操作都会创建新的对象,而不是在原对象上进行修改。这样的设计可能更适合不希望改变原始对象状态的情况。
- 复制成本:返回对象可能涉及到复制构造函数的调用,这可能导致一些额外的开销。如果你的对象比较大或者复制构造函数比较昂贵,这种设计可能会影响性能。
- 不支持链式调用:如果不返回引用,就不能支持链式调用,无法在一行代码中连续调用多个该类的成员函数。
?? ? ? ? 如果文字大家还是不能够明白的话请看这样的一段代码:
?class MyClass { private: int value; public: MyClass(int value) :value(value) { } MyClass setValue(int num) { this->value += num; return *this; } int getValue() { return this->value; } }; int main() { MyClass obj(5); obj.setValue(10); cout << obj.getValue() << endl; obj.setValue(1); cout << obj.getValue() << endl; obj.setValue(5).setValue(7).setValue(5); cout << obj.getValue() << endl; return 0; }
????????代码的运行结果如下:当我不适用引用返回的时候并且这个时候还调用了对象的链式调用是没有用的,因为*this是对象本身,如果不返回引用的话返回的是对象本身,每一次调用函数返回的都是一个新的对象,无法在原来的对象上进行操作,无法实现函数的连续调用也就是对象的链式调用。
? ? ? ? 可能到了这里小伙伴会有一个疑问,为什么*this就是对象本身的意思,可是使用引用返回就能够返回对象的引用呢?this指针指向对象,对这个指针解引用应该就是对象本身啊,难道就因为返回值的类型是引用*this就能返回引用吗?? ? ? ? 答案很简单因为编译器做了优化,给*this对象本身临时绑定了一个引用,所以链式调用的时候不管我们执行多少次返回的都是同一个对象的引用,所以可以利用链式调用对同一个对象进行多次重复的操作。
1、前置++
????????重载前置和后置递增运算符 ++ 是面向对象编程中的一项常见任务。这两者之间有一些细微的差异,前置递增运算符也就是 ++i:
T& operator++(); // 返回引用
????????1、返回类型为引用:
????????前置递增运算符返回引用,允许对同一对象进行连续递增操作,因为返回的是原始对象的引用。前置++是对对象本身的值进行了修改所以返回的也必须是对象本身。
????????2、先递增后返回:
????????首先对对象进行递增操作,然后返回递增后的对象的引用。
????????3、推荐使用前置递增:
????????在性能上,前置递增通常比后置递增更高效,因为前置递增直接对原始对象进行操作,而后置递增需要创建一个副本,增加了额外的开销。
2、后置++
? ? ? ? 后置++与前置++的区别就是在参数列表当中写一个int,这个int没有任何的意义,就是为了区分前后的。
T operator++(int); // 参数int用于区分前置和后置递增
? ? ? ? 1、回类型为值:
????????后置递增运算符返回一个值,而不是引用。这是因为后置递增要返回递增前的原始值,而不是递增后的对象。
????????2、使用参数区分前后置:
????????后置递增运算符的参数是一个(通常是未使用的)整数,用于在函数签名上区分前置和后置版本。
????????3、先返回后递增:
????????首先返回递增前的原始值,然后再对对象进行递增操作。
#include <iostream> class Counter { private: int count; public: Counter() : count(0) {} // 前置递增运算符 Counter& operator++() { ++count; return *this; } // 后置递增运算符 Counter operator++(int) { Counter temp(*this); // 保存递增前的值 ++count; // 对对象进行递增 return temp; // 返回递增前的值 } int getCount() const { return count; } }; int main() { Counter c1; std::cout << "Original Count: " << c1.getCount() << std::endl; // 前置递增 ++c1; std::cout << "After Pre-increment: " << c1.getCount() << std::endl; // 后置递增 Counter c2 = c1++; std::cout << "After Post-increment: " << c2.getCount() << std::endl; std::cout << "Final Count: " << c1.getCount() << std::endl; return 0; }
? ? ? ? 这部分内容简单容易理解,写一段代码为大家演示,这部分内容重点要理解为什么前置++需要引用返回而后置++不需要引用返回,因为前置++是先自增再返回所以这个时候对象本身已经发生了变化,而后置++不同需要先返回自身的值然后再进行自增操作,所以需要引入临时变量,一般我们推荐使用前置++,他的效率更高一些。
? ? ? ? 拷贝构造函数当中还有一个很重要的内容就是const成员函数,这个部分我的下一篇文章会为大家详细介绍,const成员不是这篇文章的重点。?
? ? ? ? 这篇文章就为大家介绍到这里,希望我的文章能够帮助到各位小伙伴,今天是2023年的最后一天,这一年发生了很多令我难以忘怀的事情,2023教会了我很多的事情,我的每一份耕耘终于有了让我满意的收获,我现在正满心期待地迎接2024的到来,就用这篇文章致敬2023努力的自己吧!也祝我的每一位粉丝小伙伴新的一年能够诸事顺遂,所得皆所愿,感谢各位的陪伴!😊