1. 简述
C++中的友元,即“友元类”或“友元函数”,历来有两种说法。有人认为它是“开后门”,破坏了类的封装设计,但也有人,包括C++之父在内,他们的观点是“友元增强了类的封装”。
C++之父原文 Does “friend” violate encapsulation? :No. It does not. “Friend” is an explicit mechanism for granting access, just like membership. You cannot (in a standard conforming program) grant yourself access to a class without modifying its source.
翻译:
问:友元是否违反了类封装性?C++之父答:不,和类的成员机制一样,成员提供了一种明确的访问控制机制。在不修改类的源代码的情况下,一个符合标准的程序无法授权自己对访问某个特定类的访问权限。
我个人比较倾向后一种说法,下面说一说我的粗浅理解。
2. 如果没有友元,事情将是怎样?
要讨论友元的对与错,不能只看有友元的情况怎样,还要看假设没有友元时,代码会变成怎样。否则,访问控制符 “public” 将成为破坏类的封装性的罪魁祸首。但显然,对“public”的全面评价,应该是:
- 有“public”,会将类的所有成员,“暴露”到整个世界;
- 没有“public”,则 “private” 无法存在,于是类设计退成 C 语言的 struct (没有private也没有public,但相当于一切都是public)。
“friend” 也可做同等分析:
- 有“friend”,会将类的所有成员,“暴露”到个别类和函数;
- 没有“friend”,会将类的某些原本不用开放的成员,“暴露”到整个世界。
举个例子,比如说有个“Women/女性”类拥有个三个数据 “胸围、腰围、臀围”,在整个程序中,它们需要也仅需要向另外三类人开放:丈夫(应该是个单例)、医生、私人形像设计团队开放。在C++中,如果没有“友元”这一访问机制,只能是通过三个“public”方法,向“全世界”开放,结果是任何其它类型都可以大大方方地通过调用“Women”公开的接口,访问某个“Wormen”对象的三围。
3. 在许多主流语言中,存在和友元目的相似的设计
从上面的描述,我们可以知道,孤立地讨论一门特定语言中的某个特定语法要素是优是劣,以及是否值得使用,意义不大;我们更应该关注和考察的是,这个技术要素所要解决的问题是否普遍存在,所要满足的需求是否有价值?如果一个技术点背后所要解决的问题,极为少见,怕要满足的需求,不具备普遍价值,那么这个技术点自身也就不值得讨论。
“我有一个类,类里有一些信息,我不想因为它们需要特定范围内的其它类开放,就一刀切成向‘全世界’开放,怎么办?”或者,换成一门编程语言的特定需求描述“向部分范围开放,而非向整个程序开放”,这样的问题及需求是否普遍存在且有价值?
下面,我们把以上问题简化为“局部开放”问题。
这是一个专业的问题,作为一名希望自己的工作重心主要是使用计算机语言应用,而非计算机语言研究,也就是PL(Programming Languages)专业的人,我在这方面缺少研究;但是,一个计算机语法功能的需求点,如果它是普遍存在的,那自然会在不同的主流编程语言中,都普遍存在,因此,我想,可以通过和Java、C#、golang、JavaScript(ES7),甚至C,等工业界成熟 的,主流的编程语言做横向对比,以更好的理解C++中存在友元的必要性或合理性。
Java、C#、golang、JavaScript(ES7)都没有“友元”这一特性,但并代表它们就认为“局部开放”问题没有价值;恰恰相反,它们都更加重视这一问题,害怕编程者在这一问题上犯错误,于是通过(相对传统的C++语言是)新增的特定语法,来固化和简化这个问题的解决方法。
以 Java为例:
- class内的数据成员若没有任何权限修饰,那么它就是使用缺省的“friendly”访问权限;同一包(package)的其它class都可以访问;
- 非静态内部类“先天”可以访问外部类的成员,哪怕是私有的;
以 C# 为例:
- 受“protected internal”修饰的成员,可被相同程序集的所有类访问,不要求是该类的派生类;
以 golang为例:
- 只要是在同一包(package)内,都可以互相访问私有(首字母为小写)的成员。
哪怕是完全不自称“面向对象”的C语言,在数据访问控制上,事实上也默认采用了朴素的,基于文件范围的控制:同一源文件内的非局部域数据,可以通过添加 static 修饰收缩为严格限制在同一个文件内可访问,相当于 private,也可以在需要用到不同文件中,通过添加数据声明以访问。这种控制当然很粗糙,但本质思想也是一种“友元”。
我对Java、C#、Golang、C等语言并不熟悉,上面描述中可能存在不精确的表达,但是确实可以有一个结论:“局部访问” 这一需求,不是C++自己生造的,而是不少主流语言都有考虑到,只是相关术语上称呼以及具体实现上,存在差异。
4. 相比其它语言的设计,友元对数据的暴露粒度更小
直到2020年标准,C++才有了“模块/module”,但仍然未得到较为完整的支持,之前更为长久的时间内,C++没有“包”或“模块”的概念(编译后二进制库只符号的可见度,不能算语言标准),因此也就不难理解C++不存在以“模块”或“包”为封装边界的“局部访问”的解决方案。
如果非要说对类的封装性做“破坏”的话,那么“以模块或包为边界”的私有数据访问和C++的友元方案,哪一种“破坏”得厉害呢?
另外,Java的内部类(严格讲是“非静态内部类”)可以访问外部类(通过自动创建的对象)的私有数据,这种设计相比C++的友元方案,哪一种对外部类的封装性“破坏”得厉害呢?
我的理解是,C++友元方案,每一个友元关系的声明,都只能向一个方法或一个类开放;但其它语言使用包、模块或者类(内部类)的方式开放出来的范围,是整个包或模块,某所有内部类。
- 以golang的包为例:假设同一个包内有100个struct(相当于面向对象语言中的“class”),则这100个结构两两之间都双向互为友元关系。C++每次声明都只能产生一个“友元”,并且是单向的(你是我朋友,不代表我就是你朋友)。正是因为这样过于粗犷开放关系,golang 又引入“内部包/internal”的规则,实现包和包之间的单向友元关系。
- 以java的内部类为例:假设一个类有三个非静态内部类,那么这三个类先天都是外部类的友元;如果要避免这种情况,需将不必要为友元的类,实现为静态内部类(相当于C++的嵌套类)。
注意,这一点我们在讨论的,只是哪一种“友元”方案开放出来的范围更局部;而不是在讨论哪一种方案更优,哪一种方案更劣。因为,可访问范围的大小,从来就不是评测某一特定语言特性好或不好的唯一要素,否则的话,就又要陷入到我的回答开始说的那种情况,难不成我们可以说,常见的三种访问权限 中,protected 比 private 坏?而 public 比 protected 更坏?
5. 结论
通过对 “有友元和没有友元”的对比,以及不同语言对相同需求的实现方式的对比,我想给出我的几点不成熟的结论:
- C++的友元是一个有的放矢的技术特性;
- 当遇上“只需向局部开放,不适合向‘全世界’开放”的需求时,应该优先使用友元,而不是直接开放为public;
- 只要是正确的使用,C++的友元所引发的“局部开放”效果,不能认为是对类的破坏。甚至,在跨语言的比较中我们发现,C++友元所谓的“开放”,是非常保守的,具体又体现到两点:(a) 每次开放范围为一个类或一个函数,(b) 所开放的友元是单向的。如果仍然觉得有些害怕的话,那我们再加上第三点:© 友元关系是不可继承的。简单点的意思是:爸爸的朋友,不会自动成为儿子的朋友。
另外,我还想做一些补充:以上的所有讨论,都是建立在对程序语言技术点的正确使用的基础之上。仍以“public”为例,程序员上来就将所有类的内部数据都加上“public”修饰,和闲着没事就到处在代码创建“友元”关系一样,都是误用,都不能因此指责“public”或“friend”破坏了类的封装。
下面这段话,真面试时,就不要说啦!
至于,有些程序员误以为C++中的友元是“只我要申明某某是我的朋友,我就可以访问他的私有数据啦……”,这就不是编程上的误用了,而是缺少基本的生活逻辑,也不想想,你可以通过登报声明一句“子怡是我的朋友”,然后就想访问她的私有数据吗?子怡同意,汪峰也不会同意呀!
加强对知识点的理解,并熟练应用,才是面对面试官提问时不慌不乱的最大的保障。针对本课的“友元”知识点,我们特意设计了一些强化练习题,用于巩固C++“友元”的相关知识。