Operator Overloading⚓︎
约 2953 个字 301 行代码 预计阅读时间 19 分钟
在 C++ 中,运算符重载(operators overloading) 是一项强大的特性,它允许我们为自定义的类类型 (class types) 赋予与内置数据类型相似的运算行为。从本质上讲,运算符重载为我们提供了一种更直观、更符合数学或逻辑习惯的方式来调用对象的成员函数或全局函数。
- 
能够被重载的一元或二元运算符: 
- 
不能被重载的运算符: 
限制和规则
- 不能创建新运算符:只能重载 C++ 中已经存在的运算符(比如不能创建一个新的 **运算符来表示幂运算)
- 至少有一个操作数是用户定义类型(类类型或枚举类型) :这防止了改变内置类型运算符的行为(比如不能改变两个 int 相加的行为)
- 保留原有特性:- 操作数个数(arity):不能改变运算符原有的操作数个数,即一元运算符重载后仍是一元,二元运算符重载后仍是二元
- 优先级(precedence):运算符的优先级固定不变,不能通过重载来改变。例如,*的优先级总是高于+
- 结合性(associativity):运算符的结合性(左结合或右结合)也保持不变
 
Declaration⚓︎
重载运算符的声明与普通函数声明非常相似,只是函数名比较特殊:它由关键字operator + 要重载的运算符符号组成。例如,重载加法运算符 + 的函数名将是 operator+。
运算符重载可以作为成员函数或全局 / 自由函数来实现:
- 
当运算符作为类的成员函数重载时: - 隐式的左操作数:对于二元运算符,左操作数是调用该成员函数的对象本身(即 this指针指向的对象) ,因此成员函数形式的二元运算符只需要一个显式参数(代表右操作数) 。而一元运算符则不需要显式参数。
- 类型转换限制:左操作数(即 this对象)不会发生隐式类型转换。
 例子class Integer { private: int i; public: Integer(int val = 0) : i(val) {} // 二元 + 作为成员函数 // this->i 是左操作数,n.i 是右操作数 const Integer operator+(const Integer& n) const { return Integer(this->i + n.i); } // 一元 - 作为成员函数(前置负号) // this->i 是操作数 const Integer operator-() const { return Integer(-(this->i)); } void display() const { std::cout << i; } }; int main() { Integer a(10), b(5); Integer c = a + b; // 等价于 a.operator+(b) Integer d = -a; // 等价于 a.operator-() c.display(); // 输出 15 d.display(); // 输出 -10 }
- 隐式的左操作数:对于二元运算符,左操作数是调用该成员函数的对象本身(即 
- 
当运算符作为全局函数重载时: - 显式参数:所有操作数都必须作为显式参数传递,即二元运算符需要两个参数,一元运算符需要一个参数。
- 类型转换:所有参数都可以参与隐式类型转换。这在希望左操作数也能进行类型转换时特别有用
- 访问权限:全局函数默认不能访问类的 private和protected成员。如果需要访问,类必须将其声明为友元;否则,全局函数只能通过类的公有接口来操作对象。
 例子class Integer { private: int i; public: Integer(int val = 0) : i(val) {} // 声明全局 operator+ 为友元,以便它可以访问私有成员 i friend const Integer operator+(const Integer& lhs, const Integer& rhs); void display() const { std::cout << i; } }; // 全局 operator+ 的定义 const Integer operator+(const Integer& lhs, const Integer& rhs) { return Integer(lhs.i + rhs.i); } int main() { Integer a(10), b(5); Integer c = a + b; // 等价于 operator+(a, b) // 如果 Integer 有一个接受 int 的转换构造函数 (非 explicit): // Integer e = 3 + a; // 等价于 operator+(Integer(3), a) c.display(); // 输出 15 }
如何选择?
- 必须作为成员函数:- 赋值运算符 =
- 函数调用运算符 ()
- 下标运算符 []
- 类成员访问运算符 ->
- (较少使用)成员指针访问运算符 ->*
 
- 赋值运算符 
- 通常推荐作为成员函数:- 一元运算符(如!,&(取址),*(解引用),++,--, 一元 , 一元+, 一元-) 。
- 复合赋值 运算符(如 +=,-=,*=,/=等) 。它们通常会修改左操作数的状态。
 
- 一元运算符(如
- 通常推 荐 作为全局 / 自由函数(通常是友元):- 对称的二元运算符,即那些不应特别偏向于左操作数或右操作数进行类型转换的运算符(如算术运算符+,-,*,/,%;比较运算符==,!=,<,>,<=,>=;位运算符&,|,^) 。将它们实现为全局函数可以允许对两个操作数都进行对称的隐式类型转换。
- 流插入 <<和流提取>>运算符必须作为全局函数(通常是友元)重载,因为它们的左操作数是流对象(如std::ostream或std::istream) ,而我们不能修改标准库的类来添加成员函数。
 
- 对称的二元运算符,即那些不应特别偏向于左操作数或右操作数进行类型转换的运算符(如算术运算符
General Design Guideline⚓︎
- 参数传递:- 对于那些不修改操作数值的运算符,通常将参数按 const引用传递,以避免不必要的拷贝并确保原始对象不被修改
- 对于成员函数形式的运算符,如果该运算不修改对象自身的状态,应将该成员函数声明为 const
- 对于会修改左操作数的运算符(如 +=) ,如果作为全局函数,左操作数应按非const引用传递;如果作为成员函数,则隐式的this指针已经指向了要修改的对象
 
- 对于那些不修改操作数值的运算符,通常将参数按 
- 返回值:- 返回值类型取决于运算符的语义
- 算术运算符(如 operator+)通常返回一个新创建的对象(按值返回) ,因此可以返回const对象,以防止对结果(通常 是 临时对象 / 右值)进行意外的赋值操作
- 赋值运算符(如 operator=、 operator+=)通常返回对左操作数(*this)的引用,以支持链式操作
- 逻辑和比较运算符(如 operator==、 operator<)应返回bool类型
- 解引用运算符(如 operator*对于智能指针)通常返回引用
- 下标运算符 operator[]通常返回引用,以允许对元素进行读写
 
Details for Special Operators⚓︎
常见运算符原型示例 :
- 算术 / 位运算符(如+,-,*,/,%,^,&,|) :- 成员函数:const T T::operatorX(const T& rhs) const;
- 全局函数:const T operatorX(const T& lhs, const T& rhs);
 
- 成员函数:
- 逻辑 / 比较运算符(如 !, &&, ||, <, <=, ==, !=, >, >=) :- 一元 !:- 成员:bool T::operator!() const;
- 全局:bool operator!(const T& operand);
 
- 成员:
- 二元:- 成员:bool T::operatorX(const T& rhs) const;
- 全局:bool operatorX(const T& lhs, const T& rhs);
 
- 成员:
 
- 一元 
- 下标运算符[]:- 必须是成员函数
- 通常提供两个版本:一个 const版本返回const引用(用于const对象) ,一个非const版本返回一般的引用(用于非const对象,允许修改) 。
- 原型:- ValueType& T::operator[](IndexType index);
- const ValueType& T::operator[](IndexType index) const;
 
 
Increment/Decrement Operators⚓︎
C++ 区分前缀(++obj)和后缀(obj++)形式:
- 前缀形式 (prefix):- 成员函数:T& T::operator++();( 或const T&如果类型是数值包装器且自身不变 )
- 全局函数:T& operator++(T& obj);
- 行为:先修改对象,然后返回修改后对象的引用。
 
- 成员函数:
- 后缀形式 (postfix):- 成员函数:T T::operator++(int);( 或const T如果类型是数值包装器 )
- 全局函数:T operator++(T& obj, int);
- 特殊之处:后缀版本接受一个额外的、未命名的 int类型参数。这个参数只是用来区分后缀和前缀形式的,编译器在调用时会自 动传递 一个 0 作为这个int参数的值,但这个值通常不被使用
- 行为:先保存对象当前值的副本,然后修改对象,最后返回修改前的副本(按值返回)
 
- 成员函数:
例子
class Integer {
private:
    int i;
public:
    Integer(int val = 0) : i(val) {}
    // 前缀 ++
    Integer& operator++() {
        i += 1;
        return *this;
    }
    // 后缀 ++
    Integer operator++(int) {
        Integer old(*this); // 保存原始值的副本
        ++(*this);          // 调用前缀++来修改对象 (或直接 i += 1;)
        return old;         // 返回原始值的副本
    }
    void display() const { std::cout << i; }
};
int main() {
    Integer num(5);
    Integer res1 = ++num;
    num.display(); res1.display(); std::cout << std::endl;
    Integer num2(10);
    Integer res2 = num2++;
    num2.display(); res2.display(); std::cout << std::endl;
}
对于用户定义的类型,前缀 形 式的自增 / 自减通常比后缀形式更高效,因为后缀形式需要创建一个临时对象来保存旧值。因此,在不影响逻辑的情况下(例如,在循环中仅用于递增计数器
Relation Operators⚓︎
我们通常只需要实现 operator== 和 operator<,其他关系运算符可以基于这两个来实现,以减少重复代码并保持一致性。
例子
class Integer {
private:
    int i;
public:
    Integer(int val = 0) : i(val) {}
    bool operator==(const Integer& rhs) const {
        return i == rhs.i;
    }
    bool operator<(const Integer& rhs) const {
        return i < rhs.i;
    }
    // 基于 == 和 < 实现其他关系运算符
    bool operator!=(const Integer& rhs) const { return !(*this == rhs); }
    bool operator>(const Integer& rhs) const { return rhs < *this; }
    bool operator<=(const Integer& rhs) const { return !(*this > rhs); } // 或 !(rhs < *this)
    bool operator>=(const Integer& rhs) const { return !(*this < rhs); }
};
Stream Operators⚓︎
流运算符(stream operators) 必 须 作为全局 / 自由函数(通常是友元)来重载,因为它们的左操作数是流对象(std::istream 或 std::ostream
- 
流提取符 operator>>:- 原型:std::istream& operator>>(std::istream& is, YourType& obj);
- 第一个参数是 std::istream的引用(例如cin)
- 第二个参数是要读取数据的对象的引用(非 const,因为要修改它)
- 函数内部实现从输入流 is读取数据并填充到obj中
- 返回 is(输入流的引用) ,以支持链式操作,如cin >> a >> b;
 
- 原型:
- 
流插入符 operator<<:- 原型:std::ostream& operator<<(std::ostream& os, const YourType& obj);
- 第一个参数是 std::ostream的引用(例如cout)
- 第二个参数是要输出的对象的 const引用(通常不应在输出时修改对象)
- 函数内部实现将 obj的内容格式化并写入到输出流os中
- 返回 os(输出流的引用) ,以支持链式操作,如cout << a << b;
 
- 原型:
例子
#include <iostream>
#include <sstream>
class Point {
private:
    int x, y;
public:
    Point(int x_val = 0, int y_val = 0) : x(x_val), y(y_val) {}
    friend std::ostream& operator<<(std::ostream& os, const Point& p);
    friend std::istream& operator>>(std::istream& is, Point& p);
};
std::ostream& operator<<(std::ostream& os, const Point& p) {
    os << "(" << p.x << ", " << p.y << ")";
    return os;
}
std::istream& operator>>(std::istream& is, Point& p) {
    // 假设输入格式为 (x, y) 或 x y
    char delim1, comma, delim2;
    // 尝试读取 (x,y) 格式
    if (is >> delim1 && delim1 == '(' &&
        is >> p.x >> comma && comma == ',' &&
        is >> p.y >> delim2 && delim2 == ')') {
        // 读取成功
    } else {
        if (is.fail()) {
            is.clear(); // 清除错误标志
            // 尝试回退流指针 (如果支持并且有必要)
            // 这里简单地假设可以重新读取
            is >> p.x >> p.y;
        }
    }
    return is;
}
int main() {
    Point p1(1, 2), p2;
    std::cout << "P1: " << p1 << std::endl; // ((cout << "P1: ") << p1) << endl;
    std::cout << "Enter point p2 (e.g., 3 4 or (5,6)): ";
    std::cin >> p2;
    std::cout << "P2 entered: " << p2 << std::endl;
}
Assignment Operator⚓︎
关于赋值运算符 =:
- 必须是成员函数
- 编译器自动生成:如果你不提供自定义的 operator=,编译器会自动为你生成一个- 这个默认的赋值运算符执行成员逐一赋值 (memberwise assignment),行为类似于默认拷贝构造函数的成员逐一拷贝
- 对于简单类(没有动态分配内存或管理其他资源) ,默认版本通常足够
 
- 自定义 operator=的必要性:如果类管理动态分配的内存(或其他需要特殊处理的资源,如文件句柄) ,那就必须得提供自定义的赋值运算符(以及拷贝构造函数和析构函 数 ——“三 / 五法则”) ,因为默认的成员逐一赋值会导致浅拷贝,从而引发悬挂指针、重复释放等问题。
- 
设计要点: - 
检查自赋值(self-assignment):防止对象被赋值给自身时可能出现的资源过早释放等问题,并提高效率。具体有以下实现策略: - 证同测试(identity test):发现自赋值就立马返回 *this,一般就用这种方式
- 精心安排语句:有时只要合理安排语句顺序就能避免自赋值问题
- 拷贝和交换 (copy and swap):拷贝传入的参数(右值) ,然后交换*this和拷贝
 例子假如有这样的一个类: 有以下几种赋值运算符重载的实现: 
- 证同测试(identity test):发现自赋值就立马返回 
- 
正确处理资源:在从 rhs拷贝资源之前,必须正确释放*this对象当前拥有的旧资源
- 为所有数据成员赋值:确保源对象的所有相关数据都被正确拷贝到目标对象
- 返回对 *this的引用:从而支持链式赋值,如a = b = c;(它被解析为a = (b = c);)- 这不是强制性的要求,即使不遵守代码也能通过编译,但也不要在这种地方标新立异口牙!
 
- 阻止赋值:如果希望类的对象不可赋值,可以将 operator=声明为private(传统方 式)或使用 C ++11 的= delete;(推荐方式)
 
- 
例子
class MyString {
private:
    char* data;
    size_t len;
public:
    // ... 构造函数, 析构函数, 拷贝构造函数 ...
    MyString(const char* s = "") {
        len = std::strlen(s);
        data = new char[len + 1];
        std::strcpy(data, s);
    }
    ~MyString() { delete[] data; }
    MyString(const MyString& other) { // 拷贝构造
        len = other.len;
        data = new char[len + 1];
        std::strcpy(data, other.len > 0 ? other.data : "");
    }
    // 赋值运算符
    MyString& operator=(const MyString& rhs) {
        std::cout << "Assignment operator called for " << (data ? data : "null") << " from " << (rhs.data ? rhs.data : "null") << std::endl;
        // 1. 检查自赋值
        if (this == &rhs) {
            return *this;
        }
        // 2. 释放当前对象的旧资源
        delete[] data;
        // 3. 分配新资源并拷贝数据
        len = rhs.len;
        data = new char[len + 1];
        std::strcpy(data, rhs.len > 0 ? rhs.data : "");
        // 4. 返回对当前对象的引用
        return *this;
    }
    void display() const { std::cout << (data ? data : ""); }
};
int main() {
    MyString s1("Hello");
    MyString s2("World");
    MyString s3;
    s3 = s1; // s3.operator=(s1)
    s3.display(); std::cout << std::endl; // Hello
    s1 = s1; // 自我赋值
    s1.display(); std::cout << std::endl; // Hello
}
何时重载运算符?
- 
指导原则: - 仅当重载能使类的使用更自然、代码更易读和维护时才进行
- 如果重载后的行为与运算符的传统含义相去甚远,或者让代码变得晦涩难懂,那么最好避免重载
 
- 
通常不应重载的运算符 : &&(逻辑与),||(逻辑或),,(逗号运算符) 。因为这些运算符有内置的“短路求值”行为(对于&&和||)或特定的求值顺序(对于逗号运算符) ,而函数调用(包括重载的运算符函数)不具备这些特殊的求值语义,重载它们可能会导致与用户期望不符的行为。
Value Classes⚓︎
值类(value classes) 是指那些行为类似于内置原始数据类型的类,它们通常:
- 可以像普通值一样作为函数的参数传递和返回
- 经常重载各种运算符,使其使用起来自 然直观(如 Complex,Date,String类)
- 可能支持与其他类型的转换(隐式或显式)
Type Conversion and Casting⚓︎
C++ 允许我们定义类类型与其他类型(包括内置类型或其他类类型)之间的转换规则。有两种主要方式实现用户定义的类型转换:
- 
单参数构造函数(converting constructors):如果一个构造函数可以用单个参数调用(或者有多个参数,但第一个之后的所有参数都有默认值 ) ,它就可以被编译器用来执行从参数类型到类类型的隐式转换。例子class PathName { private: std::string name; public: // 这个构造函数可以从 std::string 隐式转换为 PathName PathName(const std::string& s) : name(s) { std::cout << "PathName created from string: " << name << std::endl; } void print() const { std::cout << name; } }; void processPath(PathName p) { p.print(); } int main() { std::string str_path = "/usr/local"; PathName pn1(str_path); // 显式构造 processPath(str_path); // 隐式转换: str_path -> PathName(str_path) }- explicit关键字:为了防止不期望的隐式转换(这可能导致难以察觉的错误或歧义- ) ,可以在单参数构造函数前使用- explicit关键字,这样转换就必须是显式的。
 
- 
类型转换运算符(conversion operators):这是一种特殊的成员函数,用于定义从类类型到其他类型 T的转换。- 形式:operator T() const; ( 其中T是目 标类型,如double,int, 或其他类类型 )
- 特点:- 没有显式返回类型(返回类型由 T本身指定)
- 通常没有参数(因为是从 *this对象转换)
- 通常应声明为 const,因为转换操作不应修改源对象
- 如果目标类型 T也可以通过其他方式从源类构造(例如,源类有一个接受T的单参数构造函数) ,这可能导致转换歧义;C++11 允许对类型转换运算符也使用explicit关键字
 
- 没有显式返回类型(返回类型由 
 例子class Rational { private: int numerator_; int denominator_; public: Rational(int num = 0, int den = 1) : numerator_(num), denominator_(den == 0 ? 1 : den) {} // 类型转换运算符:将 Rational 转换为 double operator double() const { return static_cast<double>(numerator_) / denominator_; } }; int main() { Rational r(1, 2); double d = r; // 隐式调用 r.operator double() std::cout << "d = " << d << std::endl; // 输出 d = 0.5 double val = 0.5 + r; // r 被转换为 double,然后进行加法 std::cout << "val = " << val << std::endl; // 输出 val = 1.0 }
- 形式:
类型转换规则与歧义
- 编译器在需要类型转换时,会尝试找到“最佳匹配”的转换路径,这通常意味着成本最低的转换- 一般而言 , 精确 匹配 > 内 置类型 提升 > 标准 转换 > 用户定义转换
 
- 如果存在多种同样好的用户定义转换路径,或者内置转换和用户定义转换之间存在歧义,编译器会报错。
- 建议:过度依赖隐式类型转换可能使代码难以理解和维护,并容易引入歧义。通常建议:- 对单参数构造函数使用 explicit,除非确实需要隐式转换且其行为非常直观。
- 优先使用具名的转换函数(如toDouble(),toString())而不是隐式的类型转换运算符,这样转换意图更明显。
 
- 对单参数构造函数使用 
Modern Casting⚓︎
除了上述类型转换的方法外,C++ 还提供了以下几种转型(casting):
- const_cast:用于常量性移除(cast away the constness),简单来说就是移除原有的- const和- volatile修饰
- dynamic_cast:主要用于执行安全向下转型(safe downcasting),即实 现 基类引用 / 指针向 派 生类引用 / 指针的转换(和向上转型相反)- 如果类型不匹配,指针类型转换会返回 nullptr,引用类型转换会抛出std::bad_cast
- 基类应当为多态类型(即至少有一个 virtual函数)
- 它是唯一无法用旧式语法执行的动作,也是唯一可能耗费很多运行成本的转型动作
 
- 如果类型不匹配,指针类型转换会返回 
- reinterpret_cast:用于执行低级转型,实际动作(及结果)可能取决于编译器,这意味着它不可移植
- static_cast:用于强制隐式转换(implicit conversion)
注意
如果可以的话,应尽量避免使用转型。因为使用转型意味着:
- 破坏类型安全:转型允许我们将一个对象当作它实际不是的类型来处理。虽然 static_cast和dynamic_cast提供了一些编译时或运行时检查,但它们仍然可能导致在逻辑上不正确的类型解释,从而引发访问无效内存、数据损坏或未定义行为。
- 隐藏设计缺陷:频繁使用转型可能表明类设计或继承层次结构存在问题。一个好的面向对象设计应该通过多态性、虚函数、模板等机制来处理不同类型,而不是依赖于频繁的运行时类型转换。
- 降低代码可读性和可维护性:转型操作会使代码的意图变得不那么直观,因为它们打破了正常的类型规则。
- 引入运行时开销:dynamic_cast需要在运行时进行类型检查,这会带来一定的性能开销。
如果非要用到转型,那么请尽量将转型操作封装在类的内部,或特定的辅助函数中,而不是散布在整个代码库中。这样用户需要的时候就可以调用该函数,而无需将转型代码放进自己的代码中。
static_cast ⚓︎ 
  static_cast 是  C++ 中用于执行类型转换的运算符之一,提供了一种在编译时进行类型检查的、相对安全的显式类型转换方式,其语法为:
static_cast 主要用于以下几种情况下的类型转换:
- 
相关类型之间的转换:在具有明确定义的转换路径的类型之间进行转换,包括: - 基本类型的转换
- 子类指针 / 引用 向 父类指针 / 引用的转换
- void指针和其他类型指针的转换
 
- 
显式调用隐式转换:当存在隐式转换路径时(例如通过单参数构造函数或类型转换运算符 ) ,static_cast可以使这种转换意图更加明确。
- 消除歧义:在某些表达式中,隐式转换可能导致歧义,使用 static_cast可以明确指定所需的转换。
static_cast 的安全性体现在:
- 编译时检查:static_cast在编译时会检查转换是否“合理”。例如,我们不能用static_cast在两个完全不相关的指针类型之间进行转换,也不能用它来移除const或volatile限定符。这种编译时检 查使得 它比 C 风格的强制类型转换更安全。
- 无运行时检查:对于指针或引用的向下转型,static_cast不进行任何运行时类型检查。如果转换是错误的(例如,基类指针并未指向所期望的派生类类型的对象) ,static_cast仍然会执行转换,但后续使用这个转换后的指针或引用将导致未定义行为。
评论区