C++ 友元探讨

77 阅读10分钟

什么是友元?

友元声明出现在类内部,它可以授予函数或其他类访问友元声明的类的私有成员和保护成员的权限。(en.cppreference.com/w/cpp/langu…

可能需要注意的点:

  • 友元声明在类内部,但是具体出现的位置不限,也不受所在区域访问控制级别(即 public,private或protected)的约束。一般来说,最好在类定义开始或结束前的位置集中声明友元
  • 友元可以是函数(包括类的成员函数)或者类
  • 友元可以访问声明友元的类的私有成员和保护成员
  • 友元不可继承、不可传递、不是相互的(friendship isn’t inherited, transitive, or reciprocal)。可以如下解释:
    • 不可继承:除非额外声明, 否则我的友元的派生类不会自动成为我的友元
    • 不可传递:除非额外声明,我的友元的友元不会自动成为我的友元
    • 不是相互的:除非额外声明,我不会自动成为我的友元的友元

一个简单的友元示例:

#include <iostream>
using namespace std;

// 前向声明类,用于友元成员函数的声明
class MyClass;

// 声明一个类,其成员函数将被作为友元
class FriendClassFunction {
public:
    void accessPrivateAndProtected(MyClass& obj); // 成员函数访问MyClass的私有和保护数据
};

// 声明一个类, 将类作为友元
class FriendClass2 {
public:
    void accessPrivateAndProtected(MyClass& obj); // 成员函数访问MyClass的私有和保护数据
};

// 主类定义
class MyClass {
private:
    int privateData;
protected:
    int protectedData;
public:
    MyClass(int priv, int prot) : privateData(priv), protectedData(prot) {}
    // 声明友元函数(普通全局函数)
    friend void friendFunction(MyClass& obj);
    // 声明特定成员函数为友元(FriendClass的成员函数)
    friend void FriendClassFunction::accessPrivateAndProtected(MyClass& obj);
    // 声明友元类(FriendClass的所有成员函数均可访问私有和保护成员)
    friend class FriendClass2;
};

// 友元函数定义(非成员函数)
void friendFunction(MyClass& obj) {
    cout << "friendFunction access private: " << obj.privateData << endl;
    cout << "friendFunction access protected: " << obj.protectedData << endl;
}

// FriendClass成员函数的定义
void FriendClassFunction::accessPrivateAndProtected(MyClass& obj) {
    cout << "FriendClassFunction::accessPrivateAndProtected access private: " << obj.privateData << endl;
    cout << "FriendClassFunction::accessPrivateAndProtected access protected: " << obj.protectedData << endl;
}

// FriendClass成员函数的定义
void FriendClass2::accessPrivateAndProtected(MyClass& obj) {
    cout << "FriendClass2 access private: " << obj.privateData << endl;
    cout << "FriendClass2 access protected: " << obj.protectedData << endl;
}

int main() {

    MyClass obj(42, 100);
    // 调用友元函数
    friendFunction(obj);
    // 调用类成员函数
    FriendClassFunction fcf;
    fcf.accessPrivateAndProtected(obj);
    // 调用友元类的成员函数
    FriendClass2 fc2;
    fc2.accessPrivateAndProtected(obj);
    
    return 0;
}

/*
编译指令: g++ -std=c++11 simple_friend.cpp -o simple_friend
操作系统: centos7.6
gcc版本: 4.8.5
输出:
friendFunction access private: 42
friendFunction access protected: 100
FriendClassFunction::accessPrivateAndProtected access private: 42
FriendClassFunction::accessPrivateAndProtected access protected: 100
FriendClass2 access private: 42
FriendClass2 access protected: 100
*/

友元会破坏封装吗?

这部分的观点主要来自于C++标准官方网站的FAQ(isocpp.org/wiki/faq/fr…

这里给出的结论是:不会。

  • “友元” 是一种显式授予访问权限的机制,就像成员一样。让谁成为友元的控制权完全在开发者手上,不能在不修改类源代码的情况下授予其他类或函数对类的访问权限(像public的成员那样,不修改类的源代码,使用者就可以拥有对其的访问权限)。
  • 可以将友元函数作为类的公共成员函数的语法变体,或者可以尝试把友元函数视为类的公共接口的一部分。很明显,友元函数并不会比类的public函数对封装有更多的破坏。当需要给外界提供一个访问私有成员和保护成员的方法时,将成员改成公共访问属性无疑是最差的做法。而使用友元的方法起码不会比使用类的公共成员函数(如get()/set()) 对封装性有更多的破坏。

相较于类的公共成员函数,友元的优缺点有哪些?

这部分的观点主要来自于C++标准官方网站的FAQ(isocpp.org/wiki/faq/fr…

诚如前所述,将友元函数作为类的公共成员函数的语法变体。那相对于类的公共成员函数,友元的优缺点在哪里?

  • 优点:在接口设计选项方面提供了一定程度的自由。友元函数的调用方式类似于 f(x),而成员函数的调用方式类似于 x.f()。因此,能够在成员函数(x.f())和友元函数(f(x))之间进行选择,使设计人员能够选择可读性最高的语法,从而降低维护成本。

  • 缺点:友元函数不能是虚函数,也就无法直接实现动态绑定。当需要进行动态绑定时,需要添加额外的代码,通常是需要声明一个 protected 虚函数 。如下示例:

    • class Base {
      public:
        friend void f(Base& b);
        // ...
      protected:
        virtual void do_f();   // protected 虚函数, 被虚函数调用, 实现多态
        // ...
      };
      
      inline void f(Base& b)
      {
        b.do_f();				// 如果 b 实际上是 Derived 类 的对象,则调用Derived::do_f();而f一直是Base类的友元,不是Derived类的友元。如果是公共成员函数,则直接实现一个虚函数即可
      }
      
      class Derived : public Base {
      public:
        // ...
      protected:
        virtual void do_f();  // 覆盖 f(Base& b) 行为
        // ...
      };
      
      void userCode(Base& b)
      {
        f(b);
      }
      

什么时候使用成员函数?什么时候声明友元函数?

这部分的观点主要来自于C++标准官方网站的FAQ(isocpp.org/wiki/faq/fr…

尽量使用成员函数,必须的时候使用友元。比如以下场景下友元更有优势:

类的成员函数有一个隐式的第一个参数——this指针,而友元函数没有,所有参数都必须显示声明。这一点在运算符重载中尤其有用,特别是当需要保证运算的交换律时。比如当想重载乘法运算符 *,使得一个 Complex(复数)对象既可以与一个 double(双精度浮点数)相乘,也可以反过来。运算符重载为类的成员函数,double * complex这种写法就无法直接实现;而将运算符重载为友元函数,可以自由定义两个参数的顺序。

  • /*
    使用成员函数, 无法实现double * complex
    */
    #include <iostream>
    using namespace std;
    
    class Complex {
    private:
        double real;
        double imag;
    public:
        Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
    
        // 成员函数重载运算符:c * 2.5 有效
        Complex operator*(const double& d) const {
            return Complex(real * d, imag * d);
        }
    
        void display() const {
            cout << "(" << real << ", " << imag << "i)" << endl;
        }
    };
    
    int main() {
        Complex c1(3.0, 4.0);
        Complex c2 = c1 * 2.5; // 正确:等价于 c1.operator*(2.5)
        c2.display(); // 输出 (7.5, 10i)
    
        // Complex c3 = 2.5 * c1; // 错误!2.5.operator*(c1) 不成立
        return 0;
    }
    /*
    编译指令: g++ -std=c++11 complex_multi_member.cpp -o complex_multi_member
    操作系统: centos7.6
    gcc版本: 4.8.5
    输出:
    (7.5, 10i)
    */
    
  • /*
    使用友元, 可以实现double * complex
    */
    #include <iostream>
    using namespace std;
    
    class Complex {
    private:
        double real;
        double imag;
    public:
        Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
    
        // 声明友元函数。注意,两个参数都需要显式列出。
        friend Complex operator*(const Complex& c, const double& d);
        friend Complex operator*(const double& d, const Complex& c);
    
        void display() const {
            cout << "(" << real << ", " << imag << "i)" << endl;
        }
    };
    
    // 定义友元函数:Complex * double
    Complex operator*(const Complex& c, const double& d) {
        return Complex(c.real * d, c.imag * d);
    }
    
    // 定义另一个友元函数:double * Complex
    // 注意这里 double 是第一个参数,Complex 是第二个参数。
    // 这正是成员函数无法实现的。
    Complex operator*(const double& d, const Complex& c) {
        // 利用乘法交换律,直接调用上一个函数即可
        return c * d;
    }
    
    int main() {
        Complex c1(3.0, 4.0);
    
        Complex c2 = c1 * 2.5; // 正确:调用 operator*(c1, 2.5)
        c2.display(); // 输出 (7.5, 10i)
    
        Complex c3 = 2.5 * c1; // 正确:调用 operator*(2.5, c1)
        c3.display(); // 输出 (7.5, 10i)
    
        return 0;
    }
    /*
    编译指令: g++ -std=c++11 complex_multi_friend.cpp -o complex_multi_friend
    操作系统: centos7.6
    gcc版本: 4.8.5
    输出:
    (7.5, 10i)
    (7.5, 10i)
    */
    

在《Effective C++ (中文版)》(第三版) 的第24条中有个类似的例子,按照其中的介绍,上面的例子应该实现为:

/*
使用非友元非member函数, 可以实现double * complex
effective C++ 中的做法
*/
#include <iostream>
using namespace std;

class Complex {
private:
    double real;
    double imag;
public:
    Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}

    void display() const {
        cout << "(" << real << ", " << imag << "i)" << endl;
    }

    double getReal() const { return real; }
    double getImag() const { return imag; } 
};

// 定义非成员非友元函数:Complex * double
Complex operator*(const Complex& c, const double& d) {
    return Complex(c.getReal() * d, c.getImag() * d);
}

// 定义另一个非成员非友元函数:double * Complex
// 注意这里 double 是第一个参数,Complex 是第二个参数。
// 这正是成员函数无法实现的。
Complex operator*(const double& d, const Complex& c) {
    // 利用乘法交换律,直接调用上一个函数即可
    return c * d;
}

int main() {
    Complex c1(3.0, 4.0);

    Complex c2 = c1 * 2.5; // 正确:调用 operator*(c1, 2.5)
    c2.display(); // 输出 (7.5, 10i)

    Complex c3 = 2.5 * c1; // 正确:调用 operator*(2.5, c1)
    c3.display(); // 输出 (7.5, 10i)

    return 0;
}
/*
编译指令: g++ -std=c++11 complex_multi_friend_effective.cpp -o complex_multi_friend_effective
操作系统: centos7.6
gcc版本: 4.8.5
输出:
(7.5, 10i)
(7.5, 10i)
*/

《Effective C++ (中文版)》(第三版) 中建议不要使用friend。但是它使用了公共成员函数,按照前面的观点,使用友元的方法起码不会比使用类的公共成员函数(如get()/set()) 对封装性有更多的破坏,因此这种实现封装性也没有更好。

至于在具体实现中,采用什么样的方式就见仁见智了。但是个人感觉大多数情况下到了这一步是使用友元还是使用成员函数都不应该成为纠结的重点,因为这些都不会对程序有什么重大的影响,比如导致编译速度过低、运行问题定位困难等。可能把精力放在对程序影响更大的地方是更好的选择。

友元使用实例

  • 运算符重载(比如 <<,>>),这种情况其实提供公共接口也可以
  • 单元测试对私有成员进行测试,这种情况其实提供公共接口或者修改测试方案应该也可以
  • CRTP(Curiously Recurring Template Pattern,奇异递归模板模式) 实现单例模式。这个后面详细介绍

CRTP(Curiously Recurring Template Pattern,奇异递归模板模式) 是一种C++模板编程技术,其中类通过继承一个以自身作为模板参数的模板基类来实现静态多态。其基本形式为:

template <typename Derived>
class Base {
    // 基类实现
};

class Derived : public Base<Derived> {
    // 派生类实现
};

使用CRTP来实现单例模式,简单的测试代码如下:

#include <iostream>

template <typename T>
class Singleton {
public:
    // 删除拷贝构造和赋值操作,确保单例唯一性
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    // 全局访问点,返回唯一实例的引用
    static T& GetInstance() {
        static T instance; // C++11保证静态局部变量初始化线程安全
        return instance;
    }

protected:
    Singleton() = default; // 构造函数受保护,防止外部直接实例化
    virtual ~Singleton() = default; // 虚析构函数,确保正确析构
};

// 具体的单例类,例如一个日志管理器
class Logger : public Singleton<Logger> {
    // 声明友元,允许Singleton<Logger>访问Logger的私有构造函数
    friend class Singleton<Logger>; 

private:
    Logger() { std::cout << "Logger instance created.\n"; } // 构造函数私有
    ~Logger() override = default;

public:
    void log(const std::string& message) {
        std::cout << "Log: " << message << std::endl;
    }
};

// 使用示例
int main() {
    Logger& logger1 = Logger::GetInstance();
    logger1.log("First message");

    Logger& logger2 = Logger::GetInstance(); // logger2 和 logger1 是同一个实例
    logger2.log("Second message");

    // 验证是同一个实例
    std::cout << "Are they the same instance? " << (&logger1 == &logger2 ? "Yes" : "No") << std::endl; // 输出 Yes

    return 0;
}

/*
编译指令: g++ -std=c++11 crtp_singleton.cpp -o crtp_singleton
操作系统: centos7.6
gcc版本: 4.8.5
输出:
Log: First message
Log: Second message
Are they the same instance? Yes
*/

使用CRTP实现单例模式在系统中存在多个需要单例的类时具有以下优势:

  • 任何需要成为单例的类只需继承自 Singleton<T>即可自动获得单例的全部能力,可以减少重复代码
  • 保证所有单例实现逻辑一致

而这里面使用到了友元,目前我还没有发现其他的比较好的不用友元的替代方案。

参考

微信公众号:只做人间不老仙

欢迎关注。