跳转至

Overloaded Operators⚓︎

2561 个字 271 行代码 预计阅读时间 16 分钟

C++ 中,运算符重载(overloaded operators) 是一项强大的特性,它允许我们为自定义的类类型 (class types) 赋予与内置数据类型相似的运算行为。从本质上讲,运算符重载为我们提供了一种更直观、更符合数学或逻辑习惯的方式来调用对象的成员函数或全局函数。

  • 能够被重载的一元或二元运算符:

    + - * / % ^ & | ~
    = < > += -= *= /= %=
    ^= &= |= << >> >>= <<= ==
    != <= >= ! && || ++ --
    , ->* -> () []
    new delete
    new[] delete[]
    
  • 不能被重载的运算符:

    .        // 成员访问运算符
    .*       // 成员指针访问运算符
    ::       // 作用域解析运算符
    ?:       // 条件运算符 (三元运算符)
    sizeof   // 长度运算符
    typeid   // 类型识别运算符
    static_cast 
    dynamic_cast 
    const_cast
    reinterpret_cast
    

限制和规则

  • 不能创建新运算符:只能重载 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
    }
    
  • 当运算符作为全局函数重载时:

    • 显式参数:所有操作数都必须作为显式参数传递,即二元运算符需要两个参数,一元运算符需要一个参数。
    • 类型转换:所有参数都可以参与隐式类型转换。这在希望左操作数也能进行类型转换时特别有用
    • 访问权限:全局函数默认不能访问类的 privateprotected 成员。如果需要访问,类必须将其声明为友元;否则,全局函数只能通过类的公有接口来操作对象。
    例子
    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::ostreamstd::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::istreamstd::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):防止对象被赋值给自身时可能出现的资源过早释放等问题,并提高效率
    • 正确处理资源:在从 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⚓︎

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 关键字,这样转换就必须是显式的
    例子
    class PathNameExplicit {
    private:
        std::string name;
    public:
        explicit PathNameExplicit(const std::string& s) : name(s) {}
        // ...
    };
    
    // PathNameExplicit pn_expl = std::string("/usr"); // 错误! explicit 构造函数不能用于隐式转换
    PathNameExplicit pn_expl(std::string("/usr")); // 正确, 显式调用
    
  • 类型转换运算符 (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())而不是隐式的类型转换运算符,这样转换意图更明显。

何时重载运算符?
  • 指导原则:

    • 仅当重载能使类的使用更自然、代码更易读和维护时才进行
    • 如果重载后的行为与运算符的传统含义相去甚远,或者让代码变得晦涩难懂,那么最好避免重载
  • 通常不应重载的运算符&&(逻辑与), ||(逻辑或), ,(逗号运算符。因为这些运算符有内置的“短路求值”行为(对于 &&||)或特定的求值顺序(对于逗号运算符,而函数调用(包括重载的运算符函数)不具备这些特殊的求值语义,重载它们可能会导致与用户期望不符的行为。

充: static_cast

static_cast C++ 中用于执行类型转换的运算符之一,提供了一种在编译时进行类型检查的、相对安全的显式类型转换方式,其语法为:

static_cast<new_type>(expression)

static_cast 主要用于以下几种情况下的类型转换:

  • 相关类型之间的转换:在具有明确定义的转换路径的类型之间进行转换。例如,数值类型之间的转换(如 intfloat,或 enumint,或者类层次结构中的指针或引用转换(向上转型和有风险的向下转型
  • 显式调用隐式转换:当存在隐式转换路径时(例如通过单参数构造函数或类型转换运算符static_cast 可以使这种转换意图更加明确。
  • 消除歧义:在某些表达式中,隐式转换可能导致歧义,使用 static_cast 可以明确指定所需的转换。

static_cast 的安全性体现在:

  • 编译时检查static_cast 在编译时会检查转换是否“合理”。例如,我们不能用 static_cast 在两个完全不相关的指针类型之间进行转换,也不能用它来移除 constvolatile 限定符。这种编译时检 查使得 它比 C 风格的强制类型转换更安全。
  • 无运行时检查:对于指针或引用的向下转型,static_cast 不进行任何运行时类型检查。如果转换是错误的(例如,基类指针并未指向所期望的派生类类型的对象static_cast 仍然会执行转换,但后续使用这个转换后的指针或引用将导致未定义行为。
  • 不能移除 constvolatile

评论区

如果大家有什么问题或想法,欢迎在下方留言~