MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++运算符重载的三种实现方式

2023-03-084.4k 阅读

运算符重载概述

在 C++ 中,运算符重载允许我们赋予运算符新的含义,使其能够操作自定义的数据类型,比如类。这极大地增强了语言的灵活性和表达力,使代码更易读且符合直觉。例如,我们定义了一个 Complex 类来表示复数,如果没有运算符重载,我们在进行复数相加时可能需要调用一个函数,如 addComplex(complex1, complex2)。而通过运算符重载,我们可以像操作基本数据类型一样使用 + 运算符,即 complex3 = complex1 + complex2,这让代码看起来更加自然。

C++ 中有许多运算符可以被重载,包括算术运算符(如 +-*/)、关系运算符(如 ==!=<>)、逻辑运算符(如 &&||)、赋值运算符(如 =)以及其他一些运算符。不过,并非所有运算符都能重载,像作用域解析运算符 ::、成员选择运算符 .、条件运算符 ?: 等是不能被重载的。

运算符重载的三种实现方式

成员函数方式实现运算符重载

  1. 原理
    • 当以成员函数方式重载运算符时,运算符函数是类的成员函数。对于双目运算符,左操作数是调用该运算符函数的对象,右操作数作为参数传递给函数。对于单目运算符,操作数就是调用该运算符函数的对象。
    • 例如,对于 a + b 这样的表达式,如果 + 运算符以成员函数方式重载,那么 a 是调用 + 运算符函数的对象,b 是传递给该函数的参数。
  2. 代码示例 - 以复数类为例
#include <iostream>
class Complex {
private:
    double real;
    double imag;
public:
    Complex(double r = 0, double i = 0) : real(r), imag(i) {}
    // 以成员函数方式重载 + 运算符
    Complex operator+(const Complex& other) {
        return Complex(real + other.real, imag + other.imag);
    }
    void display() {
        std::cout << "(" << real << " + " << imag << "i)" << std::endl;
    }
};

int main() {
    Complex c1(3, 4);
    Complex c2(1, 2);
    Complex c3 = c1 + c2;
    c3.display();
    return 0;
}

在上述代码中,Complex 类定义了一个 operator+ 成员函数来重载 + 运算符。在 main 函数中,我们创建了两个 Complex 对象 c1c2,并通过 + 运算符将它们相加得到 c3,然后输出 c3 的值。 3. 注意事项

  • 对于单目运算符,如 ++(自增)、--(自减),如果以成员函数方式重载,无参数的版本表示前缀运算符,有一个 int 类型参数的版本表示后缀运算符。例如:
class Counter {
private:
    int count;
public:
    Counter(int c = 0) : count(c) {}
    // 前缀自增运算符重载
    Counter& operator++() {
        count++;
        return *this;
    }
    // 后缀自增运算符重载
    Counter operator++(int) {
        Counter temp = *this;
        count++;
        return temp;
    }
    int getCount() {
        return count;
    }
};
  • 在上述代码中,operator++() 是前缀自增运算符的重载,它先增加 count 的值,然后返回自身的引用。而 operator++(int) 是后缀自增运算符的重载,它先保存当前对象的副本,增加 count 的值,然后返回保存的副本。
  • 赋值运算符 = 通常也以成员函数方式重载。在重载赋值运算符时,需要注意处理自赋值情况,以避免内存泄漏等问题。例如:
class MyString {
private:
    char* str;
public:
    MyString(const char* s = nullptr) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }
    // 重载赋值运算符
    MyString& operator=(const MyString& other) {
        if (this == &other) {
            return *this;
        }
        delete[] str;
        str = new char[strlen(other.str) + 1];
        strcpy(str, other.str);
        return *this;
    }
    ~MyString() {
        delete[] str;
    }
};
  • 在上述 MyString 类的 operator= 重载函数中,首先检查是否是自赋值,如果是则直接返回自身。然后释放当前对象的内存,再为新的字符串分配内存并复制内容。

