Effective C++ 条款23:更喜欢非成员和非友元函数而不是成员函数

46

在考虑类设计时,特别是函数应该是成员函数还是非成员函数的问题上,我查阅了Effective c++中的第23条建议:优先使用非成员非友元函数。首先看到其中以Web浏览器为例的内容,感觉有些道理,但是书中所称的"方便函数"(指非成员函数)改变了类的状态,不是吗?

  • 那么,我的第一个问题是,这些函数难道不应该成为类的成员函数吗?

  • 继续阅读下去,作者考虑了STL函数,事实上某些没有被一些类实现的函数在STL中得到了实现。按照本书的思路,它们演化成了一些方便函数,打包进一些合理的命名空间中,例如algorithm中的std::sortstd::copy等。例如vector类没有一个sort函数,因此使用STL的sort函数而不作为vector类的成员函数。但是,人们也可以将相同的推理应用于vector类中的其他函数,例如assign也可以不作为成员函数而作为方便函数来实现。然而,这也会改变对象的内部状态,就像sort操作一样。那么,这个微妙但重要(我猜)的问题背后的原理是什么呢?

如果你有这本书的访问权限,能否更详细地为我澄清这些问题呢?


9
我很惊讶还没有人发布 Scott Meyer 撰写的非常相关的 Dr Dobbs 文章的链接(http://drdobbs.com/cpp/184401197)! - Xeo
7个回答

47

并非必须获取该书。

我们所涉及的问题是 依赖性重用性

在设计良好的软件中,您尝试将各个项相互隔离,以减少依赖性,因为依赖性是在需要更改时要克服的障碍。

在设计良好的软件中,应用 DRY 原则(不要重复自己),因为当需要更改时,在十二个不同的地方重复执行会很痛苦且容易出错。

"经典"的面向对象思维方式越来越难以处理依赖关系。通过许多方法直接依赖于类的内部,最轻微的更改就意味着整个重写。这不一定是必要的。

在 C++ 中,STL(而不是整个标准库)的设计明确目标是:

  • 减少依赖性
  • 允许重用

因此,容器提供了明确定义的接口,隐藏了其内部表示,但仍然提供足够的访问信息,以便可以在它们上执行算法。所有修改都通过容器接口进行,从而保证不变量。

例如,如果考虑 sort 算法的要求。对于 STL 通常使用的实现,它需要从容器中获取:

  • 指定索引处项的有效访问:随机访问
  • 交换两个项目的能力:不关联

因此,任何提供随机访问并且不是关联的容器(在理论上)都适合通过(例如)快速排序算法进行高效排序。

C++ 中满足这一点的容器有哪些?

  • 基本 C 数组
  • deque
  • vector

以及任何您可能编写的容器,只要注意这些细节即可。

为每个容器重新编写(复制/粘贴/调整)sort 将是浪费的,不是吗?

例如,请注意,这里有一个 std::list::sort 方法。为什么?因为 std::list 不提供随机访问(非正式的说法是 myList[4] 不起作用),因此来自算法的 sort 不适用。


有很多成员函数并不一定意味着会经常访问类的私有成员变量,这两个问题是独立的。正如外部非成员函数一样,成员函数也可以在完全相同的情况下调用其他成员函数:例如,一小组“核心”成员函数可以直接访问私有数据,而其他成员函数可以通过这些函数间接地访问数据。使这些函数成为非成员函数既不能改善也不能恶化这个问题。无论如何维护该类的人都需要强制执行约束条件。 - Some Guy
3
@SomeGuy:你在技术上是正确的,但也忽略了重点。成员函数可以访问内部,而非成员非友元则不能。即使它们现在不这样做,将来也可能会这样做。因此,该建议通过设计推动更高的封装性——促进最小接口以维护不变量。 - Matthieu M.
1
我并没有错过重点,而是不同意这种做法。我认为这种方法所造成的问题比它本来想要解决的问题更糟糕。那些有能力修改类的一部分的人通常也可以修改整个类以及同一文件中的非成员函数,因此这些更改并不能真正防止这些代码更改,就像代码注释一样。然而,这种划分对于类的客户端是可见的,这违反了封装原则,使得本来可以是“仅内部”的更改影响了类的外部接口。 - Some Guy
1
如果目标是拥有一组核心函数来执行“真正的工作”,并且可以访问私有数据成员,同时还有一堆公共函数来使用这些函数,那么更好的方法是拥有一个内部私有子对象,其中包含核心函数和数据,以及一个公共外部对象。这样,哪些函数是“核心”函数,哪些是“非核心”函数可以随时间变化而改变,而不会影响客户端依赖的外部对象的外部接口:外部对象的内部实现和内部对象的定义可以随时间变化而改变,而不会影响客户端:真正的接口和实现分离。 - Some Guy
@SomeGuy:你是对的,使用内部类来表示状态确实可以得到相同的封装好处;当处理“互斥”受保护的状态时,这实际上是我推荐的做法——通过让公共方法锁定互斥锁然后调用内部类的方法,可立即避免潜在的可重入问题。你也是正确的,用户可以区分成员和非成员,并且任何更改都是可见的,如果这些更改是不需要的,那么你的设计确实更优秀(尽管可能不需要)。 - Matthieu M.
2
@SomeGuy:另一方面,非成员函数有明显的优势=>模板非成员函数是可重用的。OP中提到的STL算法就是一个典型例子,如果可以避免,没有人想为每个容器重新编写sort。更进一步,ADL使得在模板函数中无缝调用通用模板函数或专门的函数成为可能--这在成员函数中不会如此轻松--一个主要的例子是use std::swap; swap(x, y);。该指南具有简单性和组合性的优点。 - Matthieu M.

22
我使用的标准是,如果一个函数通过成员函数的实现可以显著提高效率,那么它应该是成员函数。::std::sort不符合这个定义。实际上,内部和外部实现之间没有任何效率差异。
将某些东西实现为成员(或友元)函数可以极大地提高效率,这意味着它能够充分利用类的内部状态。
接口设计的一部分是找到最小化的成员函数集,以便通过它们可以合理有效地实现对对象的所有可能操作。而且这个集合不应支持不应在类上执行的操作。因此,你不能只实现一堆getter和setter函数并认为它很好。

3
+1 表示“应该不支持那些不应该执行的操作”。 - Tony Delroy
我想指出并不是每个人都同意“找到最小的成员函数集,以便可以合理高效地实现对象上可能要执行的所有操作”是或应该是一个目标。许多其他面向对象语言的库甚至不尝试实现这一点。可以提出一个很好的论点,即与给定类的单个实例专属或主要相关的操作应该是其成员,因为例如这允许实现(“谁调用谁?”)随时间变化而变化而不影响客户端代码。 - Some Guy
1
我的经验是,标准库的设计者通常认为将类的接口最小化是一个好主意,因为这样可以节省他们的工作量,但是库的用户经常对这种设计方式感到非常沮丧。 (例如考虑无数人问“为什么没有像其他语言一样的std :: string :: contains方法?”或std :: set :: contains?)此外,拥有许多具有常见名称的非成员函数可能会使全局命名空间混乱,并在与模板一起使用时创建意外的冲突和奇怪的行为。 - Some Guy

12

我认为这个规则的原因是,使用成员函数可能会无意中过度依赖类的内部。改变类的状态不是问题,真正的问题在于如果您在类的内部修改了一些私有属性,则需要修改的代码数量会很多。尽可能将类的接口(公共方法)保持简小可减少在这种情况下需要做的工作量,同时也降低了做一些奇怪的事情并留下实例处于不一致状态的风险。

AtoMerZ也是正确的,非成员非友元函数可以进行模板化并重用于其他类型。

顺便说一句,你应该购买Effective C++的副本,它是一本很好的书,但不要试图始终遵守书中的每一项建议。面向对象设计既包括好的实践(来自书籍等),也包括经验(我认为这也写在Effective C++的某个地方)。


3
在C++中,不要总是遵循面向对象设计准则,它是多范式的,因此有些东西最好用其他方式表达。 - Matthieu M.

7

各种想法:

  • 当非成员通过类的公共API工作时,这很好,因为它减少了代码量:
    • 需要仔细监控以确保类不变量的代码量
    • 需要更改如果对象的实现被重新设计的话
  • 当这还不够好时,非成员仍然可以成为一个friend
  • 编写非成员函数通常会稍微不方便一些,因为成员不能隐式地在作用域内,但是如果您考虑程序演进:
    • 一旦存在非成员函数,并且意识到相同的功能对其他类型也有用,通常非常容易将函数转换为模板,并使其不仅适用于两种类型,而且适用于任意未来类型。换句话说,非成员模板允许比运行时多态/虚拟调度更灵活的算法重用:模板允许所谓的鸭子类型
    • 现有类型支持有用的成员函数鼓励剪切和粘贴到希望具有类似行为的其他类型,因为大多数将函数转换为重用的方法都要求将每个隐式成员访问变为特定对象上的显式访问,这对程序员来说将是更繁琐的30秒....
  • 成员函数允许object.function(x, y, z)表示法,这在我看来非常方便,表达力强,直观。它们也与许多IDE中的发现/完成功能更好地配合使用。
  • 作为成员和非成员函数的分离可以帮助传达类的基本性质、不变量和基本操作,并逻辑地组合附加的可能是临时的“便利”功能。考虑托尼·霍尔(Tony Hoare)的智慧:

    “构建软件设计的两种方法:一种方法是使其如此简单,以至于明显没有缺陷,另一种方法是使其如此复杂,以至于没有明显的缺陷。第一种方法要困难得多。”

    • 在这里,非成员使用并不一定更加困难,但您必须更多地考虑如何访问成员数据和私有/受保护的方法以及为什么,以及哪些操作是基本的。这样的思想探索也会改善成员函数的设计,只是更容易懒惰一些 :-/。
  • 随着非成员功能的复杂性扩展或获取其他依赖项,函数可以移动到单独的头文件和实现文件,甚至是库中,因此核心功能的用户只需“支付”使用他们想要的部分。

(如果您对Omnifarious的回答还不熟悉,请务必阅读三次。)


4

我先问一下,难道它们不应该是成员吗?

不,这不正确。在惯用的 C++ 类设计中(至少在《Effective C++》中使用的惯用语境中),非成员非友元函数扩展了类接口。尽管它们不需要并且没有私有访问类,但它们可以被视为类的公共 API 的一部分。如果按照某些 OOP 的定义来看,这种设计“不符合 OOP”,那么,好吧,按照那个定义,惯用的 C++ 不符合 OOP。

将同样的推理应用到 vector 类的其他一些函数上也是正确的。例如,vector::push_back 是基于 insert 定义的,当然可以在没有对类进行私有访问的情况下实现。但在这种情况下,push_back 是一个抽象概念,即 vector 实现的 BackInsertionSequence。这样的通用概念跨越了特定类的设计,因此,如果您正在设计或实现自己的通用概念,则可能会影响您放置函数的位置。

当然,标准中有些部分可能应该是不同的,例如std::string 有太多成员函数。但已经完成了,这些类在人们真正沉淀下来形成现代C ++风格之前就设计好了。无论哪种方式,这个类都能工作,所以你从担心区别中获得的实际效益是有限的。

4
动机很简单:保持一致的语法。随着类的演变或使用,将出现各种非成员方便函数;例如,您不想修改类接口以添加像toUpper这样的字符串类功能。(当然,在std::string的情况下,您无法这样做。) Scott担心的是,当这种情况发生时,你最终会得到不一致的语法:
s.insert( "abc" );
toUpper( s );

只使用免费函数,并根据需要声明它们为友元,所有函数都具有相同的语法。否则,每次添加方便函数时都必须修改类定义。

我并不完全认同这种做法。如果一个类被设计得很好,它具有基本功能,用户清楚哪些函数是基本功能的一部分,哪些是附加的方便函数(如果有的话)。总体而言,字符串是一种特殊情况,因为它被设计用来解决许多不同的问题;我无法想象这对许多类也是如此。


你能否重新表述一下:“随着类的演变或使用,将出现各种非成员便利函数;例如,您不希望修改类接口以添加像toUpper这样的字符串类,(当然,在std :: string的情况下,您不能这样做)。Scott担心的是,当发生这种情况时,您最终会得到不一致的语法:toUpper似乎像成员一样,使其成为便利函数是不正确的,对吗?” - Umut Tabak
@Umut 是的。通过“便利函数”,我更多地指的是任何后来添加的函数,它们不需要访问类的私有成员。问题只是允许这样的附加函数使用相同的调用语法,以便后来的用户不必区分什么是添加的,什么是原始的。 - James Kanze
什么是“相同的调用语法”? - Umut Tabak
@Umut Tabak 使用相同的语法来调用这两个函数。 - James Kanze
Scott表示更喜欢非成员非友元函数 - 而不是使所有函数成为非成员,即使它们需要私有/友元访问权限。他没有说要优先选择友元而不是成员,也没有为了保持一致的调用语法或任何其他原因。 - underscore_d

2

我认为排序(sort)没有作为成员函数实现,是因为它被广泛使用,不仅仅是用于向量(vector)。 如果将其作为成员函数,每次对使用它的容器都需要重新实现它。所以我认为这样做是为了更容易地实现。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接