ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++友元函数和友元类的使用详解

C++友元函数和友元类的使用详解 前言friend友元是 C 里唯一一个允许从类外部访问私有成员的正式机制。它要解决的问题很具体类的作者有时候会写出一个函数或一个辅助类它在逻辑上就是该类实现的一部分比如运算符重载、序列化器、单元测试探针但又不适合做成成员函数——做成成员会破坏调用语法例如operator的左操作数是流对象做成公开成员又会把内部状态暴露给所有人。friend就是在这个夹缝里给出授权的手段。围绕friend流传最广的两个误解是第一认为友元破坏了封装因此在代码评审里一律禁止第二认为友元关系是可以传递的、可以继承的。前一个说法忽略了一个事实授权是类作者主动写在类里的谁能访问、访问哪些内容完全是类作者的决定它只是把访问权限从成员函数扩展到了被点名的外部实体封装的边界并没有被外部随意突破。后一个说法则是纯粹记错了——友元既不传递也不继承。本文会先讲清友元函数成员函数的友元、全局函数的友元的语法与访问规则再讲友元类然后重点讲友元 运算符重载这个最常见的实战场景最后用对照表列出真正会踩的坑。文中代码以 C17 为基准GCC 13 / Clang 17 / MSVC 19.3x 均可编译。一、友元函数语法与访问规则友元函数是用friend关键字在类体内声明的一个函数它不是类的成员但它能访问该类的private和protected成员。#include cmath #include iostream class Point { public: Point(double x, double y) : x_(x), y_(y) {} double x() const { return x_; } double y() const { return y_; } // 在类内声明为友元distance 可以访问 Point 的私有成员 friend double distance(const Point a, const Point b); private: double x_; double y_; }; // 定义在类外、命名空间作用域中注意不需要也不能写 friend double distance(const Point a, const Point b) { const double dx a.x_ - b.x_; const double dy a.y_ - b.y_; return std::sqrt(dx * dx dy * dy); } int main() { Point p1(0.0, 0.0); Point p2(3.0, 4.0); std::cout distance(p1, p2) \n; // 5 return 0; }关于这段代码有四点必须说清第一friend不是成员声明。友元函数没有this指针不能被p1.distance(p2)这种成员访问语法调用也不能被const修饰成常友元。它就是一个普通的自由函数只是在被点名的类里获得了额外的访问权限。第二friend声明写在哪个访问区段里都一样。写在public:、protected:、private:里效果完全相同标准明确规定访问说明符access specifier不影响友元声明的语义。上面把friend写在private:段后面只是社区惯例目的是把内部实现细节放在一起没有任何语法含义。第三类内定义的友元函数是inline的而且只能靠 ADL 找到。如果一个友元函数直接定义在类体内像下面Vec2的operator它是一个定义而非单纯声明它是隐式inline的且定义在最内层的命名空间作用域中——它不是成员函数。关键陷阱在于这个函数的名字在进行普通非限定查找unqualified lookup时是不可见的只能通过实参依赖查找ADLargument-dependent lookup找到。#include iostream #include ostream class Vec2 { public: Vec2(double x, double y) : x_(x), y_(y) {} friend std::ostream operator(std::ostream os, const Vec2 v) { return os ( v.x_ , v.y_ ); } private: double x_; double y_; }; int main() { Vec2 v(1.5, -2.0); std::cout v \n; // OKADL 通过实参 v 的类型找到全局命名空间中的友元 return 0; }上例能编过是因为实参v的类型是Vec2Vec2的关联命名空间是全局命名空间ADL 会去那里找operator。但如果把它挪进一个命名空间然后想用限定名调用或取函数指针就会失败#include iostream #include ostream namespace geo { class Vec2 { public: Vec2(double x, double y) : x_(x), y_(y) {} friend std::ostream operator(std::ostream os, const Vec2 v) { return os ( v.x_ , v.y_ ); } private: double x_; double y_; }; } // namespace geo int main() { geo::Vec2 v(1.0, 2.0); std::cout v \n; // ✅ 可以ADL 找到 geo::operator // auto pf geo::operator; // ❌ 编译错误名字不存在 // // 只能用 ADL不能用限定名或取地址 return 0; }要让它可被限定名查找或取地址必须在命名空间作用域中再写一次该函数的声明。第四友元关系不传递、不继承。A是B的友元B是C的友元A并不是C的友元A是Base的友元Derived也不会自动成为A的友元反之亦然。友元权限只对声明它的那个类及其所有实例化见下文生效。二、友元类把一整个类设为友元friend class X;会让X的所有成员函数包括静态成员函数都能访问本类的private与protected成员。它不授予X的对象任何额外能力授予的是X的代码。#include iostream class Inspector; // 前置声明让下面的 friend 声明有明确所指 template typename T class Box { public: explicit Box(const T v) : value_(v) {} // 声明 Inspector 为友元类Inspector 的所有成员函数都可访问 BoxT 的私有成员 friend class Inspector; private: T value_; }; class Inspector { public: template typename T static void dump(const BoxT b) { std::cout b.value_ \n; // 访问私有成员合法 } }; int main() { Boxint bi(42); Boxdouble bd(3.14); Inspector::dump(bi); Inspector::dump(bd); return 0; }请注意这里的一个细节friend class Inspector;出现在类模板Box内部它意味着Inspector是Box所有实例化Boxint、Boxdouble、……的友元。类模板中的友元声明会随每个实例化各生成一份因此Inspector::dumpBoxint、Inspector::dumpBoxdouble都合法。两个容易搞混的细节friend class Inspector;里的Inspector如果此前没有声明过C11 起会在最内层命名空间中隐式引入一个类名但该名字对普通查找不可见直到在同一个命名空间里出现匹配的声明为止。为了避免依赖这条偏门规则上面的例子统一先做前置声明。C11 起允许把friend class Inspector;简写为friend Inspector;Inspector必须是类类型或类类型的别名。C03 不支持这种简写。为兼容性起见写全friend class更保险。友元类最常见的用途是实现类拆分把一个大类拆成接口类和实现类让实现类作为友元访问接口类的私有数据以及测试探针把测试辅助类声明为友元这样测试代码不需要为了可测性把成员改成公开。三、实战友元与运算符重载友元在实际工程里最高频的用途是让非成员运算符重载能访问私有成员。核心规则是赋值类运算符、、[]、()、-必须是成员函数而、、以及需要左操作数不是本类对象的算术运算符通常写成非成员函数。以矩阵加法为例如果operator写成成员函数那么m n可以但2.0 * m标量在左就不行因为编译器会去double里找operator*。写成非成员函数再配合友元左右顺序就都自然了#include iostream #include ostream class Matrix2 { public: Matrix2(double a, double b, double c, double d) : a_(a), b_(b), c_(c), d_(d) {} Matrix2 operator(const Matrix2 rhs) { // 复合赋值必须是成员 a_ rhs.a_; b_ rhs.b_; c_ rhs.c_; d_ rhs.d_; return *this; } // 需要访问左操作数 double 的场景非成员 友元是最自然的写法 friend Matrix2 operator*(double k, const Matrix2 m); friend std::ostream operator(std::ostream os, const Matrix2 m); private: double a_, b_, c_, d_; }; Matrix2 operator*(double k, const Matrix2 m) { return Matrix2(k * m.a_, k * m.b_, k * m.c_, k * m.d_); } std::ostream operator(std::ostream os, const Matrix2 m) { return os [ m.a_ m.b_ ; m.c_ m.d_ ]; } int main() { Matrix2 id(1.0, 0.0, 0.0, 1.0); id Matrix2(1.0, 2.0, 3.0, 4.0); std::cout id \n; std::cout (2.0 * id) \n; // 标量在左靠非成员友元实现 return 0; }顺便提一句如果Matrix2提供了足够的公开读取接口a()、b()……那么这三个函数也可以不写成友元直接用公开接口实现。友元只是少写几个 getter的便利手段不是必需品。什么时候值得用友元判断标准是这个函数是否在概念上属于该类的实现且用公开接口实现会明显引入噪声或性能损失例如需要暴露内部缓冲区指针。常见坑点坑 1把友元当成成员用对象去调用。class A { friend void f(A); // f 是自由函数 public: A() default; }; void f(A) {} int main() { A a; a.f(a); // ❌ 编译错误A 没有名为 f 的成员 f(a); // ✅ 这样才能调用 return 0; }坑 2友元的声明与定义签名不一致结果定义成了另一个函数。这是最隐蔽的一类问题编译不报错链接才报错而且报的是未定义引用让人一脸茫然。参数类型、const、引用限定符必须逐字对齐。class A { friend int read(const A); // 声明的是 const A public: int v 7; }; int read(A a) { return a.v; } // ❌ 这是另一个重载友元那个只有声明没有定义 // 链接时报 undefined reference to read(A const) // ✅ 改成 int read(const A a) { return a.v; }坑 3以为友元关系可以继承。class Base { friend class Helper; private: int secret_ 1; }; class Derived : public Base { }; class Helper { public: static int read_base(const Base b) { return b.secret_; } // ✅ 可以 static int read_derived(const Derived d) { return d.secret_; } // ❌ Derived 无权访问 };Helper是Base的友元但 friendships 不被派生类继承。即使secret_作为Base的成员确实存在于Derived对象中友元权限也不会跟着传下去。坑 4以为友元关系可以传递。class A { friend class B; private: int x_ 1; }; class B { friend class C; }; class C { public: static int read(const A a) { return a.x_; } // ❌ C 不是 A 的友元 };B是A的友元、C是B的友元二者组合不产生C是A的友元。需要就在A里再写一行friend class C;。坑 5在友元声明里写默认实参。标准对这件事有明确的额外约束带默认实参的友元声明本身必须是一个定义并且必须是该函数在这个翻译单元中唯一的声明。也就是说下面这种只声明不定义的写法直接不合法class A { public: // ❌ 编译错误带默认实参的友元声明必须是定义 // 且必须是本翻译单元中该函数的唯一声明 friend void log(A, int level 0); }; // ✅ 正确做法友元声明里不写默认实参默认值放在命名空间作用域的那份声明上 void log(A, int level 0); // 全局声明默认实参写在这里 class A2 { friend void log(A2, int level); // 友元声明不带默认实参 };坑 6头文件里的友元辅助函数忘了inline导致多重定义。友元函数如果只在类内定义它隐式带inline没问题但如果它在类内只声明、在头文件中于命名空间作用域定义就必须显式加inline否则每个包含该头文件的翻译单元都会生成一个外部链接的定义链接期报 multiple definition。// widget.hpp class Widget { public: friend bool operator(const Widget, const Widget); }; inline bool operator(const Widget a, const Widget b) { return a.id_ b.id_; } // ✅ // 写成 bool operator(...) { ... } → ❌ 多个 TU 包含即 multiple definition坑 7把友元当成让私有成员变公开的捷径随手就加。一旦friend太多类的内部表示就被多个外部实体耦合住了——改一个私有成员名要连带改若干友元。工程上的建议是优先用公开接口确实需要友元时把友元的数量控制在个位数并在注释里写明它为什么必须访问私有成员。坑 8在模板里声明非模板友元函数期望每个实例化都有独立版本。在类模板里写friend void f();不加模板参数声明的是同一个非模板自由函数不是每个T一个版本要声明随实例化变化的版本需要写成函数模板友元并用T显式指定实例。template typename T class Wrap; template typename T void f(WrapT w); // 必须先在命名空间作用域声明这个函数模板 template typename T class Wrap { public: friend void fT(WrapT w); // 只把 fT 这一个特化设为友元 private: T v_{}; }; template typename T void f(WrapT w) { w.v_ T{}; // 合法fT 是 WrapT 的友元 } int main() { Wrapint w; f(w); return 0; }写成friend void f(WrapT);不带T声明的是一个非模板的自由函数语义与上面的写法完全不同而正因为是自由函数它没有WrapU这个模板参数可推导也就无法访问v_。这就是这个坑的成因。总结问题结论友元是成员吗不是。无this不能用成员访问语法调用不能有const限定写在哪个访问区段无所谓public/private完全等价类内定义的友元函数是定义、隐式inline、只可通过 ADL 找到无法用限定名或取地址友元类授的是什么授予该类的所有成员函数访问权不是授予它的对象类模板中的friend class X;X是该模板所有实例化的友元传递性无。A友元B、B友元C不代表A友元C继承性无。A是Base的友元不代表它是Derived的友元典型用途非成员运算符重载、标量在左的算术运算、实现类拆分、测试探针权限来源类作者在类内主动声明因此不构成外部突破封装友元是一个纯粹的授权机制它不改变类的布局、不引入运行时开销没有任何 vtable 或额外数据成员只在编译期的访问检查上放行。用它的时候记住一句话判断标准不是能不能用友元而是这个函数在概念上是不是该类实现的一部分。若是友元是最贴切的表达若不是那多半应该重新设计接口而不是打开后门。
返回列表