友元函数方式实现运算符重载

  1. 原理
    • 友元函数不是类的成员函数,但它可以访问类的私有和保护成员。当以友元函数方式重载运算符时,所有的操作数都作为参数传递给友元函数。对于双目运算符,第一个参数是左操作数,第二个参数是右操作数。
    • 例如,对于 a + b 这样的表达式,如果 + 运算符以友元函数方式重载,ab 都作为参数传递给友元函数 operator+
  2. 代码示例 - 仍以复数类为例
#include <iostream>
class Complex {
private:
    double real;
    double imag;
public:
    Complex(double r = 0, double i = 0) : real(r), imag(i) {}
    // 友元函数声明
    friend Complex operator+(const Complex& a, const Complex& b);
    void display() {
        std::cout << "(" << real << " + " << imag << "i)" << std::endl;
    }
};

// 友元函数定义
Complex operator+(const Complex& a, const Complex& b) {
    return Complex(a.real + b.real, a.imag + b.imag);
}

int main() {
    Complex c1(3, 4);
    Complex c2(1, 2);
    Complex c3 = c1 + c2;
    c3.display();
    return 0;
}

在上述代码中,operator+Complex 类的友元函数,它接受两个 Complex 对象作为参数,并返回它们相加的结果。在 main 函数中,同样可以像使用成员函数重载 + 运算符那样使用友元函数重载的 + 运算符。 3. 注意事项

  • 友元函数没有 this 指针,因为它不是类的成员函数。这就要求在实现时,必须显式地通过参数来访问对象的成员。
  • 对于某些运算符,如 <<(输出运算符)和 >>(输入运算符),通常以友元函数方式重载。以 << 运算符为例:
#include <iostream>
class Point {
private:
    int x;
    int y;
public:
    Point(int a = 0, int b = 0) : x(a), y(b) {}
    // 友元函数声明
    friend std::ostream& operator<<(std::ostream& os, const Point& p);
};

// 友元函数定义
std::ostream& operator<<(std::ostream& os, const Point& p) {
    os << "(" << p.x << ", " << p.y << ")";
    return os;
}

int main() {
    Point p(10, 20);
    std::cout << p << std::endl;
    return 0;
}
  • 在上述代码中,operator<<Point 类的友元函数,它接受一个 std::ostream 对象和一个 Point 对象作为参数。通过这个函数,我们可以像输出基本数据类型一样输出 Point 对象。这里返回 std::ostream 对象的引用,是为了支持链式输出,例如 std::cout << p1 << p2 << std::endl;
  • 友元函数虽然提供了访问类私有成员的便利,但也破坏了类的封装性。因此,在使用友元函数时要谨慎,尽量只在必要时使用。

普通函数方式实现运算符重载

  1. 原理
    • 普通函数方式重载运算符与友元函数方式类似,所有操作数都作为参数传递给函数。但普通函数不能直接访问类的私有和保护成员,只有当类提供了相应的公有接口时,普通函数才能操作类的对象。
    • 例如,对于 a + b 这样的表达式,如果 + 运算符以普通函数方式重载,ab 都作为参数传递给普通函数 operator+
  2. 代码示例 - 以一个简单的分数类为例
#include <iostream>
class Fraction {
private:
    int numerator;
    int denominator;
public:
    Fraction(int num = 0, int den = 1) : numerator(num), denominator(den) {}
    int getNumerator() const {
        return numerator;
    }
    int getDenominator() const {
        return denominator;
    }
};

// 普通函数重载 + 运算符
Fraction operator+(const Fraction& a, const Fraction& b) {
    int newNum = a.getNumerator() * b.getDenominator() + b.getNumerator() * a.getDenominator();
    int newDen = a.getDenominator() * b.getDenominator();
    return Fraction(newNum, newDen);
}

void displayFraction(const Fraction& f) {
    std::cout << f.getNumerator() << "/" << f.getDenominator() << std::endl;
}

int main() {
    Fraction f1(1, 2);
    Fraction f2(1, 3);
    Fraction f3 = f1 + f2;
    displayFraction(f3);
    return 0;
}

