一、友元函数与运算符重载:何时需要友元

运算符重载这事儿,说白了就是让自定义类型也能用上 +-<< 这些运算符。但有个问题——当运算符的左侧操作数不是当前类的对象时,成员函数就玩不转了。这时候,友元函数就派上了用场。

我个人习惯把友元函数看作「被授予特权的普通函数」。它本身不是类的成员,却能访问类的私有成员。嗯,听起来有点开后门的意思,但C++就是这么设计的。

什么时候必须用友元?

我总结了一下,主要有三种场景:

  1. 二元运算符的左侧操作数不是当前类对象——比如 cout << obj,左侧是 ostream,你没法给 ostream 加成员函数。
  2. 需要对称性运算——比如 3 + objobj + 3 都应该能工作,但成员函数只能处理 obj + 3
  3. 运算符需要访问两个类的私有成员——比如矩阵乘法,需要同时访问两个矩阵的内部数据。

核心原则:如果运算符的左侧操作数不是当前类的对象,或者你需要对称访问,那就用友元。否则,优先考虑成员函数。

二、友元函数重载二元运算符的典型场景

咱们直接看代码。我拿一个 Complex 类来举例,这是最经典的场景。

场景1:输出运算符 <<

这个场景我在项目中遇到过无数次。调试的时候,能直接 cout << myObj 简直太方便了。

class Complex {
private:
    double real, imag;
public:
    Complex(double r = 0, double i = 0) : real(r), imag(i) {}
    
    // 友元声明
    friend ostream& operator<<(ostream& os, const Complex& c);
};

// 友元定义
ostream& operator<<(ostream& os, const Complex& c) {
    os << "(" << c.real << ", " << c.imag << "i)";
    return os;
}

为什么这里必须用友元?因为 operator<< 的左侧是 ostream,不是 Complex。你想想看,你总不能去修改标准库的 ostream 类吧?

小技巧:我习惯把 operator<<operator>> 都声明为友元,这样输入输出就对称了。

场景2:输入运算符 >>

跟输出类似,输入运算符也需要访问私有成员来赋值。

class Complex {
    // ... 同上
    friend istream& operator>>(istream& is, Complex& c);
};

istream& operator>>(istream& is, Complex& c) {
    is >> c.real >> c.imag;
    return is;
}

场景3:对称的算术运算符

比如 3 + objobj + 3 都应该合法。用成员函数只能实现 obj + 3,因为左侧必须是当前类对象。

class Complex {
    // ...
    friend Complex operator+(const Complex& lhs, const Complex& rhs);
};

Complex operator+(const Complex& lhs, const Complex& rhs) {
    return Complex(lhs.real + rhs.real, lhs.imag + rhs.imag);
}

// 现在两种写法都支持:
Complex c1(1, 2), c2(3, 4);
Complex c3 = c1 + c2;  // 两个对象相加
Complex c4 = 3 + c1;   // int + Complex —— 隐式转换后调用友元

这里有个细节:3 + c1 能工作,是因为 3 被隐式转换成了 Complex(3, 0)。如果构造函数是 explicit 的,那就不行了。我曾经在这个坑里摔过一次,后来就特别注意。

避坑指南:我曾经在项目里把构造函数声明为 explicit,结果 3 + obj 编译不过。排查了半天才发现是隐式转换被禁用了。解决方案是显式提供 Complex 类型的重载版本。

场景4:复合赋值运算符(+=, -= 等)

这类运算符通常用成员函数实现,因为左侧一定是当前类对象。但如果你非要对称,也可以用友元。

class Complex {
public:
    // 成员函数版本 —— 推荐
    Complex& operator+=(const Complex& rhs) {
        real += rhs.real;
        imag += rhs.imag;
        return *this;
    }
    
    // 友元版本 —— 不推荐,但也可以
    friend Complex& operator+=(Complex& lhs, const Complex& rhs);
};

我个人习惯用成员函数实现 +=,然后用它来实现 +。这样代码更干净。

三、友元 vs 成员函数:一张表说清楚

运算符类型 推荐方式 原因
=, [], (), -> 成员函数 语言强制要求
+=, -=, *=, /= 成员函数 左侧一定是当前对象
+, -, *, / 友元或成员 需要对称性时用友元
<<, >> 友元 左侧是流对象
++ (前置/后置) 成员函数 修改自身状态

四、知识体系图

下面这张图总结了友元函数与运算符重载的核心逻辑。我画图的时候特意把「何时用友元」放在中心,因为这是最容易搞混的地方。

友元函数重载运算符 何时需要友元? 左侧不是当前类对象 需要对称性运算 典型场景 operator<< / operator>> 对称算术运算符 友元 vs 成员函数 友元:灵活但破坏封装 | 成员:安全但受限 原则:能用成员就不用友元

五、避坑指南与个人经验

最后,分享几个我踩过的坑:

  • 友元不是成员函数——它没有 this 指针,所以不能直接访问成员,必须通过参数传入的对象来访问。
  • 友元声明的位置——放在 public、private、protected 区域效果都一样,但为了可读性,我习惯放在类的最前面或最后面。
  • 不要滥用友元——友元破坏了封装性。能用成员函数解决的问题,就别用友元。我见过有人把所有运算符都写成友元,那代码简直没法维护。
  • 隐式转换的陷阱——前面提过,explicit 构造函数会阻止隐式转换,导致友元重载的运算符无法处理 int + obj 这种写法。

我的建议:先写成员函数版本,遇到左侧不是当前类对象的情况,再改成友元。别一开始就全用友元,那样反而容易出问题。

好了,关于友元函数与运算符重载,核心就是这些。记住那个判断标准:左侧是谁?能不能改?能改就用成员,不能改就用友元。就这么简单。


公众号:蓝海资料掘金营,微信deep3321