在上述代码中,operator+ 是一个普通函数,它通过调用 Fraction 类的公有成员函数 getNumeratorgetDenominator 来获取分子和分母,然后进行分数相加的运算。在 main 函数中,创建了两个 Fraction 对象 f1f2,通过 + 运算符得到 f3 并输出。 3. 注意事项

  • 由于普通函数不能访问类的私有成员,在设计类时需要提供足够的公有接口来支持运算符的重载。这可能会增加类的接口复杂度,因为原本可以在类内部进行的操作,现在需要通过公有函数暴露给外部。
  • 普通函数方式重载运算符在某些情况下不如成员函数和友元函数方式方便。例如,对于需要访问类内部状态的复杂操作,成员函数或友元函数可以直接访问私有成员,而普通函数则需要通过公有接口来间接访问,这可能导致代码效率降低和可读性变差。
  • 不过,普通函数方式在一些情况下也有其优势,比如当我们希望保持类的封装性,并且类的公有接口已经足够支持运算符重载时,普通函数方式可以作为一种选择。同时,普通函数不属于任何类,这在某些面向对象设计场景中可能更符合需求,例如当我们希望以一种相对独立的方式为不同类提供通用的运算符重载实现时。

三种实现方式的比较

  1. 成员函数方式
    • 优点
      • 与类的紧密结合,体现了类的行为。对于一些与对象紧密相关的操作,如 ++-- 等自增自减运算符,以成员函数方式重载更符合逻辑。
      • 由于是类的成员函数,可以直接访问类的私有和保护成员,实现相对简洁。
    • 缺点
      • 对于某些运算符,如 <<>>,如果以成员函数方式重载,调用方式会不符合习惯。例如,如果 << 以成员函数重载,那么使用时可能需要写成 obj.operator<<(std::cout),而不是习惯的 std::cout << obj
      • 对于双目运算符,左操作数必须是类的对象,这在一些情况下限制了运算符的使用灵活性。例如,如果我们希望支持 int + Complex 这样的表达式(假设 Complex 类重载了 + 运算符),以成员函数方式就无法直接实现,因为 int 不是 Complex 类的对象。
  2. 友元函数方式
    • 优点
      • 操作数都作为参数传递,对于双目运算符,不存在左操作数必须是类对象的限制。例如,可以方便地实现 int + Complex 这样的表达式,只需要在友元函数中对 intComplex 进行相应处理即可。
      • 对于 <<>> 等运算符,以友元函数方式重载可以实现符合习惯的调用方式,如 std::cout << obj
    • 缺点
      • 破坏了类的封装性,因为友元函数可以访问类的私有和保护成员。这在一些对封装性要求严格的设计中可能是一个问题。
      • 由于不是类的成员函数,没有 this 指针,在实现一些需要依赖对象状态的复杂操作时,代码可能会略显繁琐,需要通过参数显式访问对象成员。
  3. 普通函数方式
    • 优点
      • 完全不破坏类的封装性,类的私有成员对普通函数是完全隐藏的。这在一些对封装性要求极高的场景中非常有用。
      • 普通函数不属于任何类,具有更好的独立性。在为多个不同类提供通用的运算符重载实现时,普通函数方式可能更合适。
    • 缺点
      • 需要类提供足够的公有接口来支持运算符重载,这可能增加类的接口复杂度。
      • 由于不能直接访问类的私有成员,对于一些复杂的操作,实现起来可能比较困难,并且可能会降低代码效率和可读性,因为需要通过公有接口间接访问类的内部状态。

在实际编程中,需要根据具体的需求和设计目标来选择合适的运算符重载实现方式。例如,如果是实现与类紧密相关的运算符,且对封装性要求不是特别严格,成员函数方式可能是较好的选择;如果需要支持更灵活的操作数顺序,或者要实现 <<>> 等运算符,友元函数方式可能更合适;而如果对封装性要求极高,且类的公有接口足以支持运算符重载,普通函数方式可以考虑。同时,在一些复杂的场景中,可能需要结合多种方式来实现最佳的设计和功能。