C---软件设计-全-
C++ 软件设计(全)
原文:
zh.annas-archive.org/md5/25c9b3c5f6497ab39b5e760c45fb1e91
译者:飞龙
序言
你手中拿着的是我多年前就希望有的 C++书籍。不是作为我的第一本书之一,而是作为一本高级书籍,在我已经消化了语言机制并能够超越 C++语法后。是的,这本书肯定会帮助我更好地理解可维护软件的基本方面,我相信它也会帮助你。
为什么我写了这本书
当我真正深入学习这门语言时(那是在第一个 C++标准发布几年后),我已经读过几乎所有可能的 C++书籍。但尽管许多这些书都很棒,并且绝对为我目前作为 C++培训师和顾问的职业铺平了道路,它们太专注于细节和实现特定的事项,距离可维护软件的更大图景太远。
当时,真正专注于更大视角,处理大型软件系统开发的书籍很少。其中包括 John Lakos 的《大规模 C++软件设计》[1],一个关于依赖管理的伟大但字面上很重的介绍,以及所谓的四人帮书籍,这是关于软件设计模式的经典之作[2]。不幸的是,多年来,情况并没有真正改变:大多数书籍、演讲、博客等主要集中在语言的机制和功能上——小细节和具体性。很少,而且在我看来太少,新版本专注于可维护软件、可更改性、可扩展性和可测试性。如果它们尝试这样做,不幸的是很快就会回到解释语言机制和演示特性的常见习惯中。
这就是为什么我写了这本书。与大多数其他书籍不同,这本书不花时间讨论语言的机制或许多功能,而是主要关注软件的可更改性、可扩展性和可测试性。这本书不假装使用新的 C++标准或特性会决定好坏软件的差异,而是清楚地显示,决定性的是依赖的管理,我们代码中的依赖性决定了它是好还是坏。因此,在 C++世界中,这确实是一种罕见的书籍,因为它关注的是更大的视角:软件设计。
本书内容
软件设计
从我的角度来看,良好的软件设计是每个成功软件项目的核心。然而,尽管其基本作用,关于这个主题的文献很少,对于如何正确进行操作和如何做事的建议也很少。为什么?因为这很困难。非常困难。可能是我们必须面对的写软件的最困难的方面。这是因为没有单一的“正确”解决方案,没有通过软件开发人员一代代传递的“黄金”建议。它总是依赖于具体情况。
尽管如此,我将提供建议,教你如何设计优秀、高质量的软件。我将提供设计原则、设计指南和设计模式,这些将帮助你更好地理解如何管理依赖关系,并使你的软件能够在几十年间持续使用。正如前面所述,没有“黄金”建议,本书也不持有任何终极或完美的解决方案。相反,我试图展示好软件的最基本方面,最重要的细节,以及不同设计的多样性和优缺点。我还将阐述内在的设计目标,并展示如何在现代 C++ 中实现这些目标。
现代 C++
十多年来,我们一直在庆祝现代 C++ 的到来,赞美这门语言的许多新特性和扩展,由此给人留下印象,现代 C++ 将帮助我们解决所有与软件相关的问题。但是,本书不会如此。本书不会假装仅仅向代码中扔几个智能指针就能让代码“现代化”或自动产生良好的设计。此外,本书也不会将现代 C++ 描绘成一堆新特性的集合。相反,它将展示语言哲学的演变以及我们如何在今天实现 C++ 解决方案。
当然,我们也会看到代码。大量的代码。当然,本书将利用较新的 C++ 标准(包括 C++20)的特性。然而,它也会努力强调设计独立于实现细节和使用的特性。新特性并不改变好设计和坏设计的规则;它们只是改变了我们实现好设计的方式。它们使得实现好设计变得更容易。因此,本书展示并讨论实现细节,但(希望)不会在其中迷失,并始终专注于大局:软件设计和设计模式。
设计模式
一旦你开始提及设计模式,你不自觉地就会引发人们对面向对象编程和继承层次结构的期待。是的,本书将展示许多设计模式的面向对象起源。然而,它将强调一个事实:没有只有一种方法能有效地使用设计模式。我将展示设计模式实现如何演变和多样化,利用许多不同的范式,包括面向对象编程、泛型编程和函数式编程。本书承认现实情况:没有一个真正的范式,也不假装只有一种适用于所有问题的单一方法。相反,它试图展示现代 C++ 的真正面貌:结合所有范式,将它们编织成一个坚固而持久的网络,并创造能在数十年中持续发挥作用的软件设计。
我希望这本书能成为 C++文献中的一块遗失的拼图。我希望它能像它本该帮助我的那样帮助你。我希望它能提供你一些你一直在寻找的答案,并为你提供一些你缺失的关键见解。而且,我也希望这本书能让你感到有趣和有动力去读完每一页。然而,最重要的是,我希望这本书能向您展示软件设计的重要性及设计模式的作用。因为正如您将看到的那样,设计模式无处不在!
这本书的读者对象
这本书对每个 C++开发者都有价值。特别是,它适合每个对理解可维护软件的常见问题和学习解决这些问题的常见方法感兴趣的 C++开发者(我假设这确实包括每一个C++开发者)。然而,这本书不是一本面向 C++初学者的书籍。事实上,这本书中的大多数指南需要对软件开发有一定的经验,特别是对 C++有经验。例如,我假设您对继承层次结构的语言机制和一些模板的使用有很好的掌握。这样,我就可以在必要和适当的时候使用相应的特性。偶尔,我甚至会使用一些 C++20 的特性(特别是 C++20 的概念)。然而,由于重点是软件设计,我很少会深入解释特定的特性,因此如果某个特性对您来说是未知的,请参考您喜欢的 C++语言参考资料。我偶尔会添加一些提醒,主要是关于常见的 C++习惯用法(如五法则)。
本书的结构
本书分为几个章节,每个章节包含多条指南。每条指南都集中讨论可维护软件的一个关键方面或特定的设计模式。因此,这些指南代表了主要的收获,我希望它们能为您带来最大的价值。它们被编写成您可以从头到尾阅读,但由于它们只是松散耦合的,所以您也可以从您感兴趣的指南开始阅读。然而,它们并不是独立的。因此,每个指南都包含了必要的交叉引用,以向您展示一切是如何相互关联的。
本书中使用的约定
本书使用以下印刷约定:
Italic
表示新术语、网址、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
Constant width bold
显示用户应该按照字面意义输入的命令或其他文本。
Constant width italic
显示应由用户提供的值或由上下文确定的值替换的文本。
提示
此元素表示一个提示或建议。
注意
这个元素表示一个一般的注意事项。
使用代码示例
补充资料(代码示例、练习等)可在 https://github.com/igl42/cpp_software_design 下载。
如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com。
本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在自己的程序和文档中使用它。除非您复制了大量代码,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发来自 O'Reilly 图书的示例需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码纳入产品文档需要许可。
我们感谢您的支持,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“C++软件设计,作者 Klaus Iglberger(O'Reilly)。版权所有 2022 Klaus Iglberger,ISBN 978-1-098-11316-2。”
如果您认为您对代码示例的使用超出了公平使用范围或上述许可,请随时通过 permissions@oreilly.com 联系我们。
O'Reilly 在线学习
注意
40 多年来,O'Reilly Media 提供技术和业务培训、知识和见解,帮助企业取得成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O'Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境以及来自 O'Reilly 和 200 多家其他出版商的大量文本和视频。欲了解更多信息,请访问 http://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送至出版商:
-
O'Reilly Media,Inc.
-
Gravenstein Highway North 1005
-
加利福尼亚州 Sebastopol,95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为本书创建了一个网页,列出勘误、示例和任何额外信息。您可以访问 https://oreil.ly/c-plus-plus 这个页面。
通过电子邮件 bookquestions@oreilly.com 发送评论或询问有关本书的技术问题。
有关我们的图书和课程的新闻和信息,请访问 http://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media。
关注我们的 Twitter:http://twitter.com/oreillymedia。
观看我们的 YouTube 频道:http://youtube.com/oreillymedia。
致谢
这样的一本书绝不是单个个体的成就。相反,我要特别感谢许多在不同方面帮助我使这本书成为现实的人。首先,我要深深地感谢我的妻子 Steffi,她在不了解 C++的情况下阅读了整本书。还照顾我们的两个孩子,给予我必要的宁静,让我把所有这些信息都写下来(我仍然不确定这两者中哪一个是更大的牺牲)。
特别感谢我的审稿人 Daniela Engert,Patrice Roy,Stefan Weller,Mark Summerfield 和 Jacob Bandes-Storch,他们投入宝贵的时间不断挑战我的解释和示例,使这本书变得更好。
特别感谢 Arthur O’Dwyer,Eduardo Madrid 和 Julian Schmidt 对类型擦除设计模式的贡献和反馈,以及 Johannes Gutekunst 对软件架构和文档的讨论。
此外,我还要感谢我的两位冷读者 Matthias Dörfel 和 Vittorio Romeo,他们帮助捕捉了许多最后一刻的错误(确实如此)。
最后,但绝对不是最不重要的,我要特别感谢我的编辑,Shira Evans,她花了很多时间给予宝贵的建议,使这本书在一致性和阅读乐趣方面更加出色。
¹ John Lakos,《大规模 C++软件设计》(Addison-Wesley,1996 年)。
² Erich Gamma 等,《设计模式:可复用面向对象软件的元素》(Addison-Wesley,1994 年)。
第一章:软件设计的艺术
软件设计是什么?为什么你应该关注它?在本章中,我将为本书的软件设计设定舞台。我将总结软件设计的一般概念,帮助你理解它对项目成功的至关重要性,以及为什么这是你应该做到的一件事。但你也会看到,软件设计是复杂的。非常复杂。事实上,它是软件开发中最复杂的部分。因此,我还将解释几个软件设计原则,这些原则将帮助你保持在正确的道路上。
在“指南 1:理解软件设计的重要性”中,我将专注于宏观视角并解释软件的预期变更。因此,软件应能够应对变更。然而,说起来容易做起来难,因为现实中的耦合和依赖使得作为开发者的生活变得更加困难。软件设计正是应对这一问题的方法。我将介绍软件设计作为管理依赖和抽象的艺术——软件工程的一个重要组成部分。
在“指南 2:为变更设计”中,我将明确讨论耦合和依赖,并帮助你理解如何为变更设计,以及如何使软件更具适应性。为此,我将介绍单一责任原则(SRP)和不重复自己原则(DRY),这些原则将帮助你实现这一目标。
在“指南 3:避免人为耦合的接口分离”中,我将扩展有关耦合的讨论,并特别讨论通过接口进行的耦合。我还将介绍接口隔离原则(ISP)作为减少接口引发的人为耦合的手段。
在“测试性设计指南 4”中,我将专注于由人为耦合导致的测试性问题。特别是,我将提出如何测试私有成员函数的问题,并展示唯一真正的解决方案是关注责任分离的连贯应用。
在“指南 5:为扩展设计”中,我将讨论一种重要的变更类型:扩展。就像代码应该易于更改一样,它也应该易于扩展。我将为你提供如何实现这一目标的想法,并展示开闭原则(OCP)的价值。
指南 1:理解软件设计的重要性
如果我问你最重要的代码属性是什么,经过一番思考,你可能会说像可读性、测试性、可维护性、可扩展性、可重用性和可伸缩性等。我完全同意。但是,如果现在我问你如何实现这些目标,你很可能会开始列出一些 C++特性:RAII、算法、lambda、模块等等。
特性不是软件设计。
是的,C++提供了很多特性。非常多!几乎 2,000 页的印刷 C++标准中大约一半用于解释语言机制和特性。¹ 自 C++11 发布以来,还有一个明确的承诺:每三年,C++标准化委员会会为我们提供一个新的 C++标准,附带全新的特性。知道这一点,不难理解为什么在 C++社区中特性和语言机制的重视如此之高。大多数书籍、讲座和博客都专注于特性、新库和语言细节。²
几乎感觉特性是 C++编程中最重要的事情,对 C++项目的成功至关重要。但老实说,它们并不是。无论是所有特性的知识还是选择 C++标准都不能为项目的成功负责。不,你不应该期望特性来拯救你的项目。相反:即使使用旧的 C++标准,即使只使用可用特性的一个子集,项目也可以非常成功。撇开软件开发的人文因素不谈,对于项目成功或失败的问题,更为重要的是软件的总体结构。最终是结构决定了可维护性:修改代码、扩展代码和测试代码有多容易?如果不能轻松修改代码、添加新功能,并且由于测试的正确性而对其有信心,那么项目就接近其生命周期的末尾。结构也决定了项目的可扩展性:在项目的愿景实现之前,项目可以增长到多大程度,以至于它不能支撑自身的重量?多少人能够在项目中工作,而不会互相干扰?
总体结构是项目的设计。设计在项目成功中起到比任何特性更为核心的作用。优秀的软件并不主要是关于正确使用任何特性;相反,它关乎坚实的架构和设计。优秀的软件设计可以容忍一些糟糕的实现决策,但糟糕的软件设计不能仅仅通过英雄般地使用特性(无论是旧的还是新的)来拯救。
软件设计:管理依赖和抽象的艺术。
为什么软件设计对项目质量如此重要呢?嗯,假设现在一切都完美无缺,只要你的软件没有任何变化,也没有需要添加的内容,那么一切都好。然而,这种状态可能不会持续太久。可以合理地预期,会有一些变化发生。毕竟,软件开发中唯一不变的是变化。变化是我们所有问题(也是大多数解决方案)的驱动力。这就是为什么软件被称为软件:因为与硬件相比,它是柔软和可塑的。是的,软件预计应该容易适应不断变化的需求。但正如你可能知道的那样,现实中这种期望可能并不总是成立。
为了说明这一点,让我们假设你从问题追踪系统中选择了一个被团队评估为需要 2 单位的问题。在你自己的项目中,无论 2 单位意味着什么,它肯定听起来不像是一个大任务,所以你相信这将很快完成。怀着善意,你首先花了些时间理解预期的内容,然后开始在某个实体A
上做出改变。由于测试的即时反馈(你很幸运有测试!),你很快就被提醒还需要解决实体B
上的问题。这让你感到惊讶!你根本没想到B
也会牵扯其中。尽管如此,你还是继续适应了B
。然而,令人意外的是,夜间构建显示这导致C
和D
停止工作。在继续之前,你现在深入调查了一下问题,发现问题的根源遍布代码库的大部分区域。这个最初看起来无辜的小任务已经演变成了一个大的、潜在风险的代码修改。³ 你解决问题的信心已经消失。你这周剩下的计划也一样。
这个故事也许对你来说很熟悉。也许你甚至可以贡献一些自己的战斗经历。事实上,大多数开发者都有类似的经历。而且这些经历的大多数都源于同一个麻烦的根源。通常问题可以归结为一个词:依赖。正如肯特·贝克在他关于测试驱动开发的书中所表达的那样:⁴
在各个层面上,依赖性是软件开发的关键问题。
依赖性是每个软件开发人员的梦魇。你可能会争辩说:“当然会有依赖性。不同的代码片段怎样才能协同工作呢?”当然,你是正确的。不同的代码片段需要协同工作,这种交互总会创建某种形式的耦合。然而,虽然有必要的、不可避免的依赖关系,但也存在我们因为缺乏对底层问题的理解、没有明确的大局观或者没有足够注意而无意中引入的人为依赖关系。不用说,这些人为依赖关系是有害的。它们使我们更难理解软件、改变软件、添加新功能和编写测试。因此,软件开发人员的首要任务,如果不是唯一的任务,就是尽量将人为依赖关系降到最低。
减少依赖性是软件架构和设计的目标。用罗伯特·C·马丁的话来说:⁵
软件架构的目标是尽量减少构建和维护所需系统所需的人力资源。
架构和设计是任何项目中减少工作量的必要工具。它们处理依赖关系,并通过抽象降低复杂性。用我的话来说:⁶
软件设计是管理软件组件之间相互依赖的艺术。它旨在最小化人为(技术性)依赖关系,并引入必要的抽象和妥协。
是的,软件设计是一门艺术。它不是一门科学,也没有一套简单清晰的答案。⁷ 设计的整体图景往往容易使我们困惑,我们被软件实体的复杂相互依赖所压倒。但我们试图通过引入适当类型的抽象来处理这种复杂性并将其减少。这样一来,我们将详细程度保持在合理的水平。然而,在团队中,个别开发人员对架构和设计可能有不同的理解。我们可能无法实现自己对设计的愿景,不得不妥协以继续前进。
提示
“抽象”这个术语在不同的上下文中使用。它用于将功能和数据项组织成数据类型和函数。但它也用于描述共同行为的建模以及一组需求和期望的表示。在本书中关于软件设计,我主要用这个术语来指代后者(特别是见第二章)。
请注意,在前面的引用中,“架构”和“设计”这两个词可以互换,因为它们非常相似并且具有相同的目标。然而它们并不相同。如果您看看软件开发的三个层次,这些相似之处和差异将变得清晰。
软件开发的三个层次
软件架构 和 软件设计 只是软件开发的三个层次之一。它们还包括 实施细节 的层次。图 1-1 概述了这三个层次。
为了让您对这三个层次有所了解,让我们从一个关于架构、设计和实施细节之间关系的现实世界例子开始。想象您是一名建筑师。不,不要想象自己坐在电脑前的舒适椅子上,旁边放着一杯热咖啡,而是想象自己在一个建筑工地外面。是的,我说的是建筑师。⁸ 作为这样的建筑师,您将负责房屋的所有重要属性:它与社区的融合、结构完整性、房间的布置、管道等等。您还将关注它的外观和功能特性——也许是一个大客厅,厨房和餐厅之间的便捷通道等等。换句话说,您将负责整体架构,这些事情稍后难以改变,但您也会处理关于建筑的更小设计方面。然而,很难区分二者:架构和设计之间的边界看起来模糊且不明确分开。
图 1-1. 软件开发的三个层次:软件架构、软件设计 和 实施细节。惯用语 可以是设计或实施模式。
然而,这些决定将是您责任的终点。作为一名架构师,您不必担心冰箱、电视或其他家具的摆放位置。您不会处理所有关于放置图片和其他装饰品的巧妙细节。换句话说,您不会处理细节;您只需确保业主拥有良好生活所需的结构。
在这个隐喻中,家具和其他“巧妙细节”对应于软件开发的最低和最具体的层次,即实现细节。这一层次处理解决方案如何实现。你选择必要的(和可用的)C++标准或其任何子集,以及使用适当的特性、关键字和语言细节,并处理诸如内存获取、异常安全性、性能等方面。这也是实现模式的层次,例如std::make_unique()
作为工厂函数,std::enable_if
作为显式受益于 SFINAE 的经常性解决方案等。⁹
在软件设计中,你开始关注整体图景。关于可维护性、可变性、可扩展性、可测试性和可伸缩性的问题在这个层次上更加突出。软件设计主要涉及软件实体之间的交互,这些实体在前面的隐喻中由房间、门、管道和电缆的布置表示。在这个层次上,你处理组件(类、函数等)的物理和逻辑依赖关系。¹⁰ 这是 Visitor、Strategy 和 Decorator 等设计模式的层次,它们定义了软件实体之间的依赖结构,如第三章中所述。这些通常可以从一种语言转移到另一种语言,帮助你将复杂的事物分解为易于理解的部分。
软件架构是三个层次中最模糊、最难以用语言表达的层次。这是因为没有普遍公认的软件架构定义。虽然对于架构的确切定义可能有很多不同看法,但有一点是所有人似乎都同意的:架构通常涉及到你的软件中最难以在未来改变的重要决策:
架构是那些你希望在项目早期就能正确选择的决策,但实际上并不比其他决策更容易做对。¹¹
Ralph Johnson
在软件架构中,你会使用诸如客户端-服务器架构、微服务等架构模式。¹² 这些模式也涉及到如何设计系统,即你可以修改其中的一部分而不影响软件的其他部分。与软件设计模式类似,它们定义并处理软件实体之间的结构和相互依赖关系。然而,与设计模式不同的是,它们通常处理的是软件中的关键角色,即你的软件中的大实体(例如模块和组件),而不是类和函数。
从这个角度来看,软件架构代表了你的软件方法的整体战略,而软件设计则是使战略生效的战术。这个图景的问题在于没有对“大”的定义。特别是随着微服务的出现,越来越难以明确划分小和大的实体。¹³
因此,架构通常被描述为项目中的专家开发者认为的关键决策。
让架构、设计和细节之间的分离变得更加困难的是“惯用语”的概念。惯用语是常用的但与语言相关的解决方案,用于解决重复出现的问题。因此,惯用语也代表一种模式,但可以是实现模式或设计模式中的任何一种。¹⁴ 更宽泛地说,C++惯用语是 C++社区的最佳实践,无论是设计还是实现。在 C++中,大多数惯用语属于实现细节的范畴。例如,有拷贝并交换惯用语,你可能从拷贝赋值操作符的实现中了解到,以及RAII 惯用语(资源获取即初始化——如果你还不熟悉,请参阅你的第二喜欢的 C++书籍¹⁵)。这些惯用语都不引入抽象,也不帮助解耦。但它们对于实现良好的 C++代码至关重要。
我听到你问:“能不能再具体一点?RAII 也提供某种形式的解耦吗?它不是将资源管理与业务逻辑分离吗?”你说得对:RAII 将资源管理和业务逻辑分离。但它不是通过解耦(即抽象)来实现这一点,而是通过封装。抽象和封装都有助于使复杂系统更易于理解和修改,但抽象解决的是软件设计层面上出现的问题和问题,而封装则解决实现细节层面上出现的问题和问题。引用Wikipedia的话:
RAII 作为一种资源管理技术的优势在于它提供了封装、异常安全性[...]和局部性[...]。封装是因为资源管理逻辑在类中定义一次,而不是在每个调用点上定义。
虽然大多数惯用语属于实现细节的范畴,但也有属于软件设计范畴的惯用语。两个例子是非虚拟接口(NVI)惯用语和Pimpl 惯用语。这两个惯用语基于两个经典的设计模式:模板方法设计模式和桥接设计模式。¹⁶ 它们引入了抽象,并帮助解耦和设计以便变更和扩展。
关注功能
如果软件架构和软件设计如此重要,那么为什么我们在 C++ 社区如此强调功能?为什么我们制造了一个假象,即 C++ 标准、语言机制和功能对项目至关重要?我认为有三个很强的理由。首先,因为有如此多的功能,有时候还带有复杂的细节,我们需要花费大量时间讨论如何正确使用它们。我们需要形成一个共识,哪种用法是好的,哪种用法是不好的。作为一个社区,我们需要发展出一种习惯用法的 C++ 的感觉。
第二个原因是我们可能对功能设置了错误的期望。例如,让我们考虑一下 C++20 模块。不详细讨论,这个功能确实可能被认为是自 C++ 开始以来最大的技术革新。模块最终可能会结束将头文件包含到源文件中的可疑和繁琐做法。
由于这种潜力,对该功能的期望是巨大的。有些人甚至期望模块通过修复其结构性问题来拯救他们的项目。不幸的是,模块很难满足这些期望:模块并不改善代码的结构或设计,而只能表示当前的结构和设计。模块不能修复您的设计问题,但它们可能能够显现出缺陷。因此,模块根本不能拯救您的项目。因此,我们可能确实给功能设置了太多或错误的期望。
最后但同样重要的是,尽管功能非常多且复杂,与软件设计的复杂性相比,C++ 功能的复杂性是小的。解释功能的一组规则(无论它们包含多少特殊情况)要容易得多,比解释如何最佳地解耦软件实体要容易得多。
尽管与功能相关的问题通常都有一个很好的答案,但在软件设计中,常见的答案是“这取决于”。这个答案甚至可能不是经验不足的证据,而是意识到使代码更易维护、更易变更、更易扩展、更易测试和更易扩展的最佳方法在很大程度上取决于许多项目特定因素之间复杂的相互作用解耦,可能是人类有史以来最具挑战性的事业之一:
设计和编程是人类活动;忘记这一点,一切都将失去。¹⁷
对我来说,这三个原因的结合是为什么我们如此关注功能。但请不要误解。这并不是说功能不重要。相反,功能 是 重要的。是的,有必要讨论功能,并学习如何正确使用它们,但再次强调,单靠它们无法拯救您的项目。
关注软件设计和设计原则
虽然功能很重要,当然也很好谈论它们,但软件设计更为重要。软件设计是至关重要的。我甚至会认为它是我们项目成功的基础。因此,在本书中,我将尝试真正专注于软件设计和设计原则,而不是功能。当然,我仍会展示良好且最新的 C++代码,但我不会强迫使用最新和最优语言的补充。¹⁸ 当然,当合理且有益时,我会使用一些新功能,例如 C++20 的概念,但我不会在到处使用noexcept
,或者使用constexpr
。¹⁹ 相反,我将尝试解决软件的困难问题。我将大部分时间专注于软件设计,设计决策的理由,设计原则,管理依赖关系和处理抽象化。
总之,软件设计是编写软件的关键部分。软件开发人员应该对软件设计有很好的理解,以编写良好、可维护的软件。毕竟,好的软件成本低廉,而糟糕的软件则代价高昂。
准则 2:为变更设计
对于良好软件的一个基本期望是它能够轻松变更。这个期望甚至是软件这个词的一部分。与硬件相比,软件预期能够轻松适应不断变化的需求(另见“准则 1:理解软件设计的重要性”)。然而,从你自己的经验中,你可能能够说出,修改代码通常并不容易。相反,有时一个看似简单的变更可能成为一个持续一周的努力。
关注分离
减少人为依赖关系并简化变更的最佳且经验证的解决方案之一是关注分离。这个思想的核心是分割、隔离或提取功能的片段。²⁰
将系统分解为小而明确命名的可理解部件使工作更快。
关注关注分离背后的意图是更好地理解和管理复杂性,从而设计更模块化的软件。这个思想可能和软件本身一样古老,因此已经被赋予了许多不同的名称。例如,同样的思想被《实用程序员》称为正交性。²¹ 他们建议分离软件的正交方面。Tom DeMarco 称之为内聚性。²²
内聚性是模块内部元素关联强度的度量。高度内聚的模块是由语句和数据项组成的集合,应该作为一个整体来处理,因为它们关系密切。任何试图将它们分开的尝试只会导致耦合增加和可读性降低。
在SOLID原则中,²³这是最为成熟的设计原则集之一,这个理念被称为单一责任原则(SRP):
一个类应该只有一个变化的理由。²⁴
尽管这个概念很古老,并且以许多名称广为人知,但许多试图解释关注点分离的尝试更多地提出问题而非回答。对于 SRP 来说尤其如此。单单这个设计原则的名字就引发了问题:什么是一个责任?什么是“单一”的责任?为了澄清关于 SRP 的模糊性,一个常见的尝试是以下内容:
一切事物应该只做一件事。
不幸的是,这个解释在含糊性方面很难超越。就像“责任”这个词并没有多少含义一样,“只做一件事”并不能更多地阐明它。
不管名字如何,理念始终如一:只把真正应该放在一起的东西放在一起,把不严格属于同一类的东西分开。换句话说:分开那些因不同原因而变化的东西。通过这样做,你可以减少代码不同方面之间的人为耦合,帮助你使软件更能适应变化。在最好的情况下,你可以在精确一个地方改变软件的特定方面。
一个人为耦合的例子
让我们通过一个代码示例来阐明关注点分离。我确实有一个很好的例子:我向你展示抽象的Document
类:
//#include <some_json_library.h> // Potential physical dependency
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void exportToJSON( /*...*/ ) const = 0; 
virtual void serialize( ByteStream&, /*...*/ ) const = 0; 
// ... };
这听起来像是各种文档的一个非常有用的基类,不是吗?首先,有exportToJSON()
函数()。所有派生类都必须实现
exportToJSON()
函数,以便从文档生成JSON 文件。这将非常有用:不需要了解特定类型的文档(我们可以想象最终会有 PDF 文档、Word 文档等),我们总是可以导出为 JSON 格式。很棒!其次,有一个serialize()
函数()。这个函数让你可以通过
ByteStream
把一个Document
转换成字节。你可以把这些字节存储在一些持久系统中,比如文件或数据库。当然,我们可以期待还有许多其他有用的函数,可以让我们几乎可以为这个文档做任何事情。
然而,我可以看出你脸上的皱眉。不,你看起来并不确信这是一个好的软件设计。这可能是因为你对这个例子非常怀疑(看起来太美好以至于不真实)。或者可能是因为你从艰难的经验中学到,这种设计最终会带来麻烦。你可能已经经历过,使用常见的面向对象设计原则将数据和操作数据的函数捆绑在一起可能会很容易导致不幸的耦合。我同意:尽管这个基类看起来像一个完美的整体包,甚至看起来像它拥有我们可能需要的一切,但这种设计很快就会带来麻烦。
这是糟糕的设计,因为它包含许多依赖关系。当然,存在明显的直接依赖,比如对ByteStream
类的依赖。然而,这种设计也倾向于引入人为依赖,这会使后续的更改变得更加困难。在这种情况下,存在三种类型的人为依赖。其中两种是由exportToJSON()
函数引入的,另一种是由serialize()
函数引入的。
首先,exportToJSON()
需要在派生类中实现。是的,没有选择,因为它是一个纯虚函数(用= 0
序列表示,所谓的纯指示符)。由于派生类很可能不想承担手动实现 JSON 导出的负担,它们会依赖于外部的第三方 JSON 库:json、rapidjson或simdjson。无论你选择哪个库,由于exportToJSON()
成员函数的存在,派生文档突然依赖于这个库。很可能,所有派生类由于一致性原因会依赖于同一个库。因此,派生类并不是真正独立的;它们与特定的设计决策人为耦合在一起。²⁵ 另外,对特定 JSON 库的依赖性肯定会限制层次结构的可重用性,因为它将不再是轻量级的。而切换到另一个库将会导致重大变化,因为所有派生类都必须进行适应。²⁶
当然,serialize()
函数也引入了相同类型的人为依赖。很可能serialize()
也会基于第三方库来实现,比如protobuf或Boost.serialization。这显著恶化了依赖情况,因为它在两个正交、不相关的设计方面(即 JSON 导出和序列化)之间引入了耦合。一个方面的变化可能导致另一个方面的变化。
在最坏的情况下,exportToJSON()
函数可能会引入第二个依赖。在 exportToJSON()
调用中期望的参数可能会意外地反映出所选 JSON 库的一些实现细节。在这种情况下,最终切换到另一个库可能会导致 exportToJSON()
函数签名的变更,随后导致所有调用者的变更。因此,对所选 JSON 库的依赖可能会意外地比预期更广泛。
serialize()
函数引入了第三种依赖。由于这个函数,从 Document
派生的类依赖于关于如何序列化文档的全局决策。我们使用什么格式?我们使用小端还是大端?我们是否需要添加这些字节表示一个 PDF 文件还是 Word 文件的信息?如果是的话(我认为这很可能),我们如何表示这样的文档?通过一个整数值吗?例如,我们可以为此目的使用一个枚举值:²⁷
enum class DocumentType
{
pdf,
word,
// ... Potentially many more document types
};
这种方法在序列化中非常常见。然而,如果在 Document
类的实现中使用这种低级文档表示,我们会意外地耦合所有不同类型的文档。每个派生类都会隐含地知道所有其他 Document
类型。因此,添加新类型的文档将直接影响所有现有的文档类型。这将是一个严重的设计缺陷,因为这会使更改变得更加困难。
不幸的是,Document
类促进了许多不同种类的耦合。因此,Document
类不是一个良好类设计的好例子,因为它不容易改变。相反,它很难改变,因此是 SRP 的一个违反的很好例子:因为我们已经在几个正交但无关的方面之间创建了强耦合,所以从 Document
派生的类和文档的使用者可能因以下任一原因而改变:
-
exportToJSON()
函数的实现细节因为直接依赖于所使用的 JSON 库而改变。 -
exportToJSON()
函数的签名因底层实现的改变而改变。 -
Document
类和serialize()
函数因为直接依赖于ByteStream
类而改变。 -
serialize()
函数的实现细节因为直接依赖于实现细节而改变。 -
所有类型的文档都因对
DocumentType
枚举的直接依赖而改变。
显然,这种设计促进了更多的变化,每一次变更都会变得更加困难。当然,在一般情况下,还存在着附加的正交方面被人为地耦合在文档内部的风险,这会进一步增加进行更改的复杂性。此外,其中一些更改显然不仅限于代码库中的单一位置。特别是,对 exportToJSON()
和 serialize()
的实现细节的更改不仅限于单个类,而可能影响所有类型的文档(如 PDF、Word 等)。因此,一次更改将影响代码库中多个地方,这带来了维护风险。
逻辑耦合与物理耦合
耦合不仅限于逻辑耦合,还延伸到物理耦合。图 1-2 说明了这种耦合。假设我们的架构低层存在一个需要使用高层文档的 User
类。当然,User
类直接依赖于 Document
类,这是给定问题的必要依赖——一个内在的依赖,因此这不应成为我们的关注点。然而,Document
对所选的 JSON 库的(潜在)物理依赖性以及对 ByteStream
类的直接依赖性会导致 User
对 JSON 库和 ByteStream
的间接、传递依赖性,而这些在我们架构的最高层。在最坏的情况下,这意味着对 JSON 库或 ByteStream
类的更改会影响到 User
。希望很容易看出,这是一种人为的、非故意的依赖关系:User
不应该依赖于 JSON 或序列化。
注意
我应明确指出,Document
可能会对所选的 JSON 库存在潜在的物理依赖性。如果 <Document.h>
头文件包含来自所选 JSON 库的任何头文件(如在 “人工耦合示例” 中的代码片段中所示),例如因为 exportToJSON()
函数期望基于该库的某些参数,则存在对该库的明确依赖关系。然而,如果接口能够适当地抽象这些细节,并且 <Document.h>
头文件没有包含任何来自 JSON 库的内容,则可以避免物理依赖性。因此,这取决于依赖关系能够(并且是如何)被抽象化。
图 1-2. User
和 JSON、序列化等正交方面之间强传递的物理耦合。
“高级别、低级别——我现在搞混了”,你抱怨道。是的,我知道这两个术语通常会引起一些混淆。因此,在我们继续之前,让我们就高级别和低级别的术语达成一致。这两个术语的起源与我们在统一建模语言(UML)中绘制图表的方式有关:我们认为稳定的功能出现在顶部,即高级别。那些更频繁变化,因此被认为是不稳定或可塑性更大的功能出现在底部,即低级别。不幸的是,当我们绘制架构图时,我们通常试图展示事物如何彼此依赖,因此最稳定的部分出现在架构的底部。这当然会造成一些混淆。无论事物如何被绘制,只要记住这些术语:高级别指的是你架构中稳定的部分,而低级别指的是更频繁变化或更有可能变化的方面。
回到问题本身:SRP 建议我们应该分离关注点和那些不真正属于其中的事物,即非内聚的(粘合的)事物。换句话说,它建议我们将因不同原因而变化的事物分离成变化点。图 1-3 显示了如果我们将 JSON 和序列化方面分离出来,则耦合情况如何。
图 1-3. 遵循SRP可以解决User
与 JSON 和序列化之间的人为耦合。
根据这些建议,Document
类被按照以下方式重构:
class Document
{
public:
// ...
virtual ~Document() = default;
// No more 'exportToJSON()' and 'serialize()' functions.
// Only the very basic document operations, that do not
// cause strong coupling, remain.
// ...
};
JSON 和序列化方面并不是Document
类基本功能的一部分。Document
类应该仅仅表示不同种类文档的基本操作。所有正交的方面应该被分离开来。这将使得修改变得更加容易。例如,通过将 JSON 方面隔离为一个独立的变化点并成为新的JSON
组件,从一个 JSON 库切换到另一个库只会影响到这一个组件。修改可以在一个地方完成,并且与所有其他正交方面隔离开来。同时,通过多个 JSON 库支持 JSON 格式也会更加容易。此外,任何关于文档序列化方式的改变只会影响到代码中的一个组件:新的Serialization
组件。同样,Serialization
将作为一个变化点,促使隔离和简单的改变。这将是最优的情况。
在您对 Document
示例的初步失望之后,我看到您再次变得更加快乐。也许您的脸上甚至露出了“我知道了!”的微笑。然而,您仍然不完全满意:“是的,我同意分离关注点的一般想法。但我该如何组织我的软件来分离关注点呢?我该做什么才能让它起作用?”这是一个很好的问题,但是有很多答案,我将在接下来的章节中详细讨论。然而,第一个也是最重要的一点是识别变化点,即您的代码中预期变化的某个方面。这些变化点应该被提取、隔离和包装,以便不再依赖于这些变化。这最终将有助于使变更变得更容易。
“但这仍然只是表面的建议!”我听到你说。你说得对。不幸的是,没有单一的答案,也没有简单的答案。这取决于情况。但我承诺在接下来的章节中会提供许多关于如何分离关注点的具体答案。毕竟,这是一本关于软件设计的书,即一本关于管理依赖关系的书。作为一个小小的引子,在第 3 章 中,我将介绍解决这个问题的一个通用而实用的方法:设计模式。在这个总体思路下,我将展示如何使用不同的设计模式来分离关注点。例如,访问者、策略 和 外部多态性 设计模式让人想起。所有这些模式都有不同的优缺点,但它们共享一个特性,即引入某种抽象以帮助您减少依赖关系。此外,我承诺将仔细研究如何在现代 C++ 中实现这些设计模式。
提示
我将在“指南 16:使用访问者扩展操作” 中介绍访问者设计模式,并在“指南 19:使用策略来隔离操作方式” 中介绍策略设计模式。外部多态性设计模式将是“指南 31:使用外部多态性进行非侵入式运行时多态性” 的主题。
不要重复自己
另一个重要的变更性方面是有一个第二个方面。为了解释这一点,我将引入另一个例子:项目层次结构。图 1-4 给出了这个层次结构的印象。
图 1-4. Item
类层次结构。
在这个层次结构的顶部是Item
基类:
//---- <Money.h> ----------------
class Money { /*...*/ };
Money operator*( Money money, double factor );
Money operator+( Money lhs, Money rhs );
//---- <Item.h> ----------------
#include <Money.h>
class Item
{
public:
virtual ~Item() = default;
virtual Money price() const = 0;
};
Item
基类代表具有价格标签(由 Money
类表示)的任何类型的项目的抽象。通过 price()
函数,您可以查询该价格。当然,有许多可能的项目,但为了说明目的,我们限制在 CppBook
和 ConferenceTicket
:
//---- <CppBook.h> ----------------
#include <Item.h>
#include <Money.h>
#include <string>
class CppBook : public Item
{
public:
explicit CppBook( std::string title, std::string author, Money price ) 
: title_( std::move(title) )
, author_( std::move(author) )
, priceWithTax_( price * 1.15 ) // 15% tax rate
{}
std::string const& title() const { return title_; } 
std::string const& author() const { return author_; } 
Money price() const override { return priceWithTax_; } 
private:
std::string title_;
std::string author_;
Money priceWithTax_;
};
CppBook
类的构造函数需要以字符串形式提供书名和作者,并以 Money
形式提供价格()。²⁸ 除此之外,你只能通过
title()
、author()
和 price()
函数来访问书名、作者和价格(,
和
)。然而,
price()
函数有点特殊:显然,书籍是需要交税的。因此,书籍的原价需要根据给定的税率进行调整。在这个例子中,假设一个虚构的税率为 15%。
ConferenceTicket
类是 Item
的第二个示例:
//---- <ConferenceTicket.h> ----------------
#include <Item.h>
#include <Money.h>
#include <string>
class ConferenceTicket : public Item
{
public:
explicit ConferenceTicket( std::string name, Money price ) 
: name_( std::move(name) )
, priceWithTax_( price * 1.15 ) // 15% tax rate
{}
std::string const& name() const { return name_; }
Money price() const override { return priceWithTax_; }
private:
std::string name_;
Money priceWithTax_;
};
ConferenceTicket
类与 CppBook
类非常相似,但构造函数只需提供会议名称和价格()。当然,你可以通过
name()
和 price()
函数分别访问会议名称和价格。然而,对于 C++ 会议,价格也需要纳税。因此,我们再次根据虚构的税率 15% 调整原始价格。
有了这个功能,我们可以在 main()
函数中继续创建一些 Item
:
#include <CppBook.h>
#include <ConferenceTicket.h>
#include <algorithm>
#include <cstdlib>
#include <memory>
#include <vector>
int main()
{
std::vector<std::unique_ptr<Item>> items{};
items.emplace_back( std::make_unique<CppBook>("Effective C++", 19.99) );
items.emplace_back( std::make_unique<CppBook>("C++ Templates", 49.99) );
items.emplace_back( std::make_unique<ConferenceTicket>("CppCon", 999.0) );
items.emplace_back( std::make_unique<ConferenceTicket>("Meeting C++", 699.0) );
items.emplace_back( std::make_unique<ConferenceTicket>("C++ on Sea", 499.0) );
Money const total_price =
std::accumulate( begin(items), end(items), Money{},
[]( Money accu, auto const& item ){
return accu + item->price();
} );
// ...
return EXIT_SUCCESS;
}
在 main()
中,我们创建了一些物品(两本书和三个会议票),并计算了所有物品的总价格。²⁹ 总价格当然包括虚构的 15% 税率。
听起来是一个不错的设计。我们已经分离了特定种类的物品,并能够在隔离环境中改变每个物品的价格计算方式。看起来我们已经实现了 SRP,并提取和隔离了变化点。当然,还有更多的物品。很多很多。而且所有这些物品都将确保适用的税率被正确考虑。太棒了!然而,尽管这个 Item
层次结构将在一段时间内让我们感到满意,但这个设计不幸地有一个显著的缺陷。我们可能今天意识不到,但在软件问题的背后,总有一个潜在的问题在遥远的阴影中:变化。
如果由于某些原因税率发生变化会怎么样?如果 15% 的税率降低到 12% 呢?或者提高到 16% 呢?我仍然能听到当初设计被提交到代码库时的争论声:“不,那绝不会发生!”然而,最意想不到的事情可能会发生。例如,在德国,税率在 2021 年的半年内从 19% 降低到 16%。当然,这意味着我们需要在代码库中改变税率。我们在哪里应用这个改变?在目前的情况下,这个改变几乎会影响到每一个继承自 Item
类的类。这个改变会遍布整个代码库!
正如 SRP 建议分离变化点一样,我们应该注意不要在代码库中重复信息。尽管每件事都应该有一个单一责任(一个变更的唯一原因),但每个责任在系统中应该只存在一次。这个思想通常称为“不要重复自己”(DRY)原则。该原则建议我们不要在许多地方重复一些关键信息,而是设计系统使我们只需在一个地方进行更改。在最佳情况下,税率应该在确切的一个地方表示,以便您可以轻松地进行更改。
通常,SRP 和 DRY 原则可以非常好地配合。遵循 SRP 通常也会导致遵循 DRY,反之亦然。然而,有时遵循这两个原则需要额外的步骤。我知道你渴望了解这些额外步骤及如何解决问题,但在这一点上,指出 SRP 和 DRY 的一般思想已足够。我承诺会重新审视这个问题,并向您展示如何解决它(参见“指南 35:使用装饰器分层地添加定制”)。
避免过早地分离关注点。
到目前为止,希望我已经说服你遵循 SRP 和 DRY 是非常合理的想法。你甚至可能如此坚定,计划将所有东西——所有类和函数——分离成最微小的功能单元。毕竟,这是目标,对吧?如果你现在正这么想,请停下!深呼吸。再深一口。然后请仔细听凯特里娜·特拉杰夫斯卡的智慧:³⁰
不要试图实现 SOLID 原则,而是利用 SOLID 来实现可维护性。
SRP 和 DRY 都是实现更好可维护性和简化变更的工具。它们不是你的目标。虽然长远来看两者都非常重要,但在没有清晰想法的情况下分离实体可能会非常适得其反。为变更而设计通常会偏向于特定类型的变更,但不幸的是可能会使其他类型的变更变得更加困难。这种哲学是众所周知的“你不会需要它”(YAGNI)原则的一部分,该原则警告你不要过度设计(也请参阅“指南 5:设计用于扩展”)。如果有明确的计划,如果你知道可以预期什么样的变化,那么就应用 SRP 和 DRY 使这种变化变得简单。然而,如果你不知道可以预期什么样的变化,那么不要猜测——只需等待。等到你对可以预期什么样的变化有了清晰的想法,然后重构以尽可能地简化变化。
小贴士
别忘了轻松修改事物的一个方面是拥有单元测试,这些测试可以确认修改没有破坏预期的行为。
总之,软件中预期会发生变化,因此设计变更至关重要。分离关注点并尽量减少重复,使您可以轻松地进行变更,而无需担心破坏其他正交方面。
指导方针 3:分离接口以避免人为耦合
让我们重新审视来自“指导方针 2:为变更设计”的 Document
示例。我知道,到现在为止你可能已经看够了文档,但请相信,我们还没有结束。还有一个重要的耦合方面需要解决。这次我们不专注于 Document
类中的各个函数,而是整体接口:
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void exportToJSON( /*...*/ ) const = 0;
virtual void serialize( ByteStream& bs, /*...*/ ) const = 0;
// ...
};
将接口分离以分隔关注点
Document
要求派生类处理 JSON 导出和序列化两者。尽管从文档的角度来看,这似乎是合理的(毕竟,所有 文档都应该能导出为 JSON 并可序列化),但不幸的是,它引发了另一种耦合。想象一下以下用户代码:
void exportDocument( Document const& doc )
{
// ...
doc.exportToJSON( /* pass necessary arguments */ );
// ...
}
exportDocument()
函数专注于将给定文档导出为 JSON。换句话说,exportDocument()
函数并不关心文档的序列化或 Document
提供的任何其他方面。然而,由于 Document
接口的定义,由于耦合许多正交的方面在一起,exportDocument()
函数依赖的不仅仅是 JSON 导出。所有这些依赖关系都是不必要的和人为的。改变其中任何一个,例如 ByteStream
类或 serialize()
函数的签名,都会影响 所有 使用 Document
的用户,即使它们并不需要序列化。对于任何更改,包括 exportDocument()
函数在内的 所有 用户都需要重新编译、重新测试,并且在最坏的情况下重新部署(例如,如果它是在一个单独的库中提供)。如果 Document
类通过另一个函数扩展,比如导出到另一种文档类型,也会发生同样的情况。随着在 Document
中耦合更多正交功能,任何变化都可能在整个代码库中造成连锁反应。这实在令人沮丧,因为接口应该帮助解耦,而不是引入人为耦合。
这种耦合是由于违反了接口隔离原则(ISP),这是 SOLID 缩写中的 I:
客户端不应被强制依赖于它们不使用的方法³¹
ISP 建议通过隔离(解耦)接口来分离关注点。在我们的情况下,应该有两个独立的接口来表示 JSON 导出和序列化的两个正交方面:
class JSONExportable
{
public:
// ...
virtual ~JSONExportable() = default;
virtual void exportToJSON( /*...*/ ) const = 0;
// ...
};
class Serializable
{
public:
// ...
virtual ~Serializable() = default;
virtual void serialize( ByteStream& bs, /*...*/ ) const = 0;
// ...
};
class Document
: public JSONExportable
, public Serializable
{
public:
// ...
};
这种分离并不使得Document
类过时。相反,Document
类仍然代表着对所有文档提出的要求。然而,这种关注点的分离现在使您能够最小化依赖,仅限于实际需要的函数集:
void exportDocument( JSONExportable const& exportable )
{
// ...
exportable.exportToJSON( /* pass necessary arguments */ );
// ...
}
以这种形式,仅依赖于分离的JSONExportable
接口,exportDocument()
函数不再依赖于序列化功能,因此也不再依赖于ByteStream
类。因此,接口的分离有助于减少耦合。
“但这不仅仅是关注点分离吗?”你问道。“这不仅是 SRP 的另一个例子吗?”是的,确实如此。我同意我们实质上已经确定了两个正交的方面,将它们分开,并因此应用了 SRP 到Document
接口。因此,我们可以说 ISP 和 SRP 是相同的。或者至少 ISP 是 SRP 的一个特例,因为 ISP 专注于接口。这种态度似乎是社区的共识,我也同意。然而,我仍然认为讨论 ISP 是有价值的。尽管 ISP 可能只是一个特例,但我认为它是一个重要的特例。不幸的是,很容易将不相关的正交方面聚合到一个接口中。甚至可能发生在你身上,你将分离的方面耦合到一个接口中。当然,我绝不是在暗示你有意这样做,而是无意地、偶然地。我们经常没有足够的注意这些细节。当然,你可能会争辩说,“我绝不会那样做。”然而,在“Guideline 19: Use Strategy to Isolate How Things Are Done”中,你将看到一个例子,可能会说服你这种情况有多容易发生。由于以后更改接口可能非常困难,我认为引起人们对这个接口问题的注意是值得的。因此,我没有放弃 ISP,而是将其作为 SRP 的一个重要和值得注意的案例包括在内。
减少模板参数的要求
尽管看起来 ISP 仅适用于基类,并且 ISP 主要是通过面向对象编程介绍的,但通过接口最小化依赖的一般思想也可以应用于模板。例如考虑std::copy()
函数:
template< typename InputIt, typename OutputIt >
OutputIt copy( InputIt first, InputIt last, OutputIt d_first );
在 C++20 中,我们可以应用概念来表达这些要求:
template< std::input_iterator InputIt, std::output_iterator OutputIt >
OutputIt copy( InputIt first, InputIt last, OutputIt d_first );
std::copy()
期望一对输入迭代器作为复制来源的范围,并且一个输出迭代器作为目标范围。它明确要求输入迭代器和输出迭代器,因为它不需要任何其他操作。因此,它最小化了对传递参数的要求。
假设std::copy()
需要std::forward_iterator
而不是std::input_iterator
和std::output_iterator
:
template< std::forward_iterator ForwardIt, std::forward_iterator ForwardIt >
OutputIt copy( ForwardIt first, ForwardIt last, ForwardIt d_first );
这会不幸地限制std::copy()
算法的有用性。我们将无法再从输入流复制,因为它们通常不提供多遍保证,也不允许我们写入。那将是不幸的。然而,专注于依赖关系,std::copy()
现在将依赖于它不需要的操作和要求。传递给std::copy()
的迭代器将被迫提供额外的操作,因此std::copy()
会强加依赖关系。
这只是一个假设性的例子,但它说明了接口中关注点分离的重要性。显然,解决方案是意识到输入和输出能力是独立的方面。因此,在分离关注点并应用 ISP 之后,依赖关系显著减少了。
指南 4:设计可测试性
如“指南 1:理解软件设计的重要性”所讨论的,软件会变化。预期会变化。但每次在软件中改变某些东西时,都会存在意外破坏的风险。当然,这并非故意,而是无意之中,尽管你已尽力而为。风险始终存在。但作为一名有经验的开发者,你不会因此失眠。让风险存在吧——你不在乎。你有一些东西保护你免受意外破坏,一些东西将风险降到最低:你的测试。
拥有测试的目的是能够断言你的软件功能仍然正常工作,尽管不断变化。因此,显然,测试是你的保护层,你的救生衣。测试是必不可少的!但首先,你必须编写测试。为了编写测试并设置这个保护层,你的软件必须具备测试性:你的软件必须以一种可以测试的方式编写,最好是容易可以添加测试。这就引出了这个指南的核心:软件应设计为可测试性。
如何测试私有成员函数
“当然,我有测试”,你辩解道。“每个人都应该有测试。这是常识,不是吗?”我完全同意。我相信你的代码库配备了一个合理的测试套件。³² 但令人惊讶的是,尽管每个人都同意需要测试,但并非所有软件都是在这种意识下编写的。³³ 实际上,很多代码很难进行测试。有时候这只是因为代码本身并没有设计成可测试的。
为了给你一个想法,我有一个挑战给你。看看下面的Widget
类。Widget
包含一组Blob
对象,偶尔需要更新这些对象。为此,Widget
提供了updateCollection()
成员函数,我们现在假设这个函数非常重要,我们需要为它编写一个测试。这就是我的挑战:你将如何测试updateCollection()
成员函数?
class Widget
{
// ...
private:
void updateCollection( /* some arguments needed to update the collection */ );
std::vector<Blob> blobs_;
/* Potentially other data members */
};
我假设你立即看到真正的挑战:updateCollection()
成员函数被声明在类的私有部分。这意味着外部无法直接访问,因此也无法直接进行测试。所以,请花几秒钟思考一下……
“它是私有的,是的,但这并不是什么大挑战。有很多方法可以做到这一点,” 你说。我同意,你可以尝试多种方法。所以,请继续。你权衡了你的选择,然后提出了第一个想法:“好吧,最简单的方法是通过其他一些公共成员函数来测试这个函数,而这个公共成员函数内部调用updateCollection()
函数。” 这听起来是个有趣的第一个想法。让我们假设当向集合添加新的Blob
时,集合需要更新。调用addBlob()
成员函数将触发updateCollection()
函数:
class Widget
{
public:
// ...
void addBlob( Blob const& blob, /*...*/ )
{
// ...
updateCollection( /*...*/ );
// ...
}
private:
void updateCollection( /* some arguments needed to update the collection */ );
std::vector<Blob> blobs_;
/* Potentially other data members */
};
尽管这听起来是个合理的做法,但如果可能的话,你应该避免这样做。你提出的是所谓的白盒测试。白盒测试了解某个函数的内部实现细节,并基于这些知识进行测试。这会使测试代码依赖于生产代码的实现细节。这种方法的问题在于软件是会变的。代码会变。细节会变。例如,将来可能会重写addBlob()
函数,因此不再需要更新集合。如果发生这种情况,你的测试就不再执行其原本的任务了。你可能甚至都没有意识到,你会失去updateCollection()
的测试。因此,白盒测试存在风险。就像你在生产代码中应该避免和减少依赖一样(参见“指南 1:理解软件设计的重要性”),你也应该避免测试和生产代码的细节之间的依赖。
我们真正需要的是黑盒测试。黑盒测试不对内部实现细节做任何假设,只测试预期行为。当然,这种测试也可能会失败,如果你改变了一些东西,但它不应该因为实现细节的变化而失败——只有当预期行为改变时才应该失败。
“好的,我明白你的意思。”你说。“但是你并不建议将updateCollection()
函数设为公共的,对吧?”放心,我并不是在建议这样做。当然,有时这可能是一个合理的做法。但在我们的情况下,我怀疑这不是一个明智的举动。updateCollection()
函数不应该仅仅是为了好玩而调用。它应该在正确的时间、出于良好的理由下调用,可能是为了保持某种不变量。这是我们不应该委托给用户的事情。所以不,我不认为将该函数作为public
部分的一个好选择。
“好的,好的,我只是确认一下。那么我们就让测试类成为Widget
类的friend
,这样它将拥有完全访问权,并且可以无障碍地调用private
成员函数。”
class Widget
{
// ...
private:
friend class TestWidget;
void updateCollection( /* some arguments needed to update the collection */ );
std::vector<Blob> blobs_;
/* Potentially other data members */
};
是的,我们可以添加一个friend
。假设有TestWidget
测试夹具,包含了Widget
类的所有测试。我们可以让这个测试夹具成为Widget
类的friend
。虽然这听起来像是另一个合理的方法,但我很遗憾再次打扰。是的,从技术上讲,这将解决问题,但从设计的角度来看,我们刚刚又引入了一个人为的依赖关系。通过积极地修改生产代码来引入friend
声明,生产代码现在知道了测试代码的存在。虽然测试代码当然应该知道生产代码(这就是测试代码的目的),但生产代码不应该知道测试代码。这引入了一个循环依赖,这是一个不幸的人为依赖。
“你听起来好像这是世界上最糟糕的事情。真的那么糟吗?”嗯,有时这确实可能是一个合理的解决方案。它绝对是一个简单而快速的解决方案。然而,既然我们现在有时间讨论所有的选项,肯定有比添加一个friend
更好的选择。
注意
“我不想把事情弄得更糟,但是在 C++中,我们并没有很多friend
。”是的,我知道,这听起来有点悲伤和孤单,但我当然是指关键字friend
:在 C++中,friend
并不是你的朋友。原因在于friend
引入了耦合,大多数是人为的耦合,我们应该避免耦合。当然,对于好的friend
,那些你无法没有的,可以做出一些例外,比如隐式友元,或者friend
的惯用法,比如Passkey idiom。测试更像是社交媒体上的朋友,因此将测试声明为friend
并不像是一个明智的选择。
“好的,那么让我们从private
改为protected
,并让测试类从Widget
类派生,这样测试类将完全访问updateCollection()
函数:”
class Widget
{
// ...
protected:
void updateCollection( /* some arguments needed to update the collection */ );
std::vector<Blob> blobs_;
/* Potentially other data members */
};
class TestWidget : private Widget
{
// ...
};
嗯,我必须承认,从技术上讲,这种方法是可行的。然而,你建议使用继承来解决这个问题告诉我,我们确实需要讨论继承的含义以及如何正确使用它。引用两位务实的程序员如下:³⁴
继承很少是答案。
由于我们很快就会集中讨论这个话题,让我先说一下,这感觉像是我们在滥用继承,仅仅是为了访问非公开成员函数。我非常确定这不是继承被发明的原因。使用继承来访问一个类的protected
部分就像是对本应非常简单的事情使用重型武器的方法。毕竟,这几乎等同于将函数设为public
,因为每个人都可以轻易访问。看起来我们真的没有设计这个类以便轻松测试。
“来吧,我们还能做什么?还是你真的希望我使用预处理器并将所有private
标签定义为public
?”:
#define private public
class Widget
{
// ...
private:
void updateCollection( /* some arguments needed to update the collection */ );
std::vector<Blob> blobs_;
/* Potentially other data members */
};
好的,让我们深呼吸一下。虽然这最后一种方法可能看起来有点滑稽,但请记住,我们现在已经超出了合理论据的范围。³⁵ 如果我们认真考虑使用预处理器来入侵Widget
类的private
部分,那么一切都完了。
真正的解决方案:分离关注
“好的,那么,我应该怎么做来测试private
成员函数呢?你已经丢弃了所有的选择。” 不,不是所有的选择。我们还没有讨论我在“指导原则 2:为变更设计”中强调的一种设计方法:关注分离。在我们的代码库中,我的方法是将private
成员函数从类中提取出来,使其成为一个独立的实体。在这种情况下,我倾向于将该成员函数提取为一个自由函数:
void updateCollection( std::vector<Blob>& blobs
, /* some arguments needed to update the collection */ );
class Widget
{
// ...
private:
std::vector<Blob> blobs_;
/* Potentially other data members */
};
所有对前一个成员函数的调用都可以通过将blobs_
作为第一个函数参数,仅仅通过调用自由的updateCollection()
函数来替换。或者,如果函数有一些状态附加,我们可以将其提取成另一个类的形式。无论哪种方式,我们都设计出最终的代码,使得测试变得容易,甚至可能是微不足道的:
namespace widgetDetails {
class BlobCollection
{
public:
void updateCollection( /* some arguments needed to update the collection */ );
private:
std::vector<Blob> blobs_;
};
} // namespace widgetDetails
class Widget
{
// ...
private:
widgetDetails::BlobCollection blobs_;
/* Other data members */
};
“你不是认真的吧!”你惊叹道。“这不是最糟糕的选择吗?我们难道不是在人为地将本应该在一起的两个东西分开吗?单一责任原则(SRP)不是告诉我们应该将属于一起的东西保持在一起吗?”嗯,我不这么认为。相反,我坚信现在我们才真正遵循了 SRP:SRP 规定我们应该隔离不属于一起的东西,即因不同原因可能会发生变化的东西。诚然,乍一看,Widget
和updateCollection()
似乎应该放在一起,毕竟,blob_
数据成员偶尔也需要更新。然而,updateCollection()
函数无法得到恰当的测试是一个明显的迹象,表明设计尚不完善:如果任何需要显式测试的东西都无法测试,那么肯定出了些问题。为什么要让我们的生活变得更加艰难,把需要测试的函数隐藏在Widget
类的private
部分呢?由于测试在变化的存在中起着至关重要的作用,测试只是另一种帮助确定哪些东西应该在一起的方式。如果updateCollection()
函数足够重要,我们希望单独测试它,那显然是因为它因为某种与Widget
无关的原因而变化。这表明Widget
和updateCollection()
不应该放在一起。根据 SRP,应该从类中提取updateCollection()
函数。
“这可不违背封装的理念吗?”你问道。“你可别轻视封装。我认为封装非常重要!”我同意,它确实非常重要,从根本上说!然而,封装只是分离关注点的另一个理由。正如 Scott Meyers 在他的书《Effective C++》中所述,从类中提取函数是增加封装性的一步。根据 Meyers 的说法,通常应优先选择非成员非friend
函数而不是成员函数。³⁶ 这是因为每个成员函数都可以完全访问类的每个成员,甚至是private
成员。然而,在提取的形式中,updateCollection()
函数仅限于Widget
类的public
接口,无法访问private
成员。因此,这些private
成员的封装性增强了一点。请注意,对于提取BlobCollection
类,相同的论点同样适用:BlobCollection
类无法访问Widget
类的非公共成员,因此Widget
也变得更加封装了。
通过分离关注点并提取这一功能块,你现在获得了几个优势。首先,正如刚才讨论的那样,Widget
类变得更加封装。更少的成员可以访问private
成员。其次,提取的updateCollection()
函数易于测试,甚至是微不足道的。你甚至不需要一个Widget
对象,而是可以将std::vector<Blob>
作为第一个参数传递(不是任何成员函数的隐式第一个参数,即this
指针),或者调用public
成员函数。第三,你无需改变Widget
类的任何其他方面:当你需要更新集合时,只需将blobs_
成员传递给updateCollection()
函数即可。无需添加任何其他的public
getter。而且,可能最重要的是,你现在可以在隔离的情况下更改该函数,而无需处理Widget
。这表明你已经减少了依赖关系。在最初的设置中,updateCollection()
函数与Widget
类(是的,this
指针)紧密耦合,但现在我们已经切断了这些联系。updateCollection()
函数现在是一个独立的服务,甚至可以被重用。
我可以看出你仍然有疑问。也许你担心这意味着你不应该再有任何成员函数了。不,清楚地说,我并没有建议你从你的类中提取每一个成员函数。我只是建议你仔细查看那些需要测试但放在类的private
部分的函数。此外,你可能想知道这如何与虚函数一起使用,虚函数无法以自由函数的形式提取。嗯,对此没有快速的答案,但这是我们将在本书中通过多种不同的方式处理的内容。我的目标始终是减少耦合,增加可测试性,甚至是分离虚函数。
总结一下,不要因为人为的耦合和人为的边界限制你的设计和可测试性。要为可测试性进行设计。分离关注点。释放你的函数!
指南 5:为扩展性设计
还有一个关于改变软件的重要方面我尚未强调:可扩展性。可扩展性应该是你设计的主要目标之一。因为坦率地说,如果你不能再向你的代码添加新功能,那么你的代码已经到达了它的生命周期的尽头。因此,添加新功能——扩展代码库——是基本兴趣。因此,可扩展性确实应该是你的主要目标之一,也是良好软件设计的驱动因素。
开闭原则
不幸的是,设计用于扩展并不是一件掉到你膝盖上或神奇出现的事情。不,当设计软件时,你必须明确考虑可扩展性。我们已经在 “指南 2:面向变更设计” 中看到了一个序列化文档的简单方法的例子。在那种情况下,我们使用了一个带有纯虚 serialize()
函数的 Document
基类:
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void serialize( ByteStream& bs, /*...*/ ) const = 0;
// ...
};
因为 serialize()
是一个纯虚函数,所有派生类,包括 PDF
类,都需要实现它:
class PDF : public Document
{
public:
// ...
void serialize( ByteStream& bs, /*...*/ ) const override;
// ...
};
目前为止,一切顺利。有趣的问题是:我们如何实现 serialize()
成员函数?一个要求是在以后的某个时间点能够将字节转换回 PDF
实例(我们希望将字节反序列化为 PDF)。为此,存储字节所代表信息是至关重要的。在 “指南 2:面向变更设计” 中,我们通过一个枚举完成了这一点:
enum class DocumentType
{
pdf,
word,
// ... Potentially many more document types
};
这个枚举现在可以被所有派生类用来将文档类型放在字节流的开头。这样,在反序列化时,很容易检测存储的是哪种类型的文档。不幸的是,这种设计选择证明是一个不幸的决定。由于这个枚举,我们意外地耦合了所有类型的文档:PDF
类知道 Word 格式。当然,相应的 Word
类也会知道 PDF 格式。是的,你是对的——它们不知道实现细节,但它们仍然彼此知道。
这种耦合情况在 图 1-5 中有所体现。从架构的角度看,DocumentType
枚举与 PDF
和 Word
类位于同一级别。两种类型的文档都使用(因此依赖于)DocumentType
枚举。
图 1-5. 通过 DocumentType
枚举人为耦合不同的文档类型。
这个问题在试图扩展功能时显而易见。现在除了 PDF 和 Word,我们还希望支持纯 XML 格式。理想情况下,我们只需将 XML
类添加为 Document
类的派生类即可。但不幸的是,我们还必须调整 DocumentType
枚举:
enum class DocumentType
{
pdf,
word,
xml, // The new type of document
// ... Potentially many more document types
};
这种变更至少会导致所有其他文档类型(PDF、Word 等)重新编译。现在你可能会耸耸肩,想着,“哦,好吧!它只是需要重新编译。”请注意,我说的是“至少”。在最坏的情况下,这种设计显著限制了其他人扩展代码——即添加新类型的文档——因为并非每个人都能扩展 DocumentType
枚举。不,这种耦合感觉就不对:PDF
和 Word
不应该完全了解新的 XML
格式。它们不应该看到或感觉到任何东西,甚至不需要重新编译。
此示例中的问题可以解释为违反了开闭原则(OCP)。OCP 是 SOLID 原则中的第二条。它建议我们设计软件,使得必要的扩展变得容易:^(37)
软件构件(类、模块、函数等)应该对扩展开放,但对修改关闭。
OCP 告诉我们,我们应该能够扩展我们的软件(开放性扩展)。然而,扩展应该是简单的,在最好的情况下,只需添加新的代码即可。换句话说,我们不应该修改现有的代码(关闭修改)。
理论上,扩展应该很容易:我们只需添加新的派生类XML
。这个新类本身不需要在任何其他代码中进行修改。不幸的是,serialize()
函数人为地耦合了不同类型的文档,并且需要修改DocumentType
枚举。而这种修改又会对其他类型的Document
产生影响,这正是 OCP 所反对的。
幸运的是,我们已经看到了如何在Document
示例中实现这一点的解决方案。在这种情况下,正确的做法是分离关注点(见图 1-6)。
通过分离关注点,通过将真正属于一起的东西进行分组,消除了不同种类文档之间的偶然耦合。所有与序列化相关的代码现在都正确地组合在Serialization
组件内部,这可以在架构的另一个级别上逻辑地存在。Serialization
依赖于所有类型的文档(PDF、Word、XML 等),但没有文档类型依赖于Serialization
。此外,没有一个文档知道任何其他类型的文档(正如应该的那样)。
图 1-6. 关注点分离解决了 OCP 的违规问题。
“等一下!”你说,“在序列化的代码中,我们仍然需要枚举,对吧?否则我怎么存储存储的字节代表什么信息?”我很高兴你提出这个观察。是的,在Serialization
组件内,我们仍然(很可能)需要类似DocumentType
枚举。然而,通过分离关注点,我们已经正确解决了这个依赖问题。现在不再有不同类型的文档依赖于DocumentType
枚举。所有依赖箭头现在都从低级别(Serialization
组件)指向高级别(PDF
和Word
)。而这个属性对于良好的架构是至关重要的。
“但是添加新类型的文档怎么办?这不是需要修改Serialization
组件吗?” 同样,你是完全正确的。但这并不违反 OCP,它建议我们不应该在同一架构层或更高层次上修改现有代码。然而,你无法控制或阻止在更低层次上的修改。Serialization
必须依赖于所有类型的文档,因此必须针对每种新类型的文档进行适配。因此,Serialization
必须位于我们架构的较低级别(考虑依赖级别)。
如同在“指南 2:设计以便变更”中讨论的一样,这个示例中的解决方案是关注点分离。因此,看起来真正的解决方案是遵循 SRP。因此,有些批评声音认为 OCP 不是一个独立的原则,而是与 SRP 相同。我承认我理解这种推理。很多时候,关注点的分离已经导致了所需的可扩展性。在本书的多个地方我们都会多次体验到这一点,特别是当我们讨论设计模式时。因此,可以推断 SRP 和 OCP 是相关的,甚至可以说是相同的。
另一方面,在这个示例中,我们看到在谈论 SRP 时没有考虑到 OCP 的一些具体的架构考虑。同样,正如我们将在“指南 15:设计以便添加类型或操作”中体验到的那样,我们经常需要明确地决定我们想要扩展什么以及我们想要如何扩展它。这种决定可以显著影响我们如何应用 SRP 和我们设计软件的方式。因此,OCP 更多地关乎对扩展的意识以及对扩展的有意识决策,而不仅仅是 SRP 的一个附带思考。或许它取决于具体情况。
无论如何,这个例子无可争议地表明,在软件设计过程中明确考虑可扩展性是很重要的,而希望以特定方式扩展我们软件的愿望是需要分离关注点的一个很好的指示。重要的是要理解软件如何进行扩展,识别这样的定制点,并设计以便可以轻松执行这种扩展。
编译时扩展性
Document
示例可能给人的印象是所有这些设计考虑都适用于运行时多态性。不,绝对不是:相同的考虑和相同的论点也适用于编译时问题。为了说明这一点,我现在从标准库中拿出一些例子。当然,您能够扩展标准库是非常重要的。是的,您应该使用标准库,但也鼓励您在其基础上构建并添加自己的功能。因此,标准库被设计用于可扩展性。但有趣的是,它并不使用基类来实现这一目的,而主要依赖于函数重载、模板和(类)模板专门化。
函数重载扩展的一个很好的例子是std::swap()
算法。自 C++11 以来,std::swap()
已以这种方式定义:
namespace std {
template< typename T >
void swap( T& a, T& b )
{
T tmp( std::move(a) );
a = std::move(b);
b = std::move(tmp);
}
} // namespace std
由于std::swap()
被定义为函数模板,您可以对任何类型使用它:像int
和double
这样的基本类型,像std::string
这样的标准库类型,当然也包括您自己的类型。但是,可能存在一些类型需要特别注意,一些类型不能或不应通过std::swap()
进行交换(例如,因为它们无法有效地移动),但仍然可以通过其他方式有效地交换。但是,预期值类型可以交换,正如Core Guideline C.83所表达的那样:³⁹
对于类似值类型的情况,考虑提供一个
noexcept
的交换函数。
在这种情况下,您可以为自己的类型重载std::swap()
:
namespace custom {
class CustomType
{
/* Implementation that requires a special form of swap */
};
void swap( CustomType& a, CustomType& b )
{
/* Special implementation for swapping two instances of type 'CustomType' */
}
} // namespace custom
如果正确使用swap()
,这个自定义函数将对两个CustomType
实例执行一种特殊的交换操作:⁴⁰
template< typename T >
void some_function( T& value )
{
// ...
T tmp( /*...*/ );
using std::swap; // Enable the compiler to consider std::swap for the
// subsequent call
swap( tmp, value ); // Swap the two values; thanks to the unqualified call
// and thanks to ADL this would call 'custom::swap()'
// ... // in case 'T' is 'CustomType'
}
显然,std::swap()
被设计为定制点,允许您插入新的自定义类型和行为。标准库中的所有算法也是如此。例如,考虑std::find()
和std::find_if()
:
template< typename InputIt, typename T >
constexpr InputIt find( InputIt first, InputIt last, T const& value );
template< typename InputIt, typename UnaryPredicate >
constexpr InputIt find_if( InputIt first, InputIt last, UnaryPredicate p );
通过模板参数及其隐含的概念,std::find()
和std::find_if()
(正如所有其他算法一样)使您能够使用自己的(迭代器)类型执行搜索。此外,std::find_if()
允许您自定义如何处理元素的比较。因此,这些函数明确设计用于扩展和定制。
最后一种定制点是模板专门化。例如,这种方法由std::hash
类模板使用。假设来自std::swap()
示例的CustomType
,我们可以显式地为其专门化std::hash
:
template<>
struct std::hash<CustomType>
{
std::size_t operator()( CustomType const& v ) const noexcept
{
return /*...*/;
}
};
std::hash
的设计使您能够调整其行为以适应任何自定义类型。最值得注意的是,您无需修改任何现有代码;提供此单独的专门化即可满足特殊需求。
标准库几乎全部设计用于扩展和定制。然而,这并不奇怪,因为标准库应该代表您架构中的最高水平之一。因此,标准库不能依赖于您的任何代码,但您完全依赖于标准库。
避免过早地为扩展而设计
C++标准库是为扩展而设计的一个很好的例子。希望这能让您对可扩展性的重要性有所感觉。然而,尽管可扩展性很重要,这并不意味着您应该自动、不加反思地为每个可能的实现细节都使用基类或模板来保证将来的扩展性。就像您不应该过早地分离关注点一样,您也不应该过早地为扩展性而设计。当然,如果您对代码将如何演变有一个良好的想法,那么请按照这个思路来设计它。但是,请记住 YAGNI 原则:如果您不知道代码将如何演变,那么等待可能比预测一个永远不会发生的扩展更明智。也许下一个扩展会让您对将来的扩展有所思考,这样您就可以重构代码,使后续的扩展变得容易。否则,您可能会遇到这样的问题:偏爱某种扩展方式会使其他类型的扩展变得更加困难(例如,请参阅“指南 15:为类型或操作的添加进行设计”)。如果可能的话,您应该尽量避免这种情况。
总结一下,为扩展而设计是设计变更的重要部分。因此,请明确地关注预计会扩展的功能片段,并设计代码,以便扩展变得容易。
¹ 但当然您绝不会试图打印当前的 C++标准。您要么使用官方 C++标准的 PDF,要么使用当前工作草案。然而,在您的日常工作中,您可能想参考C++参考网站。
² 不幸的是,我不能提供任何数字,因为我几乎不能说我对 C++的广阔领域有完整的了解。相反,我甚至可能没有对我已知的资源有完整的了解!因此,请把这看作是我的个人印象以及我对 C++社区的感知方式。您可能有不同的看法。
³ 代码修改是否冒险,很大程度上取决于您的测试覆盖率。良好的测试覆盖率实际上可以吸收一些糟糕软件设计可能造成的损害。
⁴ Kent Beck,《通过示例驱动开发:Test-Driven Development》(Addison-Wesley,2002 年)。
⁵ 罗伯特·C·马丁,干净的架构(Addison-Wesley,2017)。
⁶ 这确实是我自己的话,因为软件设计没有单一的共同定义。因此,你可能有自己对软件设计内容的定义,这完全可以。然而,请注意,本书,包括对设计模式的讨论,是基于我的定义。
⁷ 明确一点:计算机科学是一门科学(名字就在那)。软件工程似乎是一种科学、工艺和艺术的混合形式。后者的一个方面就是软件设计。
⁸ 用这个比喻,我并不是在暗示建筑师整天都在建筑工地工作。很可能,这样的建筑师像你我一样,在舒适的椅子前和电脑前花费大量时间。但我想你明白我的意思。
⁹ Substitution Failure Is Not An Error(SFINAE)是一种基本的模板机制,通常用作 C++20 概念的替代品来约束模板。有关 SFINAE 和std::enable_if
的解释,请参考您喜欢的关于 C++模板的教材。如果没有,请选择《C++模板:完全指南》(Addison-Wesley)是个不错的选择。
¹⁰ 有关物理和逻辑依赖管理的更多信息,请参阅约翰·拉科斯的“dam”书籍,大规模 C++软件开发:过程与架构(Addison-Wesley)。
¹¹ 马丁·福勒,“谁需要建筑师?”IEEE 软件,20 卷,第 5 期(2003),11–13 页,https://doi.org/10.1109/MS.2003.1231144。
¹² 关于微服务的非常好的介绍可以在萨姆·纽曼的书籍构建微服务:设计精细化系统第二版(O'Reilly)中找到。
¹³ 马克·理查兹和尼尔·福特,《软件架构基础:一种工程方法》(O'Reilly,2020)。
¹⁴ 术语实现模式最早在肯特·贝克的书籍实现模式(Addison-Wesley)中使用。在本书中,我使用这个术语来清晰区分设计级别和实现细节级别的模式,因为术语习惯用语可能指软件设计级别或实现细节级别的模式。我将一贯使用这个术语来指代常用的实现细节级别的解决方案。
¹⁵ 当然,这是你的第二本最喜欢的书。如果这是你唯一的书,那么你可能会参考斯科特·迈耶斯(Scott Meyers)的经典著作Effective C++:改进程序和设计的 55 种具体方法,第 3 版(Addison-Wesley)。
¹⁶ 模板方法和桥接设计模式是《四人组》(GoF)一书中介绍的 23 种经典设计模式中的两种。我不会在本书中详细介绍模板方法,但你可以在各种教科书中找到很好的解释,包括《四人组》书本本身。然而,我会在“指导原则 28:建立桥梁以消除物理依赖关系”中解释桥接设计模式。
¹⁷ 比雅尼·斯特劳斯特鲁普,C++程序设计语言,第 3 版(Addison-Wesley,2000)。
¹⁸ 向约翰·拉科斯致敬,他在他的著作大规模 C++软件开发:过程与架构(Addison-Wesley)中提出了类似的观点,并使用了 C++98。
¹⁹ 是的,本和杰森,你们读对了,我不会constexpr
所有东西。参见本·迪恩和杰森·特纳,“constexpr ALL the things”,CppCon 2017。
²⁰ 迈克尔·菲瑟斯,与遗留代码有效地工作(Addison-Wesley,2013)。
²¹ 大卫·托马斯和安德鲁·亨特,务实程序员:通往精通之路,20 周年纪念版(Addison Wesley,2019)。
²² 汤姆·德马科,结构化分析与系统规格(Prentice Hall,1979)。
²³ SOLID 是一个首字母缩略词,是下面几个指导原则中描述的五个原则的缩写:SRP、OCP、LSP、ISP 和 DIP。
²⁴ SOLID 原则的第一本书是罗伯特·C·马丁的敏捷软件开发:原则、模式和实践(Pearson)。一个更新且更便宜的选择是罗伯特·C·马丁的干净架构(Addison-Wesley)。
²⁵ 不要忘记,外部库所做的设计决策可能会影响你自己的设计,这显然会增加耦合度。
²⁶ 这包括其他人可能编写的类,即你无法控制的类。而且,其他人不会对这种变化感到高兴。因此,这种变化可能会非常困难。
²⁷ 枚举看起来是一个显而易见的选择,但当然还有其他选项。最终,我们需要一组经过协商的值,这些值代表字节表示中不同的文档格式。
²⁸ 或许你会对这个构造函数中明确使用explicit
关键字感到奇怪。然后你可能也知道,Core Guideline C.46 建议默认为单参数构造函数使用explicit
。这是非常好的并且强烈推荐的建议,因为它可以防止意外的、潜在的不良转换。虽然价值不如此高,但对于除了复制和移动构造函数之外的所有其他构造函数,同样的建议也是合理的,这些构造函数并不执行转换。至少这不会有害。
²⁹ 你可能已经意识到我挑选了我经常参加的三个会议的名称:CppCon,Meeting C++,以及Cpp on Sea。当然还有许多其他的 C++会议。例如:ACCU,Core C++,pacific++,CppNorth,emBO++,以及CPPP。参加会议是了解 C++的一个很棒且有趣的方式。确保查看Standard C++ Foundation 主页,了解即将举行的任何会议。
³⁰ Katerina Trajchevska,《“Becoming a Better Developer by Using the SOLID Design Principles”》(https://oreil.ly/cwo8Y),Laracon EU,2018 年 8 月 30 日至 31 日。
³¹ Robert C. Martin,《Agile Software Development: Principles, Patterns, and Practices》。
³² 如果你没有一个完整的测试套件,那么你还有很多工作要做。认真的。一个很好的起步参考是 Ben Saks 在 CppCon 2020 上关于单元测试的演讲,《“Back to Basics: Unit Tests”》(https://oreil.ly/VBo9X)。另一个非常好的参考是 Jeff Langr 的书,《Modern C{plus}{plus} Programming with Test-Driven Development》(https://learning.oreilly.com/library/view/modern-c-programming/9781941222423/)(O’Reilly)。
³³ 我知道,“每个人都同意”的情况很不幸福。如果你需要证明测试的重要性还未普及到每个项目和每个开发者,可以看看 OpenFOAM 问题跟踪器中的这个问题。
³⁴ David Thomas 和 Andrew Hunt,《The Pragmatic Programmer: Your Journey to Mastery》。
³⁵ 我们甚至可能已经进入了未定义行为的可怕领域。
³⁶ 你可以在 Scott Meyers 的《Effective C++》的第 23 条中找到这个令人信服的论点。
³⁷ Bertrand Meyer 的《面向对象软件构造》,第 2 版(Pearson, 2000)。
³⁸ 当然,“这要看情况!”会满足甚至是最强硬的 OCP 批评者。
³⁹ C++核心指南是社区努力的结果,旨在制定一套编写良好 C++代码的指南。它们最能代表 C++惯用的常识。你可以在GitHub找到这些指南。
⁴⁰ ADL(Argument Dependent Lookup)是指参数相关的查找。请参阅CppReference或者我的CppCon 2020 演讲了解详情。
第二章:构建抽象的艺术
抽象在软件设计和软件架构中发挥着至关重要的作用。换句话说,良好的抽象是管理复杂性的关键。没有它们,良好的设计和适当的架构是难以想象的。然而,构建良好的抽象并有效使用它们却是令人惊讶地困难。事实证明,构建和使用抽象带来了许多微妙之处,因此更像是一门艺术而非科学。本章详细探讨了抽象的含义和构建抽象的艺术。
在“指南 6:遵循抽象的预期行为”,我们将讨论抽象的目的。我们还将讨论抽象代表一组要求和期望的事实,以及为什么坚持抽象的预期行为如此重要。在这个背景下,我将介绍另一个设计原则,即“里氏替换原则”(LSP)。
在“指南 7:理解基类和概念之间的相似性”,我们将比较两种最常用的抽象:基类和概念。您将了解到,从语义角度来看,这两种方法都非常相似,因为它们都能表达预期的行为。
在“指南 8:理解重载集的语义要求”,我将扩展对语义要求的讨论,并讨论第三种类型的抽象:函数重载。您将了解到,所有函数作为重载集的一部分,也都有预期的行为,因此也必须遵守 LSP。
在“指南 9:关注抽象的所有权”,我将专注于抽象的建筑意义。我将解释什么是架构,以及我们对架构的高低层次的期望。我还将向您展示,从架构的角度来看,仅仅引入抽象以解决依赖关系是不够的。为了解释这一点,我将介绍“依赖反转原则”(DIP),这是通过抽象构建架构的重要建议。
在“指南 10:考虑创建架构文档”,我们将讨论架构文档的好处。希望这将是一个创造一个架构文档的动机,以防这已经不在您的计划中。
指南 6:遵循抽象的预期行为
解耦软件的一个关键方面,因此也是软件设计的一个关键方面,是引入抽象。因此,您可能会期望这是一件相对简单、容易的事情。不幸的是,事实证明,构建抽象是困难的。
为了说明我的意思,让我们看一个例子。我选择了经典的例子作为例证。你可能已经知道这个例子。如果是这样,请随意跳过它。然而,如果你对这个例子不熟悉,那么这可能会让你眼前一亮。
违反期望的一个例子
让我们从一个Rectangle
基类开始:
class Rectangle
{
public:
// ...
virtual ~Rectangle() = default; 
int getWidth() const; 
int getHeight() const;
virtual void setWidth(int); 
virtual void setHeight(int);
virtual int getArea() const; 
// ...
private:
int width; 
int height;
};
首先,这个类被设计为一个基类,因为它提供了一个虚析构函数()。从语义上讲,
Rectangle
表示不同类型的矩形的抽象。从技术上讲,你可以通过指向Rectangle
的指针正确销毁派生类型的对象。
其次,Rectangle
类带有两个数据成员:width
和height
()。这是可以预期的,因为矩形有两个边长,分别由
width
和height
表示。getWidth()
和getHeight()
成员函数可以用来查询这两个边长(),通过
setWidth()
和setHeight()
成员函数,我们可以设置width
和height
()。重要的是要注意,我可以独立设置这两个值;也就是说,我可以设置
width
而不必修改height
。
最后,还有一个getArea()
成员函数()。
getArea()
计算矩形的面积,当然是通过返回width
和height
的乘积来实现的。
当然,可能会有更多的功能,但给定的成员是这个示例中重要的成员。目前看来,这个Rectangle
类似乎相当不错。显然,我们有了一个良好的开端。但当然还有更多。例如,还有Square
类:
class Square : public Rectangle 
{
public:
// ...
void setWidth(int) override; 
void setHeight(int) override; 
int getArea() const override; 
// ... };
Square
类公开继承自Rectangle
类()。从数学的角度来看,这似乎相当合理:一个正方形看起来就是一种特殊的矩形。¹
一个Square
是特殊的,因为它只有一个边长。但是Rectangle
基类有两个长度:width
和height
。因此,我们必须确保Square
的不变量始终得到保留。在这个给定的实现中,我们有两个数据成员和两个获取函数,我们必须确保这两个数据成员始终具有相同的值。因此,我们重写setWidth()
成员函数来同时设置width
和height
()。我们还重写
setHeight()
成员函数来同时设置width
和height
()。
一旦我们完成了这些,一个Square
将始终具有相等的边长,并且getArea()
函数将始终返回一个Square
的正确面积()。不错!
让我们充分利用这两个类。例如,我们可以考虑一个函数,用于转换不同类型的矩形:
void transform( Rectangle& rectangle ) 
{
rectangle.setWidth ( 7 ); 
rectangle.setHeight( 4 ); 
assert( rectangle.getArea() == 28 ); 
// ... }
transform()
函数通过对非const
引用接收任何类型的Rectangle
。这是合理的,因为我们希望修改给定的矩形。首先可以通过setWidth()
成员函数将矩形的width
设置为7
()。然后,我们可以通过
setHeight()
成员函数将矩形的height
设置为4
()。
此时,我会认为你有一个隐含的假设。我相当肯定你假设矩形的面积是28
,因为当然,7
乘以4
等于28
。这是一个我们可以通过断言进行测试的假设()。
唯一还缺少的是实际调用transform()
函数。这就是我们在main()
函数中所做的:
int main()
{
Square s{}; 
s.setWidth( 6 );
transform( s ); 
return EXIT_SUCCESS;
}
在main()
函数中,我们创建了一种特殊类型的矩形:一个Square
()。² 这个正方形被传递给
transform()
函数,当然可以工作,因为Square
的引用可以隐式转换为Rectangle
的引用()。
如果我问你,“会发生什么?”我非常确定你会回答,“assert()
失败了!” 是的,确实如此,assert()
将失败。传递给assert()
的表达式将求值为false
,并且assert()
将使用SIGKILL
信号使进程崩溃。嗯,这确实很不幸。因此,让我们进行事后分析:为什么assert()
会失败?我们在transform()
函数中的期望是可以独立改变矩形的宽度和高度。这个期望明确地通过对setWidth()
和setHeight()
的两次函数调用来表达。然而,出乎意料的是,这种特殊类型的矩形却不允许这样做:为了保持其自身的不变性,Square
类必须始终确保两个边长相等。因此,Square
类必须违反这个期望。在抽象层面上违反期望是 LSP 的违反。
里氏替换原则(Liskov Substitution Principle)
LSP 是 SOLID 原则中的第三条,涉及行为子类型化,即抽象的预期行为。这个设计原则以Barbara Liskov的名字命名,她于 1988 年首次提出,并在 1994 年与 Jeannette Wing 澄清了它:³
子类型要求:设是关于类型 T 的对象可证明的属性。那么对于类型 S 的对象(其中 S 是 T 的子类型),应该为真。
这个原则阐述了我们通常称为IS-A关系的概念。这种关系,即抽象中的期望,必须在子类型中遵守。这包括以下属性:
- 前置条件在子类型中不能被加强:子类型不能在函数中期望超类型所表达的更多。这将违反抽象中的期望:
struct X
{
virtual ~X() = default;
// Precondition: the function accepts all 'i' greater than 0
virtual void f( int i ) const
{
assert( i > 0 );
// ...
}
};
struct Y : public X
{
// Precondition: the function accepts all 'i' greater than 10.
// This would strengthen the precondition; numbers between 1 and 10
// would no longer be allowed. This is a LSP violation!
void f( int i ) const override
{
assert( i > 10 );
// ...
}
};
- 后置条件在子类型中不能被削弱:子类型在离开函数时不能比超类型承诺更少。再次强调,这会违反抽象中的期望。
struct X
{
virtual ~X() = default;
// Postcondition: the function will only return values larger than 0
virtual int f() const
{
int i;
// ...
assert( i > 0 );
return i;
}
};
struct Y : public X
{
// Postcondition: the function may return any value.
// This would weaken the postcondition; negative numbers and 0 would
// be allowed. This is a LSP violation!
int f( int i ) const override
{
int i;
// ...
return i;
}
};
- 函数子类型中的返回类型必须是协变的:子类型的成员函数可以返回一个类型,该类型本身是超类型中对应成员函数返回类型的子类型。这种属性在 C++中有直接的语言支持。然而,子类型不能返回超类型的任何返回类型:
struct Base { /*...some virtual functions, including destructor...*/ };
struct Derived : public Base { /*...*/ };
struct X
{
virtual ~X() = default;
virtual Base* f();
};
struct Y : public X
{
Derived* f() override; // Covariant return type
};
- 函数子类型中的参数必须是逆变的:在成员函数中,子类型可以接受超类型的函数参数,并在超类型的对应成员函数中使用。这种属性在 C++中没有直接的语言支持:
struct Base { /*...some virtual functions, including destructor...*/ };
struct Derived : public Base { /*...*/ };
struct X
{
virtual ~X() = default;
virtual void f( Derived* );
};
struct Y : public X
{
void f( Base* ) override; // Contravariant function parameter; Not
// supported in C++. Therefore the function
// does not override, but fails to compile.
};
- 超类型的不变量必须在子类型中保留:关于超类型状态的任何期望,在所有成员函数调用之前和之后,包括子类型的成员函数,在子类型中必须始终有效。
struct X
{
explicit X( int v = 1 )
: value_(v)
{
if( v < 1 || v > 10 ) throw std::invalid_argument( /*...*/ );
}
virtual ~X() = default;
int get() const { return value_; }
protected:
int value_; // Invariant: must be within the range [1..10]
};
struct Y : public X
{
public:
Y()
: X()
{
value_ = 11; // Broken invariant: After the constructor, 'value_'
// is out of expected range. One good reason to
// properly encapsulate invariants and to follow
// Core Guideline C.133: Avoid protected data.
}
};
在我们的例子中,在Rectangle
中的期望是我们可以独立改变两个边长,或者更正式地说,在调用setHeight()
之后,getWidth()
的结果不会改变。这种期望对于任何类型的矩形都是直观的。然而,Square
类本身引入了所有边必须始终相等的不变量,否则Square
无法正确表达我们对正方形的理解。但通过保护自身的不变量,Square
不幸地违反了基类中的期望。因此,在这个例子中,Square
类无法满足Rectangle
类的期望,并且这个层次结构并不表达一个 IS-A 关系。因此,Square
不能在所有需要Rectangle
的地方使用。
“但是一个正方形不是一个矩形吗?”你问道。“这难道不能正确地表达几何关系吗?”⁴ 是的,正方形和矩形之间可能存在几何关系,但在这个例子中,继承关系是破坏的。这个例子表明数学上的 IS-A 关系与 LSP 的 IS-A 关系确实是不同的。在几何学中,正方形总是矩形,但在计算机科学中,这真的取决于实际的接口和期望。只要有两个独立的setWidth()
和setHeight()
函数,一个Square
总是会违反期望。“我明白了,”你说。“没人会声称,在几何上,改变宽度后的正方形仍然是正方形,对吧?”确实如此。
该例子还表明继承不是一种自然或直观的特性,而是一种困难的特性。正如开头所述,构建抽象是困难的。每当使用继承时,必须确保基类中的所有期望都得到满足,并且派生类型的行为如预期般。
对里斯科夫替换原则的批评
有些人认为,正如早先解释的那样,LSP 实际上并不是由芭芭拉·里斯科夫和会议论文“数据抽象与层次结构”中描述的那样,并且子类型的概念是有缺陷的。这是正确的:我们通常不会用派生对象替代基对象,而是将派生对象用作基对象。然而,这种对里斯科夫声明的字面和严格解释在我们日常构建的抽象类型中并不起任何作用。在她们 1994 年的论文“子类型的行为概念”中,芭芭拉·里斯科夫和 Jeanette Wing 提出了术语行为子类型,这是今天对 LSP 的共同理解。
其他人认为,由于可能违反 LSP,基类并不符合抽象的目的。理由是使用代码也将依赖(误)用于派生类型的行为。不幸的是,这种论点颠倒了世界。基类确实代表了一种抽象,因为调用代码只能且应仅仅依赖于这种抽象的预期行为。正是这种依赖性使得 LSP 违规成为编程错误。不幸的是,有时人们试图通过引入特殊的解决方案来修复 LSP 违规:
class Base { /*...*/ };
class Derived : public Base { /*...*/ };
class Special : public Base { /*...*/ };
// ... Potentially more derived classes
void f( Base const& b )
{
if( dynamic_cast<Special const*>(&b) )
{
// ... do something "special," knowing that 'Special' behaves differently
}
else
{
// ... do the expected thing
}
}
这种解决方法确实会引入派生类型行为的依赖性。而且是非常不幸的依赖性!这应始终被视为 LSP 的违规和非常糟糕的实践。⁵ 它并不能作为反对基类抽象属性的普遍论点。
需要良好和有意义的抽象
要正确解耦软件实体,我们能够依赖我们的抽象是非常重要的。如果没有我们作为代码的人类读者完全理解的有意义的抽象,我们就无法编写健壮可靠的软件。因此,遵循 LSP 对软件设计至关重要。然而,同样重要的一部分是对抽象期望的清晰明确的传达。在最佳情况下,这通过软件本身实现(自描述代码)来实现,但也包括对抽象的适当文档化。作为一个很好的例子,我推荐查看 C++标准中的迭代器概念文档,其中清楚地列出了预期的行为,包括前置和后置条件。
指导原则 7:理解基类和概念之间的相似之处
在“指导原则 6:遵循抽象的预期行为”,我可能给人造成了 LSP 仅涉及继承层次结构和基类的印象。为了确保这种印象不会固定下来,让我明确声明 LSP不仅限于动态(运行时)多态性和继承层次结构。相反,我们同样可以将 LSP 应用于静态(编译时)多态性和模板化代码。
为了说明这一点,让我问你一个问题:下面两个代码片段有什么区别?
//==== Code Snippet 1 ====
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void exportToJSON( /*...*/ ) const = 0;
virtual void serialize( ByteStream&, /*...*/ ) const = 0;
// ...
};
void useDocument( Document const& doc )
{
// ...
doc.exportToJSON( /*...*/ );
// ...
}
//==== Code Snippet 2 ====
template< typename T >
concept Document =
requires( T t, ByteStream b ) {
t.exportToJSON( /*...*/ );
t.serialize( b, /*...*/ );
};
template< Document T >
void useDocument( T const& doc )
{
// ...
doc.exportToJSON( /*...*/ );
// ...
}
我很确定你的第一个答案是第一个代码片段展示了使用动态多态性的解决方案,而第二个代码片段展示了静态多态性。是的,很好!还有什么?好的,是的,当然,语法也不同。好的,我明白了,我应该更精确地问我的问题:这两种解决方案在语义上有什么不同?
嗯,如果你仔细思考一下,你可能会发现从语义上讲,这两种解决方案确实非常相似。在第一个代码片段中,useDocument()
函数只与派生自Document
基类的类一起工作。因此,我们可以说该函数只与符合Document
抽象期望的类一起工作。在第二个代码片段中,useDocument()
函数只与实现Document
概念的类一起工作。换句话说,该函数只与符合Document
抽象期望的类一起工作。
如果你现在有一种似曾相识的感觉,那么我的措辞希望引起共鸣。是的,在这两个代码片段中,useDocument()
函数只与符合Document
抽象期望的类一起工作。因此,尽管第一个代码片段基于运行时抽象,第二个函数代表编译时抽象,从语义上讲,这两个函数非常相似。
基类和概念都代表一组要求(语法要求,但也包括语义要求)。因此,两者都代表了对期望行为的正式描述,因此是表达和传达调用代码期望的手段。因此,概念可以被视为基类的等价物,即静态对应物。从这个角度看,也完全有理由考虑模板代码的 LSP(里氏替换原则)。
“我不买账,” 你说,“我听说 C++20 的概念不能表达语义!”⁶ 嗯,对此我只能肯定地说是和不是。是的,C++20 的概念不能完全表达语义,这是正确的。但另一方面,概念仍然表达了期望的行为。例如,考虑 std::copy()
算法的 C++20 形式:⁷
template< typename InputIt, typename OutputIt >
constexpr OutputIt copy( InputIt first, InputIt last, OutputIt d_first )
{
while( first != last ) {
*d_first++ = *first++;
}
return d_first;
}
std::copy()
算法期望三个参数。前两个参数表示需要复制的元素范围(输入范围)。第三个参数表示我们需要复制到的第一个元素(输出范围)。一般期望是输出范围足够大,可以将所有输入范围的元素复制到其中。
迭代器类型的命名隐含表达了更多的期望:InputIt
和 OutputIt
。InputIt
表示一种输入迭代器类型。C++ 标准规定了所有这类迭代器类型的期望,例如可用性比较(不等比较),通过前缀和后缀递增遍历范围(operator++()
和 operator++(int)
),以及通过解引用操作符访问元素(operator*()
)。另一方面,OutputIt
表示一种输出迭代器类型。在这里,C++ 标准也明确规定了所有期望的操作。
InputIt
和 OutputIt
或许不是 C++20 的概念,但它们代表相同的概念:这些命名的模板参数不仅告诉你需要的类型是什么,还表达了期望的行为。例如,我们期望 first
的后续递增最终会产生 last
。如果任何具体的迭代器类型不能按照这种方式行为,std::copy()
将不能按预期工作。这将是对期望行为的违反,因此也是 LSP 的违反。⁸ 因此,InputIt
和 OutputIt
都代表 LSP 的抽象。
请注意,由于概念代表了 LSP 的抽象,即一组要求和期望,它们也适用于接口隔离原则(ISP)(见“指南 3:分离接口以避免人为耦合”)。正如您应该在基类定义要求的定义中分离关注点(比如说,“接口”类),您在定义概念时也应该分离关注点。标准库迭代器通过相互构建来实现这一点,从而允许您选择所需的要求级别:
template< typename I >
concept input_or_output_iterator =
/* ... */;
template< typename I >
concept input_iterator =
std::input_or_output_iterator<I> &&
/* ... */;
template< typename I >
concept forward_iterator =
std::input_iterator<I> &&
/* ... */;
由于命名模板参数和 C++20 概念都用于表示 LSP 抽象,从现在开始,在所有后续的指南中,我将使用术语概念来指代它们。因此,用术语概念,我将指代任何表达一组要求的方式(在大多数情况下是用于模板参数,但有时甚至更广泛)。如果我想特指其中的任何一个,我会明确表明。
总结来说,任何抽象(动态和静态)都代表了一组期望的行为要求。这些期望需要由具体的实现来满足。因此,LSP 清晰地代表了所有 IS-A 关系的基本指导。
指南 8:理解重载集的语义要求
在“指南 6:遵循抽象的预期行为”中,我向您介绍了 LSP,并希望做出了强有力的论证:每个抽象都代表了一组语义要求!换句话说,抽象表达了需要满足的预期行为。否则,您(很可能)会遇到问题。在“指南 7:理解基类与概念之间的相似性”中,我扩展了 LSP 的讨论到概念,并展示了 LSP 也可以和应该应用于静态抽象。
然而,这并不是故事的结局。正如之前所述:每一个抽象都代表一组需求。还有一种抽象我们尚未考虑到,这种抽象经常被忽视,尽管它非常强大,因此在讨论中我们不应忘记它:函数重载。“函数重载?你指的是一个类可以有几个同名函数的事实?” 是的,完全正确。您可能已经体验过,这确实是一个非常强大的特性。例如,请考虑std::vector
中的两个begin()
成员函数的重载:根据您是否有一个const
或非const
向量,选择相应的重载。甚至无需您注意。非常强大!但说实话,这并不是真正的抽象。虽然重载成员函数很方便和有帮助,但我心中想到的是一种不同类型的函数重载,一种真正代表一种形式的抽象:自由函数。
自由函数的力量:一种编译时抽象机制
除了概念外,通过自由函数进行函数重载代表第二种编译时抽象:基于某些给定类型,编译器从一组同名函数中找出应该调用哪一个函数。这就是我们称之为重载集的东西。这是一种极其灵活和强大的抽象机制,具有许多出色的设计特性。首先,您可以向任何类型添加自由函数:您可以向int
、std::string
和任何其他类型添加自由函数。非侵入式地。尝试使用成员函数这样做,您会意识到这根本行不通。添加成员函数是侵入式的。您无法向不能拥有成员函数的类型或不能修改的类型添加任何东西。因此,自由函数完美地体现了开闭原则(OCP)的精神:您可以通过简单添加代码来扩展功能,而无需修改已存在的代码。
这为您带来了显著的设计优势。例如,考虑以下代码示例:
template< typename Range >
void traverseRange( Range const& range )
{
for( auto pos=range.begin(); pos!=range.end(); ++pos ) {
// ...
}
}
traverseRange()
函数在给定的range
上执行传统的基于迭代器的循环。为了获取迭代器,它调用了range
上的begin()
和end()
成员函数。尽管这段代码对许多容器类型都有效,但对于内置数组来说是不起作用的:
#include <cstdlib>
int main()
{
int array[6] = { 4, 8, 15, 16, 23, 42 };
traverseRange( array ); // Compilation error!
return EXIT_SUCCESS;
}
这段代码将无法编译,因为编译器会抱怨给定数组类型缺少begin()
和end()
成员函数。“这难道不是我们应该避免使用内置数组而使用std::array
的原因吗?” 我完全同意:您应该使用std::array
。这也被核心指南 SL.con.1非常好地解释了:
建议使用 STL 的
array
或vector
而不是 C 数组。
然而,虽然这是一个良好的实践,但我们不应忽视traverseRange()
函数的设计问题:traverseRange()
通过依赖于begin()
和end()
成员函数而限制了自己。因此,它对Range
类型施加了一个人为的要求,要求支持一个begin()
和一个end()
函数,并且由此限制了它自身的适用性。然而,有一个简单的解决方案,一种使该函数更广泛适用的简单方式:利用自由的begin()
和end()
函数的重载集合:[⁹]
template< typename Range >
void traverseRange( Range const& range )
{
using std::begin; // using declarations for the purpose of calling
using std::end; // 'begin()' and 'end()' unqualified to enable ADL
for( auto pos=begin(range); pos!=end(range); ++pos ) {
// ...
}
}
尽管如此,这个函数仍然在以前做同样的事情,但在这种形式下,它不受任何人为要求的限制。事实上,没有任何限制:任何类型都可以拥有自由的begin()
和end()
函数,或者如果缺少这些函数,则可以被赋予这些函数。非侵入式地。因此,这个函数可以与任何类型的Range
一起使用,如果某些类型不满足要求,也无需修改或重载。它的适用范围更广。它真正地通用。[¹⁰]
不过,自由函数还有更多的优势。正如在“指导原则 4:设计可测试性”中已经讨论过的那样,自由函数是一种非常优雅的技术,可以分离关注点,实现单一职责原则(SRP)。通过在类外部实现操作,你自动减少了该类对该操作的依赖。从技术上讲,这变得立即清晰,因为与成员函数相比,自由函数没有隐式的第一个参数,即this
指针。与此同时,这也促使该函数成为一个独立的、孤立的服务,可以被许多其他类使用。因此,你促进了重用并减少了重复。这非常好地符合不要重复你自己(DRY)原则的思想。
这一点在亚历山大·斯特帕诺夫的杰作、标准模板库(STL)中表现得淋漓尽致。[¹¹] STL 哲学的一部分是通过将不同功能模块松散耦合并通过将关注点分离为自由函数来促进重用。这就是为什么在 STL 中,容器和算法是两个独立的概念:从概念上讲,容器不知道算法,算法也不知道容器。它们之间的抽象是通过迭代器实现的,允许你以看似无限的方式组合这两者。这是一个非常值得注意的设计。或者用斯科特·迈尔斯的话来说:[¹²]
标准模板库从未被怀疑过代表了高效和可扩展设计的突破。
“但std::string
呢? std::string
自带几十个成员函数,包括许多算法。” 你提出了一个很好的观点,但更多的是作为一个反例。如今,社区一致认为std::string
的设计并不理想。它的设计促进了耦合、重复和增长:在每一个新的 C++标准中,都会有一些新的额外成员函数。增长意味着修改,随之而来的是意外更改的风险。这是你在设计中要避免的风险。然而,作为其辩护,std::string
并不是 STL 的原始部分。它并未与 STL 容器(std::vector
、std::list
、std::set
等)一同设计,并且后来才适应了 STL 的设计。这解释了为什么它与其他 STL 容器不同,并且并不完全分享它们美丽的设计目标。
自由函数的问题:对行为的期望
显然,自由函数在通用编程中非常强大且非常重要。它们在 STL 的设计和整个 C++标准库的设计中发挥着至关重要的作用,这建立在这种抽象机制的力量之上。¹³ 然而,所有这些力量只有在一组重载函数遵循一组规则和特定的期望时才能发挥作用。只有它遵循 LSP 时才能发挥作用。
例如,假设你已经为自己编写了一个Widget
类型,并想为其提供一个定制的swap()
操作:
//---- <Widget.h> ----------------
struct Widget
{
int i;
int j;
};
void swap( Widget& w1, Widget& w2 )
{
using std::swap;
swap( w1.i, w2.i );
}
你的Widget
只需是一个简单的包装器,用于int
值,称为i
和j
。你提供了相应的swap()
函数作为一个附带的自由函数。你通过仅交换i
值而不是j
值来实现swap()
。进一步想象一下,你的Widget
类型被其他开发者使用,也许是一个友好的同事。在某个时刻,这个同事调用了swap()
函数:
#include <Widget.h>
#include <cstdlib>
int main()
{
Widget w1{ 1, 11 };
Widget w2{ 2, 22 };
swap( w1, w2 );
// Widget w1 contains (2,11)
// Widget w2 contains (1,22)
return EXIT_SUCCESS;
}
你能想象当swap()
操作后,w1
的内容不是(2,22)
而是(2,11)
时你同事的惊讶吗?仅交换对象的部分内容是多么意外的事情?你能想象你的同事在一个小时的调试后会有多么沮丧吗?如果这不是一个友好的同事会发生什么呢?
显然,swap()
函数的实现并未满足swap()
函数的期望。显然,任何人都会期望整个可观察状态的对象被交换。显然,这里有行为期望。因此,如果你接受重载集,你立即且不可避免地要遵循重载集的预期行为。换句话说,你必须遵循 LSP。
“我明白问题,我理解了。我承诺遵守 LSP 规则”,你说道。这非常好,这是一个光荣的意图。问题在于,可能并不总是完全清楚预期的行为是什么,特别是对于散布在庞大代码库中的重载集。你可能不知道所有的期望和所有的细节。因此,有时即使你意识到了这个问题并且关注了它,你可能仍然没有做出“正确”的事情。这就是社区中一些人担心的问题:在重载集中添加可能违反 LSP 的功能的不受限制的能力。¹⁴ 正如之前所述,这是很容易做到的。任何人,在任何地方,都可以添加自由函数。
一如既往,每种方法和每种解决方案都有其优势,也有其缺点。一方面,充分利用重载集的力量是非常有益的,但另一方面,做正确的事情可能非常困难。这两面同一枚硬币的表达也被 核心指导方针 C.162 和 核心指导方针 C.163 所表达。
重载那些大致等效的操作。
核心指导方针 C.162
只为大致等效的操作进行重载。
核心指导方针 C.163
而 C.162 表达了为语义上等效的函数使用相同名称的优势,C.163 则表达了为语义上不同的函数使用相同名称的问题。每个 C++开发者都应该意识到这两个指导方针之间的紧张关系。此外,为了遵守预期的行为,每个 C++开发者都应该了解现有的重载集(如 std::swap()
、std::begin()
、std::cbegin()
、std::end()
、std::cend()
、std::data()
、std::size()
等),并了解常见的命名约定。例如,名称 find()
应该仅用于执行线性搜索的函数。对于执行二分搜索的任何函数,使用名称 find()
将引发错误的期望,并且不会传达范围需要排序的前提条件。当然,名称 begin()
和 end()
应该始终满足返回可以用于遍历范围的迭代器对的期望。它们不应该开始或结束某种过程。这个任务最好由 start()
和 stop()
函数来执行。¹⁵
“嗯,我同意所有这些观点,”你说道。“但是,我主要使用虚函数,而由于这些函数无法用自由函数实现,所以我实际上无法完全应用重载集的所有建议,对吧?”也许会让你惊讶,但这些建议仍然适用于你。因为最终目标是减少依赖关系,而虚函数可能导致相当大量的耦合,因此其中一个目标将是“释放”它们。事实上,在许多后续的准则中,也许最显著的是“准则 19:使用策略隔离操作方式”和“准则 31:使用外部多态性进行非侵入式运行时多态性”,我将讲述如何以自由函数的形式提取和分离虚函数,但并不限于此。
总之,函数重载是一个强大的编译时抽象机制,不容小觑。特别是,泛型编程大量利用了这种力量。然而,不要轻视这种力量:要记住,就像基类和概念一样,重载集合代表一组语义要求,因此受到 LSP 的约束。必须遵守重载集的预期行为,否则事情将会变得不尽如人意。
准则 9:注意抽象的所有权
如“准则 2:为变更而设计”中所述,变更是软件开发中的唯一常量。你的软件应该为变更做好准备。处理变更的一个基本要素是引入抽象(还请参阅“准则 6:遵循抽象的预期行为”)。抽象有助于减少依赖关系,从而更容易独立地变更细节。然而,引入抽象不仅仅是添加基类或模板这么简单。
依赖反转原则
需要抽象的必要性也由 Robert Martin 表达:¹⁶
最灵活的系统是那些源代码依赖仅引用抽象而不是具体实现的系统。
这条智慧被称为依赖反转原则(DIP),它是 SOLID 原则中的第五条。简而言之,它建议为了依赖关系,你应该依赖于抽象而不是具体类型或实现细节。请注意,这个声明并未提到继承层次结构,而只是一般提到抽象。
让我们看一下在图 2-1 中所示的情况。想象一下,您正在实现自动取款机(ATM)的逻辑。ATM 提供几种操作:您可以取钱、存钱和转账。由于所有这些操作涉及实际资金,它们应该要么完全成功,要么在任何错误的情况下中止并回滚所有更改。这种行为(要么 100%成功,要么完全回滚)是我们通常称为事务的。因此,我们可以引入一个名为Transaction
的抽象。所有抽象类(Deposit
、Withdrawal
和Transfer
)都继承自Transaction
类(由 UML 继承箭头表示)。
图 2-1. 几个交易和 UI 之间的初始强依赖关系
所有交易都需要银行客户通过用户界面输入的输入数据。这个用户界面由UI
类提供,该类提供许多不同的功能来查询输入的数据:requestDepositAmount()
、requestWithdrawalAmount()
、requestTransferAmount()
、informInsufficientFunds()
等,可能还有更多的功能。所有三个抽象类在需要信息时直接调用这些函数。这种关系由小实箭头表示,表明这些抽象类依赖于UI
类。
尽管这种设置可能在一段时间内有效,但您的训练眼睛可能已经发现了一个潜在的问题:如果发生了变化会怎样?例如,如果系统添加了一个新的交易怎么办?
假设我们必须为 VIP 客户添加一个SpeedTransfer
交易。这可能需要我们改变并扩展UI
类,添加一些新功能(例如,requestSpeedTransferAmount()
和requestVIPNumber()
)。这反过来也会影响所有其他交易,因为它们直接依赖于UI
类。在最好的情况下,这些交易只需重新编译和重新测试(尽管这需要时间!);在最坏的情况下,它们可能必须重新部署,因为它们是以单独的共享库交付的。
所有这些额外工作背后的根本原因是一个破碎的架构。所有交易间接地通过对UI
类的具体依赖而相互依赖。从架构角度来看,这是非常不幸的情况:交易类位于我们架构的高层,而UI
类位于低层。在这个例子中,高层依赖低层。这是错误的:在一个合适的架构中,这种依赖关系应该被反转。¹⁸
所有交易由于对 UI 类的依赖而间接依赖于彼此。此外,我们的架构高层依赖于低层。这确实是一个非常不幸的情况,一个我们应该正确解决的情况。“但这很简单!”你说。“我们只需引入一个抽象!”这正是 Robert Martin 在他的声明中表达的:我们需要引入一个抽象,以免依赖 UI 类的具体实现。
然而,一个单一的抽象并不能解决问题。三种交易仍然会间接耦合。不,正如图 2-2 所示,我们需要三个抽象:每个交易一个。¹⁹
图 2-2. 几个交易与 UI 之间放松的依赖关系
通过引入 DepositUI、WithdrawalUI 和 TransferUI 类,我们打破了三个交易之间的依赖关系。这三个交易不再依赖具体的 UI 类,而是依赖一个轻量级的抽象,该抽象仅表示相关交易真正需要的操作。如果我们现在引入 SpeedTransfer 交易,我们也可以引入 SpeedTransferUI 抽象,这样其他交易不会受到 UI 类引入的更改的影响。
“哦,是的,我明白了!这样我们就满足了三个设计原则!” 你听起来很印象深刻。“我们引入了一个抽象来削减对用户界面实现细节的依赖。那一定是 DIP。而且我们遵循了 ISP,并且移除了不同交易之间的依赖。作为奖励,我们还很好地将真正属于一起的东西进行了分组。这就是 SRP,对吧?太棒了!让我们来庆祝一下!”
等等,等等……在你跑去开香槟庆祝解决这个依赖问题之前,让我们仔细看看问题。没错,你是对的,我们通过将 UI 类分离为三个特定于客户端的接口,来遵循 ISP 分离关注点。通过这种方式,我们解决了三个交易之间的依赖情况。这确实是 ISP。非常好!
不幸的是,我们还没有解决我们的架构问题,所以不,我们还没有遵循 DIP(尽管)。但我理解了误解:看起来我们确实反转了依赖关系。图 2-3 显示我们确实引入了依赖关系的反转:现在我们不再依赖具体的 UI 类,而是依赖抽象。
图 2-3. 通过引入三个抽象 UI 类局部反转依赖关系
然而,我们引入的是一个 局部 依赖倒置。是的,仅仅是局部的倒置,而不是全局的倒置。从架构的角度来看,我们仍然有一个从高层次(我们的事务类)到低层次(我们的 UI 功能)的依赖。因此,仅仅引入一个抽象是不够的。还重要的是考虑 在哪里 引入这个抽象。Robert Martin 用以下两点表达了这一点:²⁰
- 高层次模块不应依赖于低层次模块。两者都应依赖于抽象。
- 抽象不应依赖于细节。细节应依赖于抽象。
第一个观点清楚地表达了架构的一个关键属性:即高层次,即我们软件的稳定部分,不应依赖于低层次,即实现细节。该依赖关系应该被倒置,意味着低层次应该依赖于高层次。幸运的是,第二个观点给了我们一个实现的思路:我们将三个抽象分配给高层次。图 2-4 描述了当我们将抽象视为高层次的一部分时的依赖关系。
图 2-4. 通过将抽象分配给高层次实现依赖倒置
通过将抽象分配给高层次,并使高层次成为抽象的所有者,我们真正遵循了 DIP:所有箭头现在都从低层次指向高层次。现在我们确实有了一个合适的架构。
“等一下!”你看起来有些困惑。“就这样?我们所需要的只是进行一次架构边界的思维转变?”嗯,这很可能不仅仅是一次思维转变。这可能导致将 UI 类的依赖头文件从一个模块移动到另一个模块,并完全重新排列依赖的包含语句。这不仅仅是一次思维转变——这是所有权的重新分配。
“但现在我们不再把那些应该放在一起的东西分组了,”你反驳道。“用户界面功能现在分布在两个层次上。这不是违反了单一职责原则吗?”不,不是的。相反,在将抽象分配给高层次之后,我们现在才真正遵循了单一职责原则。应该被分组在一起的不是UI
类,而是事务类和依赖的UI
抽象。只有这样,我们才能正确引导依赖关系;只有这样,我们才有了一个架构。因此,为了正确的依赖倒置,抽象 必须 属于高层次。
依赖倒置在插件架构中
或许,如果我们考虑图 2-5 中描述的情景,这个事实会更加有意义。想象一下,你创建了下一代文本编辑器。这款新文本编辑器的核心在左侧由Editor
类表示。为了确保这款文本编辑器能够成功,你希望粉丝社区能够参与开发。因此,你成功的关键因素之一是社区能够以插件形式添加新功能。然而,从架构的角度来看,初始设置相当有缺陷,几乎无法满足你的粉丝社区:Editor
直接依赖于具体的VimModePlugin
类。由于Editor
类属于架构的高层,你应该将其视为自己的领域,而VimModePlugin
则属于架构的低层,这是你的粉丝社区的领域。由于Editor
直接依赖于VimModePlugin
,这基本上意味着你的社区可以按照他们的意愿定义接口,你必须为每个新的插件更改编辑器。尽管你很乐意为你的心血之作工作,但适应不同类型的插件的时间是有限的。不幸的是,你的粉丝社区很快就会感到失望,转而使用其他文本编辑器。
图 2-5. 破损的插件架构:高层Editor
类依赖低层VimModePlugin
类
当然,这种情况是不应该发生的。在给定的Editor
示例中,让Editor
类依赖所有具体插件的做法显然不明智。相反,你应该使用抽象概念,例如Plugin
基类的形式。现在,Plugin
类代表了所有类型插件的抽象。然而,在架构的低层引入这种抽象是没有意义的(参见图 2-6)。你的Editor
仍然依赖于粉丝社区的心血来潮。
图 2-6. 破损的插件架构:高层Editor
类依赖低层Plugin
类
当查看源代码时,这种误导性的依赖关系也变得显而易见:
//---- <thirdparty/Plugin.h> ----------------
class Plugin { /*...*/ }; // Defines the requirements for plugins
//---- <thirdparty/VimModePlugin.h> ----------------
#include <thirdparty/Plugin.h>
class VimModePlugin : public Plugin { /*...*/ };
//---- <yourcode/Editor.h> ----------------
#include <thirdparty/Plugin.h> // Wrong direction of dependencies!
class Editor { /*...*/ };
建立正确的插件架构的唯一方法是将抽象分配给高层。抽象必须属于你,而不是属于你的粉丝社区。图 2-7 展示了这样做如何解决架构依赖,并解放了Editor
类对插件的依赖。这同时解决了 DIP,因为依赖关系被正确地反转了,以及 SRP,因为抽象属于高层。
图 2-7. 正确的插件架构:低级别的 VimModePlugin
类依赖于高级别的 Plugin
类
查看源代码发现,依赖方向已经固定:VimModePlugin
依赖于你的代码,而不是相反的:
//---- <yourcode/Plugin.h> ----------------
class Plugin { /*...*/ }; // Defines the requirements for plugins
//---- <yourcode/Editor.h> ----------------
#include <yourcode/Plugin.h>
class Editor { /*...*/ };
//---- <thirdparty/VimModePlugin.h> ----------------
#include <yourcode/Plugin.h> // Correct direction of dependencies
class VimModePlugin : public Plugin { /*...*/ };
再次强调,要实现适当的依赖反转,抽象必须由高层拥有。在这种情况下,Plugin
类代表了所有插件需要满足的要求集合(再次参见 “Guideline 6: Adhere to the Expected Behavior of Abstractions”)。Editor
定义并拥有这些要求,而不是依赖它们。不同的插件依赖于这些要求。这就是依赖反转。因此,DIP 不仅仅是引入抽象的概念,也涉及对该抽象的所有权问题。
通过模板实现依赖反转
到目前为止,我可能给你的印象是 DIP 只涉及继承层次和基类。然而,依赖反转也可以通过模板实现。在这种情况下,所有权问题会自动解决。例如,让我们考虑 std::copy_if()
算法:
template< typename InputIt, typename OutputIt, typename UnaryPredicate >
OutputIt copy_if( InputIt first, InputIt last, OutputIt d_first,
UnaryPredicate pred );
copy_if()
算法也遵循了 DIP。依赖反转通过概念 InputIt
、OutputIt
和 UnaryPredicate
实现。这三个概念代表了传递的迭代器和谓词需要满足调用代码的要求。通过概念指定这些要求,即通过拥有这些概念,std::copy_if()
使其他代码依赖于它自身,而不是它自己依赖于其他代码。该依赖结构在 Figure 2-8 中描述:容器和谓词都依赖于对应算法表达的要求。因此,如果我们考虑标准库内部的架构,那么 std::copy_if()
就是架构的高层,而容器和谓词(函数对象、lambda 等)则是架构的低层。
图 2-8. STL 算法的依赖结构
通过重载集实现依赖反转
继承层次结构和概念并不是倒置依赖的唯一手段。任何形式的抽象都能实现这一点。因此,应该不会感到意外,过载集也能帮助你遵循 DIP。正如你在“指南 8:理解过载集的语义要求”中看到的那样,过载集代表了一种抽象,因此也代表了一组语义要求和期望。然而,与基类和概念相比,遗憾的是没有明确描述这些要求的代码。但是,如果这些要求由架构的更高层级拥有,你就可以实现依赖反转。例如,考虑以下Widget
类模板:
//---- <Widget.h> ----------------
#include <utility>
template< typename T >
struct Widget
{
T value;
};
template< typename T >
void swap( Widget<T>& lhs, Widget<T>& rhs )
{
using std::swap;
swap( lhs.value, rhs.value );
}
Widget
拥有一个未知类型T
的数据成员。尽管T
是未知的,但可以通过依赖于swap()
函数的语义期望来为Widget
实现一个自定义的swap()
函数。只要T
的swap()
函数符合所有swap()
函数的期望并遵循 LSP[²¹],这个实现就可以工作。
#include <Widget.h>
#include <assert>
#include <cstdlib>
#include <string>
int main()
{
Widget<std::string> w1{ "Hello" };
Widget<std::string> w2{ "World" };
swap( w1, w2 );
assert( w1.value == "World" );
assert( w2.value == "Hello" );
return EXIT_SUCCESS;
}
结果,Widget
的swap()
函数本身符合期望并添加到过载集中,类似于派生类的作用。swap()
过载集的依赖结构显示在图 2-9 中。由于过载集的要求或期望属于架构的高层级,并且由于swap()
的任何实现都依赖于这些期望,因此依赖关系从低层级向高层级正确地反转了。
图 2-9:swap()
过载集的依赖结构
依赖反转原则与单一责任原则的对比
根据我们的观察,通过正确分配所有权并正确分组真正属于的东西,便能实现依赖反转原则(DIP)。从这个角度来看,认为 DIP 只是 SRP 的另一种特例听起来似乎是合理的(类似于 ISP)。然而,希望你能看到 DIP 不仅仅是这样。与 SRP 不同,DIP 非常关注架构的视角,我认为它是建立正确的全局依赖结构的重要建议。
总结一下,为了构建具有正确依赖结构的适当架构,关注抽象的所有权至关重要。由于抽象代表了对实现的要求,它们应该成为高层级的一部分,以便将所有依赖关系引导到高层级。
- 指南 10:考虑创建架构文档
让我们稍微聊一下你们的架构。让我从一个非常简单的问题开始:你们有架构文档吗?任何总结架构的主要要点和基本决策,展示高层次、低层次及它们之间依赖关系的计划或描述?如果你的答案是肯定的,那么你可以跳过这个指南,继续下一个。然而,如果你的答案是否定的,那么让我问几个后续问题。你们有持续集成(CI)环境吗?你们使用自动化测试吗?你们使用静态代码分析工具吗?都是肯定的?很好,还有希望。唯一剩下的问题是:为什么你们没有架构文档呢?
“哦,拜托,别小题大做。缺少架构文档并不是世界末日!毕竟,我们是敏捷的,我们可以快速改变事物!” 想象一下我的完全空白的表情,然后是一个长长的叹息。好吧,老实说,我很担心这会是你的解释。不幸的是,这是我经常听到的。可能存在误解:快速改变事物并不是敏捷方法的要点。遗憾的是,我还得告诉你,你的回答毫无意义。你也可以回答“毕竟,我们喜欢巧克力!”或者“毕竟,我们在脖子上戴胡萝卜!”来解释我的意思,我将快速概述敏捷方法的要点,然后解释为什么你应该投资于架构文档。
对于敏捷方法能快速改变事物的期望非常普遍。然而,正如近期几位作者所澄清的那样,敏捷方法的主要,可能也是唯一的目的是快速获取反馈。²² 在敏捷方法中,整个软件开发过程都围绕这一点构建:由于业务实践(如规划、小发布和验收测试)带来的快速反馈,由于团队实践(例如集体所有权、CI 和站会),以及由于技术实践(如测试驱动开发、重构和配对编程)带来的快速反馈。然而,与普遍认为的相反,快速反馈并不意味着你可以快速轻松地改变你的软件。尽管快速反馈当然是迅速知道需要做些什么的关键,但只有良好的软件设计和架构才能使你快速改变软件,这两者可以帮你节省大量的精力去改变事物;快速反馈只是告诉你有什么东西是出了问题的。
“好吧,你说得对。我理解你的观点——关注良好的软件设计和架构是很重要的。但架构文档有什么用?”我很高兴我们达成了共识。这是个很好的问题。看来我们在取得进展。为了解释架构文档的目的,让我给你另一个关于架构的定义:²³
在大多数成功的软件项目中,参与项目的专业开发者对系统设计有共同的理解。这种共同的理解称为‘架构’。
Ralph Johnson
Ralph Johnson 将架构描述为对代码库的共同理解——整体视角。让我们假设没有架构文档,没有总结代码库的整体图景——你代码库的整体视角。同时假设你认为自己对代码库的架构有非常清晰的理解。那么这里有几个问题:你团队有多少开发者?你确定所有这些开发者都熟悉你心中的架构吗?你确定他们都分享同样的愿景吗?你确定他们都会帮助你朝同一个方向前进吗?
如果你的答案是肯定的,那么你可能还没有理解到重点。几乎可以肯定,每个开发者都有不同的经历和略有不同的术语。同样可以肯定,每个开发者对代码的看法也各不相同,并且对当前架构有略有不同的想法。而这种对当前事务状态略有不同的看法可能会导致对未来略有不同的展望。虽然这在短期内可能不明显,但长远来看,出现意外的可能性很大。误解。误解释。这正是架构文档的要点:一个统一的文档,将思想、愿景和重要决策集中在一起;帮助维护和传达架构的状态;并帮助避免任何误解。
这个文档还保留了想法、愿景和决策。想象一下,你们代码库架构背后的一位主要软件架构师离开了组织。如果没有包含基本决策的文档,这种人力流失也会导致对你代码库的关键信息的丢失。因此,你将失去架构愿景的一致性,更重要的是,失去调整或更改架构决策的信心。任何新员工都无法取代那些知识和经验,也没有人能从代码中提取所有这些信息。因此,代码将变得更加僵化,更加“遗留”。这促使决策重写大部分代码,结果可能成问题,因为新代码最初将缺乏旧代码的许多智慧。²⁴ 因此,没有架构文档,你的长期成功岌岌可危。
如果我们认真看待建筑工地上对架构的认真程度,这种架构文档的价值显而易见。没有计划,建筑甚至无法开始。一个所有人都同意的计划。或者让我们想象一下,如果没有计划会发生什么:“嘿,我说车库应该在房子的左边!” “但我把它建在房子的左边。” “是的,但我指的是我的左边,而不是你的左边!”
这正是通过投资于架构文档可以避免的问题类型。“是的,是的,你是对的”,你承认,“但这样的文档工作量真的很大。而且所有这些信息都在代码中。随着代码的变化,文档变得如此快速过时!”嗯,如果你做得正确的话,情况就不会这样。架构文档不应该快速过时,因为它应主要反映你代码库的大局。它不应包含确实可能经常变化的细节;相反,它应包含整体结构、关键参与者之间的连接以及主要技术决策。所有这些事情不应该变化(尽管我们都同意,“不应该变化”并不意味着它们不会变化;毕竟,软件是预计会变化的)。是的,你说得对:这些细节当然也是代码的一部分。毕竟,代码包含所有细节,因此可以说代表了终极真理。然而,如果信息不易获取,藏匿于视线之外,并需要考古式的努力来提取,这并没有帮助。
起初,我也意识到创建架构文档的努力似乎是一项很大的工作。是一项巨大的工作。我所能做的就是鼓励你设法开始。起初,你不必在文档中展示其全部荣耀,也许你可以从只有最基本的结构决策开始。一些工具已经可以使用这些信息来比较你假设的架构状态及其实际状态。²⁵ 随着时间的推移,可以添加、记录甚至由工具测试更多的架构信息,这将为整个团队提供越来越普遍、已确立的智慧。
“但是我如何保持这个文档的更新?”你问道。当然,你需要维护这个文档,整合新的决策,更新旧的决策等等。然而,由于这个文档应该只包含那些不经常变化的信息,因此没有必要经常触及和重构它。每一两周安排一次高级开发人员的简短会议来讨论架构是否发生了变化,应该足够了。因此,很难想象这个文档会成为开发过程中的瓶颈。在这方面,请将这个文档视为一个银行保险箱:当你需要时,它拥有所有积累的过去决策,保持信息安全,但你不会每天都打开它。
总结来说,拥有架构文档的好处远远超过了风险和努力。架构文档应被视为任何项目的基本组成部分,并且是维护和沟通工作的一部分。它应被视为与 CI 环境或自动化测试同等重要的组成部分。
¹ 在我几年前的培训班上,有人“轻柔地”提醒我,从数学的角度来看,正方形不是长方形而是菱形。每当我想到那堂课时,我的膝盖仍然在颤抖。因此,我特意说“似乎是”而不是“是”,来表示像我这样无知的人可能会有的幼稚印象。
² 虽然不是从数学角度,而是在这种实现中。
³ LSP 最初由 Barbara Liskov 在 1988 年的论文“数据抽象和层次结构”中首次提出。1994 年,Barbara Liskov 和 Jeannette Wing 在论文“子类型的行为概念”中对其进行了重新表述。由于她的工作,Barbara Liskov 在 2008 年获得了图灵奖。
⁴ 如果你对正方形是菱形有强烈的看法,请原谅我!
⁵ 然而,在足够大的代码库中,你很有可能会找到至少一个这种类型的错误示例。根据我的经验,这通常是由于时间不足以重新思考和调整抽象而导致的。
⁶ 这确实是一个经常讨论的话题。你可以在foonathan 的博客中找到对此的很好总结。
⁷ 在 C++20 中,std::copy()
终于是constexpr
的,但尚未使用std::input_iterator
和std::output_iterator
的概念。它仍然基于输入和输出迭代器的正式描述;参见LegacyInputIterator和LegacyOutputIterator。
⁸ 不,不幸的是,这不会是编译时错误。
⁹ 自由的begin()
和end()
函数是适配器设计模式的一个例子;详见“Guideline 24: Use Adapters to Standardize Interfaces”以获取更多详情。
¹⁰ 这就是为什么基于范围的for
循环建立在自由的begin()
和end()
函数之上。
¹¹ Alexander Stepanov 和 Meng Lee,《标准模板库》(https://oreil.ly/vgm61),1995 年 10 月。
¹² Scott Meyers,《Effective STL: 50 Specific Ways to Improve Your Use of the Standard Template Library》(Addison-Wesley Professional,2001 年)。
¹³ 自由函数确实是一种非常宝贵的设计工具。举一个例子来说明,让我来讲一个简短的战争故事。你可能知道马丁·福勒(Martin Fowler)的书籍《重构:改善已有代码的设计》(Addison-Wesley),这本书可以被视为专业软件开发的经典之一。该书的第一版于 2012 年出版,并提供了 Java 编程示例。第二版于 2018 年发布,但有趣的是改用了 JavaScript 进行重写。选择 JavaScript 的一个原因是,任何具有类似 C 语法的语言被认为更容易被多数读者接受。然而,另一个重要原因是 JavaScript 与 Java 不同之处在于,它提供了自由函数,马丁·福勒认为这是解耦和分离关注点的重要工具之一。如果没有这个特性,你在达到重构目标时的灵活性将受到限制。
¹⁴ 你可以在Cpp.Chat的第 83 集中找到对此的深入讨论(https://cpp.chat/83),Jon Kalb、Phil Nash 和 Dave Abrahams 在此讨论了从 C++中学到的经验,并如何应用于 Swift 编程语言的开发中。
¹⁵ 正如凯特·格雷戈里所说,“命名是困难的:让我们做得更好。”这是她在CppCon 2019上非常推荐的演讲的标题。
¹⁶ 罗伯特·C·马丁,《干净架构》(Addison-Wesley, 2017)。
¹⁷ 这个例子出自罗伯特·马丁的书籍《敏捷软件开发:原则、模式和实践》(Prentice Hall, 2002)。马丁用这个例子来解释接口隔离原则(ISP),因此他没有详细讨论抽象所有权的问题。我将尝试填补这个空白。
¹⁸ 如果你认为Transaction
基类可以在更高的层次上,那么你是正确的。你赢得了一个奖励点!但在接下来的例子中,我们不需要这个额外的层次,因此我将忽略它。
¹⁹ 如果你对两个informInsufficientFunds()
函数感到困惑:是的,可以通过在UI
类中的单个实现来实现两个虚函数(即从WithdrawalUI
和TransferUI
)。当然,只有在这两个函数代表相同期望并且可以作为一个函数实现时,才能正常工作。然而,如果它们代表不同的期望,那么你将面临连体双胞胎问题(见 Herb Sutter 的《更出色的 C++:40 个新工程谜题、编程问题和解决方案》(Addison-Wesley),第 26 项)。对于我们的例子,让我们假设我们可以以简单的方式处理这两个虚函数。
²⁰ 马丁,《干净架构》。
²¹ 我知道你在想什么。然而,你早晚会遇到一个“Hello World”的例子。
²² 比如,敏捷宣言的签署者之一,罗伯特·C·马丁,在他的书籍《干净的敏捷:回归基础》(Pearson)中已经做出了这一点。第二个很好的总结来自贝特兰·梅耶的书《敏捷!好的、炒作的和丑陋的》(Springer)。最后,你还可以参考詹姆斯·肖尔的第二版书籍《敏捷开发艺术》(O’Reilly)。关于对“敏捷”术语误用的一个很好的讨论是戴夫·托马斯在 GOTO 2015 年的“敏捷已死”演讲。
²³ 引自马丁·福勒,《谁需要架构师?》IEEE 软件 20 卷 5 期(2003 年),11-13 页,https://doi.org/10.1109/MS.2003.1231144。
²⁴ 你可能知道乔尔·斯波尔斯基(Joel Spolsky)是Joel on Software blog的作者,也是 Stack Overflow 的创始人之一,他称重写大段代码为“任何公司可以犯的最严重战略错误”。
²⁵ 用于此目的的一种可能工具是Axivion Suite。你可以开始定义模块之间的架构边界,该工具可用于检查是否保持了架构依赖关系。另一个具有此类功能的工具是Sparx Systems Enterprise Architect。
第三章:设计模式的目的
Visitor, Strategy, Decorator。这些都是我们将在接下来的章节中涉及的设计模式名称。然而,在详细讨论每种设计模式之前,我应该让你了解一下设计模式的一般目的。因此,在本章中,我们首先会看一下设计模式的基本属性,以及为什么你会想要了解它们并使用它们。
在“指南 1:理解软件设计的重要性”中,我已经使用了术语 设计模式 并解释了你在软件开发的哪个层次上使用它们。然而,我还没有详细解释设计模式 是 什么。这将是“指南 11:理解设计模式的目的”的主题:你将理解设计模式具有表达意图的名称,引入帮助解耦软件实体的抽象,并在多年来得到验证。
在“指南 12:小心设计模式的误解”,我将集中讨论关于设计模式的几个误解,并解释设计模式 不是 什么。我会努力说服你,设计模式不是关于实现细节,也不代表针对常见问题的特定于语言的解决方案。我还将尽力向你展示,它们不仅限于面向对象编程,也不限于动态多态性。
在“指南 13:设计模式无处不在”,我将展示很难避免设计模式。它们无处不在!你将意识到特别是 C++标准库充满了设计模式,并充分利用了它们的优势。
在“指南 14:使用设计模式的名称来传达意图”,我将强调使用设计模式名称来传达意图的重要性。因此,我将向你展示通过使用设计模式的名称,你可以为你的代码添加更多信息和意义。
指南 11:理解设计模式的目的
你很可能之前听说过设计模式,并且在你的编程生涯中也很可能使用了其中一些。设计模式并不新鲜:至少自从四人帮(GoF)在 1994 年发布了关于设计模式的书籍以来就存在了。¹ 尽管总有批评者,但它们的特殊价值已经被整个软件行业认可。然而,尽管设计模式长期存在且至关重要,尽管有着丰富的知识和积累的智慧,关于它们仍然存在许多误解,特别是在 C++社区中。
要有效地使用设计模式,首先你需要理解设计模式是什么。一个设计模式:
-
有一个名字
-
具有一个意图
-
引入了一个抽象
-
已被证明
设计模式有一个名字
首先,设计模式有一个名字。虽然这听起来非常明显和必要,但这确实是设计模式的一个基本属性。假设我们两个正在一起工作,并被要求找到一个解决问题的方案。想象一下我告诉你,“我会用Visitor做这个”。² 这不仅告诉了你我理解的真正问题,而且也让你明确了我提出的解决方案的具体想法。
设计模式的名字使我们能够在非常高的水平上进行交流,并用很少的话交换大量信息:
ME: 我会用 Visitor 做这个。
YOU: 我不知道。我考虑过使用一个 Strategy。
ME: 是的,你可能有一点道理。但由于我们经常需要扩展操作,我们可能应该考虑使用 Decorator。
仅仅使用Visitor、Strategy和Decorator的名字,我们已经讨论了代码库的演变,并描述了我们希望未来几年中事物如何变化和扩展的方式。³ 没有这些名字,我们将更难表达我们的想法:
ME: 我认为我们应该创建一个系统,使我们能够在不需要一再修改现有类型的情况下扩展操作。
YOU: 我不知道。与其说是新的操作,我更倾向于经常添加新的类型。所以我更喜欢一个能让我轻松添加类型的解决方案。但为了减少与预期的实现细节的耦合,我建议通过引入一个变化点来从现有类型中提取实现细节。
ME: 是的,你可能有一点道理。但由于我们经常需要扩展操作,我们可能应该考虑设计系统,使得我们可以轻松地构建和重用给定的实现。
你看到了区别吗?你感觉到了区别吗?没有名字,我们必须明确讨论更多细节。显然,这种精确的沟通只有在我们对设计模式有相同的理解时才可能实现。这就是为什么了解设计模式并谈论它们如此重要的原因。
设计模式具有一个意图
通过使用设计模式的名字,你可以简洁地表达你的意图并限制可能的误解。这引出了设计模式的第二个属性:一个意图。设计模式的名字传达了其意图。如果你使用设计模式的名字,你隐含地陈述了你认为的问题以及你认为的解决方案。
希望你意识到,在我们的小谈话中,我们并没有讨论任何具体的实现。我们没有谈论实现细节、任何特定的特性,也没有讨论任何特定的 C++ 标准。请不要认为我通过给出设计模式的名称就隐含告诉了你如何去实现解决方案。这不是设计模式的意图。相反地,这个名称应该告诉你我提出的结构,我计划如何管理依赖,以及我期望系统如何演化。这就是意图。
实际上,许多设计模式都有相似的结构。在 GoF 的书中,许多设计模式看起来非常相似,这当然会引起很多混淆和问题。例如,在结构上,策略模式、命令模式和桥接模式几乎没有什么区别。⁴ 但是它们的意图非常不同,因此您会用它们来解决不同的问题。正如您将在接下来的章节中的各种示例中看到的那样,几乎总是有许多不同的实现可以选择。
设计模式引入一个抽象
设计模式总是通过引入某种形式的抽象来减少依赖。这意味着设计模式始终关注于管理软件实体之间的交互并解耦软件的各个部分。例如,考虑策略设计模式,这是最初的 GoF 设计模式之一,在 图 3-1 中。不详细展开,策略设计模式引入了一个抽象,即 Strategy
基类。这个基类将策略使用者(在您架构的高层中的 Context
类)与具体策略的实现细节(在您架构的低层中的 ConcreteStrategyA
和 ConcreteStrategyB
)解耦。因此,策略模式具备了设计模式的特性。⁵
图 3-1. GoF 策略设计模式
一个类似的例子是工厂方法设计模式(又是 GoF 设计模式之一;见 图 3-2)。工厂方法的意图是解耦具体产品的创建过程。为此,它引入了两个抽象:Product
和 Creator
基类,这些基类在架构上处于高层。具体的实现细节则通过 ConcreteProduct
和 ConcreteCreator
类在架构的低层给出。有了这种架构结构,工厂方法也符合设计模式的定义:它有一个名称、意图是解耦,并且引入了抽象。
图 3-2. GoF《工厂方法》设计模式
注意,设计模式引入的抽象不一定通过基类引入。正如我将在以下部分和章节中向您展示的那样,这种抽象可以通过许多不同的方式引入,例如通过模板或简单的函数重载。再次强调,设计模式不暗示任何特定的实现方式。
作为反例,让我们考虑std::make_unique()
函数:
namespace std {
template< typename T, typename... Args >
unique_ptr<T> make_unique( Args&&... args );
} // namespace std
在 C++社区中,我们经常将std::make_unique()
函数称为工厂函数。重要的是要注意,尽管术语工厂函数给人的印象是std::make_unique()
是工厂方法设计模式的一个示例,但这种印象是错误的。设计模式通过引入一个允许定制和推迟实现细节的抽象来帮助您解耦。特别是,《工厂方法》设计模式的意图是引入一个定制点用于对象实例化。std::make_unique()
并未提供这样的定制点:如果您使用std::make_unique()
,您知道您将获得一个指向您请求的类型的std::unique_ptr
,并且实例将通过new
创建:
// This will create a 'Widget' by means of calling 'new'
auto ptr = std::make_unique<Widget>( /* some Widget arguments */ );
由于std::make_unique()
不提供任何自定义行为的方式,它无法帮助减少实体之间的耦合,因此无法达到设计模式的目的。⁶ 尽管如此,std::make_unique()
是一个特定问题的常见解决方案。换句话说,它是一种模式。但是,它不是设计模式而是实现模式。它是一种流行的解决方案,用于封装实现细节(在本例中是Widget
实例的生成),但它并未抽象出你得到什么或者如何创建它。因此,它属于实现细节级别而不是软件设计级别(参见图 1-1)。
引入抽象是将软件实体相互解耦并设计以支持变更和扩展的关键。在std::make_unique()
函数模板中没有抽象,因此无法扩展功能(甚至无法正确重载或特化)。相反,《工厂方法》设计模式确实提供了从创建什么以及如何创建(包括实例化前后的操作)的抽象。由于这种抽象,您可以在以后编写新的工厂,而无需更改现有代码。因此,该设计模式帮助您解耦和扩展软件,而std::make_unique()
只是一种实现模式。
已经证明存在设计模式
最后但并非最不重要的是,设计模式经过多年的验证。四人帮并未收集所有可能的解决方案,而是收集了在不同代码库中常用于解决相同问题的解决方案(尽管可能具有不同的实现)。因此,一个解决方案必须在多次展示其价值后才能成为一个模式。
总结一下:设计模式是经过验证的、命名的解决方案,表达了非常具体的意图。它引入了某种抽象,有助于解耦软件实体,从而有助于管理软件实体之间的交互。就像我们应该使用术语设计来表示管理依赖性和解耦的艺术(参见“指南 1:理解软件设计的重要性”),我们应该精确而有目的地使用术语设计模式。
指南 12:当心设计模式的误解
上一节重点解释了设计模式的目的:将名称、意图和某种形式的抽象结合起来,以解耦软件实体。然而,理解设计模式是什么同样重要,理解设计模式不是什么也同样重要。不幸的是,关于设计模式存在几种常见的误解:
-
有些人将设计模式视为达成良好软件质量的目标和保证。
-
有人认为设计模式基于特定实现,因此是特定于语言的习语。
-
有人说设计模式仅限于面向对象编程和动态多态性。
-
有些人认为设计模式已过时甚至已经过时。
这些误解并不令人惊讶,因为我们很少谈论设计,而是专注于功能和语言机制(参见“指南 1:理解软件设计的重要性”)。因此,在本指南中,我将揭穿前三个误解,并在下一部分处理第四个。
设计模式不是目标
一些开发者热爱设计模式。他们对设计模式如此迷恋,以至于试图通过设计模式解决所有问题,无论是否合理。当然,这种思维方式可能增加代码的复杂性,降低可理解性,这可能会适得其反。因此,对设计模式的过度使用可能导致其他开发者的沮丧,普遍设计模式的坏名声,甚至对模式的一般理念的拒绝。
明言不讳:设计模式并不是目标。它们是实现目标的手段。它们可能是解决方案的一部分。但它们并非目标。正如 Venkat Subramaniam 所说的:如果你早上起来就在想“今天我要使用什么设计模式?”,那么这是一个明显的迹象,表明你没有理解设计模式的目的。⁷ 使用尽可能多的设计模式并不会带来奖励或奖牌。设计模式的使用不应该增加复杂性,相反,应该减少复杂性。代码应该变得更简单、更易于理解,更容易修改和维护,因为设计模式应该有助于解决依赖关系并创建更好的结构。如果使用设计模式导致更高的复杂性并给其他开发人员带来问题,显然这不是正确的解决方案。
明确一点:我并不是告诉你不要使用设计模式。我只是告诉你不要过度使用它们,就像我告诉你不要过度使用任何其他工具一样。这总是依赖于问题本身。例如,锤子是一个很好的工具,只要你的问题是钉子。但是,一旦你的问题变成螺丝,锤子就变成了一个有点不太优雅的工具。⁸ 要正确使用设计模式,了解何时使用它们以及何时不使用它们非常重要,必须牢固掌握它们,理解它们的意图和结构特性,并明智地应用它们。
设计模式不是关于实现细节
关于设计模式最常见的误解之一是它们基于特定的实现。这包括认为设计模式或多或少是特定语言的习语。这种误解很容易理解,因为许多设计模式,特别是 GoF 模式,通常在面向对象的环境中呈现,并通过面向对象的示例来解释。在这样的背景下,很容易把实现细节误认为是特定模式,并假设它们是一样的。
幸运的是,很容易证明设计模式不涉及实现细节、任何特定的语言特性或任何 C++ 标准。让我们看看同一设计模式的不同实现。是的,我们将从经典的面向对象版本开始讨论这个设计模式。
考虑以下情景:我们想要绘制给定形状。⁹ 代码片段通过一个圆形来演示这一点,但当然它可以是任何其他形状,比如正方形或三角形。为了绘制,Circle
类提供了 draw()
成员函数。
class Circle
{
public:
void draw( /*...*/ ); // Implemented in terms of some graphics library
// ...
};
现在显而易见的是,你需要实现draw()
函数。毫不犹豫地,你可能会通过使用诸如 OpenGL、Metal、Vulcan 或其他任何图形库来实现这一点。然而,如果Circle
类本身提供draw()
功能的实现,这将是一个很大的设计缺陷:通过直接实现draw()
函数,你会引入对所选图形库的强耦合。这带来了一些弊端:
-
对于每个
Circle
的应用,你都需要图形库可用,即使你可能对图形不感兴趣,只需将其作为几何原语使用。 -
对图形库的每一次更改都可能影响
Circle
类,导致必须进行修改、重新测试、重新部署等。 -
将来切换到另一个库意味着一切除了平稳过渡。
所有这些问题都有一个共同的根源:在Circle
类内部直接实现draw()
函数违反了单一责任原则(SRP;参见“指南 2:面向变更设计”)。该类不再为单一原因而变化,并且严重依赖于该设计决策。
针对这个问题的经典面向对象解决方案是提取如何绘制圆的决策,并通过基类引入一个抽象概念。引入这样一个变化点正是策略设计模式的效果(参见图 3-3)¹⁰。
图 3-3. 策略设计模式应用于绘制圆形
策略设计模式的意图是定义一组算法并封装每个算法,从而使它们可以互换使用。策略模式使得算法的变化独立于使用它的客户端。通过引入DrawStrategy
基类,可以轻松地改变给定Circle
的draw()
实现方式。这也使得每个人,而不仅仅是你,可以在不修改现有代码的情况下实现新的绘图行为,并且可以从外部注入到Circle
中。这就是我们通常称之为依赖注入:
#include <Circle.h>
#include <OpenGLStrategy.h>
#include <cstdlib>
#include <utility>
int main()
{
// ...
// Creating the desired drawing strategy for a circle.
auto strategy =
std::make_unique_ptr<OpenGLStrategy>( /* OpenGL-specific arguments */ );
// Injecting the strategy into the circle; the circle does not have to know
// about the specific kind of strategy, but can with blissful ignorance use
// it via the 'DrawStrategy' abstraction.
Circle circle( 4.2, std::move(strategy) );
circle.draw( /*...*/ );
// ...
return EXIT_SUCCESS;
}
这种方法大大增加了对不同绘图行为的灵活性:它消除了对特定库和其他实现细节的所有依赖,从而使代码更易于变更和扩展。例如,现在可以轻松地为测试目的提供一个特殊的实现(即TestStrategy
)。这表明增强的灵活性对设计的可测试性产生了非常积极的影响。
策略设计模式是 GoF 设计模式之一。因此,它经常被称为面向对象的设计模式,并且通常被认为需要一个基类。然而,策略的意图不仅限于面向对象编程。就像可以使用基类来进行抽象一样,同样可以依赖于模板参数:
template< typename DrawStrategy >
class Circle
{
public:
void draw( /*...*/ );
};
在这种形式下,决定如何绘制圆圈是在编译时发生的:而不是编写一个基类DrawStrategy
并在运行时传递一个指向DrawStrategy
的指针,绘制的实现细节是通过DrawStrategy
模板参数提供的。请注意,虽然模板参数允许您从外部注入实现细节,但Circle
仍然不依赖于任何实现细节。因此,您仍然将Circle
类与使用的图形库解耦。但与运行时方法相比,每次DrawStrategy
更改时都必须重新编译。
虽然基于模板的解决方案确实从根本上改变了示例的属性(即没有基类和虚函数,没有运行时决策,没有单一的Circle
类,而是每个具体的DrawStrategy
一个Circle
类型),但它仍然完美地实现了策略设计模式的意图。因此,这表明设计模式并不局限于特定的实现或特定的抽象形式。
设计模式并不局限于面向对象编程或动态多态性
让我们考虑策略设计模式的另一个用例:标准库<numeric>
头文件中的accumulate()
函数模板:
std::vector<int> v{ 1, 2, 3, 4, 5 };
auto const sum =
std::accumulate( begin(v), end(v), int{0} );
默认情况下,std::accumulate()
函数对给定范围内的所有元素求和。第三个参数指定了求和的初始值。由于std::accumulate()
使用该参数的类型作为返回类型,因此该参数的类型显式地标注为int{0}
,而不仅仅是0
,以避免微妙的误解。然而,求和元素只是冰山一角:如果需要,您可以通过向std::accumulate()
提供第四个参数来指定如何累加元素。例如,您可以使用来自<functional>
头文件的std::plus
或std::multiplies
:
std::vector<int> v{ 1, 2, 3, 4, 5 };
auto const sum =
std::accumulate( begin(v), end(v), int{0}, std::plus<>{} );
auto const product =
std::accumulate( begin(v), end(v), int{1}, std::multiplies<>{} );
通过第四个参数,std::accumulate()
可以用于任何类型的归约操作,因此第四个参数代表了归约操作的实现。因此,它使我们能够通过从外部注入归约的具体工作方式来变化实现。因此,std::accumulate()
并不依赖于单一的、特定的实现,而是可以被任何人定制为特定目的。这正是策略设计模式的意图。¹¹
std::accumulate()
从策略设计模式的通用形式中获得其能力。如果不能改变这种行为,它将只在极少数用例中有用。由于策略设计模式,可能的使用方式是无限的。¹²
std::accumulate()
的例子表明,设计模式,甚至是经典的 GoF 模式,并不局限于一个特定的实现,而且不仅限于面向对象编程。显然,许多这些模式的意图对其他范式(如函数式或通用编程)也非常有用。¹³ 因此,设计模式并不仅限于动态多态性。相反,设计模式同样适用于静态多态性,并且因此可以与 C++模板结合使用。
为了进一步强调这一点,并展示策略设计模式的另一个示例,请考虑std::vector
和std::set
类模板的声明:
namespace std {
template< class T
, class Allocator = std::allocator<T> >
class vector;
template< class Key
, class Compare = std::less<Key>
, class Allocator = std::allocator<Key> >
class set;
} // namespace std
在标准库中的所有容器(除了std::array
)中,都可以指定自定义的分配器。对于std::vector
,它是第二个模板参数;对于std::set
,则是第三个参数。容器中的所有内存请求都通过给定的分配器处理。
通过为分配器暴露一个模板参数,标准库容器为你提供了从外部定制内存分配的机会。它们使你能够定义一系列算法(在这种情况下是内存获取算法),并将每个算法封装起来,从而使它们可以互换。因此,你能够独立地变化这个算法,而不影响使用它的客户端(在这种情况下是容器)。¹⁴
阅读了这个描述后,你应该能够识别出策略设计模式。在这个例子中,策略再次基于静态多态性,并通过模板参数实现。显然,策略不仅限于动态多态性。
显然,设计模式不限于面向对象编程或动态多态性,但我还是要明确地指出,有些设计模式的意图是为了缓解面向对象编程中的常见问题(例如访问者和原型设计模式)。¹⁵ 当然,还有一些专注于函数式编程或通用编程的设计模式(例如奇异递归模板模式 [CRTP] 和表达式模板)。¹⁶ 大多数设计模式不限于特定范式,并且它们的意图可以用于各种实现,但有些更具体。
在接下来的章节中,您将看到两类示例。您将看到具有非常一般意图并因此具有一般用途的设计模式示例。此外,您还将看到一些更具范式特定性的设计模式,由于这个原因,在其目标领域之外可能无法使用。尽管如此,它们都具有设计模式的主要特征:名称、意图和某种形式的抽象。
总结:设计模式不仅限于面向对象编程,也不仅限于动态多态性。更具体地说,设计模式不是关于特定实现的,也不是语言特定的习语。相反,它们完全专注于以特定方式解耦软件实体的意图。
指导原则 13:设计模式无处不在
前面的部分已经证明了设计模式不仅限于面向对象编程或动态多态性,它们也不是语言特定的习语,也不是关于特定实现的。尽管如此,由于这些常见误解以及因为我们不再将 C++视为纯粹的面向对象编程语言,一些人甚至声称设计模式已经过时或过时了。¹⁷
我想你现在可能有些怀疑了。“过时?这不是有些夸张吗?”你问道。嗯,不幸的是并不夸张。让我来讲个小故事,在 2021 年初,我有幸在一个德国的 C++用户组中进行了一场虚拟讲座,主题是设计模式。我的主要目标是解释什么是设计模式,以及它们今天仍然被广泛使用。在讲座期间,我感觉良好,充满了使命感,希望能帮助人们看到设计模式带来的所有好处,我确实尽了最大努力让每个人都看到知识带来的光明。然而,讲座在 YouTube 上发布几天后,有个用户评论道:“真的吗?2021 年还在讲设计模式?”
我非常希望你现在在怀疑地摇摇头。是的,我也不敢相信,特别是在我已经展示出 C++标准库中有数百个设计模式示例后。不,设计模式既不过时也不过时。与事实相去甚远。为了证明设计模式仍然非常活跃和相关,让我们看看 C++标准库中更新的分配器设施。看看以下使用std::pmr
(多态内存资源)命名空间中分配器的示例代码:
#include <array>
#include <cstddef>
#include <cstdlib>
#include <memory_resource>
#include <string>
#include <vector>
int main()
{
std::array<std::byte,1000> raw; // Note: not initialized! 
std::pmr::monotonic_buffer_resource
buffer{ raw.data(), raw.size(), std::pmr::null_memory_resource() }; 
std::pmr::vector<std::pmr::string> strings{ &buffer }; 
strings.emplace_back( "String longer than what SSO can handle" );
strings.emplace_back( "Another long string that goes beyond SSO" );
strings.emplace_back( "A third long string that cannot be handled by SSO" );
// ...
return EXIT_SUCCESS;
}
这个示例演示了如何使用std::pmr::monotonic_buffer_resource
作为分配器,将所有内存分配重定向到预定义的字节缓冲区。最初,我们创建了一个大小为 1,000 字节的缓冲区,其形式为std::array
()。通过将指向第一个元素的指针(通过
raw.data()
)和缓冲区的大小(通过raw.size()
)传递给std::pmr::monotonic_buffer_resource
,将此缓冲区提供为内存来源 ()。
monotonic_buffer_resource
的第三个参数表示备用分配器,用于在monotonic_buffer_resource
耗尽内存时使用。由于在这种情况下我们不需要额外的内存,因此我们使用std::pmr::null_memory_resource()
函数,它会给我们一个指向始终无法分配的标准分配器的指针。这意味着,无论你多么礼貌地请求,由std::pmr::null_memory_resource()
返回的分配器在请求内存时总是会抛出异常。
创建的缓冲区作为分配器传递给strings
向量,该向量现在将从初始字节缓冲区获取其所有内存 ()。此外,由于向量将其分配器传递给其元素,甚至通过
emplace_back()
函数添加的三个字符串,它们都太长而不能依赖Small String Optimization (SSO),将从字节缓冲区获取其所有内存。因此,整个示例中不使用动态内存;所有内存将来自字节数组。¹⁸
乍一看,这个示例似乎不需要任何设计模式来运行。然而,此示例中使用的分配器功能至少使用了四种不同的设计模式:模板方法设计模式、装饰者设计模式、适配器设计模式以及(再次)策略设计模式。
即使你考虑Singleton模式,这里甚至有五种设计模式:null_memory_resource()
函数 () 是基于Singleton模式实现的:¹⁹ 它返回一个静态存储期对象的指针,用于确保此分配器最多只有一个实例。
所有来自pmr
命名空间的 C++分配器,包括由null_memory_resource()
返回的分配器和monotonic_buffer_resource
,都派生自std::pmr::memory_resource
基类。如果查看memory_resource
类的定义,就能看到第一个设计模式的显现:
namespace std::pmr {
class memory_resource
{
public:
// ... a virtual destructor, some constructors and assignment operators
[[nodiscard]] void* allocate(size_t bytes, size_t alignment);
void deallocate(void* p, size_t bytes, size_t alignment);
bool is_equal(memory_resource const& other) const noexcept;
private:
virtual void* do_allocate(size_t bytes, size_t alignment) = 0;
virtual void do_deallocate(void* p, size_t bytes, size_t alignment) = 0;
virtual bool do_is_equal(memory_resource const& other) const noexcept = 0;
};
} // namespace std::pmr
你可能注意到类的public
部分有三个函数,在类的private
部分有对应的虚拟函数。公共的allocate()
、deallocate()
和is_equal()
函数代表了类的用户接口,而do_allocate()
、do_deallocate()
和do_is_equal()
函数则代表了派生类的接口。这种关注点分离是非虚拟接口(NVI)模式的一个示例,它本身是模板方法设计模式的一个示例。²⁰
我们隐式使用的第二种设计模式是装饰者设计模式。²¹ 装饰者模式帮助您构建分层的分配器,并将一个分配器的功能包装和扩展到另一个分配器。这个想法在这一行变得更加清晰:
std::pmr::monotonic_buffer_resource
buffer{ raw.data(), raw.size(), std::pmr::null_memory_resource() };
通过将null_memory_resource()
函数返回的分配器传递给monotonic_buffer_resource
,我们装饰了它的功能。每当我们通过allocate()
函数向monotonic_buffer_resource
请求内存时,它可能会将调用转发给其备用分配器。这样,我们可以实现许多不同类型的分配器,这些分配器可以轻松组装成具有不同层次分配策略的完整内存子系统。这种结合和重用功能片段的能力是装饰者设计模式的优势。
你可能已经注意到,在示例代码中我们使用了std::pmr::vector
和std::pmr::string
。我假设你记得std::string
只是std::basic_string<char>
的一个类型别名。了解到这一点,也许不会让你惊讶pmr
命名空间中的这两种类型也只是类型别名:
namespace std::pmr {
template< class CharT, class Traits = std::char_traits<CharT> >
using basic_string =
std::basic_string< CharT, Traits,
std::pmr::polymorphic_allocator<CharT> >;
template <class T>
using vector =
std::vector< T, std::pmr::polymorphic_allocator<T> >;
} // namespace std::pmr
这些类型别名仍然指向常规的std::vector
和std::basic_string
类,但不再公开分配器的模板参数。相反,它们使用std::pmr::polymorphic_allocator
作为分配器。这是适配器设计模式的一个示例。²² 适配器的目的是帮助您将两个不匹配的接口粘合在一起。在这种情况下,polymorphic_allocator
有助于在经典的静态接口(经典 C++分配器所需的接口)与新的动态分配器接口(std::pmr::memory_resource
所需的接口)之间进行传递。
我们示例中使用的第四种也是最后一种设计模式,再次是策略设计模式。通过向分配器公开模板参数,标准库容器如std::vector
和std::string
为您提供了从外部定制内存分配的机会。这是策略设计模式的静态形式,并且与定制算法具有相同的意图(也请参阅“指南 12:设计模式误解”)。
这个例子令人印象深刻地展示了,设计模式远非过时。仔细观察后,我们会发现它们无处不在:任何抽象和任何试图解耦软件实体并引入灵活性和可扩展性的尝试,很可能都基于某种设计模式。因此,了解不同的设计模式并理解它们的意图,无论何时何地都有助于识别并在合适时应用它们。
指南 14:使用设计模式的名称传达意图
在最后两节中,您学到了什么是设计模式,它们不是什么,以及设计模式无处不在。您还了解到每个设计模式都有一个名称,这个名称表达了清晰、简洁和明确的意图。因此,名称具有意义。²³通过使用设计模式的名称,您可以表达问题是什么,以及您选择了哪种解决方案来解决问题,并描述代码预期如何演变。
例如,标准库的accumulate()
函数:
template< class InputIt, class T, class BinaryOperation >
constexpr T accumulate( InputIt first, InputIt last, T init,
BinaryOperation op );
第三个模板参数被命名为BinaryOperation
。虽然这表明传递的可调用对象需要接受两个参数,但名称并未传达参数的意图。为了更清晰地表达意图,考虑将其命名为BinaryReductionStrategy
:
template< class InputIt, class T, class BinaryReductionStrategy >
constexpr T accumulate( InputIt first, InputIt last, T init,
BinaryReductionStrategy op );
Reduction这个术语和Strategy这个名称对每位 C++程序员都有意义。因此,您现在更清楚地捕捉并表达了您的意图:该参数允许依赖注入一个二元操作,从而允许您指定减少操作的方式。因此,该参数解决了定制化的问题。尽管如此,正如您将在第五章中看到的那样,策略设计模式表明了对操作有一定的期望。您只能指定减少操作的方式;您不能重新定义accumulate()
的功能。如果这是您想要表达的内容,您应该使用Command设计模式的名称:²⁴
template< class InputIt, class UnaryCommand >
constexpr UnaryCommand
for_each( InputIt first, InputIt last, UnaryCommand f );
std::for_each()
算法允许您对元素范围应用任何类型的一元操作。为了表达这个意图,第二个模板参数可以命名为UnaryCommand
,这明确表明对操作几乎没有期望。
标准库的另一个示例展示了设计模式的名称能为代码带来多少价值:
#include <cstdlib>
#include <iostream>
#include <string>
#include <variant>
struct Print
{
void operator()(int i) const {
std::cout << "int: " << i << '\n';
}
void operator()(double d) const {
std::cout << "double: " << d << '\n';
}
void operator()(std::string const& s) const {
std::cout << "string: " << s << '\n';
}
};
int main()
{
std::variant<int,double,std::string> v{}; 
v = "C++ Variant example"; 
std::visit(Print{}, v); 
return EXIT_SUCCESS;
}
在main()
函数中,我们为三种不同的选项int
、double
和std::string
创建了一个std::variant
()。接下来的一行,我们分配了一个 C 风格的字符串字面量,它将在变体内转换为
std::string
()。然后,我们通过
std::visit()
函数和Print
函数对象打印了变体的内容()。
注意std::visit()
函数的名称。该名称直接指涉访问者设计模式,因此清晰地表达了其意图:您可以对包含在变体实例中的封闭类型集合应用任何操作。²⁵ 此外,您可以非侵入式地扩展操作集。
你会发现,使用设计模式的名称比使用任意名称包含更多信息。但这并不意味着命名是容易的。²⁶ 名称应该主要帮助你理解特定上下文中的代码。如果设计模式的名称可以帮助到这一点,那么考虑包含设计模式名称以表达你的意图。
¹ "四人帮",或简称 GoF,通常指的是四位作者埃里希·伽马、理查德·赫尔姆、拉尔夫·约翰逊和约翰·弗利西德斯以及他们的设计模式书籍:《设计模式:可复用面向对象软件的基础》(Prentice Hall)。数十年后,GoF 书仍然是设计模式的 权威 参考。本书的其余部分,我会用到 GoF 书、GoF 模式或特征显著的面向对象的 GoF 风格。
² 如果你还不了解访问者设计模式,不要担心。我会在第四章中介绍这个模式。
³ 策略设计模式将在第五章中详细解释,装饰者设计模式将在第九章中详细解释。
⁴ 我只提到将在后续章节中解释的设计模式(参见第五章的策略和命令设计模式以及第七章“指导原则 28:建立桥梁以消除物理依赖性”的桥接设计模式)。还有几种设计模式共享相同的结构。
⁵ 如果你对策略设计模式不熟悉,放心,第五章会提供更多信息,包括几个代码示例。
⁶ 这可能是一个有争议的例子。因为我了解 C++社区,知道你可能有不同的看法。然而,我坚持我的观点:由于其定义,std::make_unique()
无法解耦软件实体,因此在软件设计层面上并不起作用。它仅仅是一个实现细节(但却是一个宝贵和有用的细节)。
⁷ Venkat Subramaniam 和 Andrew Hunt,《敏捷开发者的实践》(The Pragmatic Programmers, LLC, 2017)。
⁸ 好吧,它确实“运行”了,在某种“运行”的定义下。
⁹ 我知道你在想什么:“你不可能是认真的!书中有这么多有趣的例子,但你选择了最古老和最无聊的例子!”好吧,我承认这可能不是选择的最令人兴奋的例子。但是,我有两个好理由选择这个例子。首先,这个场景是如此广为人知,以至于我可以假设没有人会对此感到困惑。这意味着每个人都应该能够理解我关于软件设计的论点。其次,我们可以同意,在计算机科学中以形状或动物的例子开始是一种传统。当然,我不想让传统主义者失望。
¹⁰ 第五章将全面详细地介绍策略设计模式。
¹¹ 你可能(正确地)观察到,即使没有第四个参数,通过为给定类型提供自定义的加法操作符(即operator+()
),也可以改变累加的工作方式。然而,这只能用于有限的情况。虽然你可以为用户定义的类型提供自定义的加法操作符,但你不能为基本类型(例如示例中的int
)提供自定义的加法操作符。此外,定义除加法操作(或类似于字符串连接的操作)之外的operator+()
是非常值得怀疑的。因此,依赖加法操作符在技术和语义上是有限制的。
¹² 在他 2016 年的 CppCon 演讲中,“std::accumulate: 探索一个算法帝国”,Ben Deane 生动展示了由于第四个参数的存在,std::accumulate()
有多么强大。
¹³ 欲了解更多关于 STL 算法及其函数式编程遗产的信息,请参阅伊万·库基奇(Ivan Cukic)在《C++函数式编程》(Manning)中的精彩介绍。
¹⁴ 对于策略设计模式的这种形式,另一个常用的名称是基于策略的设计;请参阅“指南 19:使用策略隔离做事的方式”。
¹⁵ 我将在第四章解释Visitor设计模式,并在“Guideline 30: Apply Prototype for Abstract Copy Operations”中介绍Prototype设计模式。
¹⁶ 再次推荐 Ivan Cukic 的C++函数式编程。CRTP设计模式将是“Guideline 26: Use CRTP to Introduce Static Type Categories”的主题。关于Expression Templates,一个基于模板的模式,请参考C++ Templates: The Complete Guide(Addison-Wesley)中的 C++模板参考:David Vandevoorde,Nicolai Josuttis 和 Douglas Gregor。
¹⁷ 我认为自 1989 年语言中首次添加模板实现以来,C++就成为了一种多范式编程语言。模板对语言的影响在 1994 年将标准模板库(STL)的部分添加到标准库中后变得明显。从那时起,C++提供了面向对象、函数式和泛型能力。
¹⁸ Small String Optimization (SSO) 是小字符串的常见优化方式。字符串不再通过提供的分配器在堆上分配动态内存,而是直接将少量字符存储在字符串的栈部分。由于字符串通常在栈上占据 24 到 32 字节(这不是 C++标准要求而是常见实现的特性),超过 32 字节的字符串将需要堆分配。这是三个给定字符串的情况。
¹⁹ Singleton 是原始的 23 个 GoF 设计模式之一。但是在“Guideline 37: Treat Singleton as an Implementation Pattern, Not a Design Pattern”中,我将尽力说服你,Singleton 实际上并不是设计模式,而是一种实现细节。因此,我将Singleton称为实现模式而非设计模式。
²⁰ 不幸的是,本书不会涵盖Template Method设计模式。这并不是因为它不重要,而仅仅是因为页数不足。请参考 GoF 书籍获取更多细节。
²¹ 我将在第九章全面介绍装饰器设计模式。
²² 适配器设计模式将在“Guideline 24: Use Adapters to Standardize Interfaces”中讨论。
²³ 好的命名总是有意义的。这就是为什么它们如此基本重要的原因。
²⁴ 我将在 第五章 中解释 Command 设计模式以及策略设计模式。
²⁵ Visitor 设计模式,包括现代实现方式 std::variant
,将是我们在 第四章 中的重点。
²⁶ 命名很难,正如 Kate Gregory 在她高度推荐的演讲 “Naming Is Hard: Let’s Do Better” 中在 CppCon 2019 上所言。
第四章:访问者设计模式
整个章节都集中讨论访问者设计模式。如果您已经听说过访问者设计模式,甚至在自己的设计中使用过它,您可能会想知道为什么我选择访问者作为首个详细解释的设计模式。是的,访问者绝对不是最引人注目的设计模式之一。但是,它确实将作为一个很好的例子,展示您在实现设计模式时有多少选择,以及这些实现有多么不同。它还将作为一个有效的示例,宣传现代 C++的优势。
在“第 15 条指南:为类型或操作的添加进行设计”,我们首先讨论您在动态多态性领域中需要做出的基本设计决策:专注于类型还是操作。在该指南中,我们还将讨论编程范式的固有优势和劣势。
在“第 16 条指南:使用访问者扩展操作”,我将向您介绍访问者设计模式。我将解释其意图是扩展操作而不是类型,并向您展示经典访问者模式的优势和缺点。
在“第 17 条指南:考虑使用 std::variant 来实现访问者”,您将会认识到访问者设计模式的现代实现。我将向您介绍std::variant
,并解释该特定实现的诸多优势。
在“第 18 条指南:警惕无环访问器的性能”,我将向您介绍无环访问器。乍一看,这种方法似乎解决了访问者模式的一些根本问题,但仔细检查后,我们会发现运行时开销可能会使此实现失效。
指南 15:为类型或操作的添加进行设计
对您来说,术语动态多态性可能听起来像是很大的自由。这可能感觉像当你还是个孩子时一样:无限的可能性,没有限制!然而,您已经长大并面对现实:您不能拥有一切,总是需要做出选择。不幸的是,动态多态性也是如此。尽管听起来像是完全的自由,但实际上有一个限制性选择:您想扩展类型还是操作?
要了解我的意思,让我们回到第三章的场景:我们想要绘制一个给定的形状。¹我们坚持动态多态性,并且在我们的初次尝试中,我们使用了古老的过程化编程来解决这个问题。
过程化解决方案
第一个头文件Point.h
提供了一个非常简单的Point
类。这主要是为了使代码完整,但也让我们明白我们在处理二维形状:
//---- <Point.h> ----------------
struct Point
{
double x;
double y;
};
第二个概念性头文件Shape.h
证明更加有趣:
//---- <Shape.h> ----------------
enum ShapeType 
{
circle,
square
};
class Shape 
{
protected:
explicit Shape( ShapeType type )
: type_( type ) 
{}
public:
virtual ~Shape() = default; 
ShapeType getType() const { return type_; } 
private:
ShapeType type_; 
};
首先,我们引入了枚举ShapeType
,目前列出了两个枚举器,circle
和square
()。显然,我们最初只处理圆形和正方形。其次,我们引入了
Shape
类()。考虑到受保护的构造函数和虚析构函数(
),您可以预期
Shape
应该作为基类工作。但这并不是关于Shape
的令人惊讶的细节:Shape
有一个类型为ShapeType
的数据成员()。这个数据成员通过构造函数初始化(
),并且可以通过
getType()
成员函数查询()。显然,一个
Shape
以ShapeType
枚举的形式存储其类型。
Shape
基类的使用示例之一是Circle
类:
//---- <Circle.h> ----------------
#include <Point.h>
#include <Shape.h>
class Circle : public Shape 
{
public:
explicit Circle( double radius )
: Shape( circle ) 
, radius_( radius )
{
/* Checking that the given radius is valid */
}
double radius() const { return radius_; }
Point center() const { return center_; }
private:
double radius_;
Point center_{};
};
Circle
公开继承自Shape
(),因此,由于
Shape
中没有默认构造函数,需要初始化基类()。由于它是一个圆,它使用
circle
枚举器作为基类构造函数的参数。
如前所述,我们希望绘制形状。因此,我们引入了draw()
函数用于圆形。由于我们不想过于依赖任何绘图的具体实现细节,draw()
函数在概念性头文件DrawCircle.h
中声明,并在相应的源文件中定义:
//---- <DrawCircle.h> ----------------
class Circle;
void draw( Circle const& );
//---- <DrawCircle.cpp> ----------------
#include <DrawCircle.h>
#include <Circle.h>
#include /* some graphics library */
void draw( Circle const& c )
{
// ... Implementing the logic for drawing a circle
}
当然,并不只有圆形。如square
枚举器所示,还有一个Square
类:
//---- <Square.h> ----------------
#include <Point.h>
#include <Shape.h>
class Square : public Shape 
{
public:
explicit Square( double side )
: Shape( square ) 
, side_( side )
{
/* Checking that the given side length is valid */
}
double side () const { return side_; }
Point center() const { return center_; }
private:
double side_;
Point center_{}; // Or any corner, if you prefer };
//---- <DrawSquare.h> ----------------
class Square;
void draw( Square const& );
//---- <DrawSquare.cpp> ----------------
#include <DrawSquare.h>
#include <Square.h>
#include /* some graphics library */
void draw( Square const& s )
{
// ... Implementing the logic for drawing a square }
Square
类看起来非常类似于Circle
类()。主要区别在于
Square
使用square
枚举器初始化其基类()。
现在有了圆形和正方形,我们现在想要绘制一个完整的不同形状的向量。因此,我们引入了drawAllShapes()
函数:
//---- <DrawAllShapes.h> ----------------
#include <memory>
#include <vector>
class Shape;
void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes ); 
//---- <DrawAllShapes.cpp> ----------------
#include <DrawAllShapes.h>
#include <Circle.h>
#include <Square.h>
void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
for( auto const& shape : shapes )
{
switch( shape->getType() ) 
{
case circle:
draw( static_cast<Circle const&>( *shape ) );
break;
case square:
draw( static_cast<Square const&>( *shape ) );
break;
}
}
}
drawAllShapes()
接受一个形状向量,形式为std::unique_ptr<Shape>
()。必须使用基类指针来保存不同类型的具体形状,特别是使用
std::unique_ptr
自动管理形状,通过RAII 习惯用法。在函数内部,我们首先遍历向量以绘制每个形状。不幸的是,此时我们只有Shape
指针。因此,我们必须通过getType()
函数()礼貌地询问每个形状:你是什么类型的?如果形状回答
circle
,我们知道必须将其作为Circle
绘制并执行相应的static_cast
。如果形状回答square
,我们将其作为Square
绘制。
我感觉到你对这个解决方案并不是特别满意。但在讨论缺点之前,让我们先考虑main()
函数:
//---- <Main.cpp> ----------------
#include <Circle.h>
#include <Square.h>
#include <DrawAllShapes.h>
#include <memory>
#include <vector>
int main()
{
using Shapes = std::vector<std::unique_ptr<Shape>>;
// Creating some shapes
Shapes shapes;
shapes.emplace_back( std::make_unique<Circle>( 2.3 ) );
shapes.emplace_back( std::make_unique<Square>( 1.2 ) );
shapes.emplace_back( std::make_unique<Circle>( 4.1 ) );
// Drawing all shapes
drawAllShapes( shapes );
return EXIT_SUCCESS;
}
它有效!使用这个main()
函数,代码编译并绘制了三个形状(两个圆和一个正方形)。是不是很棒?但是,这并不能阻止你发泄:“多么原始的解决方案!switch
语句不仅是区分不同形状的糟糕选择,而且还没有默认情况!谁有这么疯狂的想法,通过无作用域枚举来编码形状的类型?”² 你怀疑地看着我……
嗯,我能理解你的反应。但让我们更详细地分析一下问题。让我猜猜:你记得“指南 5:设计以便扩展”。现在想象一下,要添加第三种形状你将要做什么。首先,你必须扩展枚举。例如,我们将不得不添加新的枚举值triangle
():
enum ShapeType
{
circle,
square,
triangle 
};
请注意,这种添加不仅会影响drawAllShapes()
函数中的switch
语句(现在它确实不完整了),还会影响所有派生自Shape
的类(Circle
和Square
)。这些类依赖于枚举,因为它们依赖于Shape
基类,并直接使用枚举。因此,更改枚举将导致所有源文件重新编译。
这应该让你感到严重。而且确实如此。问题的核心是所有形状类和函数对枚举的直接依赖性。对枚举的任何更改都会产生连锁反应,需要重新编译依赖文件。显然,这直接违反了开闭原则(OCP)(参见“指南 5:设计以便扩展”)。这似乎不对:添加一个Triangle
不应该导致Circle
和Square
类重新编译。
不过,除此之外还有更多。除了实际编写一个Triangle
类(这部分留给你的想象力),你还得更新switch
语句来处理三角形():
void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
for( auto const& shape : shapes )
{
switch( shape->getType() )
{
case circle:
draw( static_cast<Circle const&>( *shape ) );
break;
case square:
draw( static_cast<Square const&>( *shape ) );
break;
case triangle: 
draw( static_cast<Triangle const&>( *shape ) );
break;
}
}
}
我能想象你会惊呼:“复制粘贴!重复!”是的,在这种情况下,开发者很可能会使用复制粘贴来实现新逻辑。因为新情况与之前的两种情况如此相似,所以这样做非常方便。实际上,这表明设计可以改进。然而,我看到一个更为严重的缺陷:我认为在更大的代码库中,这并不是唯一的switch
语句。相反,还会有其他需要更新的。有多少个?十几个?五十个?一百多个?那么,你怎么找到所有这些?好吧,你可能会争辩说编译器会帮助你完成这项任务。也许对于switch
语句来说是这样,但如果还有 if-else-if 级联呢?然后,在这次更新马拉松之后,当你认为自己已经完成时,你如何保证你确实更新了所有必要的部分?
是的,我能理解你的反应,以及为什么你不喜欢这种代码:这种明确处理类型的方式是维护的噩梦。引用 Scott Meyers 的话:³
这种基于类型的编程在 C 语言中有着悠久的历史,其中我们知道的一件事是,它产生的程序基本上是难以维护的。
面向对象的解决方案
所以让我问问:你会怎么做?你会如何实现形状的绘制?好吧,我可以想象你会采用面向对象的方法。这意味着你会放弃枚举,并向Shape
基类添加一个纯虚拟的draw()
函数。这样一来,Shape
就不必再记住它的类型了:
//---- <Shape.h> ----------------
class Shape
{
public:
Shape() = default;
virtual ~Shape() = default;
virtual void draw() const = 0;
};
给定这个基类,派生类现在只需实现draw()
成员函数():
//---- <Circle.h> ----------------
#include <Point.h>
#include <Shape.h>
class Circle : public Shape
{
public:
explicit Circle( double radius )
: radius_( radius )
{
/* Checking that the given radius is valid */
}
double radius() const { return radius_; }
Point center() const { return center_; }
void draw() const override; 
private:
double radius_;
Point center_{};
};
//---- <Circle.cpp> ----------------
#include <Circle.h>
#include /* some graphics library */
void Circle::draw() const
{
// ... Implementing the logic for drawing a circle }
//---- <Square.h> ----------------
#include <Point.h>
#include <Shape.h>
class Square : public Shape
{
public:
explicit Square( double side )
: side_( side )
{
/* Checking that the given side length is valid */
}
double side () const { return side_; }
Point center() const { return center_; }
void draw() const override; 
private:
double side_;
Point center_{};
};
//---- <Square.cpp> ----------------
#include <Square.h>
#include /* some graphics library */
void Square::draw() const
{
// ... Implementing the logic for drawing a square }
一旦虚拟的draw()
函数被所有派生类放置并实现,它可以被用来重构drawAllShapes()
函数:
//---- <DrawAllShapes.h> ----------------
#include <memory>
#include <vector>
class Shape;
void drawAllShapes( std::vector< std::unique_ptr<Shape> > const& shapes );
//---- <DrawAllShapes.cpp> ----------------
#include <DrawAllShapes.h>
#include <Shape.h>
void drawAllShapes( std::vector< std::unique_ptr<Shape> > const& shapes )
{
for( auto const& shape : shapes )
{
shape->draw();
}
}
我可以看到你放松下来,重新露出笑容。这样要好得多,干净得多。虽然我理解你更喜欢这种解决方案,而且你想在这个舒适区停留更长时间,但我不得不指出一个缺陷。是的,这种解决方案可能也会带来一个缺点。
正如本节开头所示,通过面向对象的方法,我们现在能够非常轻松地添加新类型。我们只需编写一个新的派生类即可。我们不需要修改或重新编译任何现有代码(除了main()
函数)。这完全符合 OCP。但是,你注意到我们现在无法轻松地再添加操作了吗?例如,假设我们需要一个虚拟的serialize()
函数将Shape
转换为字节。我们如何在不修改现有代码的情况下添加这个功能?任何人如何能够轻松地添加这个操作而不必触及Shape
基类呢?
不幸的是,这不再可能了。我们现在处理的是封闭集的操作,这意味着我们在添加操作方面违反了 OCP。要添加一个虚函数,需要修改基类,并且所有派生类(如圆形、正方形等)都需要实现新函数,即使这个函数可能永远不会被调用。总结一下,面向对象解决方案在添加类型方面满足了 OCP,但在操作方面却违反了它。
我知道你以为我们已经彻底放弃了过程化解决方案,但让我们再来看一眼。在过程化方法中,添加新操作实际上非常简单。新操作可以以自由函数或单独的类的形式添加,例如。不需要修改Shape
基类或任何派生类。因此,在过程化解决方案中,我们在添加操作方面实现了 OCP。但正如我们所见,过程化解决方案在添加类型方面违反了 OCP。因此,它似乎是面向对象解决方案的倒置。
要注意动态多态性中的设计选择
这个例子的要点是,在使用动态多态性时存在一个设计选择:你可以通过固定操作数量来轻松添加类型,或者通过固定类型数量来轻松添加操作。因此,OCP 有两个方面:在设计软件时,你必须对你期望的扩展类型做出明智的决策。
面向对象编程的优势是容易添加新类型,但其弱点是添加操作变得更加困难。过程化编程的优势是轻松添加操作,但添加类型则是真正的痛点(表 4-1)。这取决于您的项目:如果您预计会频繁添加新类型而不是操作,则应该努力寻求 OCP 解决方案,将操作视为封闭集,类型视为开放集。如果您预计会添加操作,则应该努力寻求过程化解决方案,将类型视为封闭集,操作视为开放集。如果您做出正确选择,将节省您和同事的时间,扩展将感觉自然而容易。⁴
表 4-1. 不同编程范式的优势和弱点
编程范式 | 优势 | 弱点 |
---|---|---|
过程化编程 | 添加操作 | 添加(多态)类型 |
--- | --- | --- |
面向对象编程 | 添加(多态)类型 | 添加操作 |
要注意这些优势:根据您对代码库演变的期望,选择适合扩展设计的正确方法。不要忽略弱点,并且不要让自己陷入不幸的维护地狱。
此时你可能在想,是否可能有两个开放集。据我所知,这并非不可能,但通常是不切实际的。例如,在“指南 18:警惕无环访问者的性能”中,我将向你展示性能可能会受到显著影响。
由于您可能是基于模板的编程和类似的编译时努力的粉丝,我应该明确指出,静态多态性并不具有相同的限制。在动态多态性中,设计轴(类型和操作)中的一个需要固定,而在静态多态性中,这两个信息在编译时都是可用的。因此,如果你做得正确,这两个方面都可以很容易地扩展。⁵
指南 16:使用访问者扩展操作
在前一节中,您看到面向对象编程(OOP)的优势是添加类型,其弱点是添加操作。当然,OOP 对这一弱点有解决方案:访问者设计模式。
访问者设计模式是由四人组(GoF)描述的经典设计模式之一。它的重点是允许您频繁添加操作而不是类型。请允许我使用之前的玩具例子来解释访问者设计模式:形状的绘制。
在 Figure 4-1 中,您可以看到 Shape
层次结构。Shape
类再次是某些具体形状的基类。在本例中,只有两个类,Circle
和 Square
,但当然也可能有更多形状。此外,您可能会想象到 Triangle
、Rectangle
或 Ellipse
类。
图 4-1. 形状层次结构的 UML 表示,具有两个派生类 (Circle
和 Square
)
分析设计问题
假设您确定您已经拥有所有您将来可能需要的形状。也就是说,您认为形状集合是一个封闭集。但您确实缺少额外的操作。例如,您缺少一个旋转形状的操作。此外,您希望序列化形状,即您希望将形状实例转换为字节。当然,您还希望绘制形状。此外,您希望任何人都能添加新操作。因此,您期望有一个开放集的操作。⁶
现在每个新操作都要求您向基类中插入一个新的虚函数。不幸的是,这可能以不同的方式带来麻烦。最明显的是,并非每个人都能够向 Shape
基类添加虚函数。例如,我就不能随意更改您的代码。因此,这种方法不符合每个人都能够添加操作的期望。尽管您现在可能认为这是最终的消极结论,让我们仍然更详细地分析虚函数的问题。
如果您决定使用纯虚函数,那么您将不得不在每个派生类中实现该函数。对于您自己的派生类型,您可能认为这只是额外的一点点努力。但您也可能给其他通过从 Shape
基类继承创建形状的人带来额外的工作量。⁷ 这是完全可以预料的,因为这正是面向对象编程的优势:任何人都可以轻松添加新类型。鉴于这一点,这可能是不使用纯虚函数的一个理由。
作为替代方案,您可以引入一个普通虚函数,即带有默认实现的虚函数。虽然为 rotate()
函数提供默认行为听起来是一个非常合理的主意,但为 serialize()
函数提供默认实现听起来并不容易。我承认我必须认真思考如何实现这样一个函数。您现在可能建议简单地抛出异常作为默认行为。但是,这意味着派生类必须再次实现缺失的行为,这实际上是一个伪装的纯虚函数,或者是对里氏替换原则的明显违反(参见 “Guideline 6: Adhere to the Expected Behavior of Abstractions”)。
无论如何,向Shape
基类添加新操作都很困难,甚至根本不可能。其根本原因在于添加虚函数违反了 OCP。如果确实需要经常添加新操作,那么应该设计以便轻松扩展操作。这就是访问者设计模式试图实现的目标。
解析访问者设计模式
访问者设计模式的目的是使添加操作变得容易。
访问者设计模式
意图:“表示对对象结构的元素执行的操作。访问者模式使您能够定义新的操作,而无需更改它所操作的元素的类。”⁸
除了Shape
层次结构外,我现在在图 4-2 的左侧引入了ShapeVisitor
层次结构。ShapeVisitor
基类代表了形状操作的抽象。因此,你可以说ShapeOperation
可能是该类的更好名称。然而,应用“指南 14:使用设计模式的名称来传达意图”是有益的。Visitor 这个名称将帮助他人理解设计。
图 4-2. 访问者设计模式的 UML 表示
ShapeVisitor
基类为Shape
层次结构中的每个具体形状提供了一个纯虚拟的visit()
函数:
class ShapeVisitor
{
public:
virtual ~ShapeVisitor() = default;
virtual void visit( Circle const&, /*...*/ ) const = 0; 
virtual void visit( Square const&, /*...*/ ) const = 0; 
// Possibly more visit() functions, one for each concrete shape };
在这个例子中,为Circle
()和
Square
()各有一个
visit()
函数。当然,还可以有更多的visit()
函数,例如一个用于Triangle
,一个用于Rectangle
,一个用于Ellipse
,因为这些也是从Shape
基类派生的类。
使用了ShapeVisitor
基类后,现在可以轻松添加新的操作。要添加一个操作,只需添加一个新的派生类即可。例如,为了实现旋转形状,可以引入Rotate
类并实现所有的visit()
函数。要启用绘制形状,只需引入一个Draw
类:
class Draw : public ShapeVisitor
{
public:
void visit( Circle const& c, /*...*/ ) const override;
void visit( Square const& s, /*...*/ ) const override;
// Possibly more visit() functions, one for each concrete shape
};
你可以考虑引入多个Draw
类,每个类对应一个需要支持的图形库。你可以轻松地做到这一点,因为你不必修改任何现有代码。只需通过添加新代码来扩展ShapeVisitor
层次结构。因此,这种设计在添加操作方面符合 OCP 原则。
要完全理解访问者的软件设计特性,重要的是理解访问者设计模式能够如何满足开放封闭原则。最初的问题在于每个新操作都需要更改Shape
基类。访问者将添加操作识别为变化点。通过提取这个变化点,即通过将其作为单独的类,您遵循单一职责原则(SRP):Shape
不需要为每个新操作而更改。这避免了频繁修改Shape
层次结构,并使得轻松添加新操作成为可能。因此,SRP 充当了 OCP 的促进者。
要在形状上使用访问者(从ShapeVisitor
基类派生的类),您现在必须向Shape
层次结构添加最后一个函数:accept()
函数():⁹
class Shape
{
public:
virtual ~Shape() = default;
virtual void accept( ShapeVisitor const& v ) = 0; 
// ... };
accept()
函数在基类中被引入为纯虚函数,因此必须在每个派生类中实现( 和
):
class Circle : public Shape
{
public:
explicit Circle( double radius )
: radius_( radius )
{
/* Checking that the given radius is valid */
}
void accept( ShapeVisitor const& v ) override { v.visit( *this ); } 
double radius() const { return radius_; }
private:
double radius_;
};
class Square : public Shape
{
public:
explicit Square( double side )
: side_( side )
{
/* Checking that the given side length is valid */
}
void accept( ShapeVisitor const& v ) override { v.visit( *this ); } 
double side() const { return side_; }
private:
double side_;
};
accept()
的实现很简单;然而,它仅需要调用给定访问者上的相应visit()
函数,基于具体Shape
类型传递this
指针作为参数。因此,每个派生类中accept()
的实现是相同的,但由于this
指针的不同类型,它将触发给定访问者的不同重载visit()
函数。因此,Shape
基类无法提供默认实现。
现在可以在需要执行操作的地方使用accept()
函数。例如,drawAllShapes()
函数使用accept()
来绘制给定形状向量中的所有形状:
void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
for( auto const& shape : shapes )
{
shape->accept( Draw{} );
}
}
添加了accept()
函数后,您现在可以轻松扩展操作的Shape
层次结构。您现在设计了一个开放集的操作。太棒了!然而,没有银弹,也没有一种设计能够始终奏效。每种设计都有其优势,但也有其劣势。因此,在您开始庆祝之前,我应该告诉您访问者设计模式的缺点,以便您全面了解。
分析访问者设计模式的缺点
访问者设计模式遗憾地远非完美。考虑到访问者是一种对面向对象编程弱点的变通方法,而不是建立在面向对象编程优势基础上的。
第一个缺点是低灵活性的实现。如果考虑实现Translate
访问者,这一点就变得显而易见。Translate
访问者需要将每个形状的中心点按给定偏移量移动。为此,Translate
需要为每个具体的Shape
实现一个visit()
函数。特别是对于Translate
来说,可以想象这些visit()
函数的实现会非常相似,如果不是完全相同的话:从翻译Circle
到翻译Square
并没有什么不同。尽管如此,你仍然需要编写所有的visit()
函数。当然,你可以从visit()
函数中提取逻辑,并根据 DRY 原则实现到第三个单独的函数中以最小化重复。¹⁰ 但不幸的是,基类施加的严格要求不允许你将这些visit()
函数实现为一个函数。结果就是一些样板代码:
class Translate : public ShapeVisitor
{
public:
// Where is the difference between translating a circle and translating
// a square? Still you have to implement all virtual functions...
void visit( Circle const& c, /*...*/ ) const override;
void visit( Square const& s, /*...*/ ) const override;
// Possibly more visit() functions, one for each concrete shape
};
类似的实现不灵活之处在于visit()
函数的返回类型。决定函数返回内容的是ShapeVisitor
基类。派生类无法改变这一点。通常的做法是将结果存储在访问者中,并稍后访问它。
第二个缺点是使用访问者设计模式后,添加新类型变得困难。先前我们假设你确定已经拥有了所有可能需要的形状。这一假设现在已成为一种限制。在Shape
层次结构中添加新形状将需要更新整个ShapeVisitor
层次结构:你必须向ShapeVisitor
基类添加一个新的纯虚函数,并且所有派生类都必须实现这个虚函数。当然,这带来了我们之前讨论过的所有缺点。特别是,你将强制其他开发人员更新他们的操作。¹¹ 因此,访问者设计模式要求类型集合为封闭,而提供操作集合为开放。
该限制的根本原因是ShapeVisitor
基类、具体形状(如Circle
、Square
等)和Shape
基类之间存在循环依赖(参见图 4-3)。
图 4-3. 访问者设计模式的依赖图
ShapeVisitor
基类依赖于具体的形状,因为它为每个形状提供了一个visit()
函数。具体形状依赖于Shape
基类,因为它们必须满足基类的所有期望和要求。而Shape
基类由于accept()
函数的存在,依赖于ShapeVisitor
基类。因此,由于这种循环依赖,我们现在能够在我们架构的较低层轻松添加新的操作(由于依赖反转),但我们不再能够轻松地添加类型(因为那必须发生在我们架构的高层)。因此,我们将经典的访问者设计模式称为循环访问者。
第三个缺点是访问者的侵入性。要将访问者添加到现有的层次结构中,您需要将虚拟的accept()
函数添加到该层次结构的基类中。虽然这通常是可能的,但仍然会遭受向现有层次结构添加纯虚拟函数的通常问题(参见“指南 15:为类型或操作的添加进行设计”)。然而,如果无法添加accept()
函数,这种形式的访问者就不是一个选项。如果是这种情况,不要担心:我们将在“指南 17:考虑使用 std::variant 实现访问者”中看到另一种非侵入性的访问者设计模式。
第四个,尽管较为隐晦的缺点是accept()
函数被派生类继承。如果稍后有人(也可能是您)添加另一层派生类并忘记重写accept()
函数,访问者将应用于错误的类型。不幸的是,您将得不到任何有关此问题的警告。这只是更多证据表明,添加新类型变得更加困难。对此的一个可能解决方案是将Circle
和Square
类声明为final
,然而这将限制未来的扩展。
“哇,这有很多缺点。还有其他的吗?”是的,不幸的是还有两个。第五个缺点很明显,因为我们现在对每个操作都需要调用两个虚函数。最初,我们既不知道操作的类型,也不知道形状的类型。第一个虚函数是accept()
函数,它接受一个抽象的ShapeVisitor
。accept()
函数现在解析形状的具体类型。第二个虚函数是visit()
函数,它接受一个具体类型的Shape
。visit()
函数现在解析操作的具体类型。这种所谓的双重分派是不可避免的。相反,在性能方面,你应该将访问者设计模式视为相当慢的一种模式。我将在下一个指南中提供一些性能数据。
谈到性能时,我还应该提到另外两个对性能有负面影响的方面。首先,我们通常会单独为每个形状和访问者分配内存。考虑下面的main()
函数:
int main()
{
using Shapes = std::vector< std::unique_ptr<Shape> >;
Shapes shapes;
shapes.emplace_back( std::make_unique<Circle>( 2.3 ) ); 
shapes.emplace_back( std::make_unique<Square>( 1.2 ) ); 
shapes.emplace_back( std::make_unique<Circle>( 4.1 ) ); 
drawAllShapes( shapes );
// ...
return EXIT_SUCCESS;
}
在这个main()
函数中,所有的分配都是通过std::make_unique()
来完成的(,
和
)。这些大量的小内存分配会消耗运行时,并且最终会导致内存碎片化。¹²此外,内存可能以一种不利于缓存的方式布局。因此,我们通常使用指针来处理生成的形状和访问者。这些额外的间接引用会大大增加编译器进行任何优化的难度,并且会在性能基准测试中显现出来。但是,老实说,这并不是访问者模式特有的问题,但这两个方面在面向对象编程中却很常见。
访问者设计模式的最后一个缺点是,实践经验表明这种设计模式相对难以完全理解和维护。这是一个相当主观的缺点,但这两个层次结构的错综复杂互动往往更像是一种负担,而不是真正的解决方案。
总结一下,访问者设计模式是面向对象编程中允许轻松扩展操作而不是类型的解决方案。这通过引入ShapeVisitor
基类的抽象实现,使您能够在另一组类型上添加操作来实现。虽然这是访问者的独特优势,但不幸的是,它也伴随着几个不足之处:由于与基类要求的强耦合,实现上的不灵活性在两个继承层次结构中,性能较差以及访问者的内在复杂性使其成为一个相对不受欢迎的设计模式。
如果你现在还在犹豫是否要使用经典的访问者模式,请花点时间阅读下一节。我将向你展示一种实现访问者模式的不同方式——一种更有可能让你满意的解决方案。
准则 17:考虑使用 std::variant 实现访问者
在“准则 16:使用访问者扩展操作”中,我向您介绍了访问者设计模式。我想你并不立刻喜欢它:虽然访问者确实有一些独特的特性,但它也是一种相当复杂的设计模式,内部耦合度高且性能有缺陷。不,绝对不是一见钟情!但是,不要担心,经典形式并不是您实现访问者设计模式的唯一方式。在这一节中,我想向您介绍一种不同的实现访问者的方法。我确信这种方法会更符合您的口味。
引入 std::variant
在本章开头,我们讨论了不同范式(面向对象编程与过程化编程)的优劣。特别是,我们谈到过程化编程特别擅长向现有类型集合添加新操作。因此,与其在面向对象编程中寻找变通方法,不如我们利用过程化编程的优势如何?不,别担心;当然我不是建议回到我们最初的解决方案。那种方法实在太容易出错了。相反,我谈论的是std::variant
:
#include <cstdlib>
#include <iostream>
#include <string>
#include <variant>
struct Print 
{
void operator()( int value ) const
{ std::cout << "int: " << value << '\n'; }
void operator()( double value ) const
{ std::cout << "double: " << value << '\n'; }
void operator()( std::string const& value ) const
{ std::cout << "string: " << value << '\n'; }
};
int main()
{
// Creates a default variant that contains an 'int' initialized to 0
std::variant<int,double,std::string> v{}; 
v = 42; // Assigns the 'int' 42 to the variant 
v = 3.14; // Assigns the 'double' 3.14 to the variant 
v = 2.71F; // Assigns a 'float', which is promoted to 'double' 
v = "Bjarne"; // Assigns the string literal 'Bjarne' to the variant 
v = 43; // Assigns the 'int' 43 to the variant 
int const i = std::get<int>(v); // Direct access to the value 
int* const pi = std::get_if<int>(&v); // Direct access to the value 
std::visit( Print{}, v ); // Applying the Print visitor 
return EXIT_SUCCESS;
}
既然您可能还没有享受过介绍 C++17 std::variant
的乐趣,让我简要地给您介绍一下,以防万一。变体表示几个备选项中的一个。在代码示例的main()
函数开始时,变体可以包含一个int
、一个double
或一个std::string
()。请注意,我说的是或:变体只能包含这三个备选项中的一个。它不可能同时包含它们,且在通常情况下,不应为空。因此,我们称变体为和类型:可能状态的集合是备选项可能状态的总和。
默认变体也不是空的。它被初始化为第一个备选项的默认值。在例子中,一个默认变体包含值为 0 的整数。更改变体的值很简单:您只需分配新值。例如,我们可以分配值 42,这意味着变体现在存储了一个值为 42 的整数()。如果随后分配
double
3.14,那么变体将存储值为 3.14 的double
()。如果您希望分配的类型不是可能的备选项之一,则适用通常的转换规则。例如,如果您想分配一个
float
,基于常规转换规则,它将被提升为double
()。
为了存储这些备选项,变体提供了足够的内部缓冲区来容纳最大的备选项之一。在我们的情况下,最大的备选项是std::string
,通常在 24 到 32 字节之间(取决于标准库的实现)。因此,当您分配字符串字面量"Bjarne"
时,变体将首先清除先前的值(没有太多工作;它只是一个double
),然后,因为它是唯一有效的备选项,将在其自身缓冲区内直接构造std::string
()。当您改变主意并分配整数 43 时(
),变体将通过其析构函数适当地销毁
std::string
并重用内部缓冲区以存储整数。不可思议,不是吗?变体是类型安全的,并且始终正确初始化。我们还能要求什么呢?
当然,你肯定希望对 variant 内部的值进行一些操作。如果我们只是存储值而不做任何处理,那是没有用处的。不幸的是,你不能简单地将 variant 分配给其他任何值(例如int
),以获取你想要的值。访问这个值更为复杂一些。有几种方式可以访问存储的值,最直接的方法是std::get()
()。使用
std::get()
可以查询特定类型的值。如果 variant 包含该类型的值,则返回对其的引用。如果不包含,则抛出std::bad_variant_exception
异常。这似乎是一个相当粗鲁的响应,考虑到你已经礼貌地询问。但我们应该庆幸 variant 在确实不包含值时并不会假装拥有一些值。至少它是诚实的。还有一种更好的方式,即std::get_if()
()。与
std::get()
相比,std::get_if()
不返回引用,而是返回一个指针。如果请求 variant 当前不包含的类型,则它不会抛出异常,而是返回nullptr
。然而,还有第三种方式,这种方式对我们的目的尤为有趣:std::visit()
()。
std::visit()
允许您对存储的值执行任何操作。或者更确切地说,它允许您传递自定义访问者来对封闭类型集中存储的值执行任何操作。听起来耳熟能详吗?
我们传递的Print
访问者()必须为每种可能的替代方案提供一个函数调用操作符(
operator()
)。在这个例子中,通过提供三个operator()
来实现:一个用于int
,一个用于double
,一个用于std::string
。特别值得注意的是,Print
不必继承任何基类,也没有任何虚函数。因此,与任何要求的强耦合不存在。如果愿意的话,我们也可以将int
和double
的函数调用操作符合并为一个,因为int
可以转换为double
:
struct Print
{
void operator()( double value ) const
{ std::cout << "int or double: " << value << '\n'; }
void operator()( std::string const& value ) const
{ std::cout << "string: " << value << '\n'; }
};
虽然关于我们应该偏好哪个版本的问题对我们来说并不是特别重要,但你会注意到我们有很多实现灵活性。只有根据每个备选项都需要有一个operator()
的约定,存在非常松散的耦合。我们不再有强制我们以非常特定方式做事情的Visitor
基类。我们也没有任何备选项的基类:我们可以自由使用基本类型如int
和double
,以及任意的类类型如std::string
。而且也许最重要的是,任何人都可以轻松添加新操作。不需要修改任何现有代码。因此,我们可以说这是一个过程化解决方案,只是比最初基于枚举的方法更加优雅,而那种方法使用基类来保存鉴别器。
将形状的绘制重构为基于值的、非侵入式解决方案
凭借这些特性,std::variant
非常适合我们的绘图示例。让我们使用std::variant
重新实现形状的绘制。首先,我们重构Circle
和Square
类:
//---- <Circle.h> ----------------
#include <Point.h>
class Circle
{
public:
explicit Circle( double radius )
: radius_( radius )
{
/* Checking that the given radius is valid */
}
double radius() const { return radius_; }
Point center() const { return center_; }
private:
double radius_;
Point center_{};
};
//---- <Square.h> ----------------
#include <Point.h>
class Square
{
public:
explicit Square( double side )
: side_( side )
{
/* Checking that the given side length is valid */
}
double side () const { return side_; }
Point center() const { return center_; }
private:
double side_;
Point center_{};
};
Circle
和Square
都显著简化了:不再有Shape
基类,也不再需要实现任何虚函数,特别是accept()
函数。因此,这种访问者(Visitor)方法是非侵入式的:这种形式的访问者可以轻松添加到现有类型中!而且无需为这些类准备任何即将进行的操作。我们可以完全专注于实现这两个类作为它们本身:几何原语。
重构中最美的部分,然而,是实际使用std::variant
:
//---- <Shape.h> ----------------
#include <variant>
#include <Circle.h>
#include <Square.h>
using Shape = std::variant<Circle,Square>; 
//---- <Shapes.h> ----------------
#include <vector>
#include <Shape.h>
using Shapes = std::vector<Shape>; 
由于我们封闭的类型集合是一组形状,variant 现在将包含Circle
或Square
。那么,什么是一个代表形状类型集合的抽象良好名称?嗯...Shape
()。现在,
std::variant
承担起了这个任务,而不是一个从实际形状类型抽象出来的基类。如果这是你第一次看到这个,你可能完全惊讶了。但等等,还有更多:这也意味着我们现在可以抛弃std::unique_ptr
。记住:我们使用(智能)指针的唯一原因是使我们能够在同一个向量中存储不同类型的形状。但是现在,由于std::variant
使我们能够做同样的事情,我们可以简单地将 variant 对象存储在单个向量中 ()。
有了这个功能,我们可以对形状编写自定义操作。我们仍然对绘制形状感兴趣。为此,我们现在实现Draw
访问者:
//---- <Draw.h> ----------------
#include <Shape.h>
#include /* some graphics library */
struct Draw
{
void operator()( Circle const& c ) const
{ /* ... Implementing the logic for drawing a circle ... */ }
void operator()( Square const& s ) const
{ /* ... Implementing the logic for drawing a square ... */ }
};
再次,我们遵循为每个备选实现一个operator()
的预期:一个用于Circle
,一个用于Square
。但是这次我们有选择。没有必要实现任何基类,因此也没有必要覆盖任何虚函数。因此,也没有必要为每个备选实现一个operator()
。虽然在这个例子中有两个函数感觉是合理的,但我们可以选择将两个operator()
合并成一个函数。在操作的返回类型方面,我们也有选择。我们可以在本地决定应该返回什么,而不是一个基类在不考虑特定操作的情况下做出全局决定。实现的灵活性。松散的耦合。太棒了!
这个谜题的最后一部分是drawAllShapes()
函数:
//---- <DrawAllShapes.h> ----------------
#include <Shapes.h>
void drawAllShapes( Shapes const& shapes );
//---- <DrawAllShapes.cpp> ----------------
#include <DrawAllShapes.h>
void drawAllShapes( Shapes const& shapes )
{
for( auto const& shape : shapes )
{
std::visit( Draw{}, shape );
}
}
函数drawAllShapes()
被重构,以利用std::visit()
。在这个函数中,我们现在将Draw
访问器应用于存储在向量中的所有变体。
std::visit()
的工作是为您执行必要的类型分派。如果给定的std::variant
包含一个Circle
,它将调用圆形的Draw::operator()
。否则,它将调用正方形的Draw::operator()
。如果你愿意,你可以使用std::get_if()
手动实现相同的分派:
void drawAllShapes( Shapes const& shapes )
{
for( auto const& shape : shapes )
{
if( Circle* circle = std::get_if<Circle>(&shape) ) {
// ... Drawing a circle
}
else if( Square* square = std::get_if<Square>(&shape) ) {
// ... Drawing a square
}
}
}
我知道你在想什么:“胡说八道!为什么我要那样做?那会导致与基于枚举的解决方案一样的维护噩梦。” 我完全同意你的观点:从软件设计的角度来看,这将是一个糟糕的主意。但是,我不得不承认,在这本书的背景下,有时可能有一个很好的理由做这样的事情:性能。我知道,现在我引起了你的兴趣,但既然我们几乎已经准备好讨论性能了,让我稍微推迟一下这个讨论,只需要几段话。我会回到这个话题的,我保证!
有了所有这些细节,我们最终可以重构main()
函数。但要做的工作并不多:不再通过std::make_unique()
创建圆和正方形,而是直接创建圆和正方形,并将它们添加到向量中。这得益于 variant 的非显式构造函数,它允许任何备选的隐式转换:
//---- <Main.cpp> ----------------
#include <Circle.h>
#include <Square.h>
#include <Shapes.h>
#include <DrawAllShapes.h>
int main()
{
Shapes shapes;
shapes.emplace_back( Circle{ 2.3 } );
shapes.emplace_back( Square{ 1.2 } );
shapes.emplace_back( Circle{ 4.1 } );
drawAllShapes( shapes );
return EXIT_SUCCESS;
}
这种基于值的解决方案的最终结果非常迷人:任何地方都没有基类。没有虚函数。没有指针。没有手动内存分配。一切都如此直接,而且几乎没有样板代码。此外,尽管代码看起来与以前的解决方案非常不同,但架构属性是相同的:每个人都能够添加新的操作,而无需修改现有的代码(见图 4-4)。因此,我们仍然遵守 OCP 原则,可以添加操作。
图 4-4. std::variant
解决方案的依赖图
如前所述,这种 Visitor 方法是非侵入式的。从架构的角度来看,与经典 Visitor 相比,这给你带来了另一个显著的优势。如果你将经典 Visitor 的依赖图(见图 4-3)与std::variant
解决方案的依赖图(见图 4-4)进行比较,你会发现std::variant
解决方案的依赖图具有第二个架构边界。这意味着std::variant
和其备选方案之间不存在循环依赖。我应该重复一下以强调其重要性:std::variant
和其备选方案之间没有循环依赖!看起来可能是个小细节,实际上是一个巨大的架构优势。巨大的!例如,你可以根据std::variant
即时创建一个抽象化:
//---- <Shape.h> ----------------
#include <variant>
#include <Circle.h>
#include <Square.h>
using Shape = std::variant<Circle,Square>; 
//---- <SomeHeader.h> ----------------
#include <Circle.h>
#include <Ellipse.h>
#include <variant>
using RoundShapes = std::variant<Circle,Ellipse>; 
//---- <SomeOtherHeader.h> ----------------
#include <Square.h>
#include <Rectangle.h>
#include <variant>
using AngularShapes = std::variant<Square,Rectangle>; 
除了我们已经创建的Shape
抽象化()之外,你可以为所有圆形创建
std::variant
(),也可以为所有角形创建
std::variant
(),这两者可能远离
Shape
抽象化。你可以轻松做到这一点,因为不需要从多个 Visitor 基类派生。相反,形状类将不受影响。因此,std::variant
解决方案非侵入性的事实具有最高的架构价值!
性能基准
我知道你现在的感受。是的,这就是一见钟情的感觉。但信不信由你,还有更多。还有一个我们尚未讨论的话题,这个话题对每个 C++开发者来说都很重要,那就是性能。虽然这并不是一本关于性能的书,但还是值得一提的是,你不必担心std::variant
的性能。我可以向你保证,它很快。
然而,在我展示给你基准结果之前,请允许我对基准进行几点评论。性能——叹气。不幸的是,性能总是一个棘手的话题。总会有人抱怨性能。因此,我很愿意完全跳过这个话题。但是还有其他人抱怨缺少性能数字。叹气。好吧,看起来总会有人抱怨,而且由于结果实在是太好了,我将向你展示一些基准结果。但有两个条件:首先,你不会把它们视为代表绝对真理的定量值,而只是指向正确方向的定性值。其次,你不会因为我没有使用你最喜欢的编译器、编译标志或 IDE 而在我家门前抗议。保证?
你:点头并发誓不抱怨琐事!
好的,太好了,那么表 4-2 给出了基准结果。
表 4-2. 不同访问者实现的基准结果
访问者实现 | GCC 11.1 | Clang 11.1 |
---|---|---|
经典访问者设计模式 | 1.6161 s | 1.8015 s |
面向对象的解决方案 | 1.5205 s | 1.1480 s |
枚举解决方案 | 1.2179 s | 1.1200 s |
std::variant (使用std::visit() ) |
1.1992 s | 1.2279 s |
std::variant (使用std::get_if() ) |
1.0252 s | 0.6998 s |
要理解这些数字,我应该给您更多背景信息。为了使场景更真实,我不仅使用了圆和正方形,还使用了矩形和椭圆。然后我对 10,000 个随机创建的形状运行了 25,000 次操作。我没有绘制这些形状,而是用随机向量更新了它们的中心点。¹³这是因为这种平移操作非常便宜,可以更好地显示所有这些解决方案的固有开销(如间接引用和虚函数调用的开销)。一个昂贵的操作,比如draw()
,会掩盖这些细节,并可能给出所有方法几乎相似的印象。我同时使用了 GCC 11.1 和 Clang 11.1,并且对于这两个编译器,我只添加了-O3
和-DNDEBUG
编译标志。我使用的平台是 macOS Big Sur(版本 11.4),配备了 8 核 Intel Core i7 处理器,主频 3.8 GHz 和 64 GB 主内存。
从基准结果中最明显的收获是,变体解决方案比经典的访问者模式解决方案效率要高得多。这并不奇怪:由于双重调度,经典的访问者模式实现包含了大量的间接引用,因此也很难优化。此外,形状对象的内存布局非常完美:与包括基于枚举的解决方案在内的所有其他解决方案相比,所有形状都是以连续的方式存储在内存中,这是你可以选择的最友好缓存的布局。第二个收获是,如果不出奇的话,std::variant
确实非常高效。然而,令人惊讶的是,效率很大程度上取决于我们使用std::get_if()
还是std::visit()
(我答应会回到这一点)。当使用std::visit()
时,无论是 GCC 还是 Clang 都会生成更慢的代码。我认为std::visit()
在那时可能没有被完美实现和优化。但正如我之前所说,性能总是很难把握的,我不打算深入探讨这个谜团。¹⁴
最重要的是,std::variant
的美丽并没有被糟糕的性能数字所混淆。相反,性能结果帮助加深您与std::variant
的新发现关系。
分析std::variant
解决方案的缺陷
虽然我不想危及这种关系,但我认为也是我的职责指出使用基于std::variant
的解决方案时您将不得不处理的一些缺点。
首先,我应该再次指出显而易见的事实:作为类似于访问者设计模式并基于过程化编程的解决方案,std::variant
也专注于提供一组开放的操作。缺点是你必须处理一组封闭的类型。添加新类型将导致类似于我们在“指南 15:为类型或操作的添加设计”中枚举解决方案中遇到的问题。首先,你必须更新变体本身,这可能会触发使用变体类型的所有代码的重新编译(还记得更新枚举吗?)。此外,你还必须更新所有操作,并为新的备选类型添加可能缺少的operator()
。好的一面是,如果缺少其中一个操作符,编译器会抱怨。坏的一面是,编译器不会生成漂亮、易读的错误消息,而是更接近于所有与模板相关的错误消息的母版。总的来说,这确实感觉非常类似于我们以前使用枚举解决方案的经验。
第二个潜在的问题是,你应该避免在变体中放置非常不同大小的类型。如果至少一个备选类型比其他类型大得多,你可能会浪费大量空间来存储许多小的备选类型。这将对性能产生负面影响。解决方案是不直接存储大型备选类型,而是通过指针、代理对象或使用桥接设计模式[¹⁵]进行存储。当然,这会引入一种间接性,这也会消耗性能。关于这是否在性能方面与存储不同大小的值相比具有不利因素,这是你需要进行基准测试的问题。
最后但并非最不重要的是,你应该始终意识到一个变体可以揭示大量信息的事实。虽然它代表了一个运行时的抽象,但包含的类型仍然是明显可见的。这可能会在变体上创建物理依赖性,即当修改其中一个备选类型时,可能需要重新编译任何依赖的代码。解决方案再次是存储指针或代理对象,这样可以隐藏实现细节。不幸的是,这也会影响性能,因为很多性能优势来自于编译器了解这些细节并进行相应的优化。因此,在性能和封装性之间总是需要权衡。
尽管存在这些缺点,总结来说,std::variant
确实是面向对象编程 Visitor 设计模式的一个很好的替代品。它大大简化了代码,几乎消除了所有样板代码和封装了丑陋且维护成本高的部分,并且具有优越的性能。此外,std::variant
也证明了设计模式是意图而非实现细节的又一个极好例子。
指南 18:注意 Acyclic Visitor 的性能
正如你在“指南 15:为类型或操作的添加设计”中所看到的,当使用动态多态性时,你必须做出决策:你可以支持开放的类型集合或开放的操作集合,但不能两者兼得。嗯,我特意说过,据我所知,同时拥有两者实际上并不不可能,但通常是不切实际的。为了演示,让我向你介绍访问者设计模式的又一变体:Acyclic Visitor。¹⁶
在“指南 16:使用访问者扩展操作”中,你看到访问者设计模式的关键参与者之间存在循环依赖:Visitor
基类依赖于形状的具体类型(Circle
、Square
等),形状的具体类型依赖于Shape
基类,而Shape
基类又依赖于Visitor
基类。由于这种循环依赖,将所有这些关键参与者锁定在架构的一个层次上,难以向访问者添加新类型。Acyclic Visitor 的理念就是打破这种依赖。
图 4-5 展示了 Acyclic Visitor 的 UML 图。与 GoF Visitor 相比,虽然图片右侧只有小幅差异,但左侧却有一些根本性变化。最重要的是,Visitor
基类被拆分为几个基类:AbstractVisitor
基类和每个形状具体类型的基类(在这个示例中,CircleVisitor
和 SquareVisitor
)。所有访问者都必须继承自 AbstractVisitor
基类,但现在还可以选择继承特定形状的访问者基类。如果一个操作需要支持圆形,它就继承自 CircleVisitor
基类,并为 Circle
实现 visit()
函数。如果不需要支持圆形,它就简单地不继承 CircleVisitor
。
图 4-5. Acyclic Visitor 的 UML 表示
下面的代码片段展示了 Visitor
基类的一个可能实现:
//---- <AbstractVisitor.h> ----------------
class AbstractVisitor 
{
public:
virtual ~AbstractVisitor() = default;
};
//---- <Visitor.h> ----------------
template< typename T >
class Visitor 
{
protected:
~Visitor() = default;
public:
virtual void visit( T const& ) const = 0;
};
AbstractVisitor
基类只是一个空的基类,具有虚析构函数()。不需要其他函数。正如您将看到的,
AbstractVisitor
只作为一个通用标签来标识访问者,不必自身提供任何操作。在 C++ 中,我们倾向于以类模板的形式实现特定形状访问者基类()。
Visitor
类模板是基于特定形状类型参数化的,并为该特定形状引入纯虚拟的 visit()
函数。
在我们的 Draw
访问者的实现中,我们现在会从三个基类继承:AbstractVisitor
,Visitor<Circle>
和 Visitor<Square>
,因为我们希望支持 Circle
和 Square
两者:
class Draw : public AbstractVisitor
, public Visitor<Circle>
, public Visitor<Square>
{
public:
void visit( Circle const& c ) const override
{ /* ... Implementing the logic for drawing a circle ... */ }
void visit( Square const& s ) const override
{ /* ... Implementing the logic for drawing a square ... */ }
};
这种实现选择打破了循环依赖。如图 4-6 所示,架构的高层不再依赖于具体的形状类型。现在,形状(Circle
和 Square
)和操作都位于架构边界的低层。我们现在可以添加两种类型和操作。
此时,你看起来非常怀疑,几乎在指责我的方向看。我不是说过同时拥有两者是不可能的吗?显然,这是可能的,对吧?嗯,再说一遍,我并没有声称这是不可能的。我更多的是说这可能是不实际的。既然你已经看到了无环访问者的优势,那就让我展示一下这种方法的缺点吧。
图 4-6. 无环访问者的依赖图
首先,让我们来看一下 Circle
中 accept()
函数的实现:
//---- <Circle.h> ----------------
class Circle : public Shape
{
public:
explicit Circle( double radius )
: radius_( radius )
{
/* Checking that the given radius is valid */
}
void accept( AbstractVisitor const& v ) override { 
if( auto const* cv = dynamic_cast<Visitor<Circle> const*>(&v) ) { 
cv->visit( *this ); 
}
}
double radius() const { return radius_; }
Point center() const { return center_; }
private:
double radius_;
Point center_{};
};
您可能已经注意到Shape
层次结构中的一个小改变:虚拟的 accept()
函数现在接受 AbstractVisitor
()。您还记得
AbstractVisitor
并不实现任何自己的操作。因此,不再在 AbstractVisitor
上调用 visit()
函数,而是 Circle
通过执行 dynamic_cast
到 Visitor<Circle>
来确定给定的访问者是否支持圆形()。请注意,它执行的是指针转换,这意味着
dynamic_cast
返回一个有效的 Visitor<Circle>
指针或 nullptr
。如果返回一个有效的 Visitor<Circle>
指针,则调用相应的 visit()
函数()。
尽管这种方法肯定有效,并且是打破访问者设计模式循环依赖的一部分,dynamic_cast
总是让人感觉不好。dynamic_cast
应该总是让人感到有些怀疑,因为如果使用不当,它可能会破坏架构。如果我们在架构的高层中执行从高层架构到低层架构的转换,那么这种情况就会发生¹⁷。在我们的情况下,实际上可以使用它,因为使用发生在我们架构的低层。因此,我们不会通过将关于低层的知识插入到高层来破坏架构。
真正的不足在于运行时惩罚。当在“指导原则 17:考虑使用 std::variant 实现访问者”中运行与循环访问者相同的基准测试时,你会意识到运行时几乎比循环访问者的运行时高一个数量级(见表 4-3)。原因是 dynamic_cast
很慢。非常慢。而且对于这个应用程序来说特别慢。我们在这里做的是一个交叉转换。我们不仅仅是向下转型到一个特定的派生类,而是转换到继承层次结构的另一个分支。这种交叉转换,接着是一个虚函数调用,比简单的向下转型显著昂贵。
表 4-3. 不同访问者实现的性能结果
访问者实现 | GCC 11.1 | Clang 11.1 |
---|---|---|
非循环访问者 | 14.3423 s | 7.3445 s |
循环访问者 | 1.6161 s | 1.8015 s |
面向对象的解决方案 | 1.5205 s | 1.1480 s |
枚举解决方案 | 1.2179 s | 1.1200 s |
std::variant (使用 std::visit() ) |
1.1992 s | 1.2279 s |
std::variant (使用 std::get() ) |
1.0252 s | 0.6998 s |
虽然从架构上看,非循环访问者是一个非常有趣的替代方案,但从实际的角度来看,这些性能结果可能会使它失去资格。这并不意味着你不应该使用它,但至少要意识到糟糕的性能可能是选择其他解决方案的一个很强的论据。
¹ 我看到你在翻白眼!“哦,那个无聊的例子又来了!” 但请考虑那些跳过了第三章的读者。他们现在很高兴可以阅读这一节,而不需要关于场景的冗长解释。
² 自从 C++11 开始,我们有了作用域枚举,有时也称为类枚举,因为它们的语法是 enum class
。例如,这将帮助编译器更好地警告不完整的 switch
语句。如果你发现了这个缺陷,那么你就赚到了一个额外的分数!
³ Scott Meyers,《更有效的 C++:改进程序和设计的 35 种新方法》,第 31 项(Addison-Wesley,1995 年)。
⁴ 注意,数学上的开放和闭合集合是完全不同的概念。
⁵ 作为静态多态设计的一个例子,请考虑标准模板库(STL)中的算法。你可以轻松添加新操作,即算法,也可以轻松添加可以复制、排序等的新类型。
⁶ 做预测总是很困难的。但我们通常对我们的代码库将如何发展有一个很好的想法。如果你完全不知道事情将如何发展,你应该等待第一个变化或扩展,从中学习,然后做出更明智的决策。这一哲学思想是众所周知的YAGNI 原则,它警告你不要过度设计;还请参阅“指导方针 2:为变更设计”。
⁷ 对此我可能不会感到高兴——甚至可能会非常不开心——但我可能不会生气。但你的其他同事呢?最糟糕的情况是,你可能会被排除在下次团队烧烤会上。
⁸ Erich Gamma 等,《设计模式:可复用面向对象软件的元素》。
⁹ accept()
是 GoF 书中使用的名称。在访问者设计模式的上下文中,这是传统名称。当然,你可以自由地使用任何其他名称,比如apply()
。但在重命名之前,请考虑来自“指导方针 14:使用设计模式名称传达意图”的建议。
¹⁰ 确实建议将逻辑提取到单个函数中。原因在于变更:如果以后必须更新实现,你不希望多次执行更改。这就是 DRY(不要重复自己)原则的理念。因此,请记住“指导方针 2:为变更设计”。
¹¹ 请考虑风险:这可能会使你终身被排除在团队烧烤之外!
¹² 使用std::make_unique()
而不是一些特定用途的分配方案时,内存碎片化更有可能发生,因为它封装了对new
的调用。
¹³ 我确实使用了随机向量,通过 std::mt19937
和 std::uniform_real_distribution
创建,但在验证了对于 GCC 11.1 没有性能变化,对于 Clang 11.1 只有轻微变化之后才使用。显然,创建随机数本身并不特别昂贵(至少在我的机器上是这样)。既然你答应将其视为定性结果,那我们应该没问题了。
¹⁴ 还有其他开源的 variant
替代实现。Boost 库 提供了两种实现:Abseil 提供了一种 variant 实现,值得看看 Michael Park 的实现。
¹⁵ 代理 模式是 GoF 设计模式之一,可惜本书由于页面限制未能详细涵盖。不过我会详细讲解 桥接 设计模式;详见“准则 28:建立桥接以消除物理依赖”。
¹⁶ 欲了解 Acyclic Visitor 模式及其发明者的更多信息,请参阅 Robert C. Martin 的 Agile Software Development: Principles, Patterns, and Practices(Pearson)。
¹⁷ 请参考“准则 9:关注抽象层次的所有权”以了解 高级 和 低级 这两个术语的定义。
第五章:策略和命令设计模式
本章专注于两种最常用的设计模式:策略设计模式和命令设计模式。确实是最常用的:C++标准库本身多次使用它们,很可能您自己也使用过许多次。这两者都可以被视为每位开发者的基本工具。
在“指南 19:使用策略隔离如何做事情”中,我将向您介绍策略设计模式。我将展示为什么这是最有用和最重要的设计模式之一,以及您将在许多情况下发现它非常有用。
在“指南 20:更喜欢组合而不是继承”中,我们将研究继承及其为何让许多人抱怨。您将看到它本质上并不糟糕,但像其他一切一样,它既有优势也有局限性。然而,最重要的是,我将解释许多经典设计模式并不是因为继承而获得其力量,而是因为组合。
在“指南 21:使用命令隔离所做的事情”中,我将向您介绍命令设计模式。我将展示如何有效地使用该设计模式,并且还会让您了解命令和策略的比较。
在“指南 22:偏爱值语义而不是引用语义”中,我们将踏入引用语义的领域。然而,我们会发现这个领域并不特别友好和好客,使我们担心我们代码的质量。因此,我们将重新定居到值语义的领域,它将为我们的代码库带来许多好处。
在“指南 23:偏爱基于值的策略和命令的实现”中,我们将重新审视策略和命令模式。我将展示我们如何应用在值语义领域获得的洞见,并基于std::function
实现这两种设计模式。
指南 19:使用策略隔离如何做事情
让我们假设您和您的团队即将实施一个新的 2D 图形工具。在其他要求中,它需要处理简单的几何基元,比如圆形、正方形等,这些需要被绘制(参见图 5-1)。
图 5-1。初始的Shape
继承层次结构
已经实现了几个类,例如Shape
基类,Circle
类和Square
类:
//---- <Shape.h> ----------------
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw( /*some arguments*/ ) const = 0; 
};
//---- <Circle.h> ----------------
#include <Point.h>
#include <Shape.h>
class Circle : public Shape
{
public:
explicit Circle( double radius )
: radius_( radius )
{
/* Checking that the given radius is valid */
}
double radius() const { return radius_; }
Point center() const { return center_; }
void draw( /*some arguments*/ ) const override; 
private:
double radius_;
Point center_{};
};
//---- <Circle.cpp> ----------------
#include <Circle.h>
#include /* some graphics library */
void Circle::draw( /*some arguments*/ ) const
{
// ... Implementing the logic for drawing a circle }
//---- <Square.h> ----------------
#include <Point.h>
#include <Shape.h>
class Square : public Shape
{
public:
explicit Square( double side )
: side_( side )
{
/* Checking that the given side length is valid */
}
double side () const { return side_; }
Point center() const { return center_; }
void draw( /*some arguments*/ ) const override; 
private:
double side_;
Point center_{};
};
//---- <Square.cpp> ----------------
#include <Square.h>
#include /* some graphics library */
void Square::draw( /*some arguments*/ ) const
{
// ... Implementing the logic for drawing a square }
最重要的是Shape
基类的纯虚拟draw()
成员函数()。在你度假期间,你的一个团队成员已经使用 OpenGL 为
Circle
和Square
类实现了这个draw()
成员函数(分别是和
)。这个工具已经能够绘制圆形和正方形,整个团队都认为生成的图形看起来非常整洁。大家都很开心!
分析设计问题
除了你之外,所有人都是如此。从度假归来,你立即意识到实现的解决方案违反了单一职责原则(SRP)。¹ 目前,Shape
层次结构并非为变更而设计。首先,改变形状的绘制方式并不容易。在当前实现中,只有一种固定的形状绘制方式,而且不能非侵入式地更改这些细节。因为你已经预测到这个工具将需要支持多个图形库,这绝对是一个问题。² 其次,如果最终执行更改,则需要在多个不相关的地方更改行为。
但还有更多。由于绘图功能实现在Circle
和Square
内部,所以Circle
和Square
类依赖于draw()
的实现细节,这意味着它们依赖于 OpenGL。尽管圆形和正方形应该主要是一些简单的几何原语,但这两个类现在在它们被使用时必须到处使用 OpenGL。
当向同事指出这一点时,起初他们有些目瞪口呆。而且也有些恼火,因为他们没料到你会指出他们美妙解决方案中的任何缺陷。不过,你有一种非常好的方式来解释问题,最终他们同意了你的观点,并开始思考一个更好的解决方案。
他们很快就想出了一个更好的方法。几天后的下次团队会议上,他们展示了他们的新想法:在继承层次结构中增加了另一层(见图 5-2)。
图 5-2. 扩展的Shape
继承层次结构
为了演示这个想法,他们已经实现了OpenGLCircle
和OpenGLSquare
类:
//---- <Circle.h> ----------------
#include <Shape.h>
class Circle : public Shape
{
public:
// ... No implementation of the draw() member function anymore
};
//---- <OpenGLCircle.h> ----------------
#include <Circle.h>
class OpenGLCircle : public Circle
{
public:
explicit OpenGLCircle( double radius )
: Circle( radius )
{}
void draw( /*some arguments*/ ) const override;
};
//---- <OpenGLCircle.cpp> ----------------
#include <OpenGLCircle.h>
#include /* OpenGL graphics library headers */
void OpenGLCircle::draw( /*some arguments*/ ) const
{
// ... Implementing the logic for drawing a circle by means of OpenGL
}
//---- <Square.h> ----------------
#include <Shape.h>
class Square : public Shape
{
public:
// ... No implementation of the draw() member function anymore
};
//---- <OpenGLSquare.h> ----------------
#include <Square.h>
class OpenGLSquare : public Square
{
public:
explicit OpenGLSquare( double side )
: Square( side )
{}
void draw( /*some arguments*/ ) const override;
};
//---- <OpenGLSquare.cpp> ----------------
#include <OpenGLSquare.h>
#include /* OpenGL graphics library headers */
void OpenGLSquare::draw( /*some arguments*/ ) const
{
// ... Implementing the logic for drawing a square by means of OpenGL
}
继承!当然!通过简单地从Circle
和Square
派生,并通过将draw()
函数的实现推到继承层次结构的更低层,很容易实现不同的绘制方式。例如,可能会有MetalCircle
和VulkanCircle
,假设需要支持Metal和Vulkan库。突然之间,变更变得容易了,对吧?
虽然你的同事们对他们的新解决方案感到非常自豪,但你已经意识到这种方法长期来看不会很好。而且很容易证明其缺点:你只需要考虑另一个需求,例如,一个 serialize()
成员函数:
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw( /*some arguments*/ ) const = 0;
virtual void serialize( /*some arguments*/ ) const = 0; 
};
serialize()
成员函数()被设计用来将形状转换为字节序列,这个序列可以被存储在文件或数据库中。从那里,可以反序列化字节序列以重新创建完全相同的形状。就像
draw()
成员函数一样,serialize()
成员函数可以以各种方式实现。例如,你可以使用 protobuf 或 Boost.serialization 库。
使用相同的策略将实现细节移到继承层次结构的下层,这将很快导致一个相当复杂且相当人为的层次结构(参见 图 5-3)。考虑类名:OpenGLProtobufCircle
、MetalBoostSerialSquare
等等。荒谬,对吧?我们应该如何构建这个结构:应该在层次结构中添加另一层(参见 Square
分支)吗?这种方法很快会导致一个深层且复杂的层次结构。还是我们应该展平层次结构(就像层次结构的 Circle
分支一样)?那么如何重用实现细节呢?例如,如何在 OpenGLProtobufCircle
和 OpenGLBoostSerialCircle
类之间重用 OpenGL 代码?
图 5-3. 添加 serialize()
成员函数导致一个深层且复杂的继承层次结构
解释策略设计模式
你意识到你的同事们对继承过于迷恋,而且需要你来拯救局面。他们似乎需要有人向他们展示如何正确地设计这种变化,并向他们提出问题的正确解决方案。正如两位务实的程序员所说的那样:³
继承很少是答案。
问题仍然是违反了 SRP。由于你必须计划如何改变不同形状的绘制方式,所以你应该将绘制方面标识为 变化点。有了这个认识,正确的方法是为变化进行设计,遵循 SRP,并因此提取出变化点。这就是 Strategy 设计模式的目的,是 GoF 设计模式中的经典之一。
策略设计模式
意图:“定义一组算法,将每个算法封装起来,并使它们可以互换。策略模式使得算法可以独立于使用它的客户端而变化。”⁴
不是在派生类中实现虚draw()
函数,而是引入另一个目的是绘制形状的类。在经典的面向对象形式的策略设计模式中,通过引入DrawStrategy
基类来实现这一点(参见图 5-4)。
图 5-4. 策略设计模式的 UML 表示
现在隔离绘图方面使得我们能够在不修改形状类的情况下更改绘制实现。这符合 SRP 的理念。现在,您还能够引入新的draw()
实现而无需修改任何其他代码。这符合开闭原则(OCP)。在这个 OO 设置中,再次强调,SRP 是 OCP 的实现者。
以下代码片段展示了DrawStrategy
基类的简单实现:⁵
//---- <DrawStrategy.h> ----------------
class Circle;
class Square;
class DrawStrategy
{
public:
virtual ~DrawStrategy() = default;
virtual void draw( Circle const& circle, /*some arguments*/ ) const = 0; 
virtual void draw( Square const& square, /*some arguments*/ ) const = 0; 
};
DrawStrategy
类带有虚析构函数和两个纯虚draw()
函数,一个用于圆形()和一个用于正方形(
)。为了使这个基类编译通过,你需要提前声明
Circle
和Square
类。
由于策略设计模式,Shape
基类没有变化。它仍然代表所有形状的抽象,并因此提供了一个纯虚的draw()
成员函数。策略旨在提取实现细节,因此仅影响派生类:⁶
//---- <Shape.h> ----------------
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw( /*some arguments*/ ) const = 0;
// ... Potentially other functions, e.g. a 'serialize()' member function
};
虽然由于策略模式,Shape
基类没有变化,但Circle
和Square
类受到影响:
//---- <Circle.h> ----------------
#include <Shape.h>
#include <DrawStrategy.h>
#include <memory>
#include <utility>
class Circle : public Shape
{
public:
explicit Circle( double radius, std::unique_ptr<DrawStrategy> drawer ) 
: radius_( radius )
, drawer_( std::move(drawer) ) 
{
/* Checking that the given radius is valid and that
the given std::unique_ptr instance is not nullptr */
}
void draw( /*some arguments*/ ) const override
{
drawer_->draw( *this, /*some arguments*/ ); 
}
double radius() const { return radius_; }
private:
double radius_;
std::unique_ptr<DrawStrategy> drawer_; 
};
//---- <Square.h> ----------------
#include <Shape.h>
#include <DrawStrategy.h>
#include <memory>
#include <utility>
class Square : public Shape
{
public:
explicit Square( double side, std::unique_ptr<DrawStrategy> drawer ) 
: side_( side )
, drawer_( std::move(drawer) ) 
{
/* Checking that the given side length is valid and that
the given std::unique_ptr instance is not nullptr */
}
void draw( /*some arguments*/ ) const override
{
drawer_->draw( *this, /*some arguments*/ ); 
}
double side() const { return side_; }
private:
double side_;
std::unique_ptr<DrawStrategy> drawer_; 
};
Circle
和Square
现在在它们的构造函数中期望一个unique_ptr
指向一个DrawStrategy
()。这使得我们可以从外部配置绘图行为,通常称为依赖注入。
unique_ptr
被移动到同类型的新数据成员中()。还可以提供相应的设置函数,允许稍后更改绘图行为。现在,
draw()
成员函数不必自己实现绘图,而只需调用给定DrawStrategy
的draw()
函数()。⁷
分析简单解决方案的缺点
太棒了!通过这个实现,你现在能够在本地、孤立地改变形状绘制的行为,并使每个人都能实现新的绘制行为。然而,就目前而言,我们的策略实现存在严重的设计缺陷。为了分析这个缺陷,假设你不得不添加一个新类型的形状,也许是一个Triangle
。这本应该很容易,因为正如我们在“指导原则 15:为类型或操作的添加设计”中讨论的那样,面向对象编程的强大之处在于可以添加新类型。
当你开始引入这个Triangle
时,你会意识到,添加新类型的形状并不像预期的那么简单。首先,你需要编写新的类。这是可以预料的,完全没有问题。但是然后你还需要更新DrawStrategy
基类,以便也能够绘制三角形。这反过来会对圆形和正方形产生不良影响:Circle
和Square
类都需要重新编译、重新测试,并有可能重新部署。更一般地说,所有形状都会受到这种影响。这应该让你觉得有问题。为什么要在添加Triangle
类时,圆形和正方形都需要重新编译呢?
技术原因是,通过DrawStrategy
基类,所有形状都隐式地了解彼此。因此,添加新形状会影响所有其他形状。底层设计原因是违反了接口隔离原则(ISP)(参见“指导原则 3:分离接口以避免人为耦合”)。通过定义单一的DrawStrategy
基类,你人为地将圆形、正方形和三角形耦合在一起。由于这种耦合,增加新类型变得更加困难,从而限制了面向对象编程的强大性。相比之下,这与我们讨论过的为形状绘制提供过程化解决方案非常相似(参见“指导原则 15:为类型或操作的添加设计”)。
“我们无意中重新实现了访问者设计模式吗?”你在想。我理解你的意思:DrawStrategy
看起来确实很像访问者。但不幸的是,它并不满足访问者的意图,因为你不能轻松地添加其他操作。要这样做,你必须在Shape
层次结构中侵入性地添加虚成员函数。“而且它也不是一个策略,因为我们不能添加类型,对吧?”是的,正确。你看,从设计的角度来看,这是最糟糕的情况。
要正确实现策略设计模式,你必须分别提取每种形状的实现细节。你必须为每种形状引入一个DrawStrategy
类:
//---- <DrawCircleStrategy.h> ----------------
class Circle;
class DrawCircleStrategy 
{
public:
virtual ~DrawCircleStrategy() = default;
virtual void draw( Circle const& circle, /*some arguments*/ ) const = 0;
};
//---- <Circle.h> ----------------
#include <Shape.h>
#include <DrawCircleStrategy.h>
#include <memory>
#include <utility>
class Circle : public Shape
{
public:
explicit Circle( double radius, std::unique_ptr<DrawCircleStrategy> drawer )
: radius_( radius )
, drawer_( std::move(drawer) )
{
/* Checking that the given radius is valid and that
the given 'std::unique_ptr' is not a nullptr */
}
void draw( /*some arguments*/ ) const override
{
drawer_->draw( *this, /*some arguments*/ );
}
double radius() const { return radius_; }
private:
double radius_;
std::unique_ptr<DrawCircleStrategy> drawer_;
};
//---- <DrawSquareStrategy.h> ----------------
class Square;
class DrawSquareStrategy 
{
public:
virtual ~DrawSquareStrategy() = default;
virtual void draw( Square const& square, /*some arguments*/ ) const = 0;
};
//---- <Square.h> ----------------
#include <Shape.h>
#include <DrawSquareStrategy.h>
#include <memory>
#include <utility>
class Square : public Shape
{
public:
explicit Square( double side, std::unique_ptr<DrawSquareStrategy> drawer )
: side_( side )
, drawer_( std::move(drawer) )
{
/* Checking that the given side length is valid and that
the given 'std::unique_ptr' is not a nullptr */
}
void draw( /*some arguments*/ ) const override
{
drawer_->draw( *this, /*some arguments*/ );
}
double side() const { return side_; }
private:
double side_;
std::unique_ptr<DrawSquareStrategy> drawer_;
};
对于Circle
类,您必须引入DrawCircleStrategy
基类(),对于
Square
类,则是DrawSquareStrategy
基类()。随着
Triangle
类的添加,您还必须添加DrawTriangleStrategy
基类。只有这样,您才能正确地分离关注点,并仍然允许每个人为形状的绘制添加新类型和新实现。
有了这个功能,您可以轻松实现新的策略类来绘制圆形、正方形,以及最终三角形。例如,考虑实现DrawCircleStrategy
接口的OpenGLCircleStrategy
:
//---- <OpenGLCircleStrategy.h> ----------------
#include <Circle.h>
#include <DrawCircleStrategy.h>
#include /* OpenGL graphics library */
class OpenGLCircleStrategy : public DrawCircleStrategy
{
public:
explicit OpenGLCircleStrategy( /* Drawing related arguments */ );
void draw( Circle const& circle, /*...*/ ) const override;
private:
/* Drawing related data members, e.g. colors, textures, ... */
};
在图 5-5 中,您可以看到Circle
类的依赖图。请注意,Circle
和DrawCircleStrategy
类位于相同的架构层级上。更值得注意的是它们之间的循环依赖:Circle
依赖于DrawCircleStrategy
,但DrawCircleStrategy
也依赖于Circle
。但不要担心:尽管乍看起来可能会有问题,但事实并非如此。这是一种必要的关系,显示了Circle
确实拥有DrawCircleStrategy
,从而创建了所需的依赖倒置,正如在“准则 9:注意抽象的所有权”中讨论的那样。
“是否可能使用类模板来实现不同的绘制策略类?我想象中的情况类似于非循环访问者使用的访问者类”:⁸
//---- <DrawStrategy.h> ----------------
template< typename T >
class DrawStrategy
{
public:
virtual ~DrawStrategy() = default;
virtual void draw( T const& ) const = 0;
};
图 5-5. 策略设计模式的依赖图
这是一个很好的想法,也是您应该采取的方法。通过这个类模板,您可以将DrawStrategy
提升到更高的架构层级,重用代码,并遵循 DRY 原则(参见图 5-6)。此外,如果从一开始就采用了这种方法,我们就不会陷入人为耦合不同形状类型的陷阱。是的,我真的很喜欢这个!
尽管这是我们实现此类策略类的方式,但您仍不应期望这将减少基类的数量(仍然是相同的数量,只是生成的)或者它会为您节省大量工作。DrawStrategy
的实现,如OpenGLCircleStrategy
类,代表了大部分工作,并且几乎不会改变:
//---- <OpenGLCircleStrategy.h> ----------------
#include <Circle.h>
#include <DrawStrategy.h>
#include /* OpenGL graphics library */
class OpenGLCircleStrategy : public DrawStrategy<Circle>
{
// ...
};
图 5-6. 策略设计模式的更新依赖图
假设对于OpenGLSquareStrategy
的类似实现,现在我们可以将所有内容整合在一起,并再次绘制形状,但这次使用了策略设计模式进行适当解耦:
#include <Circle.h>
#include <Square.h>
#include <OpenGLCircleStrategy.h>
#include <OpenGLSquareStrategy.h>
#include <memory>
#include <vector>
int main()
{
using Shapes = std::vector<std::unique_ptr<Shape>>;
Shapes shapes{};
// Creating some shapes, each one
// equipped with the corresponding OpenGL drawing strategy
shapes.emplace_back(
std::make_unique<Circle>(
2.3, std::make_unique<OpenGLCircleStrategy>(/*...red...*/) ) );
shapes.emplace_back(
std::make_unique<Square>(
1.2, std::make_unique<OpenGLSquareStrategy>(/*...green...*/) ) );
shapes.emplace_back(
std::make_unique<Circle>(
4.1, std::make_unique<OpenGLCircleStrategy>(/*...blue...*/) ) );
// Drawing all shapes
for( auto const& shape : shapes )
{
shape->draw( /*some arguments*/ );
}
return EXIT_SUCCESS;
}
访问者和策略的比较
现在您已经了解了访问者设计模式和策略设计模式的差异,您可能会想知道这两者之间的区别。毕竟,实现看起来非常相似。但是,尽管在实现上存在类似之处,这两种设计模式的特性却大不相同。通过访问者设计模式,我们已经将通用操作的添加标识为变化点。因此,我们创建了一个通用操作的抽象,这反过来又使每个人都能添加操作。不幸的副作用是,添加新的形状类型变得不再容易。
使用策略设计模式时,我们已经将单个函数的实现细节标识为变化点。在引入这些实现细节的抽象后,我们仍然能够轻松地添加新类型的形状,但无法轻松地添加新操作。添加操作仍然需要您侵入性地添加虚成员函数。因此,策略设计模式的意图与访问者设计模式的意图相反。
结合这两种设计模式听起来可能很有前途,以获得这两种思想的优势(使添加类型和操作都变得容易)。不幸的是,这并不奏效:不管您先应用哪种设计模式,都将固定两个自由度中的一个⁹。因此,您应该牢记这两种设计模式的优势和劣势,并根据您对代码库演进预期的期望来应用它们。
分析策略设计模式的缺陷
我已经向您展示了策略设计模式的优势:通过引入这些细节的抽象,它使您能够减少对特定实现细节的依赖。然而,在软件设计中并没有银弹,每种设计都有一些缺点。策略设计模式也不例外,因此,还要考虑潜在的缺点。
首先,尽管某个操作的实现细节已被提取和隔离,但操作本身仍然是具体类型的一部分。这个事实证明了我们仍然不能轻松地添加操作的前述限制。相比之下,策略模式保留了面向对象编程的优势,使您能够轻松地添加新类型。
其次,及早识别这种变化点是值得的。否则将需要进行大规模重构。当然,这并不意味着您应该提前使用策略模式实现所有功能,以防止重构。这可能很快导致过度设计。但是,在第一次表明实现细节可能会发生变化或者希望有多个实现时,您应该迅速实现必要的修改。最好的建议,尽管有点虚无,是尽可能保持简单(KISS原则)。
第三,如果通过基类实现策略,性能肯定会受到额外运行时间接的影响。性能还受到许多手动分配(std::make_unique()
调用)、由于多个指针导致的结果内存碎片化以及各种间接访问的影响。这是可以预期的,然而您的实现的灵活性以及每个人都能添加新实现的机会,可能会超过这种性能损失。当然,这取决于情况,您将需要逐案决定。如果使用模板实现策略(参见关于“基于策略的设计”的讨论),这种缺点就不成问题了。
最后但并非最不重要的是,策略设计模式的主要缺点是,单个策略应该处理单个操作或者一小组相关的函数。否则,您将再次违反单一责任原则(SRP)。如果需要提取多个操作的实现细节,就必须有多个策略基类和多个数据成员,可以通过依赖注入来设置。例如,考虑具有额外serialize()
成员函数的情况:
//---- <DrawCircleStrategy.h> ----------------
class Circle;
class DrawCircleStrategy
{
public:
virtual ~DrawCircleStrategy() = default;
virtual void draw( Circle const& circle, /*some arguments*/ ) const = 0;
};
//---- <SerializeCircleStrategy.h> ----------------
class Circle;
class SerializeCircleStrategy
{
public:
virtual ~SerializeCircleStrategy() = default;
virtual void serialize( Circle const& circle, /*some arguments*/ ) const = 0;
};
//---- <Circle.h> ----------------
#include <Shape.h>
#include <DrawCircleStrategy.h>
#include <SerializeCircleStrategy.h>
#include <memory>
#include <utility>
class Circle : public Shape
{
public:
explicit Circle( double radius
, std::unique_ptr<DrawCircleStrategy> drawer
, std::unique_ptr<SerializeCircleStrategy> serializer
/* potentially more strategy-related arguments */ )
: radius_( radius )
, drawer_( std::move(drawer) )
, serializer_( std::move(serializer) )
// ...
{
/* Checking that the given radius is valid and that
the given std::unique_ptrs are not nullptrs */
}
void draw( /*some arguments*/ ) const override
{
drawer_->draw( *this, /*some arguments*/ );
}
void serialize( /*some arguments*/ ) const override
{
serializer_->serialize( *this, /*some arguments*/ );
}
double radius() const { return radius_; }
private:
double radius_;
std::unique_ptr<DrawCircleStrategy> drawer_;
std::unique_ptr<SerializeCircleStrategy> serializer_;
// ... Potentially more strategy-related data members
};
尽管这导致基类的数量大量增加和由于多个指针导致的更大实例,但这也引发了如何设计类的问题,以便方便地分配多个不同的策略。因此,策略设计模式在需要隔离少量实现细节的情况下表现得最为强大。如果遇到需要提取许多操作的细节的情况,可能更好考虑其他方法(例如,第七章中的外部多态设计模式或第八章中的类型擦除设计模式)。
基于策略的设计
正如前几章已经展示的,策略设计模式并不局限于动态多态性。相反,可以使用模板在静态多态性中完美实现策略的意图。例如,考虑标准库中的以下两个算法:
namespace std {
template< typename ForwardIt, typename UnaryPredicate >
constexpr ForwardIt
partition( ForwardIt first, ForwardIt last, UnaryPredicate p ); 
template< typename RandomIt, typename Compare >
constexpr void
sort( RandomIt first, RandomIt last, Compare comp ); 
} // namespace std
std::partition()
和 std::sort()
算法均利用了策略设计模式。std::partition()
的 UnaryPredicate
参数()和
std::sort()
的 Compare
参数()代表了从外部注入部分行为的一种方式。更具体地说,这两个参数允许您指定元素的排序方式。因此,这两种算法都提取了其行为的特定部分,并以概念的形式提供了抽象(见 “Guideline 7: Understand the Similarities Between Base Classes and Concepts”)。与策略的面向对象形式相比,这种方式不会产生任何运行时性能损失。
在 std::unique_ptr
类模板中也可以看到类似的方法:
namespace std {
template< typename T, typename Deleter = std::default_delete<T> > 
class unique_ptr;
template< typename T, typename Deleter > 
class unique_ptr<T[], Deleter>;
} // namespace std
对于基本模板()及其数组特化版本(
),可以指定显式的
Deleter
作为第二个模板参数。通过这个参数,您可以决定是否要通过 delete
、free()
或任何其他释放函数来释放资源。甚至可以“滥用” std::unique_ptr
来执行完全不同类型的清理。
这种灵活性也证明了策略设计模式的存在。模板参数允许您将一些清理行为注入类中。这种策略形式也被称为 基于策略的设计,这是由安德烈·亚历山德雷斯库在 2001 年引入的设计哲学。(¹⁰)思想是相同的:提取和隔离类模板的特定行为,以改进可变性、可扩展性、可测试性和可重用性。因此,基于策略的设计可以被认为是策略设计模式的静态多态形式。显然,这种设计非常有效,因为标准库中许多应用程序都展示了这个思想的应用。
您还可以将基于策略的设计应用于形状绘制示例。考虑以下 Circle
类的实现:
//---- <Circle.h> ----------------
#include <Shape.h>
#include <DrawCircleStrategy.h>
#include <memory>
#include <utility>
template< typename DrawCircleStrategy > 
class Circle : public Shape
{
public:
explicit Circle( double radius, DrawCircleStrategy drawer )
: radius_( radius )
, drawer_( std::move(drawer) )
{
/* Checking that the given radius is valid */
}
void draw( /*some arguments*/ ) const override
{
drawer_( *this, /*some arguments*/ ); 
}
double radius() const { return radius_; }
private:
double radius_;
DrawCircleStrategy drawer_; // Could possibly be omitted, if the given
// strategy is presumed to be stateless. };
而不是在构造函数中向DrawCircleStrategy
基类传递std::unique_ptr
,您可以通过模板参数指定策略()。最大的优势在于由于减少了指针间接性能的提升:您可以直接调用由
DrawCircleStrategy
提供的具体实现,而不是通过std::unique_ptr
调用。但缺点是,您将失去在运行时调整特定Circle
实例的绘制策略的灵活性。此外,您将不再拥有单一的Circle
类,而是每个绘制策略都会有一个Circle
的实例化。最后但同样重要的是,类模板通常完全驻留在头文件中,因此您可能会失去在源文件中隐藏实现细节的机会。如常,没有完美的解决方案,“正确”解决方案的选择取决于实际的上下文。
总之,策略设计模式是设计模式目录中最通用的示例之一。您会发现在动态和静态多态的领域中,它在许多情况下都非常有用。然而,并非每个问题都适合它——请注意其潜在的缺点。
指南 20:偏爱组合而非继承
在 90 年代和 21 世纪初期对面向对象编程(OOP)的巨大热情澎湃之后,今天的 OOP 处于防御状态。反对 OOP 并突出其缺点的声音越来越强烈和响亮。这不仅限于 C++社区,也存在于其他编程语言社区中。尽管整个 OOP 确实有一些局限性,让我们专注于似乎引起大多数争议的一个特性:继承。正如 Sean Parent 所言:¹¹
继承是邪恶的基类。
虽然继承被宣传为建模现实世界关系的一种非常自然和直观的方式,但事实证明它比承诺的要困难得多。当我们讨论了关于里氏替换原则(LSP)的细微失败时,您已经看到了使用继承的微妙缺陷“指南 6:遵循抽象的预期行为”。但继承还有其他常被误解的方面。
首先,继承总是被描述为简化可重用性。这似乎很直观,因为如果你只是从另一个类继承,你可以轻松地重用代码。不幸的是,这不是继承为您带来的重用类型。继承不是关于在基类中重用代码;相反,它是关于其他使用基类多态性的代码重用。例如,假设一个稍微扩展的Shape
基类,以下函数适用于所有类型的形状,因此可以被Shape
基类的所有实现重用:
class Shape
{
public:
virtual ~Shape() = default;
virtual void translate( /*some arguments*/ ) = 0;
virtual void rotate( /*some arguments*/ ) = 0;
virtual void draw( /*some arguments*/ ) const = 0;
virtual void serialize( /*some arguments*/ ) const = 0;
// ... Potentially other member functions ... };
void rotateAroundPoint( Shape& shape ); 
void mergeShapes( Shape& s1, Shape& s2 ); 
void writeToFile( Shape const& shape ); 
void sendViaRPC( Shape const& shape ); 
// ...
所有四个功能(,
,
, 和
)都基于
Shape
抽象构建。所有这些功能仅与所有形状的共同接口耦合,而不是任何特定形状。所有类型的形状都可以围绕一个点旋转,合并,写入文件并通过 RPC 发送。每种形状都“重用”这些功能。
通过抽象表达功能的能力创造了通过类型的多态使用重用代码的机会。预计这种功能将创建大量代码,与基类包含的少量代码相比。因此,真正的可重用性是通过类型的多态使用来创建的,而不是通过多态类型来创建。¹²
其次,继承据说有助于解耦软件实体。虽然这确实是正确的(记住,例如在“指南 9:注意抽象的所有权”中讨论依赖反转原则(DIP)),但通常没有解释继承也会创建耦合。您之前已经见过这种证据。在实现访问者设计模式时,您体验到继承强制您执行某些实现细节。在经典的访问者模式中,您必须实现Visitor
基类的纯虚函数,即使这对您的应用程序不是最佳选择。您还在函数参数或返回类型方面没有很多选择。这些事情是固定的。¹³
您还在讨论策略设计模式时经历了这种耦合。在这种情况下,继承强制了结构耦合,导致了更深的继承层次结构,结果是类的命名令人质疑,并且影响了重用性。
到了这一点,你可能会有这样的印象,即我试图完全贬低继承。嗯,老实说,我确实试图让它看起来有点不好,但只要有必要。明确地说:继承不是坏事,使用它也不是错的。相反,继承是一个非常强大的特性,如果正确使用,你可以做出令人难以置信的事情。然而,当然,你记得彼得·帕克原则:
伴随强大的力量而来的是巨大的责任。
彼得·帕克,又名蜘蛛侠
问题在于“如果正确使用”部分。继承已被证明很难正确使用(绝对比我们被引导相信的要难;见我的先前推理),因此不经意地被误用。它也被过度使用,因为许多开发人员有将其用于各种问题的习惯。
差异化编程在 20 世纪 90 年代失宠了,当时 OO 社区的许多人注意到,如果过度使用继承,可能会带来很多问题。
在许多情况下,继承既不是正确的方法,也不是正确的工具。大多数时候,最好使用组合而不是继承。尽管如此,你不应该对此感到惊讶,因为你已经看到它是正确的。组合是 OO 形式的策略设计模式如此成功的原因,而不是继承。策略设计模式之所以如此强大,是因为引入了抽象和相关数据成员的聚合,而不是基于继承的实现不同策略。实际上,你会发现许多设计模式都坚定地基于组合,而不是继承。所有这些都通过继承实现扩展,但它们本身是通过组合实现的。
委托给服务:拥有-优于-是。
Andrew Hunt 和 David Thomas,《实用程序员》
这是许多设计模式的普遍启示。我建议你紧握这一洞察力,因为它将在理解本书其余部分中所见的设计模式时非常有用,并且将提高你的实现质量。
指导原则 21:使用命令隔离所做的事情
在我们开始这个指南之前,让我们试一个实验。打开你喜欢的电子邮件客户端给我写封电子邮件。添加以下内容:“我喜欢你的书!它让我整晚都精神抖擞,让我忘记所有的烦恼。”好的,现在点击发送。干得好!稍等一下让我检查我的邮件...不,还没有收到...不,还是没有收到...让我们再试一次:点击重新发送。不,什么也没有。嗯,我猜一些服务器可能宕机了。或者所有的我的命令都失败了:WriteCommand
,SendCommand
,ResendCommand
,等等。多么不幸。但尽管这个失败的实验,你现在对另一个 GoF 设计模式有了相当好的了解:命令设计模式。
命令设计模式解释
命令设计模式关注的是抽象和隔离工作包,这些工作包(通常)一次执行(通常是立即执行)。为此,它识别了不同类型的工作包存在作为变化点,并引入了相应的抽象,以便轻松实现新类型的工作包。
命令设计模式
意图:“将请求封装为对象,从而让您可以使用不同的请求参数化客户端,排队或记录请求,并支持可撤销的操作。”¹⁸
图 5-7 显示了原始的 UML 构想,摘自 GoF 书籍。
图 5-7. 命令设计模式的 UML 表示
在这种基于 OO 的形式中,命令模式通过 Command
基类引入了一种抽象。这使得任何人都可以实现一个新类型的 ConcreteCommand
。这个 ConcreteCommand
可以执行任何操作,甚至对某种 Receiver
执行操作。通过特定类型的 Invoker
触发命令的效果。
作为命令设计模式的具体例子,让我们考虑一个计算器的以下实现。第一段代码片段展示了 CalculatorCommand
基类的实现,它表示对给定整数的数学操作的抽象:
//---- <CalculatorCommand.h> ----------------
class CalculatorCommand
{
public:
virtual ~CalculatorCommand() = default;
virtual int execute( int i ) const = 0; 
virtual int undo( int i ) const = 0; 
};
CalculatorCommand
类期望派生类实现纯虚拟的 execute()
函数()和纯虚拟的
undo()
函数()。
undo()
的期望是实现必要的操作来撤销 execute()
函数的效果。
Add
和 Subtract
类都代表计算器可能的命令,因此它们实现了 CalculatorCommand
基类:
//---- <Add.h> ----------------
#include <CalculatorCommand.h>
class Add : public CalculatorCommand
{
public:
explicit Add( int operand ) : operand_(operand) {}
int execute( int i ) const override 
{
return i + operand_;
}
int undo( int i ) const override 
{
return i - operand_;
}
private:
int operand_{};
};
//---- <Subtract.h> ----------------
#include <CalculatorCommand.h>
class Subtract : public CalculatorCommand
{
public:
explicit Subtract( int operand ) : operand_(operand) {}
int execute( int i ) const override 
{
return i - operand_;
}
int undo( int i ) const override 
{
return i + operand_;
}
private:
int operand_{};
};
Add
使用加法运算实现了 execute()
函数(),并使用减法运算实现了
undo()
函数()。
Subtract
实现了其逆操作( 和
)。
多亏了 CalculatorCommand
的层次结构,Calculator
类本身可以保持相当简单:
//---- <Calculator.h> ----------------
#include <CalculatorCommand.h>
#include <stack>
class Calculator
{
public:
void compute( std::unique_ptr<CalculatorCommand> command ); 
void undoLast(); 
int result() const;
void clear();
private:
using CommandStack = std::stack<std::unique_ptr<CalculatorCommand>>;
int current_{}; 
CommandStack stack_; 
};
//---- <Calculator.cpp> ----------------
#include <Calculator.h>
void Calculator::compute( std::unique_ptr<CalculatorCommand> command ) 
{
current_ = command->execute( current_ );
stack_.push( std::move(command) );
}
void Calculator::undoLast() 
{
if( stack_.empty() ) return;
auto command = std::move(stack_.top());
stack_.pop();
current_ = command->undo(current_);
}
int Calculator::result() const
{
return current_;
}
void Calculator::clear()
{
current_ = 0;
CommandStack{}.swap( stack_ ); // Clearing the stack }
我们需要的唯一函数用于计算活动是 compute()
()和
undoLast()
()。
compute()
函数传递给 CalculatorCommand
实例,立即执行它以更新当前值(),并将其存储在堆栈上(
)。
undoLast()
函数通过从堆栈中弹出它并调用 undo()
恢复最后执行的命令。
main()
函数将所有部分结合起来:
//---- <Main.cpp> ----------------
#include <Calculator.h>
#include <Add.h>
#include <Subtract.h>
#include <cstdlib>
int main()
{
Calculator calculator{}; 
auto op1 = std::make_unique<Add>( 3 ); 
auto op2 = std::make_unique<Add>( 7 ); 
auto op3 = std::make_unique<Subtract>( 4 ); 
auto op4 = std::make_unique<Subtract>( 2 ); 
calculator.compute( std::move(op1) ); // Computes 0 + 3, stores and returns 3
calculator.compute( std::move(op2) ); // Computes 3 + 7, stores and returns 10
calculator.compute( std::move(op3) ); // Computes 10 - 4, stores and returns 6
calculator.compute( std::move(op4) ); // Computes 6 - 2, stores and returns 4
calculator.undoLast(); // Reverts the last operation,
// stores and returns 6
int const res = calculator.result(); // Get the final result: 6
// ...
return EXIT_SUCCESS;
}
我们首先创建一个 calculator
()和一系列运算(
、
、
和
),然后依次应用。之后,我们通过
undo()
操作撤销 op4
,然后查询最终结果。
这一设计非常符合 SOLID 原则。它遵循 SRP,因为通过命令设计模式,变化点 已经被提取出来了。因此,compute()
和 undo()
不必是虚函数。SRP 也作为 OCP 的启用者,使我们能够添加新操作而无需修改任何现有代码。最后,如果将 Command
基类的所有权正确分配给高级别,则该设计还遵循 DIP(见 图 5-8)。
图 5-8。 命令设计模式的依赖图
还有一个属于经典示例的命令设计模式的第二个例子:线程池(oreil.ly/jGZd5
)。线程池的目的是保持多个线程等待任务以并行执行。这个想法通过以下 ThreadPool
类实现:它提供了一些成员函数,用于将某些任务卸载到特定数量的可用线程中:²⁰
class Command 
{ /* Abstract interface to perform and undo any kind of action. */ };
class ThreadPool
{
public:
explicit ThreadPool( size_t numThreads );
inline bool isEmpty() const;
inline size_t size() const;
inline size_t active() const;
inline size_t ready() const;
void schedule( std::unique_ptr<Command> command ); 
void wait();
// ... };
最重 最重要的是,ThreadPool
允许你通过 schedule()
函数调度任务()。这可以是任何任务:
ThreadPool
并不关心其线程必须执行什么样的工作。通过 Command
基类,它完全脱离了你调度的实际任务类型()。
通过简单地派生自Command
,你可以制定任意任务:
class FormattingCommand : public Command 
{ /* Implementation of formatting a disk */ };
class PrintCommand : public Command 
{ /* Implementation of performing a printer job */ }
int main()
{
// Creating a thread pool with initially two working threads
ThreadPool threadpool( 2 );
// Scheduling two concurrent tasks
threadpool.schedule(
std::make_unique<FormattingCommand>( /*some arguments*/ ) );
threadpool.schedule(
std::make_unique<PrintCommand>( /*some arguments*/ ) );
// Waiting for the thread pool to complete both commands
threadpool.wait();
return EXIT_SUCCESS;
}
这样一个任务的一个可能示例是FormattingCommand
()。这个任务将获取触发通过操作系统格式化磁盘所需的信息。或者,你可以想象一个
PrintCommand
,它接收触发打印作业所需的所有数据()。
同样在这个ThreadPool
示例中,你可以看到命令设计模式的影响:不同类型的任务被识别为变化点并被提取出来(这再次遵循 SRP),这使你能够在不修改现有代码的情况下实现不同类型的任务(OCP 的遵循)。
当然,标准库中也有一些示例。例如,你将在std::for_each()
()算法中看到命令设计模式的应用:
namespace std {
template< typename InputIt, typename UnaryFunction >
constexpr UnaryFunction
for_each( InputIt first, InputIt last, UnaryFunction f ); 
} // namespace std
使用第三个参数,你可以指定算法应在所有给定元素上执行什么任务。这可以是任何操作,从操作元素到打印元素,可以通过简单的函数指针或强大的 lambda 表达式指定:
#include <algorithms>
#include <cstdlib>
void multBy10( int& i )
{
i *= 10;
}
int main()
{
std::vector<int> v{ 1, 2, 3, 4, 5 };
// Multiplying all integers with 10
std::for_each( begin(v), end(v), multBy10 );
// Printing all integers
std::for_each( begin(v), end(v), []( int& i ){
std::cout << i << '\n';
} );
return EXIT_SUCCESS;
}
命令设计模式与策略设计模式的比较
“等等!”我听到你们喊道。“你不是刚解释过标准库的算法是通过策略设计模式实现的吗?这不是对之前声明的完全矛盾吗?” 是的,你说得对。就在几页前,我确实解释了std::partition()
和std::sort()
算法是通过策略设计模式实现的。因此,我承认这似乎是我自相矛盾了。然而,我并没有声称所有算法都基于策略。所以让我解释一下。
从结构上看,策略(Strategy)和命令(Command)设计模式是相同的:无论是使用动态还是静态多态性,从实现的角度来看,策略和命令之间没有区别²¹。两者的区别完全在于设计模式的意图。策略设计模式指定了如何执行某些操作,而命令设计模式指定了什么操作应该执行。例如,考虑std::partition()
和std::for_each()
算法:
namespace std {
template< typename ForwardIt, typename UnaryPredicate >
constexpr ForwardIt
partition( ForwardIt first, ForwardIt last, UnaryPredicate p ); 
template< typename InputIt, typename UnaryFunction >
constexpr UnaryFunction
for_each( InputIt first, InputIt last, UnaryFunction f ); 
} // namespace std
在std::partition()
算法中,你只能控制如何选择元素(),而在
std::for_each()
算法中,你可以控制对给定范围内每个元素应用什么操作()。在形状示例中,你只能指定如何绘制某种形状,而在
ThreadPool
示例中,你完全可以决定什么操作被安排²²。
有两个指标可用于应用的两种设计模式。首先,如果你有一个对象并使用一个动作来配置它(你进行依赖注入),那么你(很可能)在使用策略设计模式。如果你不使用动作来配置对象,而是直接执行动作,那么你(很可能)在使用命令设计模式。在我们的Calculator
示例中,我们没有传递一个动作来配置Calculator
,而是立即执行了动作。因此,我们基于命令模式构建。
另外,我们也可以通过策略来实现Calculator
:
//---- <CalculatorStrategy.h> ----------------
class CalculatorStrategy
{
public:
virtual ~CalculatorStrategy() = default;
virtual int compute( int i ) const = 0;
};
//---- <Calculator.h> ----------------
#include <CalculatorStrategy.h>
class Calculator
{
public:
void set( std::unique_ptr<CalculatorStrategy> operation ); 
void compute( int value ); 
// ...
private:
int current_{};
std::unique_ptr<CalculatorStrategy> operation_; // Requires a default! };
//---- <Calculator.cpp> ----------------
#include <Calculator.h>
void set( std::unique_ptr<CalculatorStrategy> operation ) 
{
operation_ = std::move(operation);
}
void Calculator::compute( int value ) 
{
current_ = operation_.compute( value );
}
在这个Calculator
的实现中,策略是通过一个set()
函数注入的()。
compute()
函数使用注入的策略执行计算()。然而,请注意,这种方法更难以实现合理的撤销机制。
第二个指标用于判断是否使用命令或策略的是undo()
操作。如果你的动作提供了一个undo()
操作来撤销已执行的操作,并封装了执行undo()
所需的一切,那么你很可能在处理命令设计模式。如果你的动作没有提供undo()
操作,因为它专注于如何执行某事或者因为它缺少撤销操作所需的信息,那么你很可能在处理策略设计模式。然而,我应该明确指出,缺少undo()
操作并不是策略模式的确凿证据。如果意图是指定应该做什么,那么它仍然可以是命令的实现。例如,std::for_each()
算法仍然期望一个Command
,尽管不需要undo()
操作。undo()
操作应被视为命令设计模式的可选功能,而非定义性功能。在我看来,undo()
并不是命令设计模式的优势,而是纯粹的必要性:如果一个动作完全自由地做任何它想做的事情,那么只有这个动作本身才能撤销操作(当然,假设你不想为每次调用命令存储一份完整的副本)。
我承认这两种模式之间没有明确的分隔,存在一些灰色地带。然而,争论某事是命令还是策略并因此失去几个朋友是毫无意义的。比起同意你使用的是哪一种模式,更重要的是利用它们提取实现细节并分离关注点的能力。这两种设计模式都帮助你隔离变化和扩展,从而帮助你遵循单一责任原则和开闭原则。毕竟,正是这种能力可能是为什么 C++标准库中有这两种设计模式的许多示例的原因。
分析命令设计模式的缺点
命令设计模式的优势与策略设计模式类似:通过引入某种形式的抽象(例如基类或概念),命令帮助您解耦具体任务的实现细节。这种抽象使您能够轻松添加新任务。因此,命令既满足 SRP 又满足 OCP。
然而,命令设计模式也有其缺点。与策略设计模式相比,缺点列表确实相对较短。唯一真正的缺点是,如果您通过基类(即经典的 GoF 风格)来实现命令,会因为额外的间接引用而增加运行时性能开销。再次强调,您需要自己决定,增加的灵活性是否超过了运行时性能的损失。
总结一下,就像策略设计模式一样,命令设计模式是设计模式目录中最基本和最有用的之一。您将在许多不同的情况下遇到命令的实现,包括静态和动态的。因此,理解命令的意图、优势和劣势将在很多时候证明是有用的。
Guideline 22: 更倾向于值语义而非引用语义
在"Guideline 19: Use Strategy to Isolate How Things Are Done"和"Guideline 21: Use Command to Isolate What Things Are Done"中,我分别向您介绍了策略和命令设计模式。在这两种情况下,示例都坚定地建立在经典的 GoF 风格上:它们使用继承层次结构进行动态多态性。由于这种经典的面向对象风格缺乏现代感,我想现在所有你的焦虑可能已经让你的美甲师为你担忧了。您可能会想:“难道实现策略和命令没有另一种更好的方式吗?一种更‘现代化’的方法?” 是的,请放心;有的。这种方法对于我们通常称之为“现代 C ++”哲学如此重要,以至于它绝对值得一个单独的指南来解释其优势。我相信您的美甲师会理解这个小偏离的原因。
GoF 风格的缺点:引用语义
由四人组收集并在其书中介绍的设计模式是作为面向对象设计模式引入的。书中描述的几乎所有 23 种设计模式都至少使用了一个继承层次结构,因此牢固植根于面向对象编程的领域。模板作为明显的第二选择,在 GoF 的书中没有起到任何作用。这种纯粹的面向对象风格就是我所称的 GoF 风格。从今天的角度来看,这种风格可能看起来是 C++ 中一种古老而过时的做法,但我们当然要记住,该书发布于 1994 年 10 月。当时,模板可能已经成为语言的一部分(至少它们已经在 注释参考手册(ARM) 中得到正式描述),但我们没有模板相关的习惯用法,而且 C++ 仍然普遍被视为面向对象编程语言。²³
今天,我们知道 GoF 风格带来了许多不利因素。其中最重要的,通常也是最常被提及的一个,就是性能:²⁴
-
虚函数增加了运行时开销,并减少了编译器优化的机会。
-
多次分配小型多态对象会增加额外的运行时开销,导致内存碎片化,并且会导致子优化的缓存使用。
-
数据排列方式常常与数据访问方案相对立而显得低效。²⁵
性能确实不是 GoF 风格的强项之一。不过,我们不打算完全讨论 GoF 风格可能存在的所有缺陷,而是专注于我认为特别值得关注的另一种缺点:GoF 风格属于我们今天所谓的 引用语义(有时也称为 指针语义)。这种风格因其主要使用指针和引用而得名。为了演示引用语义这一术语的含义以及为什么它通常带有相当负面的内涵,让我们来看看以下使用 C++20 std::span
类模板的代码示例:
#include <cstdlib>
#include <iostream>
#include <span>
#include <vector>
void print( std::span<int> s ) 
{
std::cout << " (";
for( int i : s ) {
std::cout << ' ' << i;
}
std::cout << " )\n";
}
int main()
{
std::vector<int> v{ 1, 2, 3, 4 }; 
std::vector<int> const w{ v }; 
std::span<int> const s{ v }; 
w[2] = 99; // Compilation error! 
s[2] = 99; // Works! 
// Prints ( 1 2 99 4 );
print( s ); 
v = { 5, 6, 7, 8, 9 }; 
s[2] = 99; // Works! 
// Prints ?
print( s ); 
return EXIT_SUCCESS;
}
print()
函数()展示了
std::span
的用途。std::span
类模板表示数组的抽象。print()
函数可以与任何类型的数组(内建数组、std::array
、std::vector
等)一起使用,而不耦合到任何特定类型的数组上。在展示的 std::span
动态尺寸示例中(没有第二个模板参数表示数组的大小),std::span
的典型实现包含两个数据成员:指向数组第一个元素的指针以及数组的大小。因此,std::span
被认为易于复制并且通常按值传递。此外,print()
简单遍历 std::span
的元素(在我们的案例中是整数),并通过 std::cout
打印它们。
在main()
函数中,我们首先创建了std::vector<int>
v
,并立即用整数1
、2
、3
和4
填充它()。然后我们创建另一个
std::vector
w
作为v
的副本(),以及
std::span
s
()。
w
和s
都带有const
限定词。接着,我们试图修改w
和s
的第2
个索引处的元素。尝试修改w
失败并导致编译错误:w
被声明为const
,因此无法修改其包含的元素()。然而,尝试修改
s
却顺利进行,尽管s
也被声明为const
()。
这种情况的原因在于,s
不是v
的副本,也不代表一个值。相反,它表示对v
的引用。实质上,它的行为类似于指向v
第一个元素的指针。因此,const
限定词在语义上与声明指针为const
具有相同的效果:
std::span<int> const s{ v }; // s acts as pointer to the first element of v
int* const ptr{ v.data() }; // Equivalent semantical meaning
尽管指针ptr
在其生命周期内无法更改,并将始终引用v
的第一个元素,但引用的整数可以轻松修改。要防止对整数的赋值,你需要为int
添加额外的const
限定词:
std::span<int const> const s{v}; // s represents a const pointer to a const int
int const* const ptr{ v.data() }; // Equivalent semantical meaning
由于指针和std::span
的语义等效,显然std::span
属于引用语义的范畴。这带来了许多额外的风险,正如main()
函数的其余部分所展示的那样。作为下一步,我们打印由s
引用的元素()。请注意,你也可以直接传递向量
v
,因为std::span
提供了必要的转换构造函数来接受std::vector
。print()
函数将正确地产生以下输出:
( 1 2 99 4 )
由于我们可以(而且因为现在,数字 1 到 4 可能开始显得有点无聊),我们现在将一组新的数字分配给向量v
()。诚然,选择
5
、6
、7
、8
和9
既不特别创意,也不有趣,但它会达到预期的效果。紧接着,我们再次通过s
的方式写入第二个索引(),并再次打印由
s
引用的元素()。当然,我们期望的输出是
( 5 6 99 8 9 )
,但遗憾的是实际情况并非如此。我们可能会得到以下输出:²⁶
( 1 2 99 4 )
或许这完全让你震惊,你可能会多长几根白发。²⁷ 也许你只是感到惊讶。或者你会知情地微笑并点头:是的,当然,未定义行为!当向 std::vector
v
分配新值时,我们不仅改变了值,还改变了向量的大小。现在它需要存储五个元素,而不是四个。因此,向量可能进行了重新分配,并更改了其第一个元素的地址。不幸的是,std::span
s
没有注意到这一点,仍然坚定地持有先前第一个元素的地址。因此,当我们尝试通过 s
写入 v
时,我们并没有写入当前 v
的数组,而是写入了一个已经丢弃的内存块,它曾经是 v
的内部数组。经典的未定义行为,以及引用语义的经典问题。
“嘿,你是在试图贬低 std::span
吗?”你问道。不,我并不是在暗示 std::span
,也包括 std::string_view
,不好用。相反,我实际上很喜欢这两者,因为它们分别提供了非常简单和便宜的从各种数组和字符串抽象出来的工具。然而,请记住,每种工具都有其优点和缺点。当我使用它们时,我会有意识地使用,充分意识到任何非拥有引用类型都需要注意所引用值的生命周期。例如,虽然我认为它们对于函数参数非常有用,但我倾向于不将它们用作数据成员。生命周期问题的风险实在是太高了。
引用语义:第二个例子
“好吧,当然我知道这一点,”你辩解道。“我也不会长时间存储 std::span
。但是,我仍然不确定引用和指针是否会有问题。”好吧,如果第一个例子还不够震撼,我还有第二个例子。这次我使用 STL 算法之一 std::remove()
。std::remove()
算法接受三个参数:一个迭代器对,用于遍历以删除特定值的所有元素的范围,以及表示要删除的值的第三个参数。特别要注意第三个参数是通过引用传递的 const
:
template< typename ForwardIt, typename T >
constexpr ForwardIt remove( ForwardIt first, ForwardIt last, T const& value );
让我们看一下以下代码示例:
std::vector<int> vec{ 1, -3, 27, 42, 4, -8, 22, 42, 37, 4, 18, 9 }; 
auto const pos = std::max_element( begin(vec), end(vec) ); 
vec.erase( std::remove( begin(vec), end(vec), *pos ), end(vec) ); 
我们从 std::vector
v
开始,它被初始化为一些随机数()。现在我们有兴趣移除所有代表向量中最大值的元素。在我们的例子中,这个值是
42
,在向量中存储了两次。执行移除的第一步是使用 std::max_element()
算法确定最大值。std::max_element()
返回一个指向最大值的迭代器。如果范围内有多个等于最大元素的元素,则返回指向第一个这样的元素的迭代器()。
移除最大值的第二步是调用std::remove()
算法()。我们通过解引用
pos
迭代器传递元素范围(使用begin(vec)
和end(vec)
)和最大值。最后但同样重要的是,我们通过调用erase()
成员函数完成操作:我们删除std::remove()
算法返回的位置到向量末尾之间的所有值。这些操作序列通常被称为擦除-移除习语。
我们期望向量中的两个 42
值都被移除,因此我们期望得到以下结果:
( 1 -3 27 4 -8 22 37 4 18 9 )
不幸的是,这种期望没有实现。相反,向量现在包含以下值:
( 1 -3 27 4 -8 22 42 37 18 9 )
请注意,向量仍然包含 42
,但现在缺少 4
。这种行为异常的根本原因再次是引用语义:通过将解引用的迭代器传递给remove()
算法,我们隐含地表明该位置存储的值应该被移除。然而,在移除第一个 42
后,该位置保存的值是 4
。remove()
算法会移除所有值为 4
的元素。因此,接下来被移除的不是下一个 42
而是下一个 4
,依此类推。²⁸
“好的,我明白了!但那个问题已经是历史了!今天我们不再使用擦除-移除习语了。C++20 最终为我们提供了免费的std::erase()
函数!”我很想同意这个说法,但不幸的是我只能承认std::erase()
函数的存在:
template< typename T, typename Alloc, typename U >
constexpr typename std::vector<T,Alloc>::size_type
erase( std::vector<T,Alloc>& c, U const& value );
std::erase()
函数也通过引用-to-const
方式接受其第二个参数,即要移除的值。因此,我刚描述的问题依然存在。解决这个问题的唯一方法是明确确定最大元素并将其传递给std::remove()
算法():
std::vector<int> vec{ 1, -3, 27, 42, 4, -8, 22, 42, 37, 4, 18, 9 };
auto const pos = std::max_element( begin(vec), end(vec) );
auto const greatest = *pos; 
vec.erase( std::remove( begin(vec), end(vec), greatest ), end(vec) );
“你是认真建议我们不再使用引用参数了吗?”不,绝对不是!当然你应该使用引用参数,例如出于性能考虑。但是,我希望引起一定的注意。希望你现在理解问题了:引用,尤其是指针,使我们的生活变得更加困难。理解代码变得更加困难,因此更容易在代码中引入 bug。特别是指针会引发更多问题:它是有效指针还是nullptr
?谁拥有指针后面的资源并管理生命周期?当然,由于我们扩展了工具箱并有智能指针可供使用,生命周期问题并不是什么大问题。正如核心指导方针 R.3清楚地指出:
一个原始指针(a T*)是非拥有的。
结合智能指针负责所有权的概念,这极大地清理了指针语义的含义。但尽管智能指针当然是一个非常宝贵的工具,有充分的理由被誉为“现代 C++”的一大成就,但最终它们只是修补了引用语义在我们理解代码方面造成的漏洞。是的,引用语义使理解代码和推理重要细节变得更加困难,因此我们希望避免使用它。
现代 C++ 哲学:值语义
“但等等,”我能听到你的反对声,“我们还有什么选择?我们该怎么办?以及我们如何处理继承层次结构?我们无法避免在那里使用指针,对吧?”如果你在思考类似的问题,那么我有一个非常好的消息告诉你:是的,有一个更好的解决方案。一个可以使你的代码更易于理解、更易于推理的解决方案,甚至可能对其性能产生积极影响(记住我们也谈到了引用语义的负面性能影响)。这个解决方案就是值语义。
在 C++ 中,值语义并非什么新鲜事物。这个概念早已成为原始 STL 的一部分。让我们来考虑 STL 中最著名的容器之一,std::vector
:
std::vector<int> v1{ 1, 2, 3, 4, 5 };
auto v2{ v1 }; 
assert( v1 == v2 ); 
assert( v1.data() != v2.data() ); 
v2[2] = 99; 
assert( v1 != v2 ); 
auto const v3{ v1 }; 
v3[2] = 99; // Compilation error!
我们从一个名为 v1
的 std::vector
开始,其中装有五个整数。在接下来的一行中,我们创建了 v1
的一个副本,称为 v2
()。向量
v2
是一个真正的副本,有时也被称为深拷贝,它现在包含了自己的一块内存和自己的整数,并且不引用 v1
中的整数。²⁹ 我们可以通过比较这两个向量来确认(它们证明是相等的;参见 ),但第一个元素的地址是不同的(
)。改变
v2
中的一个元素()导致这两个向量不再相等(
)。是的,这两个向量都有自己的数组。它们不共享内容,也就是说,它们不尝试“优化”复制操作。你可能听说过这样的技术,比如写时复制技术。而且,你可能已经知道,在 C++11 之前,
std::string
通常使用这种常见的实现。然而,自从 C++11 开始,由于 C++ 标准中规定的要求,std::string
不再允许使用写时复制。原因是这种“优化”在多线程世界中很容易变成一种逆优化。因此,我们可以确信,复制构造确实创建了一个真正的副本。
最后但同样重要的是,我们创建了另一个名为v3
的副本,我们声明为const
()。如果我们现在试图改变
v3
的值,我们将会得到编译错误。这表明const
向量不仅防止添加和删除元素,而且所有元素也被视为const
。
从语义角度来看,这意味着std::vector
,就像 STL 中的任何容器一样,被视为一个值。是的,一个值,就像一个int
一样。如果我们复制一个值,我们不是复制值的一部分,而是整个值。如果我们将一个值设为const
,它不仅部分const
,而是完全const
。这就是值语义的原理。而且我们已经看到了几个优点:值比指针和引用更容易推理。例如,改变一个值不会影响到其他值。变化发生在本地,而不是其他地方。这是编译器在优化工作中大量利用的优势。此外,值不让我们考虑所有权问题。一个值负责其自身的内容。值也使得思考线程问题变得(更)容易。这并不意味着问题就完全没有了(你希望!),但代码确实更容易理解。值不会给我们留下很多问题。
“好吧,我明白代码清晰性的观点了,”你反驳道,“但性能呢?处理复制操作会不会特别昂贵?”嗯,你说得对;复制操作可能很昂贵。然而,只有在真正发生时它们才昂贵。在实际代码中,我们通常可以依赖于拷贝省略,移动语义,以及呃……按引用传递。³⁰ 此外,从性能角度来看,我们已经看到值语义可能会给我们带来性能提升。是的,我当然是指在“指导原则 17:考虑使用 std::variant 来实现访问者”中的std::variant
示例。在那个例子中,使用类型为std::variant
的值由于较少的指针间接引用和更好的内存布局和访问模式显著提高了性能。
值语义:第二个例子
让我们看一个第二个例子。这次我们考虑以下to_int()
函数:³¹
int to_int( std::string_view );
此函数解析给定的字符串(是的,我正在使用std::string_view
来提高性能),并将其转换为int
。现在对我们来说最有趣的问题是,如果函数无法将字符串转换为int
,该函数应该如何处理错误,或者换句话说,函数在这种情况下应该怎么做。第一种选择是返回0
。然而,这种方法是值得怀疑的,因为0
是to_int()
函数的有效返回值。我们将无法区分成功和失败。³² 另一种可能的方法是抛出异常。尽管异常可能是用于信号错误情况的 C++本地工具,但对于这个特定问题来说,根据个人风格和偏好,这可能会显得有些过度。此外,知道异常在 C++社区的大部分情况下不能使用,这种选择可能会限制函数的可用性。³³
第三种可能性是稍微改变签名:
bool to_int( std::string_view s, int& );
现在,该函数的第二个参数是对可变int
的引用,并返回一个bool
。如果成功,函数返回true
并设置传递的整数;如果失败,则返回false
并保持int
不变。虽然这对你来说可能是一个合理的折中,但我认为我们现在已经偏离了引用语义的领域(包括所有潜在的误用)。与此同时,代码的清晰度已经减弱:返回结果的最自然方式是通过返回值,但现在结果却是通过输出值产生的。例如,这阻止了我们将结果赋给const
值。因此,到目前为止,我认为这是最不理想的方法。
第四种方法是通过指针返回:
std::unique_ptr<int> to_int( std::string_view );
从语义上讲,这种方法非常吸引人:如果成功,函数返回一个指向int
的有效指针;如果失败,则返回nullptr
。因此,代码的清晰度得到了改善,因为我们可以清楚地区分这两种情况。然而,我们是以动态内存分配、需要使用std::unique_ptr
来管理生命周期的代价来换取这一优势,同时我们仍然停留在引用语义的领域。因此,问题是:我们如何利用语义优势,但又坚持值语义?解决方案以std::optional
的形式呈现:³⁴
std::optional<int> to_int( std::string_view );
std::optional
是一个值类型,代表任何其他值,在我们的例子中是一个int
。因此,std::optional
可以取得所有int
可以取得的值。然而,std::optional
的特殊之处在于它为包装值添加了一个额外的状态,表示没有值。因此,我们的std::optional
是一个可能存在也可能不存在的int
:
#include <charconv>
#include <cstdlib>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
std::optional<int> to_int( std::string_view sv )
{
std::optional<int> oi{};
int i{};
auto const result = std::from_chars( sv.data(), sv.data() + sv.size(), i );
if( result.ec != std::errc::invalid_argument ) {
oi = i;
}
return oi;
}
int main()
{
std::string value = "42";
if( auto optional_int = to_int( value ) )
{
// ... Success: the returned std::optional contains an integer value
}
else
{
// ... Failure: the returned std::optional does not contain a value
}
}
在语义上,这相当于指针方法,但我们不付出动态内存分配的代价,也不必处理生命周期管理。³⁵ 这种解决方案在语义上清晰、易理解且高效。
偏爱使用值语义来实现设计模式。
“那设计模式呢?”你问道。“几乎所有 GoF 模式都基于继承层次结构,因此使用引用语义。我们应该如何处理这个问题?”这是一个很好的问题。它为我们提供了一个完美的过渡到下一个指南。简短地回答:您应该优先使用值语义解决方案来实现设计模式。是的,认真的!这些解决方案通常会导致更全面、可维护的代码,而且(通常)性能更好。
指南 23:偏爱基于值的策略和命令实现。
在“指南 19:使用策略隔离操作的方式”中,我向您介绍了策略设计模式,在“指南 21:使用命令隔离所做的操作”中,我向您介绍了命令设计模式。我展示了这两种设计模式是您日常工具箱中必不可少的解耦工具。然而,在“指南 22:偏爱值语义而非引用语义”中,我向您提出了使用值语义而非引用语义的建议。当然,这引发了一个问题:您如何将这一智慧应用于策略和命令设计模式?好吧,这里有一个可能的值语义解决方案:利用std::function
的抽象能力。
std::function 简介
如果你还没有听说过std::function
,请允许我向您介绍。std::function
代表了一个可调用对象(例如函数指针、函数对象或 lambda 表达式)的抽象。唯一的要求是可调用对象满足特定的函数类型,这个函数类型作为唯一的模板参数传递给std::function
。以下代码给出了一个印象:
#include <cstdlib>
#include <functional>
void foo( int i )
{
std::cout << "foo: " << i << '\n';
}
int main()
{
// Create a default std::function instance. Calling it results
// in a std::bad_function_call exception
std::function<void(int)> f{}; 
f = []( int i ){ // Assigning a callable to 'f' 
std::cout << "lambda: " << i << '\n';
};
f(1); // Calling 'f' with the integer '1' 
auto g = f; // Copying 'f' into 'g' 
f = foo; // Assigning a different callable to 'f' 
f(2); // Calling 'f' with the integer '2' 
g(3); // Calling 'g' with the integer '3' 
return EXIT_SUCCESS;
}
在main()
函数中,我们创建了一个std::function
的实例,称为f
()。模板参数指定所需的函数类型。在我们的例子中,这是
void(int)
。“函数类型……”你说。“你难道不是指函数 指针 类型吗?”嗯,因为这确实是你可能很少见到的东西,让我解释一下函数类型是什么,与你可能更常见的函数指针进行对比。以下示例同时使用了函数类型和函数指针类型:
using FunctionType = double(double);
using FunctionPointerType = double(*)(double);
// Alternatively:
// using FunctionPointerType = FunctionType*;
第一行显示一个函数类型。这种类型表示任何接受double
并返回double
的函数。这种函数类型的例子包括对应的 std::sin
,std::cos
,std::log
,或 std::sqrt
的重载。第二行显示一个函数指针类型。注意括号中的小星号—它使它成为指针类型。这种类型表示函数类型 FunctionType
的一个函数的地址。因此,函数类型和函数指针类型之间的关系非常类似于 int
和指向 int
的指针之间的关系:虽然有很多 int
值,但指向 int
的指针存储的是确切一个 int
的地址。
回到std::function
的例子:最初,实例是空的,因此无法调用它。如果你仍然尝试调用,std::function
实例将抛出std::bad_function_call
异常。最好不要挑衅它。让我们更好地为其分配一些满足函数类型要求的可调用对象,例如一个(可能是有状态的)lambda()。该 lambda 接受一个
int
,不返回任何东西。相反,它通过描述性输出消息打印出已被调用的信息():
lambda: 1
好的,这个工作得很好。让我们尝试其他事情:现在我们通过f
创建另一个std::function
实例g
()。然后我们将另一个可调用对象分配给
f
()。这次,我们分配一个指向函数
foo()
的指针。再次,这个可调用对象满足std::function
实例的要求:它接受一个int
并返回空。在赋值后,直接调用f
并传入整数2
,触发预期的输出()。
foo: 2
这可能是一个容易的例子。然而,下一个函数调用就有趣多了。如果你使用整数3
调用g
(),输出表明
std::function
坚定地基于值语义:
lambda: 3
在初始化g
时,实例f
被复制了。它被复制为应当复制的值:并不执行“浅复制”,即在后续更改f
时影响g
,而是执行完全复制(深复制),包括 lambda 的复制。³⁶ 因此,更改f
不会影响g
。这就是值语义的好处:代码简单直观,你不必担心无意中在其他地方破坏了什么。
此时,std::function
的功能可能感觉有点像魔法:std::function
实例如何能接受任何类型的可调用对象,包括像 lambda 这样的东西?它如何存储任何可能的类型,即使它不能知道这些类型,而这些类型显然没有任何共同之处?别担心:在第八章中,我将为你介绍一种称为类型擦除的技术,这是std::function
背后的魔法。
重构形状的绘制
std::function
提供了重构我们的形状绘制示例的一切需要,来自于“指导原则 19:使用策略隔离事务的执行方式”:它表示单个可调用对象的抽象,这正是我们需要替换DrawCircleStrategy
和DrawSquareStrategy
层次结构的东西,每个都包含一个虚函数。因此,我们依赖于std::function
的抽象能力:
//---- <Shape.h> ----------------
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw( /*some arguments*/ ) const = 0;
};
//---- <Circle.h> ----------------
#include <Shape.h>
#include <functional>
#include <utility>
class Circle : public Shape
{
public:
using DrawStrategy = std::function<void(Circle const&, /*...*/)>; 
explicit Circle( double radius, DrawStrategy drawer ) 
: radius_( radius )
, drawer_( std::move(drawer) ) 
{
/* Checking that the given radius is valid and that
the given 'std::function' instance is not empty */
}
void draw( /*some arguments*/ ) const override
{
drawer_( *this, /*some arguments*/ );
}
double radius() const { return radius_; }
private:
double radius_;
DrawStrategy drawer_; 
};
//---- <Square.h> ----------------
#include <Shape.h>
#include <functional>
#include <utility>
class Square : public Shape
{
public:
using DrawStrategy = std::function<void(Square const&, /*...*/)>; 
explicit Square( double side, DrawStrategy drawer ) 
: side_( side )
, drawer_( std::move(drawer) ) 
{
/* Checking that the given side length is valid and that
the given 'std::function' instance is not empty */
}
void draw( /*some arguments*/ ) const override
{
drawer_( *this, /*some arguments*/ );
}
double side() const { return side_; }
private:
double side_;
DrawStrategy drawer_; 
};
首先,在Circle
类中,我们为std::function
的预期类型添加了一个类型别名()。这个
std::function
类型代表任何可调用对象,它可以接受一个Circle
以及可能还有其他几个与绘图相关的参数,并且不返回任何内容。当然,在Square
类中我们也添加了相应的类型别名()。在
Circle
和Square
的构造函数中,我们现在接受一个std::function
类型的实例(),作为替换指向策略基类的指针(
DrawCircleStrategy
或DrawSquareStrategy
)。这个实例立即被移动()到
drawer_
数据成员中,它也是DrawStrategy
类型的()。
“嘿,为什么你通过值来接受std::function
实例?这不是非常低效吗?我们难道不应该优先通过引用传递到const
吗?”简而言之:不,通过值传递并不低效,而是一种优雅的折中方案。尽管我承认,这可能令人惊讶。由于这绝对是值得注意的实现细节,让我们更仔细地看一看。
如果我们使用了对const
的引用,我们将会遇到rvalues不必要被复制的缺点。如果我们传递的是右值,该右值将绑定到对const
的引用。然而,当将这个对const
的引用传递给数据成员时,它将被复制。这并不是我们的意图:我们自然希望它被移动。简单的原因是,我们无法从const
对象中移动(即使使用std::move
)。因此,为了有效处理右值,我们需要为Circle
和Square
的构造函数提供接受DrawStrategy
的右值引用(DrawStrategy&&
)的重载。出于性能考虑,我们将为Circle
和Square
都提供两个构造函数。³⁷
提供两个构造函数的方法(一个用于左值,一个用于右值)确实有效且高效,但我不一定会称其为优雅。此外,我们可能应该避免同事们必须处理这个问题。³⁸ 因此,我们利用了std::function
的实现。std::function
提供了复制构造函数和移动构造函数,因此我们知道它可以高效地移动。当我们按值传递std::function
时,将调用复制构造函数或移动构造函数。如果我们传递的是左值,则调用复制构造函数,复制该左值。然后我们将该副本移动到数据成员中。总之,我们将执行一次复制和一次移动来初始化drawer_
数据成员。如果我们传递的是右值,则调用移动构造函数,移动该右值。然后将结果参数strategy
移动到数据成员drawer_
中。总之,我们将执行两次移动操作来初始化drawer_
数据成员。因此,这种形式代表了一个很好的折衷:它既优雅,又在效率上几乎没有区别。
一旦我们重构了Circle
和Square
类,我们可以以任何形式实现不同的绘制策略(函数、函数对象或 lambda 表达式)。例如,我们可以将以下OpenGLCircleStrategy
实现为函数对象:
//---- <OpenGLCircleStrategy.h> ----------------
#include <Circle.h>
class OpenGLCircleStrategy
{
public:
explicit OpenGLCircleStrategy( /* Drawing related arguments */ );
void operator()( Circle const& circle, /*...*/ ) const; 
private:
/* Drawing related data members, e.g. colors, textures, ... */
};
我们唯一需要遵循的约定是,我们需要提供一个调用运算符,该运算符接受一个Circle
和可能还有几个与绘制相关的参数,并且不返回任何内容(满足函数类型void(Circle const&, /*…*/)
)()。
假设我们为OpenGLSquareStrategy
实现了类似的方法,现在我们可以创建不同类型的形状,配置它们所需的绘制行为,最终绘制它们:
#include <Circle.h>
#include <Square.h>
#include <OpenGLCircleStrategy.h>
#include <OpenGLSquareStrategy.h>
#include <memory>
#include <vector>
int main()
{
using Shapes = std::vector<std::unique_ptr<Shape>>;
Shapes shapes{};
// Creating some shapes, each one
// equipped with the corresponding OpenGL drawing strategy
shapes.emplace_back(
std::make_unique<Circle>( 2.3, OpenGLCircleStrategy(/*...red...*/) ) );
shapes.emplace_back(
std::make_unique<Square>( 1.2, OpenGLSquareStrategy(/*...green...*/) ) );
shapes.emplace_back(
std::make_unique<Circle>( 4.1, OpenGLCircleStrategy(/*...blue...*/) ) );
// Drawing all shapes
for( auto const& shape : shapes )
{
shape->draw();
}
return EXIT_SUCCESS;
}
main()
函数与使用经典策略实现的原始实现非常相似(参见“指导原则 19:使用策略隔离如何完成事情”)。然而,这种非侵入式、无基类的 std::function
方法进一步减少了耦合。这在该解决方案的依赖图中变得明显(参见图 5-9):我们可以以任何形式实现绘图功能(作为自由函数、函数对象或 lambda),而不必遵守基类的要求。此外,通过 std::function
,我们自动反转了依赖关系(参见“指导原则 9:注意抽象的所有权”)。
图 5-9. std::function
解决方案的依赖图
性能基准
“我喜欢这种灵活性,这种自由。这太棒了!但性能如何?” 是的,说得像一个真正的 C++ 开发者。当然性能很重要。在展示性能结果之前,让我提醒你一下我们用来获取 表 4-2 中数字的基准场景,这也是我们用来获取“指导原则 16:使用访问者扩展操作”中的数字的基准场景。对于基准测试,我实现了四种不同类型的形状(圆形、正方形、椭圆和矩形)。再次运行 25,000 次转换操作,对 10,000 个随机创建的形状进行操作。我同时使用 GCC 11.1 和 Clang 11.1,对于两个编译器,我只添加了 -O3
和 -DNDEBUG
编译标志。我使用的平台是 macOS Big Sur(版本 11.4),配备 8 核 Intel Core i7 处理器,主频 3.8 GHz,64 GB 主内存。
有了这些信息,你就准备好看性能结果了。表 5-1 展示了基于策略的绘图示例实现和使用 std::function
的结果解决方案的性能数据。
表 5-1. 不同策略实现的性能结果
策略实现 | GCC 11.1 | Clang 11.1 |
---|---|---|
面向对象的解决方案 | 1.5205 秒 | 1.1480 秒 |
std::function |
2.1782 秒 | 1.4884 秒 |
手动实现 std::function |
1.6354 秒 | 1.4465 秒 |
经典策略 | 1.6372 秒 | 1.4046 秒 |
供参考,第一行显示了来自“指导原则 15:为类型或操作的添加设计”中的面向对象解决方案的性能。正如你所见,这个解决方案提供了最佳性能。然而,这并不意外:由于策略设计模式,无论实际实现如何,都会引入额外的开销,因此性能预计会降低。
然而,让人意外的是,std::function
实现会产生性能开销(即使在 GCC 中可能相当大)。但在您将这种方法抛弃之前,请先考虑第三行。它展示了使用类型擦除手动实现 std::function
的技术,在第八章中我将详细解释这一技术。事实上,这种实现的性能要好得多,事实上与经典的策略设计模式实现相当(对于 Clang 来说几乎一样好,参见第四行)。这个结果表明问题不在于值语义,而是在于 std::function
的具体实现细节。³⁹ 总之,就性能而言,值语义方法不比经典方法差,反而如前所述,它改善了代码的许多重要方面。
分析 std::function
解决方案的缺点
总体而言,std::function
实现的策略设计模式提供了许多好处。首先,您的代码变得更清晰、更易读,因为您不必处理指针及其相关的生命周期管理(例如,使用 std::unique_ptr
),也不会遇到与引用语义相关的常见问题(参见“指南 22:优先选择值语义而不是引用语义”)。其次,它促进了松散耦合。实际上是非常松散的耦合。在这种情况下,std::function
就像一个编译防火墙,保护您免受不同策略实现的具体细节影响,同时为开发人员提供了在如何实现不同策略解决方案方面的巨大灵活性。
尽管有这些优点,没有一种解决方案是没有缺点的 —— 即使 std::function
方法也有其劣势。我已经指出了如果依赖标准实现可能存在的性能劣势。虽然有方法可以最小化这种影响(参见第八章),但这仍然是您在代码库中需要考虑的问题。
还有一个与设计相关的问题。std::function
只能替换一个虚函数。如果你需要抽象多个虚函数,比如你想使用策略设计模式配置多个方面,或者在命令设计模式中需要一个undo()
函数,你将需要使用多个std::function
实例。这不仅会因为多个数据成员而增加类的大小,还会因为如何优雅地处理传递多个std::function
实例的问题而带来接口负担。因此,对于替换单个或非常少量虚函数,std::function
方法是最佳选择。但这并不意味着你不能为多个虚函数使用值语义方法:如果遇到这种情况,请考虑通过将直接应用于你的类型的std::function
技术来泛化该方法。我将在第八章中解释如何做到这一点。
尽管存在这些缺点,值语义方法仍然是策略设计模式的一个绝佳选择。命令设计模式也是如此。因此,请将这个准则牢记,作为迈向现代 C++的重要步骤。
¹ 参见“准则 2:为变更设计”。
² 你可能会正确地认为,对于这个问题有多种解决方案:你可以为每个图形库有一个源文件,你可以在代码中加入一些#ifdef
来依赖预处理器,或者你可以在图形库周围实现一个抽象层。前两个选项感觉像是对有缺陷设计的技术性变通。然而,后一种选择是我将提出的一个合理的替代解决方案。这是一个基于Facade设计模式的解决方案,不幸的是,我在本书中没有涵盖这个模式。
³ 大卫·托马斯和安德鲁·亨特,《实用程序员》。
⁴ 埃里希·伽马等,《设计模式:可复用面向对象软件的元素》。
⁵ 请明确注意,我说的是天真的。虽然这个代码示例在教学上可能有些问题,但我会展示一个常见误解,然后展示一个正确的实现。我希望通过这种方式,你永远不会陷入这个常见的陷阱。
⁶ 虽然这不是一本关于实现细节的书籍,但请允许我强调一个我在培训课程中发现是许多问题源头的实现细节。我确信你已经听说过五则法则——如果没有,请参阅C++核心指南。因此,你会意识到声明虚析构函数会禁用移动操作。严格来说,这违反了五则法则。然而,正如核心指南 C.21解释的那样,对于基类而言,这并不被认为是一个问题,只要基类不包含任何数据成员。
⁷ 正如我之前引用过的核心指南 C.21,同样值得一提的是Circle
和Square
类都符合零号法则;参见核心指南 C.20。通过不习惯性地添加析构函数,编译器本身为两个类生成所有特殊成员函数。是的,不用担心——析构函数仍然是虚的,因为基类的析构函数是虚的。
⁸ 请参阅“指南 18:警惕非循环访问者的性能问题”讨论非循环访问者设计模式。
⁹ 我应明确说明它在动态多态性中不起作用。它在静态多态性中确实起作用,而且效果相当好。例如,模板和函数重载。
¹⁰ Andrei Alexandrescu,《现代 C++设计:泛型编程与设计模式实践》(Addison-Wesley,2001 年)。
¹¹ Sean Parent,《继承是邪恶的基类》(链接),GoingNative,2013 年。
¹² 根据 Sean Parent 的说法,没有多态类型,只有类似类型的多态用法;参见 2017 年 NDC 伦敦会议上的“更好的代码:运行时多态性”。我的声明支持这种观点。
¹³ 另一个继承导致耦合的例子在 Herb Sutter 的《卓越 C++:47 个工程难题、编程问题和异常安全解决方案》(Pearson Education)中有所讨论。
¹⁴ 他们真的要因这个习惯受到指责吗?因为他们几十年来一直被教导这是正确的方法,谁能因他们这样思考而责备他们呢?
¹⁵ Michael C. Feathers,《与遗留代码有效工作》。
¹⁶ 通过差异编程是一种基于继承的极端形式,甚至小的差异都通过引入新的派生类来表达。详见 Michael 的书籍了解更多详情。
¹⁷ 例如,查看策略设计模式在“指导原则 19:使用策略隔离操作方式”,观察者设计模式在“指导原则 25:将观察者应用为抽象通知机制”,适配器设计模式在“指导原则 24:使用适配器标准化接口”,装饰器设计模式在“指导原则 35:使用装饰器以分级方式添加定制化”,或桥接设计模式在“指导原则 28:建立桥接以消除物理依赖”。
¹⁸ Erich Gamma 等人,《设计模式:可复用面向对象软件的基本元素》。
¹⁹ 是的,它遵循 SOLID 原则,尽管当然是通过经典形式的命令设计模式。如果你现在在为这些问题感到沮丧或者只是想知道是否有更好的解决方法,请耐心等待。我将在“指导原则 22:更喜欢值语义而不是引用语义”中展示一个更加优美、更加“现代”的解决方案。
²⁰ 所提供的 ThreadPool
类远非完整,主要作为命令设计模式的示例。如需工作中的专业线程池实现,请参考 Anthony William 的书籍,《C++并发实战》,第二版 (Manning)。
²¹ 这是我关于设计模式不涉及实现细节的另一个例子;参见“指导原则 12:警惕设计模式的误解”。
²² 完整的形状示例,请参见“指导原则 19:使用策略隔离操作方式”。
²³ Margaret A. Ellis 和 Bjarne Stroustrup,《C++注释参考手册》(Addison-Wesley, 1990)。
²⁴ 要全面了解 C++ 性能方面的概述,特别是关于继承层次结构的性能相关问题,请参阅 Kurt Guntheroth 的书籍,《优化的 C{plus}{plus}》(O’Reilly)。
²⁵ 解决方案之一是采用数据导向设计技术;参见 Richard Fabian,《数据导向设计:面向有限资源和紧张进度的软件工程》。
²⁶ 记住我的措辞:“我们可能会得到以下输出。”确实,我们可能会得到这个输出,但也可能得到其他输出。这取决于我们不经意地进入了未定义行为的领域。因此,这个输出是我的最佳猜测,而不是保证。
²⁷ 现在不仅你的美甲师,还有你的理发师也有工作要做了…
²⁸ 多了几根白发,理发师要多做些工作了。
²⁹ 我应该明确指出,“深拷贝”的概念取决于向量中元素类型T
:如果T
执行深拷贝,那么std::vector
也会执行深拷贝,但如果T
执行浅拷贝,那么语义上std::vector
也会执行浅拷贝。
³⁰ 关于移动语义的最佳和最完整的介绍是 Nicolai Josuttis 的书籍《C++ Move Semantics - The Complete Guide》(NicoJosuttis, 2020)。
³¹ 查看 Patrice Roy 在 CppCon 2016 年的演讲“异常情况”,有类似的例子和讨论。
³² 然而,这正是std::atoi()
函数采取的方法。
³³ 在他的标准提案P0709中,Herb Sutter 解释了 52%的 C++开发人员没有或只有有限的异常访问权限。
³⁴ 有经验的 C++开发人员也知道,C++23 将为我们带来一个非常类似的类型,称为std::expected
。在未来几年内,这可能是编写to_int()
函数的适当方式。
³⁵ 从函数式编程的角度来看,std::optional
代表一个monad。在 Ivan Čukić的书籍《C++中的函数式编程》中,你会找到更多有价值的信息关于monad和函数式编程的一般知识。
³⁶ 在这个例子中,std::function
对象执行深拷贝,但一般来说,std::function
根据其拷贝语义(“深”或“浅”)复制包含的可调用对象。std::function
没有强制执行深拷贝的方法。
³⁷ 这个实现细节在 Nicolai Josuttis 在 CppCon 2017 年的演讲“Move 语义对于平凡类的噩梦”中有详细解释。
³⁸ 另一个KISS原则的例子。
³⁹ 关于一些std::function
实现性能缺陷的讨论超出了本书的范围和目的。但是,请记住这个细节,尤其是代码性能关键部分。
第六章:适配器、观察者和 CRTP 设计模式
在本章中,我们关注三种必须了解的设计模式:两种 GoF 设计模式,适配器和观察者,以及奇异递归模板模式(CRTP)设计模式。
在 “指南 24:使用适配器标准化接口” 中,我们讨论通过适配器将不兼容的事物整合在一起的方法。为了实现这一点,我将向您展示适配器设计模式及其在继承层次结构和泛型编程中的应用。您还将获得各种适配器的概述,包括对象适配器、类适配器和函数适配器。
在 “指南 25:将观察者应用作抽象通知机制” 中,我们将讨论如何观察状态变化以及如何收到通知。在这个背景下,我将向您介绍观察者设计模式,这是最著名和最常用的设计模式之一。我们将讨论经典的 GoF 风格的观察者,以及如何在现代 C++ 中实现观察者模式。
在 “指南 26:使用 CRTP 引入静态类型类别” 中,我们将转向 CRTP。我将向您展示如何使用 CRTP 定义一组相关类型之间的编译时关系,以及如何正确实现 CRTP 基类。
在 “指南 27:使用 CRTP 创建静态混合类” 中,我将继续讲解 CRTP,向您展示如何使用 CRTP 创建编译时混合类。我们还将看到语义继承与技术继承的区别,语义继承用于创建抽象,而技术继承仅用于技术上的优雅和便利性的实现细节。
指南 24:使用适配器标准化接口
假设您已经根据 “指南 3:分离接口以避免人为耦合” 实现了 Document
示例,并且因为您正确遵循了接口隔离原则(ISP),您对其工作方式感到满意:
class JSONExportable
{
public:
// ...
virtual ~JSONExportable() = default;
virtual void exportToJSON( /*...*/ ) const = 0;
// ...
};
class Serializable
{
public:
// ...
virtual ~Serializable() = default;
virtual void serialize( ByteStream& bs, /*...*/ ) const = 0;
// ...
};
class Document
: public JSONExportable
, public Serializable
{
public:
// ...
};
然而,有一天,您需要介绍 Pages 文档格式。¹ 当然,它类似于您已经使用的 Word 文档,但不幸的是,您并不熟悉 Pages 格式的细节。更糟糕的是,您没有太多时间去熟悉这种格式,因为您有太多其他事情要做。幸运的是,您了解到有一个相当合理的开源实现:OpenPages
类:
class OpenPages
{
public:
// ...
void convertToBytes( /*...*/ );
};
void exportToJSONFormat( OpenPages const& pages, /*...*/ );
光明面是,这个类提供了您所需的几乎所有内容:一个 convertToBytes()
成员函数来序列化文档的内容,以及一个免费的 exportToJSONFormat()
函数来将 Pages 文档转换为 JSON 格式。不幸的是,它不符合您的接口期望:您期望一个 serialize()
成员函数而不是 convertToBytes()
成员函数。而且您期望一个 exportToJSON()
成员函数而不是免费的 exportToJSONFormat()
函数。当然,最终,第三方类没有从您的 Document
基类继承,这意味着您无法轻松地将该类整合到您现有的层次结构中。然而,这个问题是有解决方案的:使用适配器设计模式进行无缝集成。
解释适配器设计模式
适配器设计模式是另一个经典 GoF 设计模式之一。它专注于标准化接口,帮助在现有的继承层次结构中非侵入地添加功能。
适配器设计模式
意图:“将一个类的接口转换成客户期望的另一个接口。适配器模式使得因接口不兼容而不能在一起工作的类能够在一起工作。”²
图 6-1 展示了适配器场景的 UML 图:您已经准备好了 Document
基类(我们暂时忽略 JSONExportable
和 Serializable
接口),并且已经实现了几种不同类型的文档(例如使用 Word
类)。这个层次结构的新添加是 Pages
类。
图 6-1. 适配器设计模式的 UML 表示
Pages
类作为第三方 OpenPages
类的包装器:
class Pages : public Document
{
public:
// ...
void exportToJSON( /*...*/ ) const override
{
exportToJSONFormat(pages, /*...*/); 
}
void serialize( ByteStream& bs, /*...*/ ) const override
{
pages.convertToBytes(/*...*/); 
}
// ...
private:
OpenPages pages; // Example of an object adapter };
Pages
通过将调用转发到相应的 OpenPages
函数来实现 Document
接口:调用 exportToJSON()
被转发到自由的 exportToJSONFormat()
函数(),而调用
serialize()
被转发到 convertToBytes()
成员函数()。
使用 Pages
类,您可以轻松地将第三方实现集成到您现有的层次结构中。非常容易:您可以在不修改任何内容的情况下进行集成。适配器设计模式的这种非侵入性本质是您应该考虑的适配器设计模式的最大优势之一:任何人都可以添加一个适配器来将一个接口适配到另一个现有的接口。
在这种情境下,Pages
类作为 OpenPages
类中实际实现细节的一个抽象。因此,适配器设计模式将接口的关注点与实现细节分离开来。这样做很好地满足了单一责任原则(SRP),并且与开闭原则(OCP)的意图融合得很好(参见“指南 2:设计以便改变” 和 “指南 5:设计以便扩展”)。
从某种角度来说,Pages
适配器作为一种间接方式工作,并将一组函数映射到另一组函数。请注意,并不严格要求将一个函数映射到确切的另一个函数。相反,你完全可以灵活地将预期的函数集映射到可用的函数集上。因此,适配器不一定代表一对一的关系,而是可以支持一对多的关系。³
对象适配器与类适配器的比较。
Pages
类是所谓的对象适配器的一个例子。这个术语指的是你存储包装类型的一个实例。或者,考虑到包装类型是继承层次结构的一部分,你可以存储该层次结构的基类指针。这将允许你对层次结构中的所有类型使用对象适配器,从而显著提升对象适配器的灵活性。
相反,还有实现所谓的类适配器的选项:
class Pages : public Document
, private OpenPages // Example of a class adapter 
{
public:
// ...
void exportToJSON( /*...*/ ) const override
{
exportToJSONFormat(*this, /*...*/);
}
void serialize( ByteStream& bs, /*...*/ ) const override
{
this->convertToBytes(/*...*/);
}
// ... };
而不是存储适配类型的实例,你可以继承它(如果可能的话,非公开地)并相应地实现预期的接口()。然而,正如在“指南 20:更喜欢组合而非继承”中讨论的那样,最好建立在组合之上。一般来说,对象适配器比类适配器更为灵活,因此应该是你的首选。只有少数情况下,你会更倾向于选择类适配器:
-
如果你必须覆盖一个虚函数。
-
如果你需要访问一个
protected
成员函数。 -
如果你要求适配类型在另一个基类之前构造。
-
如果你需要共享一个共同的虚基类或者覆盖虚基类的构造。
-
如果你可以从空基类优化 (EBO)中获得显著优势。⁴
否则,对大多数情况而言,你应该优先选择对象适配器。
“我喜欢这个设计模式——它很强大。然而,我刚想起你推荐在代码中使用设计模式的名称来传达意图。难道这个类不应该叫做 PagesAdapter
吗?” 你提出了一个很好的观点。我很高兴你记得“Guideline 14: Use a Design Pattern’s Name to Communicate Intent”,在这里我确实主张设计模式的名称有助于理解代码。我承认在这种情况下,我对两种命名约定都持开放态度。虽然我确实看到了 PagesAdapter
这个名称的优点,因为它立即传达了你基于适配器设计模式的构建,但我不认为在这种情况下传达这个类代表一个适配器是必要的。对我来说,适配器在这种情况下感觉像是一个实现细节:我不需要知道 Pages
类并没有自己实现所有细节,而是使用 OpenPages
类来实现。这就是为什么我建议“考虑使用名称”。你应该根据具体情况来决定。
标准库中的例子
适配器设计模式的一个有用应用是标准化不同类型容器的接口。假设有以下 Stack
基类:
//---- <Stack.h> ----------------
template< typename T >
class Stack
{
public:
virtual ~Stack() = default;
virtual T& top() = 0; 
virtual bool empty() const = 0; 
virtual size_t size() const = 0; 
virtual void push( T const& value ) = 0; 
virtual void pop() = 0; 
};
这个 Stack
类提供了访问堆栈顶部元素的必要接口 (), 检查堆栈是否为空 (
), 查询堆栈大小 (
), 将元素推入堆栈 (
), 以及移除堆栈顶部元素 (
)。现在可以使用这个基类来实现不同的适配器,用于各种数据结构,比如
std::vector
:
//---- <VectorStack.h> ----------------
#include <Stack.h>
template< typename T >
class VectorStack : public Stack<T>
{
public:
T& top() override { return vec_.back(); }
bool empty() const override { return vec_.empty(); }
size_t size() const override { return vec_.size(); }
void push( T const& value ) override { vec_.push_back(value); }
void pop() override { vec_.pop_back(); }
private:
std::vector<T> vec_;
};
你可能会担心,“你真的建议通过抽象基类来实现堆栈吗?你不担心性能问题吗?每次使用成员函数都要付出虚函数调用的代价!” 当然,我并不建议这样做。显然,你是正确的,我完全同意你的观点:从 C++ 的角度来看,这种容器的设计感觉很奇怪,而且效率非常低下。出于效率考虑,我们通常会通过类模板来实现相同的想法。这也是 C++ 标准库采用的方法,例如三个 STL 类称为容器适配器:std::stack
, std::queue
, 和 std::priority_queue
:
template< typename T
, typename Container = std::deque<T> >
class stack;
template< typename T
, typename Container = std::deque<T> >
class queue;
template< typename T
, typename Container = std::vector<T>
, typename Compare = std::less<typename Container::value_type> >
class priority_queue;
这三个类模板将给定的 Container
类型的接口适配到特定目的。例如,std::stack
类模板的目的是将容器的接口适配到栈操作 top()
、empty()
、size()
、push()
、emplace()
、pop()
和 swap()
。⁵ 默认情况下,您可以使用三种可用的序列容器:std::vector
、std::list
和 std::deque
。对于任何其他容器类型,您可以专门化 std::stack
类模板。
“这感觉非常熟悉”,你说道,显然松了一口气。我完全同意。我认为标准库的方法更适合容器的目的。但是比较这两种方法仍然很有趣。虽然 Stack
基类和 std::stack
类模板之间有许多技术上的不同,但这两种方法的目的和语义非常相似:两者都提供了将任何数据结构适配到给定栈接口的能力。而且两者都作为变异点,允许您在不必修改现有代码的情况下非侵入式地添加新的适配器。
适配器与策略的比较
“STL 的这三个类似乎实现了适配器的意图,但这不是和策略设计模式中配置行为的方式相同吗?这和 std::unique_ptr
及其删除器有相似之处?” 你问道。是的,你说得对。从结构上看,策略模式和适配器设计模式非常相似。然而,正如在 “指南 11: 理解设计模式的目的” 中所解释的,设计模式的结构可能相似甚至相同,但其意图是不同的。在这种情况下,Container
参数不仅仅指定行为的单个方面,而是大多数甚至全部行为。类模板主要充当给定类型功能的包装器——它们主要是适配接口。因此,适配器的主要焦点是标准化接口并将不兼容的功能集成到现有的约定集中;而另一方面,策略设计模式的主要焦点是允许从外部配置行为,构建并提供预期的接口。此外,适配器不需要在任何时候重新配置行为。
函数适配器
适配器设计模式的另一个示例是标准库的自由函数begin()
和end()
。“你是认真的吗?”你问道,感到惊讶。“你说自由函数作为适配器设计模式的一个例子?这不是类的工作吗?”嗯,并非完全如此。自由函数begin()
和end()
的目的是将任何类型的迭代器接口适配到预期的 STL 迭代器接口。因此,它将可用函数集映射到预期函数集,并起到与任何其他适配器相同的作用。其主要区别在于,与基于继承(运行时多态性)或模板(编译时多态性)的对象适配器或类适配器不同,begin()
和end()
依靠函数重载获得其能力,这是 C++中第二个主要的编译时多态机制。尽管如此,某种形式的抽象仍在发挥作用。
注意
记住,所有种类的抽象都代表一组要求,因此必须遵守里氏替换原则(LSP)。这对于重载集合也是如此;参见“指导原则 8:理解重载集的语义要求”。
考虑以下函数模板:
template< typename Range >
void traverseRange( Range const& range )
{
for( auto&& element : range ) {
// ...
}
}
在traverseRange()
函数中,我们通过基于范围的for
循环遍历给定范围内包含的所有元素。遍历通过编译器使用的自由函数begin()
和end()
获取的迭代器进行。因此,前述的for
循环等同于以下形式的for
:
template< typename Range >
void traverseRange( Range const& range )
{
{
using std::begin;
using std::end;
auto first( begin(range) );
auto last ( end(range) );
for( ; first!=last; ++first ) {
auto&& element = *first;
// ...
}
}
}
显然,基于范围的for
循环更加方便使用。但在表面下,编译器生成基于自由函数begin()
和end()
的代码。请注意它们开头的两个using
声明:其目的是为了为给定范围的类型启用Argument-Dependent Lookup (ADL)。ADL 是一种机制,确保调用“正确”的begin()
和end()
函数,即使它们是存在于用户特定命名空间中的重载。这意味着您有机会为任何类型重载begin()
和end()
,并将预期接口映射到不同的特定函数集。
这种函数适配器在 2004 年被 Matthew Wilson 称为shim⁶。这种技术的一个宝贵特性是它完全不侵入:可以向任何类型添加自由函数,甚至是无法适配的类型,例如第三方库提供的类型。因此,任何以 shims 或函数适配器为术语编写的通用代码都为您提供了适应几乎任何类型到预期接口的巨大能力。因此,您可以想象 shims 或函数适配器是通用编程的支柱。
分析适配器设计模式的缺点
尽管适配器设计模式的价值很高,但有一个问题我必须明确指出。考虑以下例子,我从埃里克·弗里曼和伊丽莎白·罗布森那里采用的:⁷
//---- <Duck.h> ----------------
class Duck
{
public:
virtual ~Duck() = default;
virtual void quack() = 0;
virtual void fly() = 0;
};
//---- <MallardDuck.h> ----------------
#include <Duck.h>
class MallardDuck : public Duck
{
public:
void quack() override { /*...*/ }
void fly() override { /*...*/ }
};
我们从抽象的Duck
(鸭子)类开始,引入了两个纯虚函数quack()
和fly()
。事实上,这看起来是一个非常预期和自然的Duck
类接口,并且当然会引发一些期望:鸭子发出非常特征性的声音并且能够飞得很好。这个接口被许多可能的Duck
类实现,比如MallardDuck
(绿头鸭)类。现在,出于某种原因,我们也不得不处理火鸡:
//---- <Turkey.h> ----------------
class Turkey
{
public:
virtual ~Turkey() = default;
virtual void gobble() = 0; // Turkeys don't quack, they gobble!
virtual void fly() = 0; // Turkeys can fly (a short distance)
};
//---- <WildTurkey.h> ----------------
class WildTurkey : public Turkey
{
public:
void gobble() override { /*...*/ }
void fly() override { /*...*/ }
};
火鸡由抽象的Turkey
类表示,当然,这个类被许多不同种类的具体Turkey
(火鸡)实现,比如WildTurkey
(野火鸡)。更糟糕的是,出于某种原因,鸭子和火鸡被期望一起使用。一个可能的解决方案是假装火鸡是鸭子。毕竟,火鸡与鸭子非常相似。好吧,它不会嘎嘎叫,但它可以咯咯叫(典型的火鸡声音),而且它也能飞(虽然飞行距离不远,但确实可以飞)。因此,你可以用TurkeyAdapter
(火鸡适配器)来适配火鸡到鸭子:
//---- <TurkeyAdapter.h> ----------------
#include <memory>
class TurkeyAdapter : public Duck
{
public:
explicit TurkeyAdapter( std::unique_ptr<Turkey> turkey )
: turkey_{ std::move(turkey) }
{}
void quack() override { turkey_->gobble(); }
void fly() override { turkey_->fly(); }
private:
std::unique_ptr<Turkey> turkey_; // This is an example for an object adapter
};
虽然这是对鸭子类型的一个有趣解释,这个例子很好地展示了将外来事物集成到现有层次结构中的过于简单。一只Turkey
(火鸡)根本不是Duck
(鸭子),即使我们希望它是。我认为quack()
和fly()
函数可能都违反了 LSP。这两个函数都不是我期望的(至少我相当确定我想要的是呱呱叫而不是咯咯叫的生物,以及我想要的是真正像鸭子一样能飞的东西)。当然,这取决于具体的上下文,但不可否认的是,适配器设计模式确实使得将不相容的事物组合在一起变得非常容易。因此,在应用此设计模式时,考虑期望的行为并检查 LSP 违规非常重要:
#include <MallardDuck.h>
#include <WildTurkey.h>
#include <TurkeyAdapter.h>
#include <memory>
#include <vector>
using DuckChoir = std::vector<std::unique_ptr<Duck>>;
void give_concert( DuckChoir const& duck_choir )
{
for( auto const& duck : duck_choir ) {
duck->quack();
}
}
int main()
{
DuckChoir duck_choir{};
// Let's hire the world's best ducks for the choir
duck_choir.push_back( std::make_unique<MallardDuck>() );
duck_choir.push_back( std::make_unique<MallardDuck>() );
duck_choir.push_back( std::make_unique<MallardDuck>() );
// Unfortunately we also hire a turkey in disguise
auto turkey = std::make_unique<WildTurkey>();
auto turkey_in_disguise = std::make_unique<TurkeyAdapter>( std::move(turkey) );
duck_choir.push_back( std::move(turkey_in_disguise) );
// The concert is going to be a musical disaster...
give_concert( duck_choir );
return EXIT_SUCCESS;
}
总之,适配器设计模式可以被认为是结合不同功能片段并使它们协同工作的最有价值的设计模式之一。我保证它将在你的日常工作中证明是一个宝贵的工具。但是,请不要滥用适配器的力量,试图将苹果和橙子(甚至橙子和葡萄柚)结合在一起。始终注意 LSP 的期望。
准则 25:将观察者应用为抽象通知机制
你很有可能之前就听说过观察者了。“哦,是的,当然听过——这不就是所谓的社交媒体平台对我们所做的事情吗?”你问道。嗯,并不完全是我想要的,但是是的,我相信我们可以称这些平台为观察者。而且,确实有它们所做的事情的模式,尽管不是设计模式。但我实际上是在谈论一个最流行的 GoF 设计模式之一,观察者设计模式。即使你对这个想法不熟悉,你很可能在现实生活中有一些有用的观察者的经验。例如,你可能注意到,在一些即时通讯应用程序中,当你阅读新消息时,消息的发送者会立即收到通知。这意味着消息显示为“已读”,而不仅仅是“已发送”。这项小服务本质上就是现实生活中观察者的工作:一旦新消息的状态发生变化,发送者就会收到通知,从而有机会对状态变化作出响应。
观察者设计模式解析
在许多软件情况下,当某些状态发生变化时,及时获得反馈是非常可取的:例如,将作业添加到任务队列中,更改配置对象中的设置,准备好可以获取结果等等。但与此同时,引入主题(变化的观察实体)与其观察者(基于状态变化通知的回调)之间的显式依赖是非常不可取的。相反,主题应该对潜在的许多不同类型的观察者毫不知情。这样做的简单原因是任何直接的依赖关系都会使软件更难以改变和扩展。这种主题与其潜在的许多观察者之间的解耦是观察者设计模式的意图。
观察者设计模式
意图:“定义对象之间的一对多依赖关系,以便当一个对象改变状态时,所有依赖它的对象都会自动收到通知并更新。”⁹
与所有设计模式一样,观察者设计模式将一个变化点(即变化或预期变化的方面)标识出来,并以抽象形式提取出来。因此,它有助于解耦软件实体。在观察者的情况下,识别到需要引入新观察者——需要扩展一对多的依赖——被认为是变化点。正如图 6-2 所示,这个变化点在Observer
基类的形式下得以实现。
图 6-2. 观察者设计模式的 UML 表示
Observer
类代表了所有可能的观察者实现的抽象。这些观察者附加到特定的主题,由ConcreteSubject
类表示。为了减少观察者与其主题之间的耦合,或者简单地通过为不同的观察者提供所有共同服务来减少代码重复,可以使用Subject
抽象。这个Subject
也可能通知所有附加的观察者关于状态变化,并触发它们相应的update()
功能。
Observer
基类的引入是 SRP 的另一个例子,你可能会问。是的,你完全正确:抽取Observer
类,提取一个变化点,就是 SRP 的实践(参见“指导原则 2:为变化设计”)。再次强调,SRP 作为 OCP 的一个推动因素(参见“指导原则 5:为扩展设计”):通过引入Observer
抽象,任何人都能够添加新类型的观察者(例如ConcreteObserver
),而无需修改现有代码。如果你关注Observer
基类的所有权,并确保Observer
类存在于架构的高层级,则也符合依赖反转原则(DIP)。
经典 Observer 模式实现
“太好了,我明白了!再次看到这些设计原则在实践中的应用真是太棒了,但我想看一个具体的 Observer 示例。”我理解。那么让我们来看一个具体的实现。不过,在我们开始查看代码之前,我应该明确说明以下示例的局限性。你可能已经熟悉了Observer
,因此你可能正在寻求帮助,并希望深入了解许多 Observer 的棘手实现细节:如何处理附加和分离观察者的顺序,多次附加观察者,特别是在并发环境中使用观察者。我应该诚实地提前声明,我并不打算提供这些问题的答案。那样的讨论会像打开潘多拉盒子,迅速把我们吸引到实现细节的领域。不,尽管你可能会感到失望,但我的意图大部分是留在软件设计的层面上。¹⁰
就像之前的设计模式一样,我们从 Observer 设计模式的经典实现开始。其核心元素是Observer
基类:
//---- <Observer.h> ----------------
class Observer
{
public:
virtual ~Observer() = default;
virtual void update( /*...*/ ) = 0; 
};
这个类的最重要的实现细节是纯虚拟的update()
函数(),每当观察者被通知到某个状态变化时就会调用它。有三种定义
update()
函数的替代方法,它们提供了合理的实现和设计灵活性。第一种替代方法是通过一个或多个update()
函数推送更新的状态:
class Observer
{
public:
// ...
virtual void update1( /*arguments representing the updated state*/ ) = 0;
virtual void update2( /*arguments representing the updated state*/ ) = 0;
// ...
};
这种观察者形式通常被称为推送观察者。在这种形式中,观察者由主体提供所有必要的信息,因此不需要自行从主体拉取任何信息。这可以显著减少与主体的耦合,并创造重用Observer
类的机会。此外,还可以选择为每种状态变化使用单独的重载。在前面的代码片段中,有两个update()
函数,分别用于两种可能的状态变化。由于状态变化始终明确,因此观察者无需“搜索”任何状态变化,这证明是高效的。
“对不起”,你说,“但这不是违反 ISP 吗?我们不应该通过将update()
函数分成几个基类来分离关注点吗?” 这是一个很好的问题!显然,您正在关注人为耦合。非常好!而且您是正确的:我们可以将具有多个update()
函数的观察者分离成较小的Observer
类:
class Observer1
{
public:
// ...
virtual void update1( /*arguments representing the updated state*/ ) = 0;
// ...
};
class Observer2
{
public:
// ...
virtual void update2( /*arguments representing the updated state*/ ) = 0;
// ...
};
理论上,这种方法可以帮助减少对特定主题的耦合,并更容易地为不同的主题重用观察者。它可能还有助于不同的观察者可能对不同的状态变化感兴趣,因此人为耦合所有可能的状态变化可能会违反 ISP。当然,如果能够避免大量不必要的状态变化通知,这可能会带来效率提升。
不幸的是,特定的主题不太可能区分不同类型的观察者。首先,这需要它存储不同类型的指针(对于主题来说是不方便的),其次,不同的状态变化可能以某种方式相互关联。在这种情况下,主题将期望观察者对所有可能的状态变化感兴趣。从这个角度来看,将几个update()
函数合并到一个基类中可能是合理的。无论如何,很可能一个具体的观察者将不得不处理所有类型的状态变化。我知道,即使只有很少一部分是有趣的,也必须处理几个update()
函数,这可能会让人感到困扰。但是,请确保您不会因为不遵守某些预期行为(如果有的话)而意外违反里氏替换原则。
推送观察者还有几个潜在的缺点。首先,观察者总是被所有信息传递,无论他们是否需要。因此,如果观察者大部分时间都需要信息,这种推送样式才有效。否则,大量的工作会浪费在不必要的通知上。其次,推送会创建对传递给观察者的参数数量和种类的依赖性。对这些参数的任何更改都需要在派生观察者类中进行大量的后续更改。
一些这些缺点通过第二个Observer
的替代方案得以解决。只需将主题的引用传递给观察者即可:¹²
class Observer
{
public:
// ...
virtual void update( Subject const& subject ) = 0;
// ...
};
由于观察者未传递特定信息,派生自Observer
基类的类必须自行从主题拉取新信息。因此,这种形式的观察者通常被称为“拉取观察者”。优点是对参数数量和类型的依赖性减少。派生观察者可以自由查询任何信息,而不仅限于已更改的状态。另一方面,此设计在Observer
派生类与主题之间创建了强烈直接的依赖性。因此,对主题的任何更改都很容易反映在观察者身上。此外,如果多个细节发生了变化,观察者可能需要“搜索”状态变化,这可能会被证明是不必要的低效率。
如果你只将单个信息片段视为变化状态,性能劣势可能不会对你构成限制。但请记住软件会变化:主题可能增长,因此希望通知不同类型的变化。在此过程中调整观察者将导致大量额外工作。从这个角度来看,“推送观察者”似乎是一个更好的选择。
幸运的是,还有第三种选择,消除了许多先前的缺点,因此成为我们的首选方法:除了传递主题的引用外,还传递一个标签,提供有关主题哪个属性已更改的信息:
//---- <Observer.h> ----------------
class Observer
{
public:
virtual ~Observer() = default;
virtual void update( Subject const& subject
, /*Subject-specific type*/ property ) = 0;
};
该标签可能帮助观察者自行决定某些状态变化是否有趣。通常由特定于主题的枚举类型表示,列出所有可能的状态变化。不幸的是,这会增加Observer
类与特定主题之间的耦合。
“通过将Observer
基类实现为一个类模板,能否删除对特定Subject
的依赖?看一下以下代码片段:”
//---- <Observer.h> ----------------
template< typename Subject, typename StateTag > 
class Observer
{
public:
virtual ~Observer() = default;
virtual void update( Subject const& subject, StateTag property ) = 0;
};
这是一个很好的建议。通过将Observer
类定义为一个类模板的形式(),我们可以轻松地将
Observer
提升到更高的架构层次。在这种形式下,该类不依赖于任何特定的主题,因此可以被许多不同希望定义一对多关系的主题重复使用。然而,你不应期望这种改进能起到太大作用:效果仅限于Observer
类。具体的主题将期望具体的这个观察者类的实例化,因此Observer
的具体实现仍然会强烈依赖于主题。
要更好地理解其中的原因,让我们看一下可能的主题实现。在您对社交媒体的初步评论之后,我建议我们为人员实现一个观察者。好吧,好吧,这个例子可能在道德上有问题,但它会达到它的目的,所以让我们这么做吧。至少我们知道谁该为此负责。
下面的Person
类表示一个被观察的人:
//---- <Person.h> ----------------
#include <Observer.h>
#include <string>
#include <set>
class Person
{
public:
enum StateChange
{
forenameChanged,
surnameChanged,
addressChanged
};
using PersonObserver = Observer<Person,StateChange>; 
explicit Person( std::string forename, std::string surname )
: forename_{ std::move(forename) }
, surname_{ std::move(surname) }
{}
bool attach( PersonObserver* observer ); 
bool detach( PersonObserver* observer ); 
void notify( StateChange property ); 
void forename( std::string newForename ); 
void surname ( std::string newSurname );
void address ( std::string newAddress );
std::string const& forename() const { return forename_; }
std::string const& surname () const { return surname_; }
std::string const& address () const { return address_; }
private:
std::string forename_; 
std::string surname_;
std::string address_;
std::set<PersonObserver*> observers_; 
};
在这个例子中,Person
仅仅是三个数据成员的聚合:forename_
、surname_
和address_
()(我知道,这只是一个人的一个相当简单的表示)。此外,一个人持有已注册观察者的
std::set
()。请注意,观察者是通过指向
PersonObserver
实例的指针来注册的()。出于两个原因这是有趣的:首先,这展示了模板化
Observer
类的目的:Person
类从类模板实例化其自己的观察者类型。其次,指针在这种情况下证明非常有用,因为对象的地址是唯一的。因此,常见的做法是使用地址作为观察者的唯一标识符。
“这里应该用std::unique_ptr
还是std::shared_ptr
?”你问道。不,在这种情况下不需要。这些指针仅仅作为已注册观察者的句柄;它们不应该拥有这些观察者。因此,在这种情况下,任何拥有性智能指针都是错误的选择。唯一合理的选择将是std::weak_ptr
,它允许您检查悬空指针。然而,std::weak_ptr
并不是std::set
的一个好选择(即使使用自定义比较器)。尽管仍有方法可以使用std::weak_ptr
,我将坚持使用裸指针。但不要担心,这并不意味着我们放弃了现代 C++的好处。不,在这种情况下使用裸指针是完全有效的。这也在 C++ 核心指南 F.7中表达了:
对于一般使用,取
T*
或T&
作为参数,而不是智能指针。
每当您对获取人的状态变化通知感兴趣时,您可以通过attach()
成员函数注册一个观察者()。每当您不再对获取通知感兴趣时,您可以通过
detach()
成员函数注销一个观察者()。这两个函数是观察者设计模式的一个重要组成部分,也清楚地表明了设计模式的应用:
bool Person::attach( PersonObserver* observer )
{
auto [pos,success] = observers_.insert( observer );
return success;
}
bool Person::detach( PersonObserver* observer )
{
return ( observers_.erase( observer ) > 0U );
}
你完全可以自由地实现attach()
和detach()
函数。在这个例子中,我们允许一个观察者在std::set
中只注册一次。如果尝试第二次注册观察者,函数会返回false
。如果尝试取消注册一个未注册的观察者也会发生同样的事情。请注意,不允许多次注册的决定是我在这个例子中的选择。在其他情况下,允许重复注册可能是可取的,甚至是必要的。无论如何,主题的行为和接口在所有情况下都应该是一致的。
观察者设计模式的另一个核心功能是notify()
成员函数()。每当发生状态变化时,此函数被调用以通知所有注册的观察者。
void Person::notify( StateChange property )
{
for( auto iter=begin(observers_); iter!=end(observers_); )
{
auto const pos = iter++;
(*pos)->update(*this,property);
}
}
“为什么notify()
函数的实现这么复杂?难道使用基于范围的for
循环就不够吗?”你说得对;我应该解释一下这里发生了什么。给定的表述确保在迭代期间可以检测到detach()
操作。例如,在调用update()
函数期间,观察者决定分离时就会发生这种情况。但我并不认为这种表述是完美的:不幸的是,它无法处理attach()
操作。至于并发问题,更是不能接触!因此,这只是说明为什么观察者模式的实现细节会如此棘手的一个例子。
notify()
函数在所有三个 setter 函数中被调用()。请注意,在所有三个函数中,我们始终传递不同的标签来指示发生了哪种属性的更改。派生自
Observer
基类的类可以使用此标签来确定变化的性质。
void Person::forename( std::string newForename )
{
forename_ = std::move(newForename);
notify( forenameChanged );
}
void Person::surname( std::string newSurname )
{
surname_ = std::move(newSurname);
notify( surnameChanged );
}
void Person::address( std::string newAddress )
{
address_ = std::move(newAddress);
notify( addressChanged );
}
有了这些机制,你现在可以编写符合完全 OCP 的新型观察者了。例如,你可以决定实现NameObserver
和AddressObserver
:
//---- <NameObserver.h> ----------------
#include <Observer.h>
#include <Person.h>
class NameObserver : public Observer<Person,Person::StateChange>
{
public:
void update( Person const& person, Person::StateChange property ) override;
};
//---- <NameObserver.cpp> ----------------
#include <NameObserver.h>
void NameObserver::update( Person const& person, Person::StateChange property )
{
if( property == Person::forenameChanged ||
property == Person::surnameChanged )
{
// ... Respond to changed name
}
}
//---- <AddressObserver.h> ----------------
#include <Observer.h>
#include <Person.h>
class AddressObserver : public Observer<Person,Person::StateChange>
{
public:
void update( Person const& person, Person::StateChange property ) override;
};
//---- <AddressObserver.cpp> ----------------
#include <AddressObserver.h>
void AddressObserver::update( Person const& person, Person::StateChange property )
{
if( property == Person::addressChanged ) {
// ... Respond to changed address
}
}
有了这两个观察者,现在无论人的姓名还是地址发生变化时,您都会收到通知了。
#include <AddressObserver.h>
#include <NameObserver.h>
#include <Person.h>
#include <cstdlib>
int main()
{
NameObserver nameObserver;
AddressObserver addressObserver;
Person homer( "Homer" , "Simpson" );
Person marge( "Marge" , "Simpson" );
Person monty( "Montgomery", "Burns" );
// Attaching observers
homer.attach( &nameObserver );
marge.attach( &addressObserver );
monty.attach( &addressObserver );
// Updating information on Homer Simpson
homer.forename( "Homer Jay" ); // Adding his middle name
// Updating information on Marge Simpson
marge.address( "712 Red Bark Lane, Henderson, Clark County, Nevada 89011" );
// Updating information on Montgomery Burns
monty.address( "Springfield Nuclear Power Plant" );
// Detaching observers
homer.detach( &nameObserver );
return EXIT_SUCCESS;
}
在这么多实现细节之后,让我们再退一步,再次审视更大的图景。图 6-3 展示了这个观察者示例的依赖图。
图 6-3。观察者设计模式的依赖图
由于决定以类模板形式实现Observer
类,所以Observer
类位于我们架构的最高层。这使得你可以为多个目的重用Observer
类,例如Person
类。Person
类声明了自己的Observer<Person, Person::StateChange>
类型,并通过这种方式将代码注入到自己的架构层次中。具体的人员观察者,例如NameObserver
和AddressObserver
,随后可以基于这个声明进行构建。
基于值语义的观察者实现
“我理解你为什么从经典实现开始,但是既然你提到了偏爱值语义,那么在值语义世界中观察者会是什么样子呢?” 这是一个很好的问题,因为这是一个非常合理的下一步。正如在 “指南 22:偏爱值语义而非引用语义” 中解释的那样,有很多充分的理由避免引用语义的领域。然而,我们不会完全偏离经典实现:为了注册和注销观察者,我们始终需要一些唯一的标识符来标识观察者,而观察者的唯一地址只是解决这个问题最简单和最方便的方法。因此,我们将继续使用指针来引用注册的观察者。然而,std::function
是避免继承层次结构的一种优雅方式 — std::function
:
//---- <Observer.h> ----------------
#include <functional>
template< typename Subject, typename StateTag >
class Observer
{
public:
using OnUpdate = std::function<void(Subject const&,StateTag)>; 
// No virtual destructor necessary
explicit Observer( OnUpdate onUpdate ) 
: onUpdate_{ std::move(onUpdate) }
{
// Possibly respond on an invalid/empty std::function instance
}
// Non-virtual update function
void update( Subject const& subject, StateTag property )
{
onUpdate_( subject, property ); 
}
private:
OnUpdate onUpdate_; 
};
与其将 Observer
类实现为基类,从而要求派生类以非常特定的方式继承并实现 update()
函数,我们将关注点分离,而是建立在组合之上(参见 “指南 20:偏爱组合而非继承”)。Observer
类首先为我们 update()
函数期望签名的 std::function
类型提供了一个类型别名称为 OnUpdate
()。通过构造函数,您将得到一个
std::function
实例(),并将其移动到您的数据成员
onUpdate_
中()。现在,
update()
函数的工作是将调用转发到 onUpdate_
,包括参数在内()。
使用 std::function
获得的灵活性可以很容易地通过更新后的 main()
函数来演示:
#include <Observer.h>
#include <Person.h>
#include <cstdlib>
void propertyChanged( Person const& person, Person::StateChange property )
{
if( property == Person::forenameChanged ||
property == Person::surnameChanged )
{
// ... Respond to changed name
}
}
int main()
{
using PersonObserver = Observer<Person,Person::StateChange>;
PersonObserver nameObserver( propertyChanged );
PersonObserver addressObserver(
/*captured state*/{
if( property == Person::addressChanged )
{
// ... Respond to changed address
}
} );
Person homer( "Homer" , "Simpson" );
Person marge( "Marge" , "Simpson" );
Person monty( "Montgomery", "Burns" );
// Attaching observers
homer.attach( &nameObserver );
marge.attach( &addressObserver );
monty.attach( &addressObserver );
// ...
return EXIT_SUCCESS;
}
由于选择了较少侵入性的方法,并与 std::function
解耦,如何实现 update()
函数完全由观察者的实现者决定(无状态的、有状态的等)。对于 nameObserver
,我们依赖于自由函数 propertyChanged()
,它本身解耦性强,因为它不绑定到一个类,并且可以在多个场合重复使用。另一方面,addressObserver
选择了一个可能捕获一些状态的 lambda 表达式。无论哪种方式,这两者唯一需要遵循的约定是满足所需 std::function
类型的必要签名。
“为什么我们仍然需要Observer
类?我们不能直接使用std::function
吗?” 是的,看起来确实是这样。从功能角度来看,Observer
类本身并没有增加任何东西。然而,由于std::function
是值语义的真正子集,我们倾向于复制或移动std::function
对象。但在这种情况下这是不可取的:特别是如果您使用有状态的观察者,您不希望调用观察者的副本。虽然技术上可能,但通常不会传递指向std::function
的指针。因此,在std::function
的适配器形式下,Observer
类仍可能具有价值(参见“指南 24:使用适配器标准化接口”)。
分析观察者设计模式的缺陷
“这并不完全是我期望的值语义解决方案,但我仍然喜欢它!” 嗯,我很高兴你有这种感觉。实际上,值语义的优势与观察者设计模式的好处(即,将事件与执行该事件的操作分离,以及轻松添加新类型的观察者的能力)结合起来,效果非常好。不幸的是,没有完美的设计,每种设计也都伴随着缺点。
首先,我应明确说明,演示的std::function
方法仅适用于具有单个update()
函数的拉模式观察者。由于std::function
只能处理单个可调用对象,任何需要多个update()
函数的方法都无法通过单个std::function
处理。因此,std::function
通常不适用于具有多个update()
函数或可能增加update()
函数数量的推模式观察者(请记住,代码倾向于变化!)。但可以推广std::function
的方法。如果需要,首选的设计模式是类型擦除(参见第八章)。
第二个(较小的)缺点,如您所见,是没有纯粹基于值的实现。尽管我们可能能够通过std::function
实现update()
功能以获得灵活性,但我们仍然使用原始指针来附加和分离观察者。这很容易解释:使用指针作为唯一标识符的优点实在是太好了,不容忽视。此外,对于有状态的观察者,我们不希望处理实体的副本。当然,这需要我们检查nullptr
(这需要额外的工作量),而且我们始终需要支付指针所代表的间接引用的开销。¹³ 我个人认为这只是一个小问题,因为这种方法有许多优点。
另一个更大的缺点是 观察者 的潜在实现问题:注册和注销的顺序可能非常重要,特别是如果允许一个观察者多次注册。此外,在多线程环境中,观察者的线程安全注册和注销以及事件处理是非常不平凡的问题。例如,如果一个不受信任的观察者在回调期间表现不当,它可能会冻结服务器,而为任意计算实现超时则是非常非平凡的。然而,这个主题远远超出了本书的范围。
然而,本书所涉及的范围是观察者的过度使用可能很容易导致复杂的互联网网络。事实上,如果你不小心,你可能会意外地引入回调的无限循环!因此,开发者有时会对使用观察者感到担忧,并担心单个通知可能会由于这些互联网连接而导致巨大的全局响应。当然,如果你有一个适当的架构,并且正确地实现了你的观察者,那么任何通知序列应始终沿着你的架构向下运行一个有向无环图(DAG)。当然,这正是良好软件设计的美丽所在。
简而言之,观察者设计模式的目的是提供一种解决状态变化通知的方案,它被证明是最著名和最常用的设计模式之一。除了可能棘手的实现细节外,它绝对是每个开发者工具箱中应该有的设计模式之一。
指南 26:使用 CRTP 引入静态类型类别
C++ 真的有很多东西要提供。它带来了许多功能,许多句法上的奇特之处,以及大量令人惊讶、完全无法发音和(对于未经过初始化的人)明显神秘的首字母缩写:RAII、ADL、CTAD、SFINAE、NTTP、IFNDR 和 SIOF。哦,这多有趣啊!其中一个神秘的首字母缩写就是 CRTP,即 Curiously Recurring Template Pattern。¹⁴ 如果你因为名字对你来说毫无意义而摸不着头脑,不要担心:就像在 C++ 中经常发生的那样,这个名字是随意选择的,但却被固守并从未被重新考虑或更改过。这个模式是由詹姆斯·科普利恩在 C++ Report 1995 年 2 月号上命名的,他意识到,奇怪的是,这个模式在许多不同的 C++ 代码库中反复出现。¹⁵ 令人好奇的是,尽管这种模式建立在继承之上(潜在地)作为一种抽象,但它并不表现出许多其他经典设计模式通常具有的性能缺陷。因此,CRTP 绝对值得一看,因为它可能成为你设计模式工具箱中一个有价值的,或者说是 奇特 的补充。
CRTP 的动机
性能在 C++中非常重要。事实上,在几种情境中,使用虚函数的性能开销被认为是完全不能接受的。因此,在对性能要求极高的情境下,比如某些电脑游戏或高频交易的部分场景中,不使用虚函数。同样适用于高性能计算(HPC)。在 HPC 中,任何形式的条件判断或间接引用,包括虚函数,在性能最关键的部分,如计算核心的最内层循环中,都是被禁止的。使用它们会带来过多的性能开销。
为了举例说明这个问题的重要性和原因,让我们考虑来自线性代数(LA)库的以下DynamicVector
类模板:
//---- <DynamicVector.h> ----------------
#include <numeric>
#include <iosfwd>
#include <iterator>
#include <vector>
// ...
template< typename T >
class DynamicVector
{
public:
using value_type = T; 
using iterator = typename std::vector<T>::iterator;
using const_iterator = typename std::vector<T>::const_iterator;
// ... Constructors and special member functions
size_t size() const; 
T& operator[]( size_t index ); 
T const& operator[]( size_t index ) const;
iterator begin(); 
const_iterator begin() const;
iterator end();
const_iterator end() const;
// ... Many numeric functions
private:
std::vector<T> values_; 
// ... };
template< typename T >
std::ostream& operator<<( std::ostream& os, DynamicVector const<T>& vector ) 
{
os << "(";
for( auto const& element : vector ) {
os << " " << element;
}
os << " )";
return os;
}
template< typename T >
auto l2norm( DynamicVector const<T>& vector ) 
{
using std::begin, std::end;
return std::sqrt( std::inner_product( begin(vector), end(vector)
, begin(vector), T{} ) );
}
// ... Many more
尽管名字是DynamicVector
,但它并不表示一个容器,而是用于 LA 计算的数值向量。名字中的Dynamic
部分暗示它以动态方式分配其类型为T
的元素,例如在这个例子中,以std::vector
的形式()。因此,它适用于大型 LA 问题(绝对是数百万个元素的范围)。尽管这个类可能加载了许多数值操作,从接口的角度来看,您确实可能会倾向于将其称为容器:它提供了常见的嵌套类型(
value_type
、iterator
和const_iterator
)(),一个
size()
函数来查询当前元素的数量(),通过索引访问单个元素的下标操作符(一个用于非
const
向量,一个用于const
向量)(),以及
begin()
和end()
函数来迭代元素()。除了成员函数外,它还提供了一个输出运算符(
),并且,为了展示至少一个 LA 操作,提供了一个计算向量的欧几里得范数的函数(通常也称为L2 范数,因为它近似于离散向量的 L2 范数)(
)。
然而,DynamicVector
并不是唯一的向量类。在我们的线性代数库中,您还会找到以下StaticVector
类:
//---- <StaticVector.h> ----------------
#include <array>
#include <numeric>
#include <iosfwd>
#include <iterator>
// ...
template< typename T, size_t Size >
class StaticVector
{
public:
using value_type = T; 
using iterator = typename std::array<T,Size>::iterator;
using const_iterator = typename std::array<T,Size>::const_iterator;
// ... Constructors and special member functions
size_t size() const; 
T& operator[]( size_t index ); 
T const& operator[]( size_t index ) const;
iterator begin(); 
const_iterator begin() const;
iterator end();
const_iterator end() const;
// ... Many numeric functions
private:
std::array<T,Size> values_; 
// ... };
template< typename T, size_t Size >
std::ostream& operator<<( std::ostream& os, 
StaticVector<T,Size> const& vector )
{
os << "(";
for( auto const& element : vector ) {
os << " " << element;
}
os << " )";
return os;
}
template< typename T, size_t Size >
auto l2norm( StaticVector<T,Size> const& vector ) 
{
using std::begin, std::end;
return std::sqrt( std::inner_product( begin(vector), end(vector)
, begin(vector), T{} ) );
}
“这与DynamicVector
类几乎相同,不是吗?”你想知道。是的,这两个类确实非常相似。StaticVector
类提供与DynamicVector
相同的接口,例如嵌套类型value_type
、iterator
和const_iterator
();
size()
成员函数();下标运算符(
);以及
begin()
和end()
函数()。它还带有一个输出运算符(
)和一个自由的
l2norm()
函数()。然而,这两个向量类之间有一个重要的性能差异:正如名称中的
Static
所示,StaticVector
不会动态分配其元素。相反,它使用一个内部缓冲区来存储其元素,例如,使用std::array
()。因此,与
DynamicVector
相比,StaticVector
的整个功能都针对少量固定数量的元素进行了优化,比如二维或三维向量。
“好的,我明白这对性能很重要,但还是有很多代码重复,对吧?”再次,你是正确的。如果你仔细查看这两个向量类的相关输出运算符,你会发现这两个函数的实现是相同的。这是非常不可取的:如果有任何变化,例如向量格式的改变(记住:变化是软件开发中唯一的常量,并且需要预料到;参见“指南 2:为变更设计”),那么你将不得不在许多地方进行更改,而不仅仅是一个地方。这是违反了“不要重复自己”(DRY)原则:很容易忘记或者遗漏更新其中一个地方,从而引入不一致性甚至错误。
“但是通过稍微更通用的函数模板难道不可以轻松解决这种重复吗?例如,我可以想象为各种密集向量编写以下输出运算符:”
template< typename DenseVector >
std::ostream& operator<<( std::ostream& os, DenseVector const& vector )
{
// ... as before
}
尽管这似乎是一个足够的解决方案,但我不会在拉取请求中接受这段代码。这个函数模板确实更加通用,但我绝对不会称其为“稍微”更通用;你建议的是可能写出的最通用的输出运算符。是的,函数模板的名称可能表明它仅适用于密集向量(包括DynamicVector
和StaticVector
),但实际上这个函数模板将接受任何类型:DynamicVector
、StaticVector
、std::vector
、std::string
,以及诸如int
和double
等基本类型。它只是未能指定任何要求或任何类型的约束。因此,它违反了核心指南 T.10:¹⁶
为所有模板参数指定概念。
虽然这个输出运算符将适用于所有密集向量和序列容器,但对于不提供预期接口的所有类型,您将获得编译错误。或者更糟糕的是,您可能会微妙地违反隐含的要求和期望,从而违反 LSP(见“Guideline 6: Adhere to the Expected Behavior of Abstractions”)。当然,这不是有意的,而是可能是意外的:这个输出运算符对任何类型都是完美匹配,可能会在您不期望的情况下使用。因此,这个函数模板将是输出运算符重载集合中非常不幸的一个补充。我们需要的是一组全新的类型,一个新的类型类别。
“这不是基类的用途吗?我们难道不能简单地制定一个DenseVector
基类,为所有密集向量定义预期接口吗?考虑下面的DenseVector
基类草图:”
template< typename T > // Type of the elements
class DenseVector
{
public:
virtual ~DenseVector() = default;
virtual size_t size() const = 0;
virtual T& operator[]( size_t index ) = 0;
virtual T const& operator[]( size_t index ) const = 0;
// ...
};
template< typename T >
std::ostream& operator<<( std::ostream& os, DenseVector<T> const& vector )
{
// ... as before
}
“这应该可行,对吧?我只是不确定如何声明begin()
和end()
函数,因为我不知道如何从不同的迭代器类型(例如std::vector<T>::iterator
和std::array<T>::iterator
)抽象出来。” 我也有种感觉这可能是个问题,我承认我对此也没有快速解决方案。但有一件事更令人担忧:使用这个基类,我们会把所有成员函数都变成虚成员函数。这将包括begin()
和end()
函数,但更重要的是两个下标运算符。后果将是显著的:现在每次访问向量的元素时,我们都必须调用一个虚函数。每次访问都是如此!因此,使用这个基类,我们可以告别高性能。
总体上,使用基类建立抽象的想法是好的。我们只需要以不同的方式做。这就是我们应该更仔细研究 CRTP 的地方。
解释 CRTP 设计模式
CRTP 设计模式建立在使用基类创建抽象的常见思想之上。但它不是通过虚函数在基类和派生类之间建立运行时关系,而是创建编译时关系。
CRTP 设计模式
意图:“为一系列相关类型定义编译时抽象。”
将DenseVector
基类升级为类模板,创建了与DynamicVector
派生类之间的编译时关系:
//---- <DenseVector.h> ----------------
template< typename Derived > 
struct DenseVector
{
// ...
size_t size() const { return static_cast<Derived const&>(*this).size(); } 
// ... };
//---- <DynamicVector.h> ----------------
template< typename T >
class DynamicVector : public DenseVector<DynamicVector<T>> 
{
public:
// ...
size_t size() const; 
// ... };
有关 CRTP 的有趣细节是,DenseVector
基类的新模板参数代表了关联派生类的类型()。例如,派生类如
DynamicVector
期望提供其自己的类型来实例化基类()。
“哇,等等,这真的可能吗?” 你问道。是的。要实例化一个模板,你并不需要完整的类型定义。使用不完整类型就足够了。这样的不完整类型在编译器看到class DynamicVector
声明之后就可用了。从本质上讲,这段语法像是前向声明一样起作用。因此,DynamicVector
类确实可以将自身用作DenseVector
基类的模板参数。
当然,你可以随意命名基类的模板参数(例如,简单地T
),但正如在“Guideline 14: Use a Design Pattern’s Name to Communicate Intent”中讨论的那样,使用设计模式的名称或通常用于模式的名称有助于传达意图。因此,你可以将参数命名为CRTP
,这样很好地传达了模式,但遗憾的是只有理解的人才能明白。其他人会对这个缩写感到困惑。因此,模板参数通常被称为Derived
,这完美地表达了它的目的并传达了它的意图:它代表了派生类的类型。
通过这个模板参数,基类现在可以意识到派生类型的实际类型。虽然它仍然代表一种抽象和所有稠密向量的共同接口,但它现在能够访问并调用派生类型中的具体实现。例如,在size()
成员函数中():
DenseVector
使用static_cast
将自身转换为派生类的引用,并在其上调用size()
函数。乍一看可能像是递归函数调用(在size()
函数内部调用size()
函数),实际上是在派生类中调用size()
成员函数()。
“这就是你所说的编译时关系吗?基类代表具体派生类型和实现细节的抽象,但仍然清楚地知道实现细节在哪里。所以我们确实不需要任何虚函数。” 正确。通过 CRTP,我们现在能够实现一个共同的接口,并通过简单的static_cast
将每个调用转发到派生类。这样做没有性能惩罚。事实上,基类函数很可能被内联,如果DenseVector
是唯一或第一个基类,static_cast
甚至不会导致单个汇编指令。它仅仅告诉编译器将对象视为派生类型的对象。
要提供一个干净的 CRTP 基类,我们应该更新一些细节,尽管如此:
//---- <DenseVector.h> ----------------
template< typename Derived >
struct DenseVector
{
protected:
~DenseVector() = default; 
public:
Derived& derived() { return static_cast<Derived&>( *this ); } 
Derived const& derived() const { return static_cast<Derived const&>( *this ); }
size_t size() const { return derived().size(); }
// ... };
由于我们希望避免任何虚函数,我们对虚析构函数也不感兴趣。因此,我们在类的protected
部分实现了非虚析构函数 ()。这完全符合Core Guideline C.35的要求:
基类的析构函数应该是公共的且虚拟的,或者是受保护的且非虚拟的。
但是请记住,这个析构函数的定义阻止编译器生成两个移动操作。由于 CRTP 基类通常为空且没有任何内容可移动,这不是问题;但仍然要始终注意Rule of 5。
我们也应该避免在基类的每一个成员函数中使用static_cast
。虽然这样做是正确的,但是任何强制转换都应该被视为可疑的,而且应该尽量减少强制转换。¹⁷ 因此,我们添加了两个derived()
成员函数,用于执行转换并可以在其他成员函数中使用 ()。这样一来,生成的代码不仅更清晰,符合DRY原则,而且看起来更不可疑。
借助于derived()
函数,我们现在可以继续定义下标操作符和begin()
以及end()
函数:
template< typename Derived >
struct DenseVector
{
// ...
??? operator[]( size_t index ) { return derived()[index]; }
??? operator[]( size_t index ) const { return derived()[index]; }
??? begin() { return derived().begin(); }
??? begin() const { return derived().begin(); }
??? end() { return derived().end(); }
??? end() const { return derived().end(); }
// ...
};
然而,这些函数并不像size()
成员函数那样直截了当。特别是,返回类型的确定稍微复杂一些,因为这些类型依赖于Derived
类的实现。“好吧,这应该不会太难,”你说道。“这就是为什么派生类型提供了一些嵌套类型,比如value_type
、iterator
和const_iterator
,对吧?”确实,看起来很直观,只需友好地询问:
template< typename Derived >
struct DenseVector
{
// ...
using value_type = typename Derived::value_type; 
using iterator = typename Derived::iterator;
using const_iterator = typename Derived::const_iterator;
value_type& operator[]( size_t index ) { return derived()[index]; }
value_type const& operator[]( size_t index ) const { return derived()[index]; }
iterator begin() { return derived().begin(); }
const_iterator begin() const { return derived().begin(); }
iterator end() { return derived().end(); }
const_iterator end() const { return derived().end(); }
// ... };
在派生类中查询value_type
、iterator
和const_iterator
类型(别忘了typename
关键字),并使用它们来指定我们的返回类型 ()。简单,对吧?你几乎可以打赌,事情并没有那么简单。如果你尝试这样做,Clang 编译器将会投诉一个非常奇怪和令人困惑的错误信息:
CRTP.cpp:29:41: error: no type named 'value_type' in 'DynamicVector<int>'
using value_type = typename Derived::value_type;
~~~~~~~~~~~~~~~~~~^~~~~~~~~~
“DynamicVector<int>
中没有value_type
——奇怪。”你脑海中闪过的第一个想法是你搞砸了。肯定是打字错误。当然!于是你回到代码,检查拼写。然而,事实证明一切似乎都没问题。没有拼写错误。你再次检查DynamicVector
类:嵌套的value_type
成员在那里。而且一切都是public
的。这个错误信息简直毫无意义。你重新审视一切,再次,半个小时后你得出结论,“编译器有 bug!”
不,这不是编译器的错误。不是 Clang 或任何其他编译器的错误。GCC 提供了一个不同的,稍微更具启发性但可能仍然有点令人费解的错误信息:¹⁸
CRTP.cpp:29:10: error: invalid use of incomplete type 'class DynamicVector<int>'
29 | using value_type = typename Derived::value_type;
| ^~~~~~~~~~
Clang 编译器是正确的:在DynamicVector
类中不存在value_type
。还没有!当你查询嵌套类型时,DynamicVector
类的定义还没有被看到,而DynamicVector
仍然是一个不完整的类型。这是因为编译器会在DynamicVector
类的定义之前实例化DenseVector
基类。毕竟,从语法上来说,基类是在类的主体之前指定的:
template< typename T >
class DynamicVector : public DenseVector<DynamicVector<T>>
// ...
因此,你无法使用派生类的嵌套类型作为 CRTP 类的返回类型。实际上,在派生类是不完整类型的情况下,你不能使用任何东西。“但为什么我可以调用派生类的成员函数?这不应该导致相同的问题吗?”幸运的是,这是有效的(否则 CRTP 模式根本无法工作)。但它之所以有效,仅因为类模板的一个特殊属性:成员函数只有在需要时才会被实例化,也就是在实际调用它们时。由于实际调用通常发生在派生类定义可用之后,因此缺少定义时并不存在问题。在那时,派生类就不再是不完整类型了。
“好的,我明白了。但是我们如何指定下标运算符、begin()
和end()
函数的返回类型?”处理这个问题最方便的方法是使用返回类型推导。这是使用decltype(auto)
返回类型的一个绝佳机会:
template< typename Derived >
struct DenseVector
{
// ...
decltype(auto) operator[]( size_t index ) { return derived()[index]; }
decltype(auto) operator[]( size_t index ) const { return derived()[index]; }
decltype(auto) begin() { return derived().begin(); }
decltype(auto) begin() const { return derived().begin(); }
decltype(auto) end() { return derived().end(); }
decltype(auto) end() const { return derived().end(); }
};
“只使用auto
不就足够了吗?例如,我们可以像这样定义返回类型:”
template< typename Derived >
struct DenseVector
{
// ... Note: this doesn't always work, whereas decltype(auto) always works
auto& operator[]( size_t index ) { return derived()[index]; }
auto const& operator[]( size_t index ) const { return derived()[index]; }
auto begin() { return derived().begin(); }
auto begin() const { return derived().begin(); }
auto end() { return derived().end(); }
auto end() const { return derived().end(); }
};
对于这个例子来说,这是足够的,是的。然而,正如我一直强调的,代码会变化。最终,可能会有另一个派生的向量类,它不存储其值并返回其值的引用,而是生成值并通过值返回。是的,这很容易想象:比如考虑一个ZeroVector
类,它代表向量的零元素。这样的向量不会存储所有元素,因为这样做很浪费,而可能被实现为一个空类,每次访问元素时都通过值返回零。在这种情况下,使用auto&
返回类型将是不正确的。是的,编译器会(希望)警告你这个问题。但是你可以通过返回确切与派生类返回相同的内容来避免整个问题。这种返回类型正是decltype(auto)
返回类型所表示的。
分析 CRTP 设计模式的缺点
“哇,这个 CRTP 设计模式听起来太棒了。所以说,除了这些比通常稍微复杂的实现细节外,这难道不是解决所有虚函数性能问题的方案吗?这不是所有继承相关问题的圣杯吗?” 我能理解这种热情!乍一看,CRTP 确实看起来像是所有继承层次结构问题的终极解决方案。不幸的是,这是一种幻觉。请记住:每种设计模式都有其好处,但不幸的是也有其局限性。而 CRTP 设计模式有几个相当限制性的缺点。
第一个,也是最为限制的缺点之一是缺乏一个公共基类。我会重复这一点以强调其重要性:没有 共同的基类!实际上,每个派生类都有一个不同的基类。例如,DynamicVector<T>
类具有 DenseVector<DynamicVector<T>>
基类。StaticVector<T,Size>
类具有 DenseVector<StaticVector<T,Size>>
基类(参见 图 6-4)。因此,每当需要一个公共基类时,例如用于存储集合中的不同类型的公共抽象,CRTP 设计模式 不 是正确的选择。
图 6-4. CRTP 设计模式的依赖图
“哦,哇,我看到这可能是一个真正的限制。但我们难道不可以让 CRTP 基类从一个公共基类继承吗?” 你提出了反驳。不行,不行,因为这将要求我们再次引入虚函数。“好的,我明白了。那用 std::variant
模拟一个公共基类怎么样?” 是的,这是一个选择。但请记住,std::variant
是 Visitor 设计模式的一种表示(参见 “准则 16: 使用 Visitor 扩展操作”)。而且由于 std::variant
需要知道其所有潜在的替代方案,这将限制您添加新类型的自由。所以你看,尽管你可能不喜欢,CRTP 真的 不 是每个继承层次结构的替代品。
第二个,同样可能非常限制的缺点是,一旦与 CRTP 基类接触的一切都变成了模板本身。这对所有与这样的基类一起工作的函数特别适用。例如,考虑升级后的输出运算符和 l2norm()
函数:
template< typename Derived >
std::ostream& operator<<( std::ostream& os, DenseVector<Derived> const& vector );
template< typename Derived >
auto l2norm( DenseVector<Derived> const& vector );
这两个函数应该适用于所有从 DenseVector
CRTP 类派生的类。当然,它们不应依赖于派生类的具体类型。因此,这两个函数必须是函数模板:必须推导出 Derived
类型。虽然在线性代数库的上下文中,这通常不是问题,因为几乎所有功能都以模板实现,但在其他上下文中可能会有很大的缺点。大量将代码转换为模板并将定义移动到头文件中,实际上是牺牲了源文件的封装性。是的,这可能确实是一个严重的缺点!
第三,CRTP 是一种侵入式的设计模式。派生类必须明确选择通过继承 CRTP 基类来参与其中。虽然在我们自己的代码中可能不是问题,但在外部代码中,你不能轻易地添加一个基类。在这种情况下,你将不得不使用适配器设计模式(参见 “Guideline 24: Use Adapters to Standardize Interfaces”)。因此,CRTP 不具备非侵入式设计模式的灵活性(例如,使用 std::variant
实现的访问者设计模式,适配器设计模式等)。
最后但同样重要的是,CRTP 不提供运行时多态性,只提供编译时多态性。因此,该模式仅在需要某种静态类型抽象时才有意义。如果不需要,它再次不能替代所有继承层次结构。
CRTP 的未来:CRTP 与 C++20 概念的比较
“我明白了,你说得对。CRTP 是纯粹的编译时多态性。然而,这让我想知道:我们是否可以依赖 C++20 概念而不是 CRTP?考虑以下代码。我们可以使用概念来定义一组类型的要求,并将函数和操作符限定为仅接受提供所期望接口的类型:”¹⁹
template< typename T >
concept DenseVector =
requires ( T t, size_t index ) {
t.size();
t[index];
{ t.begin() } -> std::same_as<typename T::iterator>;
{ t.end() } -> std::same_as<typename T::iterator>;
} &&
requires ( T const t, size_t index ) {
t[index];
{ t.begin() } -> std::same_as<typename T::const_iterator>;
{ t.end() } -> std::same_as<typename T::const_iterator>;
};
template< DenseVector VectorT >
std::ostream& operator<<( std::ostream& os, VectorT const& vector )
{
// ... as before
}
你完全正确。我同意,这是一个非常合理的替代方案。确实,C++20 概念与 CRTP 非常相似,但代表了一种更简单、非侵入式的选择。特别是通过非侵入性,如果你可以使用 C++20 概念并且可以通过概念定义静态类型集合,你应该优先选择概念而非 CRTP。
尽管如此,我对这个解决方案并不完全满意。虽然这种输出操作符的表述有效地限制了函数模板仅接受那些提供所期望接口的类型,但它并未完全将函数模板限定为我们的稠密向量类型集合。仍然可以传递 std::vector
和 std::string
(std::string
在 std
命名空间中已经有了输出操作符)。因此,这个概念还不够具体。但如果你遇到这种情况,不要担心:使用标签类有解决方案:
struct DenseVectorTag {}; 
template< typename T >
concept DenseVector =
// ... Definition of all requirements on a dense vector (as before)
&& std::is_base_of_v<DenseVectorTag,T>;
template< typename T >
class DynamicVector : private DenseVectorTag 
{
// ... };
通过从 DenseVectorTag
类(最好是非公开方式)继承,像 DynamicVector
这样的类可以识别为某一类类型的一部分()。因此,函数和操作符模板可以有效地限制为仅接受那些明确选择加入类型集合的类型。不幸的是,这种方法不再是非侵入式的。为了克服这一限制,我们引入了通过可定制的类型特性类进行编译时间接的方法。换句话说,我们应用 SRP 原则并分离关注点:
struct DenseVectorTag {};
template< typename T >
struct IsDenseVector 
: public std::is_base_of<DenseVectorTag,T>
{};
template< typename T >
constexpr bool IsDenseVector_v = IsDenseVector<T>::value; 
template< typename T >
concept DenseVector =
// ... Definition of all requirements on a dense vector (as before)
&& IsDenseVector_v<T>; 
template< typename T >
class DynamicVector : private DenseVectorTag 
{
// ... };
template< typename T, size_t Size >
class StaticVector
{
// ... };
template< typename T, size_t Size >
struct IsDenseVector< StaticVector<T,Size> > 
: public std::true_type
{};
IsDenseVector
类模板及其对应的变量模板,指示给定类型是否属于稠密向量类型集合( 和
)。而不是直接查询给定类型,
DenseVector
概念会通过 IsDenseVector
类型特性间接询问()。这为类提供了两种选择:要么通过侵入式继承自
DenseVectorTag
(),要么通过非侵入式方式专门化
IsDenseVector
类型特性()。这种形式下,概念方法真正超越了经典的 CRTP 方法。
总结一下,CRTP 是一个了不起的设计模式,用于定义一组相关类型之间的编译时关系。最有趣的是,它解决了继承层次结构可能带来的所有性能问题。然而,CRTP 也带来了一些潜在的限制性缺点,例如缺乏共同的基类、模板代码的快速传播以及限制于编译时多态性。使用 C++20,考虑将 CRTP 替换为概念,这提供了一个更简单且非侵入式的替代方案。但是,如果你没有使用 C++20 概念的机会,并且 CRTP 适合你的情况,它将为你证明其极大的价值。
指南 27:使用 CRTP 实现静态 Mixin 类
在 “指南 26:使用 CRTP 引入静态类型类别” 中,我向你介绍了 CRTP 设计模式。我可能也给你留下了一个印象,即 CRTP 是老掉牙的,已经被 C++20 概念取代。然而有趣的是,并非完全如此。这是因为我还没有完全告诉你全部情况。CRTP 仍然可能有价值:只是不再作为设计模式,而是作为实现模式。因此,让我们先进入实现模式的领域,让我解释一下。
强类型的动机
考虑下面的 StrongType
类模板,它代表了任何其他类型的包装,用于创建一个独特的、命名的类型:²⁰
//---- <StrongType.h> ----------------
#include <utility>
template< typename T, typename Tag >
struct StrongType
{
public:
using value_type = T;
explicit StrongType( T const& value ) : value_( value ) {}
T& get() { return value_; }
T const& get() const { return value_; }
private:
T value_;
};
例如,这个类可以用来定义 Meter
、Kilometer
和 Surname
类型:²¹
//---- <Distances.h> ----------------
#include <StrongType.h>
template< typename T >
using Meter = StrongType<T,struct MeterTag>;
template< typename T >
using Kilometer = StrongType<T,struct KilometerTag>;
// ...
//---- <Person.h> ----------------
#include <StrongType.h>
using Surname = StrongType<std::string,struct SurnameTag>;
// ...
使用别名模板来表示Meter
和Kilometer
可以使您选择long
或double
来表示距离。然而,尽管这些类型建立在基本类型或标准库类型上(例如Surname
的情况下是std::string
),它们代表具有语义意义的不同类型(强类型),不会(意外地)在算术操作中结合,例如加法:
//---- <Main.cpp> ----------------
#include <Distances.h>
#include <cstdlib>
int main()
{
auto const m1 = Meter<long>{ 120L };
auto const m2 = Meter<long>{ 50L };
auto const km = Kilometer<long>{ 30L };
auto const surname1 = Surname{ "Stroustrup" };
auto const surname2 = Surname{ "Iglberger" };
// ...
m1 + km; // Correctly does not compile! 
surname1 + surname2; // Also correctly does not compile! 
m1 + m2; // Inconveniently this does not compile either. 
return EXIT_SUCCESS;
}
虽然Meter
和Kilometer
都用long
表示,但不能直接将它们加在一起()。这很好:它不留下任何意外错误的可能。对于字符串连接,虽然
std::string
提供了加法操作符,但两个Surname
也不能相加()。但这也是好事:强类型有效地限制了底层类型的不必要操作。不幸的是,这种“特性”也阻止了两个
Meter
实例的相加()。尽管如此,这个操作是可取的:它直观自然,并且由于操作的结果再次是
Meter
类型,因此在物理上是准确的。为了使其工作,我们可以为Meter
类型实现加法操作符。然而显然,这不会是唯一的加法操作符。我们还需要为所有其他强类型实现一个加法操作符,如Kilometer
、Mile
、Foot
等。由于所有这些实现看起来都一样,这违反了 DRY 原则。因此,将StrongType
类模板扩展为加法操作符似乎是合理的:
template< typename T, typename Tag >
StrongType<T,Tag>
operator+( StrongType<T,Tag> const& a, StrongType<T,Tag> const& b )
{
return StrongType<T,Tag>( a.get() + b.get() );
}
由于这个加法操作符的制定,不能将两个不同实例化的StrongType
(例如Meter
和Kilometer
)相加,但可以对相同实例化的两个实例进行加法。"哦,但我看到一个问题:虽然现在可以添加两个Meter
或两个Kilometer
,但也可以添加两个Surname
。我们不希望这样!” 您是正确的:这是不可取的。我们需要的是对特定实例化的StrongType
进行有意义的操作添加。这就是 CRTP 发挥作用的地方。
使用 CRTP 作为实现模式
相比直接在StrongType
类模板中添加操作,我们通过mixin类来提供操作:这些基类“注入”所需的操作。这些 mixin 类是通过 CRTP 实现的。例如,考虑Addable
类模板,它表示加法操作:
//---- <Addable.h> ----------------
template< typename Derived >
struct Addable
{
friend Derived& operator+=( Derived& lhs, Derived const& rhs ) { 
lhs.get() += rhs.get();
return lhs;
}
friend Derived operator+( Derived const& lhs, Derived const& rhs ) { 
return Derived{ lhs.get() + rhs.get() };
}
};
模板参数的名称揭示了它的作用:Addable
是一个 CRTP 基类。Addable
仅提供两个函数,作为hidden friends实现:一个加法赋值运算符(参见)和一个加法运算符(参见
)。这两个运算符都为指定的
Derived
类型定义,并注入到周围的命名空间中。²²
//---- <StrongType.h> ----------------
#include <stdlib>
#include <utility>
template< typename T, typename Tag >
struct StrongType : private Addable< StrongType<T,Tag> >
{ /* ... */ };
//---- <Distances.h> ----------------
#include <StrongType.h>
template< typename T >
using Meter = StrongType<T,struct MeterTag>;
// ...
//---- <Main.cpp> ----------------
#include <Distances.h>
#include <cstdlib>
int main()
{
auto const m1 = Meter<long>{ 100 };
auto const m2 = Meter<long>{ 50 };
auto const m3 = m1 + m2; // Compiles and results in 150 meters
// ...
return EXIT_SUCCESS;
}
“我理解混合类的用途,但在这种形式下,所有StrongType
的实例化都会继承加法运算符,即使在不需要加法的情况下也是如此,对吧?” 是的,确实如此。因此,我们还没有完成。我们想要的是仅向那些需要该操作的StrongType
实例化中选择性地添加混合类。我们的选择解决方案是提供可选的模板参数形式的混合类。为此,我们通过一组可变模板模板参数扩展了StrongType
类模板:²³
//---- <StrongType.h> ----------------
#include <utility>
template< typename T, typename Tag, template<typename> class... Skills >
struct StrongType
: private Skills< StrongType<T,Tag,Skills...> >... 
{ /* ... */ };
此扩展使我们能够为每个单独的强类型单独指定所需的技能。例如,考虑两个额外的技能Printable
和Swappable
:
//---- <Printable.h> ----------------
template< typename Derived >
struct Printable
{
friend std::ostream& operator<<( std::ostream& os, const Derived& d )
{
os << d.get();
return os;
}
};
//---- <Swappable.h> ----------------
template< typename Derived >
struct Swappable
{
friend void swap( Derived& lhs, Derived& rhs )
{
using std::swap; // Enable ADL
swap( lhs.get(), rhs.get() );
}
};
与Addable
技能一起,我们现在可以组装具备所需和期望技能的强类型:
//---- <Distances.h> ----------------
#include <StrongType.h>
template< typename T >
using Meter =
StrongType<T,struct MeterTag,Addable,Printable,Swappable>; 
template< typename T >
using Kilometer =
StrongType<T,struct KilometerTag,Addable,Printable,Swappable>; 
// ...
//---- <Person.h> ----------------
#include <StrongType.h>
#include <string>
using Surname =
StrongType<std::string,struct SurnameTag,Printable,Swappable>; 
// ...
Meter
和Kilometer
都可以相加、打印和交换(参见和
),而
Surname
可以打印和交换,但不能相加(即不接收Addable
混合,因此不继承自它)(参见)。
“太好了。我理解了在这种上下文中 CRTP 混合类的目的。但是这个 CRTP 示例与先前的示例有何不同?” 非常好的问题。你说得对,实现细节非常相似。但是有几个显著的区别。请注意,CRTP 基类不提供虚拟或受保护的析构函数。因此,与先前的示例相比,在这个例子中将 CRTP 基类用作私有基类足以甚至更可取。 (参见)
因此,在这种情况下,CRTP 基类并不代表一种抽象,而仅仅是一种实现细节。因此,CRTP 不满足设计模式的属性,也不起到设计模式的作用。它仍然是一种模式,毫无疑问,但在这种情况下仅充当实现模式。
在 CRTP 示例的实现中,主要差异在于我们使用继承的方式。对于 CRTP 设计模式,我们根据 LSP 使用继承作为抽象:基类代表了要求以及派生类可用和预期的行为。用户代码直接通过指针或引用访问操作,这反过来要求我们提供virtual
或protected
析构函数。以这种方式实现时,CRTP 成为软件设计的真正元素——一种设计模式。
相比之下,对于 CRTP 实现模式,我们使用继承来实现技术上的优雅和方便。基类成为实现细节,不需要被调用代码知道或使用。因此,它不需要virtual
或protected
析构函数。当以这种方式实现时,CRTP 保持在实现细节的层面上,因此是一种实现模式。然而,以这种形式实现的 CRTP 不与 C++20 概念竞争。相反,在这种形式下,CRTP 无可匹敌,因为它代表了提供静态 mixin 功能的独特技术。因此,CRTP 至今仍然被使用,并且是每个 C++开发者工具箱中的宝贵补充。
总之,CRTP 并非过时,但其价值已经发生了变化。在 C++20 中,CRTP 被概念取代,因此作为一种设计模式逐渐退出。然而,它继续作为 mixin 类的实现模式非常有价值。
¹ Pages 格式是苹果公司对应微软 Word 格式的等效产品。
² Erich Gamma 等人,《设计模式:可复用面向对象软件的基础》。
³ 如果你是设计模式的专家,你可能会意识到 1 对N适配器与 Facade 设计模式有一定的相似性。详细信息请参阅 GoF 书籍。
⁴ 在 C++20 中,通过将[[no_unique_address]]
属性应用于数据成员,可以实现类似的效果。如果数据成员为空,它可能不会单独占用任何存储空间。
⁵ 在这种情况下,特别有趣的是注意到std::stack
不允许通过迭代器遍历元素。与堆栈一样,你只能访问顶部元素。
⁶ Matthew Wilson,《Imperfect C++: Practical Solutions for Real-Life Programming》(Addison-Wesley,2004 年)。
⁷ Eric Freeman 和 Elisabeth Robson,《Head First Design Patterns: Building Extensible and Maintainable Object-Oriented Software》(O’Reilly,2021 年)。
⁸ 当然,你知道不能在家里尝试这个,但让我们假设这是一种那些奇怪的、星期一早晨的管理决策之一。
⁹ Erich Gamma 等人,《设计模式:可复用面向对象软件的元素》。
¹⁰ 尽管我不深入研究观察者模式的实现细节,但我仍然可以为你提供一些关于如何实现观察者的参考资料。关于许多实现方面的良好概述可以在 Victor Ciura 的 CppCon 2021 演讲“Spooky Action at a Distance”中找到。关于如何处理Observer
模式的并发问题的非常详细的讨论可以在 Tony Van Eerd 的 C++Now 2016 演讲“Thread-Safe Observer Pattern—You’re Doing It Wrong”中找到。
¹¹ 如果你熟悉非虚拟接口(NVI)惯用语或模板方法设计模式,那么请随意将这个虚函数移动到类的private
部分,并为它提供一个公共的、非虚拟的包装函数。你可以在 Herb Sutter 的Guru of the Week blog中或者C++ Users Journal第 19 卷第 9 期(2001 年 9 月)的文章"Virtuality"中找到关于 NVI 的更多信息。
¹² 或者,观察者也可以自己记住主题。
¹³ 您还可以选择使用来自指南支持库(GSL)的gsl::not_null<T>
。
¹⁴ 如果你想知道这些缩写代表什么:RAII:资源获取即初始化(被认为是 C++中最有价值的理念,但同时也是官方最糟糕的首字母缩略词;它实际上毫无意义);ADL:参数相关查找;CTAD:类模板参数推断;SFINAE:替换失败不是错误;NTTP:非类型模板参数;IFNDR:格式错误,无需诊断;SIOF:静态初始化顺序混乱。有关(几乎)所有 C++缩写的概述,请参见Arthur O’Dwyer 的博客。
¹⁵ 啊,C++ Report——那些辉煌的岁月!然而,你可能是那些从未有机会阅读原始C++ Report的可怜人之一。如果是这样的话,你应该知道,它是由 SIGS Publications Group 在 1989 年至 2002 年间出版的一个双月刊计算机杂志。原始的C++ Report如今很难找到,但其中许多文章已经被收录在由 Stanley Lippmann 编辑的书籍C++ Gems: Programming Pearls from the C++ Report(剑桥大学出版社)中。这本书包括了 James Coplien 的文章“Curiously Recurring Template Patterns”。
¹⁶ 如果你还不能使用 C++20 概念,std::enable_if
提供了一个替代方案。请参阅核心指南 T.48:“如果你的编译器不支持概念,请用 enable_if
模拟它们。”同时也查阅你喜欢的 C++ 模板参考资料。
¹⁷ 将任何类型的转换(static_cast
、reinterpret_cast
、const_cast
、dynamic_cast
,特别是旧式的 C 风格转换)视为成年特性:你需对自己的行为负全责,而编译器则会遵循。因此,强烈建议减少对转换操作符的调用(另请参阅核心指南 ES.48:“避免使用转换”)。
¹⁸ 这是一个很好的例子,展示了能够在几个主要编译器(如 Clang、GCC、MSVC 等)上编译代码库是值得的。不同的错误消息可能帮助你找到问题的源头。只使用一个编译器应被视为一种风险!
¹⁹ 如果你对 C++20 概念的思想或语法不熟悉,你可以在 Sándor Dargó 的C++ Concepts中快速无痛地入门,该书由Leanpub出版。
²⁰ 这种StrongType
的实现灵感来源于 Jonathan Boccara 的Fluent C++ 博客和相关的NamedType 库。还有几个更多的强类型库可供选择:你也可以使用 Jonathan Müller 的type_safe 库、Björn Fahller 的strong_type 库,或者 Anthony William 的strong_typedef 库。
²¹ 技术上唯一的奇特之处是在模板参数列表中声明标签类。是的,这样做是有效的,并且绝对有助于为实例化不同强类型创建独特的类型。
²² 多年前,更具体地说是在 90 年代末,这种命名空间注入被称为Barton-Nackman 技巧,以 John J. Barton 和 Lee R. Nackman 的名字命名。在 1995 年 3 月的C++ Report期刊中,他们使用命名空间注入作为当时无法重载函数模板的一种解决方法。令人惊讶的是,今天这种技术已经复兴,成为隐藏友元惯用法。
²³ 在Jonathan Bocarra 的博客中,这些可选的、可变参数被恰当地称为技能。我非常喜欢这个说法,因此我采纳了这个命名约定。
第七章:桥梁、原型和外部多态性设计模式
在本章中,我们将重点研究两个经典的 GoF 设计模式:桥梁设计模式和原型设计模式。此外,我们还将研究外部多态性设计模式。乍一看,这个选择可能看起来是一个名声显赫、几乎随机选择的设计模式。然而,我选择这些模式有两个原因:首先,在我的经验中,这三种模式在设计模式目录中是最有用的。因此,您应该对它们的意图、优点和缺点有一个相当好的理解。其次,同样重要的是:它们都将在第八章中发挥至关重要的作用。
在“Guideline 28: 构建桥梁以消除物理依赖”中,我将向您介绍桥接设计模式及其最简形式,Pimpl idiom。更重要的是,我将演示如何通过桥梁来减少物理耦合,将接口与实现细节解耦。
在“Guideline 29: 注意桥梁的性能优劣”中,我们将明确看一下桥梁的性能影响。我们将为没有桥梁的实现、基于桥梁的实现和“部分”桥梁运行基准测试。
在“Guideline 30: 用于抽象复制操作的原型模式”中,我将介绍克隆的艺术。换句话说,我们将讨论复制操作,特别是抽象复制操作。这个意图的模式选择将是原型设计模式。
在“Guideline 31: 使用外部多态性进行非侵入式运行时多态性”中,我们继续通过将函数的实现细节从类中提取来分离关注点的旅程。为了进一步减少依赖,我们将把这种关注点分离提升到一个全新的层次:我们不仅将提取虚函数的实现细节,还将使用外部多态性设计模式提取完整的函数本身。
Guideline 28: 构建桥梁以消除物理依赖
根据词典的定义,术语 bridge 表示时间、地点或连接或过渡的手段。如果我问你 bridge 这个词对你意味着什么,我相信你会有类似的定义。你可能会心里想着连接两个东西,从而使它们更加接近。例如,你可能会想到一座被河流分隔的城市。一座桥将连接城市的两岸,使它们更加接近,并节省人们大量的时间。你也可能想到电子学中,桥连接电路的两个独立部分。在音乐中也有桥梁,还有许多来自现实世界的例子,桥梁帮助连接事物。是的,直观地看,bridge 这个词暗示着增加接近和亲近感。因此,桥梁设计模式恰恰相反:它支持你减少物理依赖关系,并帮助解耦,即保持需要共同工作但不应了解彼此太多细节的两个功能部件保持一定距离。
一个激励的例子
为了说明我的想法,请考虑以下 ElectricCar
类:
//---- <ElectricEngine.h> ----------------
class ElectricEngine
{
public:
void start();
void stop();
private:
// ... };
//---- <ElectricCar.h> ----------------
#include <ElectricEngine.h>
// ...
class ElectricCar
{
public:
ElectricCar( /*maybe some engine arguments*/ );
void drive();
// ...
private:
ElectricEngine engine_; 
// ... more car-specific data members (wheels, drivetrain, ...) };
//---- <ElectricCar.cpp> ----------------
#include <ElectricCar.h>
ElectricCar::ElectricCar( /*maybe some engine arguments*/ )
: engine_{ /*engine arguments*/ }
// ... Initialization of the other data members {}
// ...
正如其名称所示,ElectricCar
类配备了一个 ElectricEngine
()。然而,尽管在现实中这样的汽车可能非常吸引人,当前的实现细节令人担忧:因为
engine_
数据成员的存在,<ElectricCar.h>
头文件需要包含 <ElectricEngine.h>
头文件。编译器需要看到 ElectricEngine
类的定义,否则将无法确定 ElectricCar
实例的大小。然而,包含 <ElectricEngine.h>
头文件很容易导致传递性的物理耦合:每个包含 <ElectricCar.h>
头文件的文件都将直接依赖 <ElectricEngine.h>
头文件。因此,每当头文件发生变化时,ElectricCar
类及可能还有其他许多类都会受到影响。它们可能需要重新编译、重新测试,甚至在最坏的情况下重新部署……叹气。
除此之外,这种设计向所有人揭示了所有的实现细节。“你是什么意思?private
部分不是用来隐藏和封装实现细节的吗?” 是的,它可能是 private
的,但 private
标签仅仅是一个访问标签。它不是一个可见性标签。因此,你类定义中的一切(我是说一切)对所有看到 ElectricCar
类定义的人都是可见的。这意味着你不能在没有任何人注意的情况下更改此类的实现细节。特别是在需要提供 ABI 稳定性时可能会有问题,即如果你的类的内存表示不能改变的话。¹
一个稍微好一点的方法是仅存储到 ElectricEngine
的指针 ():²
//---- <ElectricCar.h> ----------------
#include <memory>
// ... struct ElectricEngine; // Forward declaration
class ElectricCar
{
public:
ElectricCar( /*maybe some engine arguments*/ );
void drive();
// ...
private:
std::unique_ptr<ElectricEngine> engine_; 
// ... more car-specific data members (wheels, drivetrain, ...) };
//---- <ElectricCar.cpp> ----------------
#include <ElectricCar.h>
#include <ElectricEngine.h> 
ElectricCar::ElectricCar( /*maybe some engine arguments*/ )
: engine_{ std::make_unique<ElectricEngine>( /*engine arguments*/ ) }
// ... Initialization of the other data members {}
// ... Other 'ElectricCar' member functions, using the pointer to an // 'ElectricEngine'.
在这种情况下,只需为ElectricEngine
类提供前向声明即可,因为编译器不需要知道类定义就能确定ElectricCar
实例的大小。此外,物理依赖已经消失,因为<ElectricEngine.h>
头文件已经移动到源文件中()。因此,从依赖性的角度来看,这种解决方案要好得多。仍然存在的是实现细节的可见性。每个人仍然能看到
ElectricCar
依赖于ElectricEngine
,因此每个人仍然隐式依赖于这些实现细节。因此,对这些细节的任何更改,比如升级到新的PowerEngine
,都会影响与<ElectricCar.h>
头文件一起工作的任何类。“这不好,对吧?”确实如此,因为预计会发生变化(见“指导原则 2:为变化设计”)。为了摆脱这种依赖性并获得在任何时候轻松更改实现细节的便利性而不被任何人察觉,我们必须引入一个抽象层。抽象的经典形式是引入抽象类:
//---- <Engine.h> ----------------
class Engine 
{
public:
virtual ~Engine() = default;
virtual void start() = 0;
virtual void stop() = 0;
// ... more engine-specific functions
private:
// ... };
//---- <ElectricCar.h> ----------------
#include <Engine.h>
#include <memory>
class ElectricCar
{
public:
void drive();
// ...
private:
std::unique_ptr<Engine> engine_; 
// ... more car-specific data members (wheels, drivetrain, ...) };
//---- <ElectricEngine.h> ----------------
#include <Engine.h>
class ElectricEngine : public Engine
{
public:
void start() override;
void stop() override;
private:
// ... };
//---- <ElectricCar.cpp> ----------------
#include <ElectricCar.h>
#include <ElectricEngine.h>
ElectricCar::ElectricCar( /*maybe some engine arguments*/ )
: engine_{ std::make_unique<ElectricEngine>( /*engine arguments*/ ) } 
// ... Initialization of the other data members {}
// ... Other 'ElectricCar' member functions, primarily using the 'Engine' // abstraction, but potentially also explicitly dealing with an // 'ElectricEngine'.
有了Engine
基类的基础(),我们可以使用这个抽象来实现我们的
ElectricCar
类()。没有人需要知道我们使用的引擎的实际类型。也没有人需要知道我们何时升级我们的引擎。通过这种实现,我们可以随时只通过修改源文件(
)轻松更改实现细节。因此,采用这种方法,我们真正减少了对
ElectricEngine
实现的依赖。我们已经使得这个细节的知识成为我们自己的、秘密的实现细节。通过这样做,我们建立了一个桥梁。
注意
正如介绍中所述,与其说这个桥梁是为了让ElectricCar
和Engine
类更接近,不如说它是关于分离关注点和松耦合。另一个显示出naming is hard的例子来自 Kate Gregory 在 CppCon 的演讲。
解释桥梁设计模式
桥接设计模式是 1994 年引入的另一个经典 GoF 设计模式。桥梁的目的是通过封装某些实现细节在抽象后最小化物理依赖。在 C++中,它充当编译防火墙,便于变更:
桥梁设计模式
意图:“将抽象与其实现分离,使得它们可以独立变化。”³
在这个意图的表述中,四人组谈到了“抽象”和“实现”。在我们的例子中,ElectricCar
类表示“抽象”,而 Engine
类表示“实现”(见图 7-1)。这两者应该能够独立变化;即对任何一个的修改不应影响另一个。造成易于修改的障碍是 ElectricCar
类与其引擎之间的物理依赖。因此,理念是提取并隔离这些依赖关系。通过在 Engine
抽象中隔离它们,分离关注点,并满足 SRP,你获得了改变、调整或升级引擎的灵活性(见“准则 2:设计用于变更”)。现在在 ElectricCar
类中不再显露这种改变。因此,现在很容易添加新类型的引擎而不被“抽象”察觉。这符合 OCP 的理念(见“准则 5:设计用于扩展”)。
图 7-1. 基本桥接设计模式的 UML 表示
虽然这使我们能够轻松应用更改,并实现桥接的概念,但我们还可以进一步解耦和减少重复。假设我们不仅对电动车感兴趣,还对燃烧引擎车感兴趣。因此,对于我们计划实现的每种车辆,我们都有兴趣引入与引擎详细信息的相同解耦,即相同类型的桥接。为了减少重复并遵循 DRY 原则,我们可以将与桥接相关的实现细节提取到 Car
基类中(见图 7-2)。
图 7-2. 完整桥接设计模式的 UML 表示
Car
基类封装了与关联引擎的桥接。
//---- <Car.h> ----------------
#include <Engine.h>
#include <memory>
#include <utility>
class Car
{
protected:
explicit Car( std::unique_ptr<Engine> engine ) 
: pimpl_( std::move(engine) )
{}
public:
virtual ~Car() = default;
virtual void drive() = 0;
// ... more car-specific functions
protected:
Engine* getEngine() { return pimpl_.get(); } 
Engine const* getEngine() const { return pimpl_.get(); }
private:
std::unique_ptr<Engine> pimpl_; // Pointer-to-implementation (pimpl) 
// ... more car-specific data members (wheels, drivetrain, ...) };
加入 Car
类之后,“抽象”和“实现”都能轻松扩展并可以独立变化。虽然在这种桥接关系中,Engine
基类仍代表“实现”,但 Car
类现在扮演“抽象”的角色。Car
类的第一个值得注意的细节是其 protected
构造函数()。这个选择确保只有派生类能够指定引擎的类型。构造函数接受
std::unique_ptr
到一个 Engine
,并将其移动到其 pimpl_
数据成员中()。这个指针数据成员是所有
Car
类型的 pointer-to-implementation,并且通常称为 pimpl。这个 opaque pointer 代表了封装实现细节的桥接,并且本质上代表了整个桥接设计模式。因此,在代码中使用 pimpl 作为您意图的指示名称是个好主意(请参考 “Guideline 14: Use a Design Pattern’s Name to Communicate Intent”)。
注意,尽管派生类将会使用它,但 pimpl_
被声明在类的 private
部分。这个选择是由 Core Guideline C.133 驱使的:
避免使用
protected
数据。
实际上,经验表明,protected
数据成员几乎不比 public
数据成员更好。因此,为了授予对 pimpl 的访问权限,Car
类改为提供 protected
的 getEngine()
成员函数()。
ElectricCar
类相应进行了调整:
//---- <ElectricCar.h> ----------------
#include <Engine.h>
#include <memory>
class ElectricCar : public Car 
{
public:
explicit ElectricCar( /*maybe some engine arguments*/ );
void drive() override;
// ... };
//---- <ElectricCar.cpp> ----------------
#include <ElectricCar.h>
#include <ElectricEngine.h>
ElectricCar::ElectricCar( /*maybe some engine arguments*/ )
: Car( std::make_unique<ElectricEngine>( /*engine arguments*/ ) ) 
{}
// ...
而非实现桥接本身,ElectricCar
类现在从 Car
基类继承()。这种继承关系引入了通过指定引擎来初始化
Car
基类的要求。这个任务在 ElectricCar
构造函数中完成()。
Pimpl 惯用法
有一种更简单的桥接设计模式形式在 C 和 C++ 中已经被广泛使用成功数十年。为了看一个例子,让我们考虑以下 Person
类:
class Person
{
public:
// ...
int year_of_birth() const;
// ... Many more access functions
private:
std::string forename_;
std::string surname_;
std::string address_;
std::string city_;
std::string country_;
std::string zip_;
int year_of_birth_;
// ... Potentially many more data members
};
一个人由许多数据成员组成:forename
、surname
、完整的邮政地址、year_of_birth
,可能还有更多。未来可能需要添加更多数据成员:一个手机号码、一个 Twitter 账号,或者下一个社交媒体热潮的账户信息。换句话说,Person
类很可能需要随时间推移而扩展或更改,甚至可能经常这样。对于这个类的用户来说,这可能会带来许多不便:每当 Person
更改时,Person
的用户都必须重新编译他们的代码。更不用说 ABI 的稳定性:Person
实例的大小将会变化!
为了隐藏Person
实现细节的所有更改并获得 ABI 稳定性,您可以使用桥接设计模式。然而,在这种特定情况下,并不需要提供一个基类的抽象:存在且仅存在一个Person
的实现。因此,我们只需引入一个称为Impl
的private
嵌套类 ():
//---- <Person.h> ----------------
#include <memory>
class Person
{
public:
// ...
private:
struct Impl; 
std::unique_ptr<Impl> const pimpl_; 
};
//---- <Person.cpp> ----------------
#include <Person.h>
#include <string>
struct Person::Impl 
{
std::string forename;
std::string surname;
std::string address;
std::string city;
std::string country;
std::string zip;
int year_of_birth;
// ... Potentially many more data members };
嵌套的Impl
类的唯一任务是封装Person
的实现细节。因此,Person
类中仅剩的数据成员是指向Impl
实例的std::unique_ptr
()。所有其他数据成员以及可能的一些非虚拟辅助函数都被移动到
Impl
类中。请注意,Impl
类仅在Person
类中声明但未定义。相反,它在相应的源文件中定义 ()。仅仅因为这个原因,您对细节的所有更改,如添加或删除数据成员、更改数据成员类型等,都对
Person
的用户隐藏起来。
这个Person
的实现使用了桥接设计模式的最简形式:这种本地的、非多态的桥接形式称为Pimpl idiom。它具有桥接模式的所有解耦优势,但尽管简单,仍导致Person
类的实现稍微复杂一些:
//---- <Person.h> ----------------
//#include <memory>
class Person
{
public:
// ...
Person(); 
~Person(); 
Person( Person const& other ); 
Person& operator=( Person const& other ); 
Person( Person&& other ); 
Person& operator=( Person&& other ); 
int year_of_birth() const; 
// ... Many more access functions
private:
struct Impl;
std::unique_ptr<Impl> const pimpl_;
};
//---- <Person.cpp> ----------------
//#include <Person.h> //#include <string>
struct Person::Impl
{
// ... };
Person::Person() 
: pimpl_{ std::make_unique<Impl>() }
{}
Person::~Person() = default; 
Person::Person( Person const& other ) 
: pimpl_{ std::make_unique<Impl>(*other.pimpl_) }
{}
Person& Person::operator=( Person const& other ) 
{
*pimpl_ = *other.pimpl_;
return *this;
}
Person::Person( Person&& other ) 
: pimpl_{ std::make_unique<Impl>(std::move(*other.pimpl_)) }
{}
Person& Person::operator=( Person&& other ) 
{
*pimpl_ = std::move(*other.pimpl_);
return *this;
}
int Person::year_of_birth() const 
{
return pimpl_->year_of_birth;
}
// ... Many more Person member functions
Person
构造函数通过std::make_unique()
初始化了pimpl_
数据成员 ()。当然,这涉及动态内存分配,这意味着需要再次清理动态内存。“这就是为什么我们使用
std::unique_ptr
”,你说道。正确。但或许令人惊讶的是,尽管我们为此目的使用了std::unique_ptr
,仍然需要手动处理析构函数 ()。
“我们到底为什么要这么做?std::unique_ptr
的目的不是避免处理清理吗?”事实上,我们还是需要处理清理工作。让我来解释一下:如果你不写析构函数,编译器会觉得有义务为你生成析构函数。不幸的是,它会在 <Person.h>
头文件中生成析构函数。Person
类的析构函数会触发 std::unique_ptr
数据成员的析构函数实例化,这又需要 Impl
类的析构函数定义。然而,Impl
的定义在头文件中是不可用的。相反,它需要在源文件中定义,否则会违背桥接的初衷。因此,编译器会报告关于不完整类型 Impl
的错误。幸运的是,你不必放弃 std::unique_ptr
来解决这个问题(事实上,你不应该放弃它)。问题解决起来相当简单。你只需将 Person
的析构函数定义移到源文件中:在类定义中声明析构函数,然后在源文件中通过 =default
进行定义。
由于 std::unique_ptr
不能被复制,你需要实现复制构造函数以保持 Person
类的复制语义()。对于复制赋值运算符也是如此(
)。请注意,该运算符的实现基于这样一个假设:每个
Person
实例始终有一个有效的 pimpl_
。这一假设解释了移动构造函数的实现方式:它不仅仅是移动 std::unique_ptr
,而是使用 std::make_unique()
进行可能失败或抛出异常的动态内存分配。因此,它不声明为 noexcept
()。这一假设也解释了为什么
pimpl_
数据成员被声明为 const
。一旦初始化,指针将不再更改,即使在移动赋值运算符中也是如此()。
最后值得注意的细节是,year_of_birth()
成员函数的定义位于源文件中()。尽管这个简单的获取函数是一个很好的
inline
候选,但是定义必须移到源文件中。原因在于,在头文件中,Impl
是一个不完整类型。这意味着在头文件中,你无法访问任何成员(包括数据和函数)。这只能在源文件中实现,或者一般来说,只要编译器知道 Impl
的定义。
桥接与策略的比较
“我有一个问题,”你说,“我看到桥接(Bridge)和策略(Strategy)设计模式之间有很强的相似之处。我知道你说设计模式有时在结构上非常相似,唯一的区别在于它们的意图。但是这两者之间究竟有什么区别?”⁵ 我理解你的问题。这两者之间的相似性确实有点令人困惑。然而,有一点可以帮助你区分它们:如何初始化相应的数据成员是区分它们的一个强有力的指标。
如果一个类不想知道某些实现细节,因此提供了通过外部传入细节来配置行为的机会(例如通过构造函数或设置函数),那么你很可能在处理策略设计模式。因为灵活配置行为,即减少逻辑依赖,是其主要关注点,策略模式属于行为设计模式的类别。例如,在下面的代码片段中,Database
类的构造函数就是一个显著的标志:
class DatabaseEngine
{
public:
virtual ~DatabaseEngine() = default;
// ... Many database-specific functions };
class Database
{
public:
explicit Database( std::unique_ptr<DatabaseEngine> engine );
// ... Many database-specific functions
private:
std::unique_ptr<DatabaseEngine> engine_;
};
// The database is unaware of any implementation details and requests them // via its constructor from outside -> Strategy design pattern Database::Database( std::unique_ptr<DatabaseEngine> engine ) 
: engine_{ std::move(engine) }
{}
实际上,DatabaseEngine
的实际类型是从外部传入的(),这使得这个示例成为策略设计模式的一个很好的例子。
图 7-3 展示了这个例子的依赖图。最重要的是,Database
类与 DatabaseEngine
抽象处于同一架构层次,从而为其他人提供了实现行为的机会(例如通过 ConcreteDatabaseEngine
的形式)。由于 Database
仅依赖于抽象,因此没有依赖于任何具体的实现。
图 7-3. 策略设计模式的依赖图
然而,如果一个类了解实现细节但主要希望减少对这些细节的物理依赖,那么你很可能在处理桥接设计模式。在这种情况下,类不提供任何从外部设置指针的机会,即指针是一个实现细节并在内部设置。由于桥接设计模式主要关注实现细节的物理依赖而不是逻辑依赖,桥接模式属于结构设计模式的类别。例如,考虑以下代码片段:
class Database
{
public:
explicit Database();
// ...
private:
std::unique_ptr<DatabaseEngine> pimpl_;
};
// The database knows about the required implementation details, but does // not want to depend too strongly on it -> Bridge design pattern Database::Database()
: pimpl_{ std::make_unique<ConcreteDatabaseEngine>( /*some arguments*/ ) } 
{}
再次,应用桥接设计模式的一个显著标志是:而不是接受外部的引擎,Database
类的构造函数知道 ConcreteDatabaseEngine
并在内部设置它()。
图 7-4 展示了 Database
示例的桥接实现的依赖图。尤其是,Database
类与 ConcreteDatabaseEngine
类处于相同的架构级别,并且不留下为其他提供不同实现的机会。这表明,与策略设计模式相比,桥接在逻辑上与特定实现耦合,但通过 DatabaseEngine
抽象在物理上是解耦的。
图 7-4. 桥接设计模式的依赖图
分析桥接设计模式的缺点
“我完全理解为什么桥接设计模式在社区中如此流行。它的解耦属性确实非常棒!” 你欣喜地说道。“然而,你一直告诉我每种设计都有其优缺点。我想这里肯定存在性能损耗?” 很好,你记得总会有一些不足之处。当然,桥接设计模式也不例外,尽管它被证明非常有用。是的,你的想法是对的,使用它确实会带来一些性能开销。
桥接模式的第一种开销类型源于桥接引入了额外的间接性:pimpl 指针增加了对实现细节的访问成本。然而,这个指针导致的性能损耗有多大,我将在单独讨论中详细说明,见 “指南 29:注意桥接的性能收益和损失”。然而,这并非性能开销的唯一来源;还有更多。取决于您是否使用抽象层,您可能还需要为虚函数调用开销付出代价。此外,即使是访问数据成员的最简单函数也会因为无法进行内联而增加开销。当然,每次创建基于桥接实现的类的新实例时,还需支付额外的动态内存分配开销。⁶ 最后但并非最不重要的是,您还应考虑引入 pimpl 指针所导致的内存开销。所以,是的,隔离物理依赖并隐藏实现细节并非免费,而是会导致相当大的开销。但这并不应该是通常丢弃桥接方案的理由:一切都是具体问题具体分析。例如,如果底层实现执行缓慢、昂贵的任务(例如系统调用),那么这种开销可能根本无法测量。换句话说,是否使用桥接模式应根据具体情况和性能基准来决定。
此外,您已经看到了实现细节并意识到代码复杂性增加了。由于代码的简洁性和可读性是一种美德,这应该被视为一个缺点。这确实只影响类的内部而不是用户代码。但是,某些细节(例如在源文件中定义析构函数的必要性)可能会让经验不足的开发人员感到困惑。
总之,桥接设计模式是减少物理依赖最有价值和最常用的解决方案之一。但是,您应该意识到桥接引入了开销和复杂性。
指南 29:了解桥接的性能优劣
在“指南 28:构建桥梁以消除物理依赖”中,我们详细讨论了桥接设计模式。虽然我想桥接模式的设计和解耦方面给您留下了积极的印象,但我必须提醒您,使用这种模式可能会引入性能损耗。“是的,这让我担忧。性能对我很重要,听起来桥接会带来巨大的性能开销,”您说道。这是一个相当普遍的期待。由于性能至关重要,我确实应该让您了解在使用桥接时需要预期多大的开销。然而,我也应该展示如何明智地使用桥接来改善代码性能。听起来不可思议?好吧,让我向您展示一下。
桥接的性能影响
正如在“指南 28:构建桥梁以消除物理依赖”中所讨论的,桥接实现的性能受许多因素影响:通过间接访问、虚拟函数调用、内联、动态内存分配等。由于这些因素以及大量可能的组合,对于桥接会带来多少性能开销,没有确定的答案。简而言之,没有捷径,也没有替代方法,可以为您自己的代码组装一些基准测试并运行它们以评估一个确定的答案。不过,我想要证明的是,通过间接访问确实会有性能损耗,但您仍然可以使用桥接来实际提升性能。
现在让我们开始讨论一下基准测试的概念。为了对指针间接访问的成本有所了解,让我们比较下面两个Person
类的实现:
#include <string>
//---- <Person1.h> ----------------
class Person1
{
public:
// ...
private
std::string forename_;
std::string surname_;
std::string address_;
std::string city_;
std::string country_;
std::string zip_;
int year_of_birth_;
};
Person1
结构体表示一种不是以桥接方式实现的类型。所有七个数据成员(六个std::string
和一个int
)直接是结构体的一部分。总的来说,在 64 位机器上,假设使用 Clang 11.1,则一个Person1
实例的总大小为 152 字节,而使用 GCC 11.1 则为 200 字节。⁷
另一方面,Person2
结构体则采用了 Pimpl 惯用法:
//---- <Person2.h> ----------------
#include <memory>
class Person2
{
public:
explicit Person2( /*...various person arguments...*/ );
~Person2();
// ...
private:
struct Impl;
std::unique_ptr<Impl> pimpl_;
};
//---- <Person2.cpp> ----------------
#include <Person2.h>
#include <string>
struct Person2::Impl
{
std::string forename;
std::string surname;
std::string address;
std::string city;
std::string country;
std::string zip;
int year_of_birth;
};
Person2::Person2( /*...various person arguments...*/ )
: pimpl{ std::make_unique<Impl>( /*...various person arguments...*/ ) }
{}
Person2::~Person2() = default;
所有七个数据成员都已移至嵌套的 Impl
结构中,并且只能通过 pimpl
指针访问。虽然嵌套 Impl
结构的总大小与 Person1
的大小相同,但 Person2
结构的大小仅为 8 字节(假设是 64 位机器)。
注意
通过桥接设计,您可以减少一种类型的大小,有时甚至可以显著减少。例如,如果您想在 std::variant
中作为替代使用该类型,这可能非常有价值(参见 “Guideline 17: Consider std::variant for Implementing Visitor”)。
所以让我来概述一下基准测试:我将创建两个包含 25,000 人的 std::vector
,每个 std::vector
对应两种 Person
实现中的一种。这个元素数量确保我们超出了底层 CPU 内部缓存的大小(例如,使用 Clang 11.1 是 3.2 MB,使用 GCC 11.1 是 4.2 MB)。所有这些人都被赋予任意的姓名、地址和出生年份,年份范围在 1957 年至 2004 年之间(在写作时,这代表了一个组织中雇员年龄的合理范围)。然后,我们将遍历这两个人的向量各五千次,并每次使用 std::min_element()
确定最老的人。由于基准测试的重复性质,结果会显得相当无聊。经过一百次迭代,你会觉得太无聊而不想再看。唯一重要的是看到直接访问数据成员(Person1
)和间接访问数据成员(Person2
)之间的性能差异。表 7-1 展示了性能结果,以 Person1
实现的性能为标准化结果。
表 7-1. 不同 Person
实现的性能结果(标准化性能)
Person 实现 | GCC 11.1 | Clang 11.1 |
---|---|---|
Person1(无 pimpl) | 1.0 | 1.0 |
Person2(完整的 Pimpl 习惯用法) | 1.1099 | 1.1312 |
在这个特定的基准测试中,桥接实现导致性能显著下降:对于 GCC 是 11.0%,对于 Clang 是 13.1%。听起来很多!然而,不要对这些数字过于认真:显然,结果严重依赖于实际元素数量、数据成员的实际数量和类型、我们运行的系统,以及基准测试中执行的实际计算。如果更改任何这些细节,数字也会相应更改。因此,这些数字仅表明,由于对数据成员的间接访问,存在一些甚至更多的额外开销。
通过部分桥接改善性能
“好吧,但这是一个预期的结果,对吧?我应该从中学到什么?”你问道。嗯,我承认这个基准测试相当具体,不能回答所有问题。但它确实为我们提供了使用桥接来提高性能的机会。如果你仔细看Person1
的实现,你可能会意识到对于给定的基准测试,可达到的性能是非常有限的:尽管Person1
的总大小为 152 字节(Clang 11.1)或 200 字节(GCC 11.1),我们只使用了 4 字节,即一个int
。这被证明是相当浪费和低效的:因为在基于缓存的体系结构中,内存总是以缓存行加载,实际上我们加载的大部分数据根本没有被使用。事实上,几乎所有从内存加载的数据都没有被使用:假设缓存行长度为 64 字节,我们只使用了加载数据的约 6%。因此,尽管我们根据所有人的出生年份确定最老的人,这听起来像一个计算密集型操作,实际上我们完全受限于内存:机器根本无法快速传送数据,整数单元大部分时间都会处于空闲状态。
此设置使我们有机会通过桥接来提高性能。假设我们可以区分经常使用的数据(如forename
、surname
和year_of_birth
)和很少使用的数据(例如邮政地址)。基于这一区分,我们现在按照以下方式排列数据成员:所有经常使用的数据成员直接存储在Person
类中。所有很少使用的数据成员存储在Impl
结构体内。这导致了Person3
的实现:
//---- <Person3.h> ----------------
#include <memory>
#include <string>
class Person3
{
public:
explicit Person3( /*...various person arguments...*/ );
~Person3();
// ...
private:
std::string forename_;
std::string surname_;
int year_of_birth_;
struct Impl;
std::unique_ptr<Pimpl> pimpl_;
};
//---- <Person3.cpp> ----------------
#include <Person3.h>
struct Person3::Impl
{
std::string address;
std::string city;
std::string country;
std::string zip;
};
Person3::Person3( /*...various person arguments...*/ )
: forename_{ /*...*/ }
, surname_{ /*...*/ }
, year_of_birth_{ /*...*/ }
, pimpl_{ std::make_unique<Impl>( /*...address-related arguments...*/ ) }
{}
Person3::~Person3() = default;
在 Clang 11.1 下,一个Person3
实例的总大小为 64 字节(两个 24 字节的std::string
,一个整数,一个指针和由于对齐限制而产生的四个填充字节),在 GCC 11.1 下为 80 字节(两个 32 字节的std::string
,一个整数,一个指针和一些填充)。因此,Person3
实例的大小仅为Person1
实例的约一半。这种大小差异是可测量的:表 7-2 显示了包括Person3
在内的所有Person
实现的性能结果,再次对性能进行了标准化。
表 7-2. 不同Person
实现的性能结果(性能标准化)
人员实现 | GCC 10.3 | Clang 12.0 |
---|---|---|
Person1(无 pimpl) | 1.0 | 1.0 |
Person2(完全 Pimpl 习惯用法) | 1.1099 | 1.1312 |
Person3(部分 Pimpl 习惯用法) | 0.8597 | 0.9353 |
与Person1
实现相比,对于Person3
来说,GCC 11.1 的性能提高了 14.0%,Clang 11.1 提高了 6.5%。而且,正如之前所述,这仅仅是因为我们减少了Person3
的实现大小。“哇,这真是出乎意料。我明白了,桥梁并不一定对性能完全有害,”你说道。是的,确实如此。当然,这始终取决于具体的设置,但是区分频繁使用的数据成员和不经常使用的数据成员,并通过实现“部分”桥梁来减少数据结构的大小,可能会对性能产生非常积极的影响。⁸
“性能提升非常大,这太棒了,但这是否与桥梁的初衷相悖?”你问道。确实,你意识到隐藏实现细节与为了性能而“内联”数据成员之间存在着一种二元对立。一如既往,这取决于情况:你将不得不在每种情况下决定偏向哪一方面。你希望也意识到,存在两个极端之间的整个解决方案范围:没有必要将所有数据成员都隐藏在桥梁后面。最终,你将是为特定问题找到最佳解决方案的那个人。
总之,虽然一般来说桥梁很可能会造成性能损失,但在适当的情况下,实现部分桥梁可能会对性能产生非常积极的影响。然而,这只是影响性能的许多方面之一。因此,你应该始终检查桥梁是否导致性能瓶颈,或者部分桥梁是否正在解决性能问题。确认这一点的最佳方式是通过代表性基准测试,尽可能基于实际代码和实际数据。
指导原则 30:应用原型进行抽象复制操作
想象一下自己坐在一个高档意大利餐厅里研究菜单。哦,天啊,他们提供了这么多美味的东西;千层面听起来很不错。但是,他们提供的披萨选择也是令人惊叹的。真难选啊……然而,你的思绪被打断了,因为服务员走过来端着这道看起来令人难以置信的菜肴。不幸的是,这不是为你准备的,而是为另一张桌子上的人。哦哇,那个香味……在这一刻,你知道自己不再需要考虑要吃什么:不管是什么,你都想要一样。所以你点了:“啊,服务员,我要和他们一样的。”
在您的代码中可能会遇到相同的问题。从 C++ 的角度来看,您要求服务员复制另一个人的菜肴。复制对象,即创建一个实例的精确副本,在 C++ 中是一个非常重要的操作。如此重要,以至于类默认配备了复制构造函数和复制赋值运算符——这两个所谓的特殊成员函数。⁹ 然而,当要求复制菜肴时,您不幸地不知道是什么菜肴。从 C++ 的角度来看,您只有一个指向基类的指针(比如 Dish*
)。不幸的是,尝试通过 Dish*
使用复制构造函数或复制赋值运算符通常不起作用。但是,您确实希望获得一个精确的副本。这个问题的解决方案是另一个经典的《GoF》设计模式:原型设计模式。
一个羊的例子:复制动物
举个例子,让我们考虑下面的 Animal
基类:
//---- <Animal.h> ----------------
class Animal
{
public:
virtual ~Animal() = default;
virtual void makeSound() const = 0;
// ... more animal-specific functions
};
除了虚析构函数表明 Animal
应该是一个基类外,该类仅提供了 makeSound()
函数,用于打印可爱的动物声音。其中一个动物的示例是 Sheep
类:
//---- <Sheep.h> ----------------
#include <Animal.h>
#include <string>
class Sheep : public Animal
{
public:
explicit Sheep( std::string name ) : name_{ std::move(name) } {}
void makeSound() const override;
// ... more animal-specific functions
private:
std::string name_;
};
//---- <Sheep.cpp> ----------------
#include <Sheep.h>
#include <iostream>
void Sheep::makeSound() const
{
std::cout << "baa\n";
}
在 main()
函数中,我们现在可以创建一只羊,并让它发出声音:
#include <Sheep.h>
#include <cstdlib>
#include <memory>
int main()
{
// Creating the one and only Dolly
std::unique_ptr<Animal> const dolly = std::make_unique<Sheep>( "Dolly" );
// Triggers Dolly's beastly sound
dolly->makeSound();
return EXIT_SUCCESS;
}
多拉很棒,对吧?而且超级可爱!事实上,她太有趣了,我们想要另一个多拉。然而,我们只有一个指向基类的指针——Animal*
。我们不能通过 Sheep
的复制构造函数或复制赋值运算符进行复制,因为(从技术上讲)我们甚至不知道我们正在处理的是 Sheep
。它可能是任何类型的动物(例如狗、猫、羊等)。我们不想仅复制 Sheep
的 Animal
部分,因为这就是我们所说的切片。
哦,我刚意识到这可能是一个特别糟糕的例子来解释原型设计模式。切片动物。听起来不好。所以让我们迅速过渡吧。我们在哪儿?啊,是的,我们想要多拉的一个副本,但我们只有一个 Animal*
。这就是原型设计模式发挥作用的地方。
解释原型设计模式
原型设计模式是《四人组》收集的五种创建型设计模式之一。它专注于提供创建某个抽象实体副本的抽象方式。
原型设计模式
意图:“使用原型实例指定要创建的对象类型,并通过复制该原型创建新对象。”¹⁰
图 7-5 显示了原始的 UML 表示,摘自《GoF》书籍。
图 7-5. 原型设计模式的 UML 表示
原型设计模式通常通过基类中的虚拟 clone()
函数来实现。考虑更新后的 Animal
基类:
//---- <Animal.h> ----------------
class Animal
{
public:
virtual ~Animal() = default;
virtual void makeSound() const = 0;
virtual std::unique_ptr<Animal> clone() const = 0; // Prototype design pattern
};
通过这个clone()
函数,任何人都可以请求给定(原型)动物的抽象副本,而不必知道任何特定类型的动物(Dog
、Cat
或Sheep
)。当Animal
基类被适当分配到您的架构的高层时,它遵循 DIP(参见图 7-6)。
图 7-6。原型设计模式的依赖图
clone()
函数声明为纯虚函数,这意味着派生类需要实现它。但是,派生类不能随意实现该函数,而是期望返回自己的精确副本(任何其他结果都将违反 LSP;参见“指南 6:遵循抽象的预期行为”)。此副本通常动态创建并通过指向基类的指针返回。当然,这不仅导致指针,还需要显式delete
副本。由于在现代 C++中,手动清理被认为是非常糟糕的做法,因此将指针返回为std::unique_ptr
到Animal
。¹¹
Sheep
类据此进行更新:
//---- <Sheep.h> ----------------
#include <Animal.h>
class Sheep : public Animal
{
public:
explicit Sheep( std::string name ) : name_{ std::move(name) } {}
void makeSound() const override;
std::unique_ptr<Animal> clone() const override; // Prototype design pattern
private:
std::string name_;
};
//---- <Sheep.cpp> ----------------
#include <Sheep.h>
#include <iostream>
void Sheep::makeSound() const
{
std::cout << "baa\n";
}
std::unique_ptr<Animal> Sheep::clone() const
{
return std::make_unique<Sheep>(*this); // Copy-construct a sheep
}
现在要求Sheep
类实现clone()
函数并返回Sheep
的精确副本:在其自己的clone()
函数内部,它利用了std::make_unique()
函数和自己的复制构造函数,即使Sheep
类未来发生更改,也始终假定会执行正确的操作。这种方法有助于避免不必要的重复,因此遵循 DRY 原则(参见“指南 2:设计变更”)。
请注意,Sheep
类既不删除也不隐藏其复制构造函数和复制赋值运算符。因此,如果你有一只羊,仍然可以使用特殊成员函数复制羊。这是完全可以的:clone()
仅仅添加了一种创建副本的方法——一种执行virtual
复制的方法。
有了clone()
函数,我们现在可以创建多莉的精确副本。我们可以比 1996 年克隆第一只多莉时更轻松地完成这个任务:
#include <Sheep.h>
#include <cstdlib>
#include <memory>
int main()
{
std::unique_ptr<Animal> dolly = std::make_unique<Sheep>( "Dolly" );
std::unique_ptr<Animal> dollyClone = dolly->clone();
dolly->makeSound(); // Triggers the first Dolly's beastly sound
dollyClone->makeSound(); // The clone sounds just like Dolly
return EXIT_SUCCESS;
}
比较原型模式和std::variant
原型设计模式确实是一个经典的、非常面向对象的设计模式,自 1994 年发布以来,它是提供virtual
复制的首选解决方案。正因如此,函数名clone()
几乎可以视为识别原型设计模式的关键字。
由于特定的用例,没有“现代化”的实现(除非稍微更新,使用std::unique_ptr
替代原始指针)。与其他设计模式相比,也没有值语义的解决方案:一旦我们有一个值,最自然和直观的解决方案将是基于两个复制操作(复制构造函数和复制赋值运算符)进行构建。
“你确定没有值语义的解决方案吗?考虑以下使用std::variant
的例子:”
#include <cstdlib>
#include <variant>
class Dog {};
class Cat {};
class Sheep {};
int main()
{
std::variant<Dog,Cat,Sheep> animal1{ /* ... */ };
auto animal2 = animal1; // Creating a copy of the animal
return EXIT_SUCCESS;
}
“在这种情况下,我们难道不是执行抽象复制操作吗?这个复制操作难道不是由复制构造函数执行的吗?那么这不是原型设计模式的例子,而没有clone()
函数?” 不,尽管听起来你有一个令人信服的论点,但这不是原型设计模式的例子。我们两个例子之间有一个非常重要的区别:在你的例子中,你有一个封闭的类型集合(典型的访问者设计模式)。std::variant
animal1
包含狗、猫或羊,但没有其他内容。因此,可以使用复制构造函数执行显式复制。在我的例子中,我有一个开放的类型集合。换句话说,我不知道我要复制什么样的动物。它可能是狗、猫或羊,但也可能是大象、斑马或树懒。一切皆有可能。因此,我不能依赖复制构造函数,而只能使用虚拟的clone()
函数进行复制。
分析原型设计模式的缺点
是的,原型设计模式没有值语义的解决方案,但它是引用语义领域的一种典型代表。因此,每当需要应用原型设计模式时,我们必须接受它所带来的一些缺点。
可以说,第一个缺点是由于指针引起的间接性带来的负面性能影响。然而,由于我们只在存在继承层次结构时才需要克隆,认为这是原型模式本身的缺点是不公平的。这更是问题基本设置的结果。由于很难想象另一种没有指针和相关间接性的实现,这似乎是原型设计模式的固有属性。
第二个潜在的缺点是,很多时候,该模式通过动态内存来实现。分配本身以及可能导致的内存碎片化会导致进一步的性能问题。然而,并非要求使用动态内存,您将在“指导方针 33:注意类型擦除的优化潜力”中看到,在某些情况下,您也可以建立在类内存上。不过,这种优化仅适用于少数特殊情况,在大多数情况下,该模式仍然依赖于动态内存。
与执行抽象复制操作的能力相比,少数缺点是很容易接受的。然而,正如在“第 22 条指南:更倾向于值语义而非引用语义”中讨论的那样,如果能用值语义方法替换我们的Animal
层次结构,这将更简单且更易理解,因此可以避免应用基于引用语义的原型设计模式。但是,每当你遇到需要创建抽象副本的情况时,具有相应clone()
函数的原型设计模式是正确的选择。
指南 31:使用外部多态性进行非侵入式运行时多态性
在“第 2 条指南:面向变更的设计”中,我们看到了关注分离设计原则的巨大好处。在“第 19 条指南:使用策略来隔离事务如何执行”中,我们利用了这一力量,从一组形状中提取了绘图实现细节,采用了策略设计模式。然而,尽管这显著减少了依赖性,并且我们在“第 23 条指南:更倾向于基于值的策略和命令实现”中借助std::function
现代化了解决方案,但仍然存在一些缺点。特别是,形状类仍然被迫处理draw()
操作,尽管出于耦合原因,处理实现细节是不可取的。此外,更重要的是,策略方法在提取多个多态操作方面被证明有些不实际。为了进一步减少耦合并从形状中提取多态操作,我们现在继续这一旅程,并将关注分离原则推向一个全新、可能陌生的水平:我们将整体多态行为分离出来。为此,我们将应用外部多态设计模式。
外部多态设计模式解释
让我们回到绘制形状的示例以及我们从“第 23 条指南:更倾向于基于值的策略和命令实现”得到的Circle
类的最新版本:
//---- <Shape.h> ----------------
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw( /*some arguments*/ ) const = 0; 
};
//---- <Circle.h> ----------------
#include <Shape.h>
#include <memory>
#include <functional>
#include <utility>
class Circle : public Shape
{
public:
using DrawStrategy = std::function<void(Circle const&, /*...*/)>; 
explicit Circle( double radius, DrawStrategy drawer )
: radius_( radius )
, drawer_( std::move(drawer) )
{
/* Checking that the given radius is valid and that
the given 'std::function' instance is not empty */
}
void draw( /*some arguments*/ ) const override 
{
drawer_( *this, /*some arguments*/ );
}
double radius() const { return radius_; }
private:
double radius_;
DrawStrategy drawer_;
};
通过策略设计模式,我们已经克服了对draw()
成员函数实现细节的初始强耦合()。我们还基于
std::function
找到了值语义的解决方案()。然而,
draw()
成员函数仍然是所有从Shape
基类派生的类的公共接口的一部分,并且所有形状都继承了实现它的义务()。这是一个明显的不完美:可以说,绘图功能应该是独立的,形状通常不应该知道它们可以被绘制。¹² 事实上,我们已经提取了实现细节,这一论点已经得到了相当的加强。
“好吧,那么,我们就提取draw()
成员函数,对吗?”你争辩道。你是对的。不过,这乍一看似乎是一件困难的事情。希望你还记得“指南 15:为类型或操作的添加设计”,在那里我们得出结论,当你主要想要添加类型时,应该首选面向对象的解决方案。从这个角度来看,似乎我们被虚拟的draw()
函数和Shape
基类所束缚,后者代表所有形状可用操作的集合,即需求列表。
不过,问题有解决方案。一个相当惊人的解决方案:我们可以使用外部多态设计模式提取完整的多态行为。该模式在 1996 年由 Chris Cleeland、Douglas C. Schmidt 和 Timothy H. Harrison 的论文中首次提出。¹³ 它的目的是使非多态类型(没有单一虚函数的类型)能够进行多态处理。
外部多态设计模式
意图:“允许 C++中不通过继承关系和/或没有虚方法的类以多态方式对待。这些无关的类可以被使用它们的软件以一种常见的方式处理。”
图 7-7 首次展示了设计模式如何实现这一目标的第一印象。最引人注目的细节之一是不再有Shape
基类。在外部多态设计模式中,不同类型的形状(Circle
、Square
等)被假定为简单的非多态类型。此外,形状不需要了解绘图的任何信息。设计模式并未要求形状继承自Shape
基类,而是引入了ShapeConcept
和ShapeModel
类的独立继承层次结构。该外部层次结构通过引入所有预期形状的操作和要求,引入了形状的多态行为。
图 7-7. 外部多态设计模式的 UML 表示
在我们简单的示例中,多态行为仅由draw()
函数组成。当然,要求集合可能更大(例如rotate()
、serialize()
等)。这组虚函数已移至抽象的ShapeConcept
类中,现在取代了以前的Shape
基类。主要区别在于具体形状不需要了解ShapeConcept
,特别是不需要继承它。因此,形状与虚函数集完全解耦。唯一从ShapeConcept
继承的类是ShapeModel
类模板。此类为特定类型的形状(如Circle
、Square
等)实例化,并充当其包装器。但是,ShapeModel
本身不实现虚函数的逻辑,而是将请求委托给所需的实现。
“哇,太棒了!我明白了:这个外部层次结构提取了形状的整套虚函数行为。” 是的,完全正确。再次强调,这是关注点分离和 SRP 的一个示例。在这种情况下,完整的多态行为被识别为一个变化点,并从形状中提取出来。再次强调,SRP 作为 OCP 的一种促进因素:通过ShapeModel
类模板,您可以轻松地将任何新的非多态形状类型添加到ShapeConcept
层次结构中。只要新类型满足所有必需操作即可。
“我真的很印象深刻。不过,我不确定您所说的满足所有必需操作的含义。您能详细说明一下吗?” 当然!我认为当我展示一个具体的代码示例时,其好处将变得清晰起来。因此,让我们使用外部多态设计模式重构形状示例的完整绘制。
重新审视形状的绘制
让我们从Circle
和Square
类开始:
//---- <Circle.h> ----------------
class Circle
{
public:
explicit Circle( double radius )
: radius_( radius )
{
/* Checking that the given radius is valid */
}
double radius() const { return radius_; }
/* Several more getters and circle-specific utility functions */
private:
double radius_;
/* Several more data members */
};
//---- <Square.h> ----------------
class Square
{
public:
explicit Square( double side )
: side_( side )
{
/* Checking that the given side length is valid */
}
double side() const { return side_; }
/* Several more getters and square-specific utility functions */
private:
double side_;
/* Several more data members */
};
这两个类都被简化为基本几何实体。它们完全不具有多态性,即不再有基类,也没有一个虚函数。然而,最重要的是,这两个类完全不知道任何可能引入人为依赖的操作,如绘制、旋转、序列化等。
反而,所有这些功能都是通过ShapeConcept
基类引入并由ShapeModel
类模板实现的:¹⁴
//---- <Shape.h> ----------------
#include <functional>
#include <stdexcept>
#include <utility>
class ShapeConcept
{
public:
virtual ~ShapeConcept() = default;
virtual void draw() const = 0; 
// ... Potentially more polymorphic operations };
template< typename ShapeT >
class ShapeModel : public ShapeConcept 
{
public:
using DrawStrategy = std::function<void(ShapeT const&)>; 
explicit ShapeModel( ShapeT shape, DrawStrategy drawer )
: shape_{ std::move(shape) }
, drawer_{ std::move(drawer) }
{
/* Checking that the given 'std::function' is not empty */
}
void draw() const override { drawer_(shape_); } 
// ... Potentially more polymorphic operations
private:
ShapeT shape_; 
DrawStrategy drawer_; 
};
ShapeConcept
类引入了一个纯虚拟的draw()
成员函数(参见#code_g31_4)。在我们的示例中,这个虚拟函数代表了形状的整套要求。尽管要求集合很小,但ShapeConcept
类代表了 LSP 中的经典抽象概念(见“Guideline 6: Adhere to the Expected Behavior of Abstractions”)。这种抽象在ShapeModel
类模板内部实现(参见#code_g31_5)。值得注意的是,只有ShapeModel
的实例化类可以继承自ShapeConcept
;不会有其他类进入这种关系。ShapeModel
类模板将为每种所需形状实例化,即ShapeT
模板参数是像Circle
、Square
等类型的占位符。请注意,ShapeModel
存储了相应形状的一个实例(参见#code_g31_6)(组合而非继承;请记住“Guideline 20: Favor Composition over Inheritance”)。它作为一个包装器,为特定形状类型增加了所需的多态行为(在我们的情况下,即draw()
函数)。
由于ShapeModel
实现了ShapeConcept
抽象,它需要为draw()
函数提供实现。但是,ShapeModel
本身不需要实现draw()
的详细内容。相反,它应将绘制请求转发给实际的实现。为此,我们可以再次使用策略设计模式和std::function
的抽象能力(参见#code_g31_7)。这种选择很好地解耦了绘制的实现细节以及所有必要的绘制数据(颜色、纹理、透明度等),这些数据可以存储在可调用对象内部。因此,ShapeModel
存储了DrawStrategy
的一个实例(参见#code_g31_8),并在触发draw()
函数时使用该策略(参见#code_g31_9)。
策略设计模式和std::function
并非你唯一的选择。在ShapeModel
类模板中,你完全可以根据自己的需求实现绘制功能。换句话说,在ShapeModel::draw()
函数内,你定义了特定形状类型的实际需求。例如,你可以选择转发到ShapeT
形状的成员函数(不一定要命名为draw()
!),或者转发到形状的自由函数。你只需确保不对ShapeModel
或ShapeConcept
抽象施加人为的要求。无论如何,用于实例化ShapeModel
的任何类型都必须满足这些要求,以使代码编译通过。
注意
从设计角度来看,基于成员函数构建会对给定类型引入更严格的要求,因此会引入更强的耦合。然而,基于自由函数构建将使您能够反转依赖关系,类似于使用策略设计模式(参见“指南 9:注意抽象拥有权”)。如果您更喜欢自由函数的方法,只需记住“指南 8:理解重载集的语义要求”。
“ShapeModel
不是初始Circle
和Square
类的泛化形式吗?这些类也持有std::function
实例?”是的,这是一个很好的认识。确实,您可以说ShapeModel
在某种程度上是初始形状类的模板化版本。因此,它有助于减少引入策略行为所需的样板代码,并且在设计变更方面改善了实现(参见“指南 2:面向变更设计”)。然而,您获得的远不止这些:例如,由于ShapeModel
已经是一个类模板,您可以轻松地从当前的运行时策略实现切换到编译时策略实现(即基于策略的设计;参见“指南 19:使用策略隔离事物如何完成”)。
template< typename ShapeT
, typename DrawStrategy > 
class ShapeModel : public ShapeConcept
{
public:
explicit ShapeModel( ShapeT shape, DrawStrategy drawer )
: shape_{ std::move(shape) }
, drawer_{ std::move(drawer) }
{}
void draw() const override { drawer_(shape_); }
private:
ShapeT shape_;
DrawStrategy drawer_;
};
而不是依赖于std::function
,您可以向ShapeModel
类模板传递一个额外的模板参数,该参数表示绘图策略(参见))。这个模板参数甚至可以有一个默认值:
struct DefaultDrawer
{
template< typename T >
void operator()( T const& obj ) const {
draw(obj);
}
};
template< typename ShapeT
, typename DrawStrategy = DefaultDrawer >
class ShapeModel : public ShapeConcept
{
public:
explicit ShapeModel( ShapeT shape, DrawStrategy drawer = DefaultDrawer{} )
// ... as before
};
与直接应用策略设计模式到Circle
和Square
类相比,在这种情况下的编译时方法只有好处,没有任何不利之处。首先,由于减少了运行时间接性(std::function
的预期性能劣势),您可以获得更好的性能。其次,您不会通过为每个形状类人工增加模板参数来配置绘图行为。现在,您只需为包装器增加绘图行为,并且只需在一个地方完成此操作(这非常好地遵循了 DRY 原则)。第三,您不会通过将常规类转换为类模板来强制额外的代码进入头文件。只有已经是类模板的精简的ShapeModel
类需要存放在头文件中。因此,您避免了创建额外的依赖关系。
“哇,这个设计模式越来越棒了。这确实是继承和模板的非常引人注目的结合!” 是的,我完全同意。这是结合运行时和编译时多态的典范:ShapeConcept
基类提供了所有可能类型的抽象,而派生的 ShapeModel
类模板则为特定形状的代码生成提供支持。然而,最令人印象深刻的是,这种组合对于减少依赖关系有着巨大的好处。
查看 图 7-8,显示了我们对外部多态设计模式实现的依赖图。在我们架构的最高级别上是 ShapeConcept
和 ShapeModel
类,它们共同表示形状的抽象。Circle
和 Square
是这一抽象的可能实现,但它们完全独立:没有继承关系,没有组合,什么都没有。只有针对特定类型的形状和特定 DrawStrategy
实现的 ShapeModel
类模板的实例化将所有方面整合在一起。但特别注意,所有这些都发生在我们架构的最低级别上:模板代码在所有依赖关系已知并且“注入”到我们架构的正确级别的点上生成。因此,我们真正拥有合适的架构:所有依赖连接向更高级别运行,几乎自动遵守 DIP 原则。
图 7-8. 外部多态 设计模式的依赖图
有了这个功能,我们现在可以自由实现任何所需的绘图行为。例如,我们可以再次使用 OpenGL:
//---- <OpenGLDrawStrategy.h> ----------------
#include <Circle>
#include <Square>
#include /* OpenGL graphics library headers */
class OpenGLDrawStrategy
{
public:
explicit OpenGLDrawStrategy( /* Drawing related arguments */ );
void operator()( Circle const& circle ) const; 
void operator()( Square const& square ) const; 
private:
/* Drawing related data members, e.g. colors, textures, ... */
};
由于 OpenGLDrawStrategy
不必继承任何基类,您可以根据需要自由实现它。如果愿意,可以将画圆和画正方形的实现合并到一个类中。这不会创建任何人为的依赖,类似于我们在 “第 19 条指导原则:使用策略隔离操作的执行方式” 中遇到的情况,我们将这些功能合并到了基类中。
注意
注意,将画圆和画正方形结合在一个类中代表与从两个策略基类继承的同一事物。在架构的这个级别上,它不会创建任何人为的依赖,仅仅是一个实现细节。
您唯一需要遵循的约定是为 Circle
()和
Square
()提供函数调用运算符,因为这是
ShapeModel
类模板中定义的调用约定。
在 main()
函数中,我们将所有细节整合在一起:
#include <Circle.h>
#include <Square.h>
#include <Shape.h>
#include <OpenGLDrawStrategy.h>
#include <memory>
#include <vector>
int main()
{
using Shapes = std::vector<std::unique_ptr<ShapeConcept>>; 
using CircleModel = ShapeModel<Circle,OpenGLDrawStrategy>; 
using SquareModel = ShapeModel<Square,OpenGLDrawStrategy>; 
Shapes shapes{};
// Creating some shapes, each one
// equipped with an OpenGL drawing strategy
shapes.emplace_back(
std::make_unique<CircleModel>(
Circle{2.3}, OpenGLDrawStrategy(/*...red...*/) ) );
shapes.emplace_back(
std::make_unique<SquareModel>(
Square{1.2}, OpenGLDrawStrategy(/*...green...*/) ) );
shapes.emplace_back(
std::make_unique<CircleModel>(
Circle{4.1}, OpenGLDrawStrategy(/*...blue...*/) ) );
// Drawing all shapes
for( auto const& shape : shapes )
{
shape->draw();
}
return EXIT_SUCCESS;
}
再次,我们首先创建一个空的形状向量(这次是 std::unique_ptr
的形状向量,类型为 ShapeConcept
)(),然后添加三种形状。在调用
std::make_unique()
时,我们为 Circle
和 Square
实例化 ShapeModel
类(称为 CircleModel
()和
SquareModel
(),以提高可读性),并传递必要的细节(具体形状和相应的
OpenGLDrawStrategy
)。之后,我们可以以所需的方式绘制所有形状。
总之,这种方法给您带来了许多令人惊叹的优势:
-
通过分离关注点并从形状类型中提取多态行为,您消除了对图形库等的所有依赖。这样做会创建非常松散的耦合,并且非常符合 SRP 原则。
-
形状类型变得更简单且非多态。
-
您可以轻松地添加新的形状种类。这些甚至可能是第三方类型,因为您不再需要侵入性地从
Shape
基类继承或创建适配器(参见 “指南 24:使用适配器标准化接口”)。因此,您完全符合 OCP 原则。 -
您显著减少了通常与继承相关的样板代码,并且在一个地方实现它,非常好地遵循了 DRY 原则。
-
由于
ShapeConcept
和ShapeModel
类彼此关联,并且共同形成抽象概念,因此更容易遵循 DIP 原则。 -
通过利用可用的类模板,减少间接引用的数量,可以提高性能。
还有一个优点,我认为是外部多态设计模式最令人印象深刻的好处:您可以非侵入地为任何类型赋予多态行为。真的,任何 类型,甚至是像 int
这样简单的类型。为了演示这一点,让我们看一下以下代码片段,假设 ShapeModel
装配了一个 DefaultDrawer
,期望包装类型提供一个自由的 draw()
函数:
int draw( int i ) 
{
// ... drawing an int, for instance by printing it to the command line }
int main()
{
auto shape = std::make_unique<ShapeModel<int>>( 42 ); 
shape->draw(); // Drawing the integer 
return EXIT_SUCCESS;
}
我们首先为 int
提供一个自由的 draw()
函数()。在
main()
函数中,我们现在为 int
实例化一个 ShapeModel
()。这一行将编译通过,因为
int
满足所有要求:它提供了一个自由的 draw()
函数。因此,在下一行我们可以“绘制”这个整数()。
“你真的希望我做这样的事情吗?”你皱着眉头问道。不,我不希望你在家里这样做。请把这看作是技术演示,而不是建议。但尽管如此,这仍然令人印象深刻:我们刚刚非侵入地赋予了一个 int
多态行为。确实令人印象深刻!
外部多态与适配器之间的比较
“你刚才提到了适配器设计模式,我觉得它与外部多态设计模式非常相似。它们两者有什么区别?” 很好的观点!你提出了克利兰、施密特和哈里森原始论文中也涉及的问题。是的,这两种设计模式确实非常相似,但它们有一个非常明显的区别:适配器设计模式侧重于标准化接口,并将类型或函数适配到现有接口,而外部多态设计模式则创建一个新的外部层次结构,以抽象出一组相关的非多态类型。因此,如果你将某物件适配到现有接口,你(很可能)应用的是适配器设计模式。然而,如果你为了将一组现有类型多态地对待而创建新的抽象,则(很可能)应用的是外部多态设计模式。
分析外部多态设计模式的缺点
“我感觉你非常喜欢外部多态设计模式,我猜对了吗?” 你想知道。哦是的,确实如此,我对这种设计模式非常着迷。从我的角度来看,这种设计模式对于松耦合非常关键,令人惋惜的是它并不被更广泛地知晓。也许这是因为许多开发者没有完全接受关注点分离,倾向于将所有东西都放在几个类中。尽管我对此兴奋,但我并不希望给人留下外部多态设计模式完美无缺的印象。不,正如之前多次声明的那样,每种设计都有其优点和缺点。对于外部多态设计模式也是如此。
然而,只有一个主要的缺点:外部多态设计模式确实无法实现一个清晰简单的解决方案,绝对不能满足基于值语义的期望。它无法帮助减少指针,无法减少手动分配的数量,也无法降低继承层次的数量,更无法简化用户代码。相反,由于需要显式实例化ShapeModel
类,用户代码可能会稍微复杂一些。然而,如果你认为这是一个严重的缺点,或者如果你在考虑“这应该以某种方式自动化”,那么我有一个非常好的消息:在“指南 32:考虑用类型擦除替代继承层次”中,我们将看一看现代 C++解决方案,它会优雅地解决这个问题。
除此之外,我只有两个提醒,作为警告的话语。首先要记住的是,外部多态的应用并不能免除你思考适当抽象的必要性。ShapeConcept
基类与任何其他基类一样受到接口隔离原则的影响。例如,我们可以轻松地将外部多态应用到来自“指导原则 3:分离接口以避免人为耦合”的 Document
示例中:
class DocumentConcept
{
public:
// ...
virtual ~Document() = default;
virtual void exportToJSON( /*...*/ ) const = 0;
virtual void serialize( ByteStream& bs, /*...*/ ) const = 0;
// ...
};
template< typename DocumentT >
class DocumentModel
{
public:
// ...
void exportToJSON( /*...*/ ) const override;
void serialize( ByteStream& bs, /*...*/ ) const override;
// ...
private:
DocumentT document_;
};
DocumentConcept
类扮演 ShapeConcept
基类的角色,而 DocumentModel
类模板则扮演 ShapeModel
类模板的角色。然而,这种外部化的层次结构展示了与原始层次结构相同的问题:对于仅需要 exportToJSON()
功能的所有代码,它引入了对 ByteStream
的人为依赖:
void exportDocument( DocumentConcept const& doc )
{
// ...
doc.exportToJSON( /* pass necessary arguments */ );
// ...
}
正确的方法应该通过将接口分离为 JSON 导出和序列化的两个正交方面来分离关注点:
class JSONExportable
{
public:
// ...
virtual ~JSONExportable() = default;
virtual void exportToJSON( /*...*/ ) const = 0;
// ...
};
class Serializable
{
public:
// ...
virtual ~Serializable() = default;
virtual void serialize( ByteStream& bs, /*...*/ ) const = 0;
// ...
};
template< typename DocumentT >
class DocumentModel
: public JSONExportable
, public Serializable
{
public:
// ...
void exportToJSON( /*...*/ ) const override;
void serialize( ByteStream& bs, /*...*/ ) const override;
// ...
private:
DocumentT document_;
};
任何仅对 JSON 导出感兴趣的函数现在可以专门要求该功能:
void exportDocument( JSONExportable const& exportable )
{
// ...
exportable.exportToJSON( /* pass necessary arguments */ );
// ...
}
第二点要注意的是,外部多态和适配器设计模式一样,使得包装不符合语义期望的类型变得非常容易。类似于在“指导原则 24:使用适配器标准化接口”中的鸭子类型示例,我们假装一只火鸡是一只鸭子,同样假装一个 int
是一个形状。我们只需要提供一个自由的 draw()
函数来满足需求。简单。也许太简单了。因此,请记住,用于实例化 ShapeModel
类模板(例如 Circle
、Square
等)的类 必须 遵循 LSP。毕竟,ShapeModel
类只是一个包装器,将 ShapeConcept
类定义的要求传递给具体的形状。因此,具体的形状负责正确实现预期的行为(参见“指导原则 6:遵循抽象的预期行为”)。任何未能完全满足期望的行为可能会导致(潜在的微妙)不正确行为。不幸的是,由于这些要求已被外部化,因此更难传达预期的行为。
然而,在int
示例中,说实话,也许是我们自己的错。也许ShapeConcept
基类并不真正代表形状的抽象。可以合理地争论说形状不仅仅是绘制。也许我们应该将这种抽象命名为Drawable
,这样 LSP 就会得到满足。也许不会。因此,最终一切都取决于抽象的选择。这又让我们回到了第二章的标题:“构建抽象的艺术”。不,这并不容易,但也许这些例子表明这是重要的。非常重要。这可能是软件设计的本质。
总结一下,尽管外部多态设计模式可能无法满足你对简单或基于值的解决方案的期望,但必须考虑它作为解耦软件实体的重要一步。从减少依赖性的角度来看,这种设计模式似乎是松耦合的关键组成部分,并且是分离关注点能力的一个奇妙例子。它还给我们一个关键的洞察力:使用这种设计模式,你可以非侵入式地为任何类型提供多态行为,例如虚函数,因此任何类型都可以表现出多态性,即使是简单的值类型如int
。这种认识打开了一个全新而激动人心的设计空间,我们将在下一章继续探索。
¹ ABI 稳定性是 C++社区中一个重要且经常争论的话题,特别是在 C++20 发布前。如果你对此感兴趣,我推荐听一下 CppCast 采访Titus Winters和Marshall Clow,以了解双方的看法。
² 请记住,std::unique_ptr
不能被复制。因此,从ElectricEngine
切换到std::unique_ptr<ElectricEngine>
会使你的类成为不可复制的。为了保持复制语义,你必须手动实现复制操作。在这样做时,请记住复制操作会禁用移动操作。换句话说,请坚持遵循五法则。
³ Erich Gamma 等人,《设计模式:可复用面向对象软件的元素》。
⁴ 通常,移动操作预期是noexcept
的。这由核心指导方针 C.66解释。然而,有时这可能不可能,例如,假设某些std::unique_ptr
数据成员从不是nullptr
。
⁵ 请参阅“指导方针 11:理解设计模式的目的”,了解我关于设计模式结构相似性的看法。
⁶ 如果动态分配证明是一个严重的障碍或不使用桥梁的原因,您可以考虑快速 Pimpl 模式,它基于类内存。为此,您可以参考 Herb Sutter 的第一本书:《Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Exception-Safety Solutions》(Pearson)。
⁷ Person1
的大小差异可以轻松地解释为不同编译器中 std::string
实现的大小不同。因为编译器供应商针对不同的用例优化 std::string
,在 Clang 11.1 上,单个 std::string
占用 24 字节,在 GCC 11.1 上则占用 32 字节。因此,在 Clang 11.1 下,一个 Person1
实例的总大小为 152 字节(六个 24 字节的 std::string
,加上一个 4 字节的 int
,再加上 4 字节的填充),而在 GCC 11.1 下为 200 字节(六个 32 字节的 std::string
,加上一个 4 字节的 int
,再加上 4 字节的填充)。
⁸ 您可能已经意识到我们仍然 远 未达到最佳性能。为了朝着最佳性能的方向发展,我们可以根据数据使用情况来安排数据。对于此基准测试,这意味着将所有人的year_of_birth
值存储在一个大的静态整数向量中。这种数据排列方式将使我们朝向 面向数据的设计 的方向前进。有关此范式的更多信息,请参阅 Richard Fabian 关于此主题的书籍,《Data-Oriented Design: Software Engineering for Limited Resources and Short Schedules》。
⁹ 当编译器生成这两个复制操作的规则超出了本书的范围,但是这里有一个简短的总结:每个类都有这两个操作,这意味着它们总是存在。它们由编译器生成,或者您已经显式声明或甚至定义了它们(可能位于类的private
部分或通过=delete
),或者它们被隐式删除。请注意,删除这些函数并不意味着它们不存在,但=delete
作为一种定义。由于这两个函数 总是 是类的一部分,它们将 始终 参与重载决议。
¹⁰ Erich Gamma 等人,《设计模式:可复用面向对象软件的基本元素》。
¹¹ 核心指南 R.3 明确指出,原始指针(T*
)是非拥有的。从这个角度来看,甚至返回一个指向基类的原始指针都是不正确的。然而,这意味着你不能再直接利用协变返回类型的语言特性了。如果这是可取或必需的,一个常见的解决方案是遵循模板方法设计模式,将 clone()
函数拆分为一个返回原始指针的 private virtual
函数,以及一个调用私有函数并返回 std::unique_ptr
的 public
非 virtual
函数。
¹² 参见“指南 2:面向变化的设计”,其中包含一个不同类型文档的类似示例。
¹³ Chris Cleeland, Douglas C. Schmidt 和 Timothy H. Harrison,《外部多态性——一种透明扩展 C++ 具体数据类型的对象结构模式》,第三届编程语言模式会议论文集,伊利诺伊州奥勒顿公园,1996 年 9 月 4-6 日。
¹⁴ 名称 Concept
和 Model
是基于类型擦除设计模式中的常见术语选择的,外部多态性在其中起到重要作用;详见第八章。
第八章:类型擦除设计模式
分离关注点和值语义是本书的两个重要收获,我已经多次提到过。在本章中,这两者被美妙地结合成为现代 C++设计模式中最有趣的之一:类型擦除。由于这种模式可以被认为是最热门的选项之一,本章将非常详细地介绍类型擦除的各个方面。当然,这包括所有与设计相关的方面以及大量的实现细节。
在“指南 32:考虑使用类型擦除替代继承层次结构”,我将向您介绍类型擦除,并让您了解为何这种设计模式如此出色地结合了依赖项减少和值语义。我还将带您走过一个基本的拥有型类型擦除实现。
“指南 33:了解类型擦除的优化潜力”是一个例外:尽管在本书中我主要关注依赖和设计方面,但在这一指南中我将完全专注于与性能相关的实现细节。我将向您展示如何应用小缓冲区优化(SBO)以及如何实现手动虚分派来加速您的类型擦除实现。
在“指南 34:了解拥有型类型擦除包装器的设置成本”,我们将调查拥有型类型擦除实现的设置成本。我们将发现,与值语义相关联的成本有时可能是我们不愿支付的。因此,我们敢于踏入引用语义的领域,并实现一种非拥有型类型擦除形式。
指南 32:考虑使用类型擦除替代继承层次结构
在本书中有几个反复出现的建议:
-
最小化依赖。
-
分离关注点。
-
更倾向于组合而非继承。
-
倾向于非侵入性的解决方案。
-
更倾向于值语义而非引用语义。
单独使用,所有这些对您的代码质量都有非常积极的影响。然而,这些指南的组合证明要好得多。这就是我们在关于“第 31 条指南:使用外部多态性进行非侵入式运行时多态性”的讨论中所经历的。提取多态行为被证明是极其强大的,并解锁了前所未有的松散耦合水平。然而,可能令人失望的是,外部多态性的演示实现并没有让你觉得是解决问题的现代方式。与其遵循更倾向于值语义的建议,该实现坚定地建立在引用语义上:许多指针、许多手动分配和手动生命周期管理。¹因此,你等待的缺失细节是基于值语义的外部多态性设计模式实现。我不会再让你等待了:得到的解决方案通常被称为类型擦除。²
类型擦除的历史
在我详细介绍之前,让我们快速谈谈类型擦除的历史。“来吧”,你反驳道。“这真的有必要吗?我渴望最终看到这些东西如何工作。”好吧,我承诺简短地介绍一下。但是是的,我认为这对于我们讨论的这个问题是必要的细节,有两个原因。首先,要证明我们作为一个社区,除了最有经验的 C++专家圈子之外,可能已经忽视和忽略了这种技术太久了。其次,要给这项技术的发明者一些应得的赞赏。
类型擦除设计模式通常归功于最早的、因此也是最著名的这种技术的演示之一。在 2013 年的 GoingNative 会议上,Sean Parent 做了一个名为“继承是邪恶的基类”的演讲³,回顾了他在开发 Photoshop 过程中的经验,并讨论了基于继承的实现的危险和缺点。然而,他还提出了解决继承问题的方法,后来被称为类型擦除。
尽管肖恩的讲话是第一个被记录下来的关于类型擦除的讲话,因此可能是最著名的资源之一,但这种技术早在那之前就已经被使用了。例如,类型擦除在Boost库中的几个地方都有使用,例如,Douglas Gregor 为boost::function
使用了它。尽管据我所知,该技术首次在Kevlin Henney于 2000 年 7-8 月刊登在C++ Report上的一篇文章中进行了讨论。⁴ 在这篇文章中,Kevlin 用一个代码示例演示了类型擦除,后来演变为我们今天所知的 C++17 的std::any
。最重要的是,他是第一个巧妙地结合多个设计模式来形成基于值语义的实现,围绕一组不相关的非多态类型。
从那时起,许多常见类型已经采用了该技术,为各种应用程序提供值类型。其中一些类型甚至已经进入了标准库。例如,我们已经见过std::function
,它代表了一个可调用对象的基于值的抽象。⁵ 我已经提到过std::any
,它表示几乎任何东西的抽象容器状值(因此得名),但不暴露任何功能:
#include <any>
#include <cstdlib>
#include <string>
using namespace std::string_literals;
int main()
{
std::any a; // Creating an empty 'any'
a = 1; // Storing an 'int' inside the 'any';
a = "some string"s; // Replacing the 'int' with a 'std::string'
// There is nothing we can do with the 'any' except for getting the value back
std::string s = std::any_cast<std::string>( a );
return EXIT_SUCCESS;
}
还有std::shared_ptr
,它使用类型擦除来存储分配的删除器:
#include <cstdlib>
#include <memory>
int main()
{
{
// Creating a 'std::shared_ptr' with a custom deleter
// Note that the deleter is not part of the type!
std::shared_ptr<int> s{ new int{42}, [](int* ptr){ delete ptr; } };
}
// The 'std::shared_ptr' is destroyed at the end of the scope,
// deleting the 'int' by means of the custom deleter.
return EXIT_SUCCESS;
}
“看起来简单的方法就是像std::unique_ptr
那样为删除器提供第二个模板参数。为什么std::shared_ptr
不是用同样的方式实现?”你询问道。嗯,std::shared_ptr
和std::unique_ptr
的设计出于非常充分的理由是不同的。std::unique_ptr
的哲学是仅代表最简单的可能是原始指针的包装:它应该像原始指针一样快速,并且应该具有与原始指针相同的大小。因此,不希望将删除器与托管指针一起存储。因此,std::unique_ptr
设计成这样,对于无状态的删除器,可以避免任何大小的开销。但是,不幸的是,这第二个模板参数很容易被忽视,并引起人为的限制:
// This function takes only unique_ptrs that use the default deleter,
// and thus is artificially restricted
template< typename T >
void func1( std::unique_ptr<T> ptr );
// This function does not care about the way the resource is cleaned up,
// and thus is truly generic
template< typename T, typename D >
void func2( std::unique_ptr<T,D> ptr );
这种耦合在std::shared_ptr
的设计中是避免的。由于std::shared_ptr
必须在其所谓的控制块中存储许多数据项(包括引用计数、弱引用计数等),因此它有机会使用类型擦除字面上擦除删除器的类型,消除任何可能的依赖关系。
类型擦除设计模式的解释
“哇,这听起来真是令人兴奋。这让我更加期待了解类型擦除。” 好吧,我们开始吧。不过,请不要期望有任何魔法或革命性的新想法。类型擦除只是一个复合设计模式,意味着它是三种其他设计模式非常聪明和优雅的组合。选择的三种设计模式是外部多态(实现解耦效果和类型擦除的非侵入性本质的关键因素;参见“准则 31:使用外部多态实现非侵入式运行时多态”)、桥接(创建基于值语义的实现的关键;参见“准则 28:构建桥梁以消除物理依赖”)和(可选的)原型(处理结果值的复制语义所必需;参见“准则 30:应用原型进行抽象复制操作”)。这三种设计模式构成了类型擦除的核心,但当然要记住,存在不同的解释和实现,主要是为了适应特定的上下文。结合这三种设计模式的目的是创建一个包装类型,该类型代表一个松散耦合的、非侵入性的抽象。
类型擦除复合设计模式
意图:“为一组不相关的、潜在非多态类型提供基于值的、非侵入性抽象。”
这种表述的目的是尽可能简短,同时又足够精确。然而,这种意图的每个细节都有其含义。因此,详细说明可能会有所帮助:
基于值
类型擦除的目的是创建可以复制、可移动,最重要的是易于推理的值类型。然而,这样的值类型与常规值类型不同质量上有一些限制。特别是,类型擦除在一元操作中表现最佳,但在二元操作中存在限制。
非侵入性
类型擦除的目的是创建一个基于外部非侵入式抽象的设计模式,这种抽象受到外部多态设计模式的影响。所有提供所期望行为的类型都会自动支持,无需对它们进行任何修改。
可扩展的、不相关的类型集合
类型擦除牢固地基于面向对象的原则,即它使您能够轻松添加类型。但这些类型不应以任何方式相连。它们不必通过某个基类共享常见行为。相反,应该可以添加任何适合的类型,而不需要任何侵入性措施。
可能非多态的
正如外部多态设计模式所示,类型不应该通过继承购买集合。它们也不必自行提供虚拟功能,但它们应该与它们的多态行为解耦。但是,具有基类或虚拟函数的类型并不被排除在外。
具有相同的语义行为
目标不是为所有可能的类型提供抽象,而是为一组提供相同操作(包括相同语法)并遵循某些预期行为的类型提供语义抽象,根据 LSP(参见“Guideline 6: Adhere to the Expected Behavior of Abstractions”)。如果可能,对于任何不提供期望功能的类型,应该创建编译时错误。
有了这个意图的表述,让我们来看看 Type Erasure 的依赖图(参见图 8-1)。该图应该看起来非常熟悉,因为模式的结构主要由外部多态设计模式的固有结构主导(参见图 7-8)。最重要的区别和补充是架构的最高级别上的Shape
类。这个类作为外部多态引入的外部层次结构的包装器。主要是因为这个外部层次结构将不再直接使用,但也反映了ShapeModel
存储或“拥有”具体类型的事实,类模板的名称已经调整为OwningShapeModel
。
图 8-1. Type Erasure 设计模式的依赖图
拥有型 Type Erasure 实现
好的,现在,有了 Type Erasure 的结构,让我们来看看它的实现细节。尽管你之前已经看过所有的组成部分在实际中的应用,但实现细节并不特别适合初学者,并非易懂。这是因为我选择了我所知道的最简单的 Type Erasure 实现。因此,我会尽量保持在一个合理的水平上,不过多涉及实现细节的领域。这意味着我不会试图挤出每一个微小的性能提升。例如,我不会使用转发引用或避免动态内存分配。同时,我会倾向于可读性和代码清晰度。虽然这可能会让你失望,但我相信这将避免我们许多头疼的问题。然而,如果你想深入了解实现细节和优化选项,我建议参考“Guideline 33: Be Aware of the Optimization Potential of Type Erasure”。
我们再次从Circle
和Square
类开始:
//---- <Circle.h> ----------------
class Circle
{
public:
explicit Circle( double radius )
: radius_( radius )
{}
double radius() const { return radius_; }
/* Several more getters and circle-specific utility functions */
private:
double radius_;
/* Several more data members */
};
//---- <Square.h> ----------------
class Square
{
public:
explicit Square( double side )
: side_( side )
{}
double side() const { return side_; }
/* Several more getters and square-specific utility functions */
private:
double side_;
/* Several more data members */
};
自从我们在外部多态性讨论中遇到它们以来,这两个类并未发生变化。但是再次强调,这两个类完全没有关联,彼此不知道,最重要的是它们是非多态的,这意味着它们不继承任何基类,也不引入自己的虚函数。
我们之前也见过 ShapeConcept
和 OwningShapeModel
类,后者以 ShapeModel
的名义出现:
//---- <Shape.h> ----------------
#include <memory>
#include <utility>
namespace detail {
class ShapeConcept 
{
public:
virtual ~ShapeConcept() = default;
virtual void draw() const = 0; 
virtual std::unique_ptr<ShapeConcept> clone() const = 0; 
};
template< typename ShapeT
, typename DrawStrategy >
class OwningShapeModel : public ShapeConcept 
{
public:
explicit OwningShapeModel( ShapeT shape, DrawStrategy drawer ) 
: shape_{ std::move(shape) }
, drawer_{ std::move(drawer) }
{}
void draw() const override { drawer_(shape_); } 
std::unique_ptr<ShapeConcept> clone() const override
{
return std::make_unique<OwningShapeModel>( *this ); 
}
private:
ShapeT shape_; 
DrawStrategy drawer_; 
};
} // namespace detail
名称更改之外,还有几个重要的不同之处。例如,这两个类都已移至detail
命名空间。命名空间的名称表明,这两个类现在正在成为实现细节,即它们不再用于直接使用。⁶ ShapeConcept
类 () 仍然引入了纯虚函数
draw()
来表示绘制形状的要求 ()。此外,
ShapeConcept
现在还引入了一个纯虚的 clone()
函数 ()。"我知道这是什么了,这是原型设计模式!" 你大声说道。是的,正确。
clone()
的命名与原型设计模式紧密相关,并且是这种设计模式的一个强烈指示(但不是保证)。然而,尽管函数名的选择非常合理和经典,但让我明确指出,clone()
和 draw()
的函数名选择是我们自己的:这些名称现在是实现细节,并且与我们从 ShapeT
类型中所需的名称没有任何关系。我们也可以将它们命名为 do_draw()
和 do_clone()
,这对 ShapeT
类型没有任何影响。对 ShapeT
类型的真正要求是由 draw()
和 clone()
函数的实现定义的。
由于 ShapeConcept
再次成为外部层次结构的基类,因此 draw()
函数、clone()
函数和析构函数代表了所有形状的一组要求。这意味着所有形状必须提供某种绘制行为 — 它们必须是可复制和可销毁的。请注意,这三个函数仅是此示例的要求选择。特别是,复制性不是类型擦除所有实现的通用要求。
OwningShapeModel
类()再次代表了
ShapeConcept
类的唯一实现。与之前一样,OwningShapeModel
在其构造函数中接受一个具体的形状类型和一个绘制策略(),并使用它们来初始化其两个数据成员(
和
)。由于
OwningShapeModel
继承自 ShapeConcept
,因此必须实现这两个纯虚函数。draw()
函数通过应用给定的绘制策略来实现(),而
clone()
函数则实现为返回对应的 OwningShapeModel
的精确副本()。
注意
如果你现在在想,“哦不,std::make_unique()
。那意味着动态内存。那么我不能在我的代码中使用!” ——不用担心。std::make_unique()
只是一个实现细节,选择它只是为了保持示例的简洁。在 “指南 33:了解类型擦除的优化潜力”,你将看到如何使用 SBO 避免动态内存。
“到目前为止,我对此并不感到特别印象深刻。我们几乎没有超越外部多态设计模式的实现。” 我完全理解这种批评。然而,我们距离将外部多态转换为类型擦除只有一步之遥,距离从引用语义转换为值语义也只有一步之遥。我们所需要的只是一个值类型,一个围绕 ShapeConcept
和 OwningShapeModel
引入的外部层次结构的包装器,处理我们不希望手动执行的所有细节:OwningShapeModel
类模板的实例化、管理指针、执行分配以及处理生命周期。这个包装器以 Shape
类的形式给出:
//---- <Shape.h> ----------------
// ...
class Shape
{
public:
template< typename ShapeT
, typename DrawStrategy >
Shape( ShapeT shape, DrawStrategy drawer ) 
{
using Model = detail::OwningShapeModel<ShapeT,DrawStrategy>; 
pimpl_ = std::make_unique<Model>( std::move(shape) 
, std::move(drawer) );
}
// ...
private:
// ...
std::unique_ptr<detail::ShapeConcept> pimpl_; 
};
Shape
类的第一个,也许是最重要的细节是模板化的构造函数()。作为第一个参数,这个构造函数接受任何类型的形状(称为
ShapeT
),作为第二个参数,接受期望的 DrawStrategy
。为了简化对应的 detail::OwningShapeModel
类模板的实例化,使用一个便捷的类型别名()是有帮助的。这个别名用于通过
std::make_unique()
实例化所需的模型()。形状和绘制策略都传递给新模型。
新创建的模型用于初始化Shape
类的一个数据成员:pimpl_
()。“我也认识这个;这是桥接模式!”你高兴地宣布道。是的,再次正确。这是桥接设计模式的应用。在构造中,我们基于实际给定的类型
ShapeT
和DrawStrategy
创建一个具体的OwningShapeModel
,但我们将其存储为指向ShapeConcept
的指针。通过这样做,你创建了一个到实现细节的桥梁,一个到真实形状类型的桥梁。然而,在初始化pimpl_
之后,构造函数完成之后,Shape
不记得实际类型了。Shape
没有模板参数或任何会显示其存储的具体类型的成员函数,也没有记住给定类型的数据成员。它仅仅持有指向ShapeConcept
基类的指针。因此,它对真实形状类型的记忆已经被抹去。这就是设计模式名称的由来:类型擦除。
在我们的Shape
类中唯一缺少的功能是真正值类型所需的功能:复制和移动操作。幸运的是,由于应用了std::unique_ptr
,我们的工作相当有限。由于编译器生成的析构函数和两个移动操作将起作用,我们只需要处理两个复制操作:
//---- <Shape.h> ----------------
// ...
class Shape
{
public:
// ...
Shape( Shape const& other ) 
: pimpl_( other.pimpl_->clone() )
{}
Shape& operator=( Shape const& other ) 
{
// Copy-and-Swap Idiom
Shape copy( other );
pimpl_.swap( copy.pimpl_ );
return *this;
}
~Shape() = default;
Shape( Shape&& ) = default;
Shape& operator=( Shape&& ) = default;
private:
friend void draw( Shape const& shape ) 
{
shape.pimpl_->draw();
}
// ... };
复制构造函数()可能是一个非常难以实现的函数,因为我们不知道存储在
other
Shape
中的具体类型。然而,通过在ShapeConcept
基类中提供clone()
函数,我们可以请求一个精确的副本,而无需知道任何具体类型的信息。实现复制赋值运算符()最简短、最无痛、最方便的方法是建立在复制-交换惯用法之上。
此外,Shape
类提供了一个所谓的 隐藏的friend
,称为draw()
()。这个
friend
函数被称为隐藏的友元,因为虽然它是一个自由函数,但它定义在Shape
类的主体内。作为friend
,它被授予对private
数据成员的完全访问权限,并将被注入到周围的命名空间中。
“你不是说friend
s 很糟糕吗?”你问道。我承认,在“Guideline 4: Design for Testability”中确实是这样说的。然而,我也明确表示隐藏的friend
s 是可以接受的。在这种情况下,draw()
函数是Shape
类的一个组成部分,绝对是一个真正的friend
(几乎是家庭的一部分)。“但那它不应该是成员函数吗?”你反驳道。确实,那也是一个有效的替代方案。如果你更喜欢这样,就这么做吧。在这种情况下,我更倾向于使用自由函数,因为我们的目标之一是通过提取draw()
操作来减少依赖关系。这个目标也应该在Shape
的实现中体现出来。然而,由于这个函数需要访问pimpl_
数据成员,并且为了不增加draw()
函数的重载集合,我将其实现为一个隐藏的friend
。
就是这样。所有的一切。让我们看看新功能的美妙之处:
//---- <Main.cpp> ----------------
#include <Circle.h>
#include <Square.h>
#include <Shape.h>
#include <cstdlib>
int main()
{
// Create a circle as one representative of a concrete shape type
Circle circle{ 3.14 };
// Create a drawing strategy in the form of a lambda
auto drawer = []( Circle const& c ){ /*...*/ };
// Combine the shape and the drawing strategy in a 'Shape' abstraction
// This constructor call will instantiate a 'detail::OwningShapeModel' for
// the given 'Circle' and lambda types
Shape shape1( circle, drawer );
// Draw the shape
draw( shape1 ); 
// Create a copy of the shape by means of the copy constructor
Shape shape2( shape1 );
// Drawing the copy will result in the same output
draw( shape2 ); 
return EXIT_SUCCESS;
}
我们首先创建shape1
作为一个抽象的Circle
和相关的绘制策略。这感觉很简单,对吧?无需手动分配,也无需处理指针。通过draw()
函数,我们能够绘制这个Shape
()。紧接着,我们创建形状的一个副本。一个真正的副本——一个“深复制”,不只是指针的复制。使用
draw()
函数绘制副本将得到相同的输出()。再次,这感觉很好:你可以依赖值类型的复制操作(在这种情况下是复制构造函数),而不必手动
clone()
。
相当惊人,对吧?绝对比手动使用外部多态性要好得多。我承认,在所有这些实现细节之后,也许一下子看起来有点困难,但是如果你跨越了实现细节的丛林,希望你能意识到这种方法的美丽:你不再需要处理指针,没有手动分配,也不再需要处理继承层次结构。所有这些细节都在那里,是的,但所有的证据都很好地封装在Shape
类内部。然而,你没有失去任何解耦的好处:你仍然能够轻松地添加新类型,并且具体的形状类型对绘制行为仍然是毫不知情的。它们只通过Shape
构造函数连接到所需的功能。
“我在想”,你开始问道,“我们难道不能让这个变得更简单吗?我设想一个像这样的main()
函数”:
//---- <YourMain.cpp> ----------------
int main()
{
// Create a circle as one representative of a concrete shape type
Circle circle{ 3.14 };
// Bind the circle to some drawing functionality
auto drawingCircle = [=]() { myCircleDrawer(circle); };
// Type-erase the circle equipped with drawing behavior
Shape shape( drawingCircle );
// Drawing the shape
draw( shape );
// ...
return EXIT_SUCCESS;
}
那是一个很好的想法。记住,你要负责类型擦除包装器的所有实现细节,以及如何将类型和它们的操作实现结合在一起。如果你更喜欢这种形式,就去做吧!然而,请不要忘记,在我们的Shape
示例中,为了简洁和代码的简短性,我故意仅使用了一个具有外部依赖项(绘图)的功能。可能会有更多引入依赖的功能,比如形状的序列化。在这种情况下,Lambda 方法将不起作用,因为你需要多个命名函数(例如draw()
和serialize()
)。所以,最终,这取决于具体情况。这取决于你的类型擦除包装器代表的抽象类型。但无论你更喜欢哪种实现方式,都要确保不要在不同功能和/或代码之间引入人为依赖和/或代码重复。换句话说,请记住“指导原则 2:设计用于变更”!这就是我偏爱基于策略设计模式的解决方案的原因,但你不应该认为这是唯一的解决方案。相反,你应该努力充分利用类型擦除的松耦合潜力。
分析类型擦除设计模式的缺点
尽管类型擦除的美丽以及从设计角度获得的大量好处,我并不认为这种设计模式没有任何缺点。不,隐瞒潜在的缺点对你也不公平。
对你来说,可能最明显的第一个缺点就是这种模式的实现复杂性。如前所述,我明确地保持了实现细节在一个合理的水平上,希望这能帮助你理解这个概念。我希望你也有这样的印象,即毕竟这并不是那么难:Type Erasure 的基本实现可以在大约 30 行代码内实现。然而,你可能觉得它太复杂了。而且,一旦你开始超越基本实现并考虑性能、异常安全性等,实现细节确实很快变得非常棘手。在这些情况下,你最安全、最方便的选择是使用第三方库,而不是自己处理所有这些细节。可能的库包括 Louis Dionne 的dyno 库,Eduardo Madrid 的zoo 库,Gašper Ažman 的erasure 库,以及 Steven Watanabe 的Boost Type Erasure 库。
在解释类型擦除意图时,我提到了第二个缺点,这个缺点更加重要和限制性:尽管我们现在处理的是可以复制和移动的值,但在二进制操作中使用类型擦除并不简单。例如,对这些值进行等式比较并不容易,就像对常规值所期望的那样。
int main()
{
// ...
if( shape1 == shape2 ) { /*...*/ } // Does not compile!
return EXIT_SUCCESS;
}
原因是,毕竟,Shape
只是从具体形状类型中抽象出来,并且只存储一个指向基类的指针。如果你直接使用外部多态性,你会面对完全相同的问题,所以这绝对不是类型擦除中的一个新问题,你甚至可能不把它算作一个真正的缺点。尽管当你处理指向基类的指针时,等式比较不是一个预期的操作,但在值上通常是一个预期的操作。
比较两个类型擦除封装器
“这只是在Shape
接口中公开必要功能的问题,对吗?”你想知道。“例如,我们可以简单地在形状的public
接口中添加一个area()
函数,并用它来比较两个项目”:
bool operator==( Shape const& lhs, Shape const& rhs )
{
return lhs.area() == rhs.area();
}
“这很容易做到。那我错过了什么?”我同意这可能是你所需要的一切:如果两个对象在某些公共属性相等时是相等的,那么这个运算符将适用于你。总的来说,答案将是“取决于情况”。在这种特殊情况下,这取决于Shape
类所代表的抽象的语义。问题是:什么时候两个Shape
是相等的?考虑以下Circle
和Square
的例子:
#include <Circle.h>
#include <Square.h>
#include <cstdlib>
int main()
{
Shape shape1( Circle{3.14} );
Shape shape2( Square{2.71} );
if( shape1 == shape2 ) { /*...*/ }
return EXIT_SUCCESS;
}
这两个Shape
何时相等?如果它们的面积相等,它们就相等吗?或者如果抽象背后的实例相等,即这两个Shape
是相同类型并且具有相同属性,它们就相等吗?这取决于情况。同样地,我可以问,两个Person
何时相等?如果他们的名字相等,它们就相等吗?还是如果他们的所有特征都相等,它们就相等?这取决于所需的语义。虽然第一个比较很容易完成,但第二个不是。在一般情况下,我认为第二种情况更有可能是所需的语义,因此我认为在等式比较和更普遍地在二进制操作中使用类型擦除不是一件简单的事情。
请注意,我并没有说等式比较是不可能的。从技术上讲,你可以让它工作,尽管它会变成一个相当丑陋的解决方案。因此,你必须承诺不告诉任何人你是从我这里得到这个想法的。“你刚刚让我更加好奇了,”你笑着耐心等待。好吧,这就是它:
//---- <Shape.h> ----------------
// ...
namespace detail {
class ShapeConcept
{
public:
// ...
virtual bool isEqual( ShapeConcept const* c ) const = 0;
};
template< typename ShapeT
, typename DrawStrategy >
class OwningShapeModel : public ShapeConcept
{
public:
// ...
bool isEqual( ShapeConcept const* c ) const override
{
using Model = OwningShapeModel<ShapeT,DrawStrategy>;
auto const* model = dynamic_cast<Model const*>( c ); 
return ( model && shape_ == model->shape_ );
}
private:
// ... };
} // namespace detail
class Shape
{
// ...
private:
friend bool operator==( Shape const& lhs, Shape const& rhs )
{
return lhs.pimpl_->isEqual( rhs.pimpl_.get() );
}
friend bool operator!=( Shape const& lhs, Shape const& rhs )
{
return !( lhs == rhs );
}
// ... };
//---- <Circle.h> ----------------
class Circle
{
// ... };
bool operator==( Circle const& lhs, Circle const& rhs )
{
return lhs.radius() == rhs.radius();
}
//---- <Square.h> ----------------
class Square
{
// ... };
bool operator==( Square const& lhs, Square const& rhs )
{
return lhs.side() == rhs.side();
}
要使相等比较工作,你可以使用dynamic_cast
()。然而,这种相等比较的实现有两个严重的缺点。首先,正如你在“第 18 条准则:注意无环访问者的性能”中看到的那样,
dynamic_cast
绝对不能算是一种快速的操作。因此,每次比较都会产生相当大的运行时开销。其次,在这种实现中,只有在两个Shape
具有相同的DrawStrategy
时才能成功比较。虽然这在某些情境下可能是合理的,但在另一些情境下可能被视为一个不幸的限制。我唯一知道的解决方案是返回到使用std::function
来存储绘制策略,然而这会导致另一种性能惩罚。⁷ 总结来说,根据具体情境,相等比较可能是可能的,但通常并不容易或便宜。这证明了我之前的论点,即类型擦除不支持二元操作。
接口分离的类型擦除包装器
“接口分离原则(ISP)呢?”你问道。“在使用外部多态性时,很容易在基类中分离关注点。看起来我们失去了这种能力,对吧?”好问题。所以你还记得我在“第 31 条准则:使用外部多态性进行非侵入式运行时多态性”中的例子,使用了JSONExportable
和Serializable
基类。确实,使用类型擦除后,我们不再能够使用隐藏的基类,只能使用抽象的值类型。因此,看起来 ISP 是难以实现的:
class Document // Type-erased 'Document'
{
public:
// ...
void exportToJSON( /*...*/ ) const;
void serialize( ByteStream& bs, /*...*/ ) const;
// ...
};
// Artificial coupling to 'ByteStream', although only the JSON export is needed
void exportDocument( Document const& doc )
{
// ...
doc.exportToJSON( /* pass necessary arguments */ );
// ...
}
幸运的是,这种印象是不正确的。你可以通过提供多个类型擦除的抽象轻松遵循 ISP:⁸
Document doc = /*...*/; // Type-erased 'Document'
doc.exportToJSON( /* pass necessary arguments */ );
doc.serialize( /* pass necessary arguments */ );
JSONExportable jdoc = doc; // Type-erased 'JSONExportable'
jdoc.exportToJSON( /* pass necessary arguments */ );
Serializable sdoc = doc; // Type-erased 'Serializable'
sdoc.serialize( /* pass necessary arguments */ );
在考虑这点之前,请看“第 34 条准则:注意拥有类型擦除包装器的设置成本”。
“除了实现复杂性和限制于一元操作之外,似乎没有其他缺点了。好吧,那么,我必须说这确实是令人惊奇的东西!其好处显然超过了缺点。” 当然,这总是取决于具体的情境,这意味着在某些情况下,这些问题可能会引起一些困扰。但我同意,总的来说,类型擦除证明是一种非常有价值的设计模式。从设计的角度来看,你已经获得了可观的解耦程度,这肯定会在更改或扩展软件时减少痛苦。然而,尽管这已经很迷人,还有更多内容。我多次提到性能,但还没有展示任何性能数据。所以让我们看看性能结果。
性能基准测试
在展示类型擦除的性能结果之前,让我提醒您一下我们用来测试访问者和策略解决方案性能的基准场景(参见 表 4-2 中的“指南 16:使用访问者扩展操作”和 表 5-1 中的“指南 23:偏好基于值的策略和命令实现”)。这次,我使用了基于 OwningShapeModel
实现的类型擦除解决方案扩展了基准测试。在此基准测试中,我们仍然使用四种不同类型的形状(圆形、正方形、椭圆和矩形)。而且,我对 10,000 个随机创建的形状执行了 25,000 次转换操作。我同时使用了 GCC 11.1 和 Clang 11.1,并为这两个编译器添加了 -O3
和 -DNDEBUG
编译标志。我使用的平台是 macOS Big Sur(版本 11.4),配备有 8 核 Intel Core i7 处理器,主频为 3.8 GHz,64 GB 主存。
表 8-1 展示了性能数据。为了您的方便,我重新制作了策略基准测试的性能结果。毕竟,策略设计模式是针对相同设计空间的解决方案。尽管如此,最有趣的一行是最后一行。它展示了类型擦除设计模式的性能结果。
表 8-1. 类型擦除实现的性能结果
类型擦除实现 | GCC 11.1 | Clang 11.1 |
---|---|---|
面向对象解决方案 | 1.5205 秒 | 1.1480 秒 |
std::function 手动实现 |
2.1782 秒 | 1.4884 秒 |
std::function 的手动实现 |
1.6354 秒 | 1.4465 秒 |
经典策略 | 1.6372 秒 | 1.4046 秒 |
类型擦除 | 1.5298 秒 | 1.1561 秒 |
“看起来非常有趣。类型擦除似乎非常快。显然,只有面向对象的解决方案更快。” 是的。对于 Clang 而言,面向对象的解决方案略好一些。但只是略好一些。然而,请记住,面向对象的解决方案并未解耦任何东西:draw()
函数在 Shape
层次结构中作为虚成员函数实现,因此您会经历与绘图功能的密切耦合。虽然这可能带来一些性能开销,但从设计的角度来看,这是最糟糕的情况。考虑到这一点,类型擦除的性能数据确实令人惊叹:它的性能比任何策略实现都要好,提升了 6%到 20%。因此,类型擦除不仅提供最强的解耦,而且比所有其他减少耦合尝试的性能都更好。⁹
关于术语的说明
总结一下,类型擦除是一种实现高效且松散耦合代码的惊人方法。虽然它可能有一些局限性和缺点,但您很可能无法轻易忽略的是其复杂的实现细节。因此,包括我和 Eric Niebler 在内的许多人认为,类型擦除应该成为一种语言特性:¹⁰
如果我能穿越时空并有权改变 C++,而不是添加虚函数,我会为类型擦除和概念(concepts)添加语言支持。定义一个单一类型概念,自动生成其类型擦除包装器。
然而,要将类型擦除确立为真正的设计模式还有更多工作要做。我已将类型擦除作为由外部多态性、桥接(Bridge)和原型(Prototype)构建的复合设计模式引入。我将其介绍为一种基于值的技术,用于强大地将一组类型与其关联操作解耦。然而,不幸的是,您可能会看到其他“形式”的类型擦除:随着时间的推移,术语Type Erasure已被误用和滥用于各种技术和概念。例如,有时人们将void*
称为类型擦除。偶尔,您还会在继承层次结构或更具体地说是指向基类的指针的上下文中听到关于类型擦除的提及。最后,您还可能会在std::variant
的上下文中听到关于类型擦除的提及。¹¹
特别是std::variant
示例展示了过度使用术语Type Erasure的严重缺陷。虽然外部多态性(External Polymorphism)作为类型擦除背后的主要设计模式是关于允许您添加新类型的,但访问者(Visitor)设计模式及其现代实现作为std::variant
是关于添加新操作的(参见“指南 15:设计以添加类型或操作”)。从软件设计的角度来看,这两种解决方案完全是正交的:尽管类型擦除真正地解耦了具体类型并擦除了类型信息,但std::variant
的模板参数揭示了所有可能的替代方案,因此使您依赖这些类型。对这两者使用相同的术语导致在使用术语Type Erasure时传达的信息完全为空,并引发这些类型的评论:“我建议我们使用类型擦除来解决这个问题。”“请您更具体一些好吗?您是想添加类型还是操作?”因此,该术语将无法满足设计模式的特性;它不会携带任何意图。因此,它将毫无用处。
要让类型擦除(Type Erasure)在设计模式的殿堂中占据其应得的位置,并赋予其任何意义,请考虑仅在本指南讨论的意图中使用此术语。
指南 33:了解类型擦除的优化潜力
本书的主要重点是软件设计。因此,关于软件结构、设计原则、管理依赖和抽象的工具,当然还有设计模式的所有信息都是焦点所在。但我已经多次提到性能的重要性。非常 重要!毕竟,C++ 是一个以性能为中心的编程语言。因此,我现在要例外了:这个指导原则是专门讨论性能的。是的,我很认真:不谈论依赖,(几乎) 不举例说明关注点分离,也不谈价值语义。就只有性能。"终于,一些性能内容——太棒了!" 你会欢呼。然而,请注意后果:这个指导原则在实现细节上非常重。而且因为涉及到 C++,一提到一个细节,你就需要处理另外两个细节,所以你很快就会深陷于实现细节的领域。为了避免这种情况(并让我的出版商满意),我不会详细说明每一个实现细节或展示所有的替代方案。但是,我会提供额外的参考资料,这应该帮助你深入挖掘。¹²
在 “指导原则 32:考虑用类型擦除替换继承层次结构” 中,你看到了我们基本、未优化的类型擦除实现的出色性能数字。然而,由于我们现在拥有值类型和包装类,而不仅仅是一个指针,我们获得了许多提高性能的机会。这就是为什么我们将看看两个提高性能的选项:SBO 和手动虚拟分发。
小缓冲优化
让我们开始加速我们的类型擦除实现的性能之旅。在谈论性能时,通常首先想到的是优化内存分配。这是因为获取和释放动态内存可能非常 缓慢 和不确定。而且确实如此:优化内存分配可以决定程序是慢还是飞快。
然而,研究内存的第二个原因在于 “指南 32:考虑用类型擦除替换继承层次结构”,我可能让你误以为我们需要动态内存来实现类型擦除。事实上,在我们第一个 Shape
类的初始实现细节中,构造函数和 clone()
函数中无条件地进行动态内存分配,独立于给定对象的大小,因此无论对象大小如何,我们总是使用 std::make_unique()
进行动态内存分配。这种选择是有限的,不仅仅是因为性能问题,特别是对于小对象,而且因为在某些环境中动态内存是不可用的。因此,我应该向你展示,在内存方面你可以做很多事情。事实上,你完全可以控制内存管理!因为你正在使用值类型,一个包装器,你可以按照自己的意愿处理内存。其中的一个选择是完全依赖于类内存,并在对象过大时发出编译时错误。或者,你可以根据存储对象的大小在类内存和动态内存之间切换。这两种方式都是由 SBO 可能实现的。
为了让你了解 SBO 的工作原理,让我们看一个 Shape
的实现,它从不动态分配,而是仅使用类内存:
#include <array>
#include <cstdlib>
#include <memory>
template< size_t Capacity = 32U, size_t Alignment = alignof(void*) > 
class Shape
{
public:
// ...
private:
// ...
Concept* pimpl() 
{
return reinterpret_cast<Concept*>( buffer_.data() );
}
Concept const* pimpl() const 
{
return reinterpret_cast<Concept const*>( buffer_.data() );
}
alignas(Alignment) std::array<std::byte,Capacity> buffer_; 
};
这个 Shape
类不再存储 std::unique_ptr
,而是拥有一个正确对齐的字节数组 ()¹³。为了让
Shape
的用户能够调整数组的容量和对齐方式,你可以向 Shape
类提供两个非类型模板参数,Capacity
和 Alignment
()¹⁴。虽然这提高了适应不同情况的灵活性,但这种方法的缺点是将
Shape
类变成了一个类模板。因此,所有使用这种抽象的函数可能都会变成函数模板。这可能是不希望的,例如,因为你可能不得不将代码从源文件移到头文件中。但是,请注意,这只是众多可能性之一。正如前面所述,你完全可以控制。
为了方便地处理std::byte
数组,我们添加了一对pimpl()
函数(基于这个事实命名,这仍然实现了桥接设计模式,只是使用了类内存)( 和
)。“哦不,一个
reinterpret_cast
!”你说。“这不是超级危险吗?”你是正确的;一般来说,reinterpret_cast
应被视为潜在的危险操作。然而,在这种特定情况下,我们有C++标准的支持,解释了我们在这里所做的是完全安全的。
如您现在可能期望的那样,我们还需要基于外部多态设计模式引入一个外部继承层次结构。这一次,我们将在Shape
类的private
部分实现这个层次结构。并不是因为这样做更好或更适合这个Shape
的实现,而只是为了向您展示另一种选择:
template< size_t Capacity = 32U, size_t Alignment = alignof(void*) >
class Shape
{
public:
// ...
private:
struct Concept
{
virtual ~Concept() = default;
virtual void draw() const = 0;
virtual void clone( Concept* memory ) const = 0; 
virtual void move( Concept* memory ) = 0; 
};
template< typename ShapeT, typename DrawStrategy >
struct OwningModel : public Concept
{
OwningModel( ShapeT shape, DrawStrategy drawer )
: shape_( std::move(shape) )
, drawer_( std::move(drawer) )
{}
void draw() const override
{
drawer_( shape_ );
}
void clone( Concept* memory ) const override 
{
std::construct_at( static_cast<OwningModel*>(memory), *this );
// or:
// auto* ptr =
// const_cast<void*>(static_cast<void const volatile*>(memory));
// ::new (ptr) OwningModel( *this );
}
void move( Concept* memory ) override 
{
std::construct_at( static_cast<OwningModel*>(memory), std::move(*this) );
// or:
// auto* ptr =
// const_cast<void*>(static_cast<void const volatile*>(memory));
// ::new (ptr) OwningModel( std::move(*this) );
}
ShapeT shape_;
DrawStrategy drawer_;
};
// ...
alignas(Alignment) std::array<std::byte,Capacity> buffer_;
};
在这个背景下,第一个有趣的细节是clone()
函数()。由于
clone()
负责创建一个副本,它需要适应类内存。因此,与其通过std::make_unique()
创建一个新的Model
,它通过std::construct_at()
在原地创建一个新的Model
。或者,您可以使用放置new
在给定的内存位置创建副本。¹⁵
“哇,等等!这段代码有点难理解。所有这些强制转换是怎么回事?它们真的必要吗?”我承认,这些代码有点具有挑战性。因此,我应该详细解释一下。在原地创建实例的传统方法是通过放置new
。然而,使用new
总是存在一个危险,即有人(无意或恶意)可能提供一个替换类特定new
操作符的实现。为了避免任何问题并可靠地在原地构造对象,给定的地址首先通过static_cast
转换为void const volatile*
,然后通过const_cast
转换为void*
。得到的地址被传递给全局的放置new
操作符。确实,这不是最明显的代码片段。因此,建议使用 C++20 算法std::construct_at()
:它为您提供完全相同的功能,但具有明显更友好的语法。
然而,我们需要另外一个函数:clone()
仅涉及复制操作。它不适用于移动操作。因此,我们在Concept
中扩展了一个纯虚拟的move()
函数,并在OwningModel
类模板中实现了它()。
“这真的有必要吗?我们使用的是类内存,不能将其移动到另一个Shape
实例中。那move()
的意义何在?” 嗯,你是对的,我们不能将内存本身从一个对象移动到另一个对象,但我们仍然可以移动存储在内部的形状。因此,move()
函数将OwningModel
从一个缓冲区移动到另一个缓冲区,而不是复制它。
clone()
和move()
函数用于Shape
的复制构造函数()、复制赋值运算符(
)、移动构造函数(
)以及移动赋值运算符。
Shape
():
template< size_t Capacity = 32U, size_t Alignment = alignof(void*) >
class Shape
{
public:
// ...
Shape( Shape const& other )
{
other.pimpl()->clone( pimpl() ); 
}
Shape& operator=( Shape const& other )
{
// Copy-and-Swap Idiom
Shape copy( other ); 
buffer_.swap( copy.buffer_ );
return *this;
}
Shape( Shape&& other ) noexcept
{
other.pimpl()->move( pimpl() ); 
}
Shape& operator=( Shape&& other ) noexcept
{
// Copy-and-Swap Idiom
Shape copy( std::move(other) ); 
buffer_.swap( copy.buffer_ );
return *this;
}
~Shape() 
{
std::destroy_at( pimpl() );
// or: pimpl()->~Concept();
}
private:
// ...
alignas(Alignment) std::array<std::byte,Capacity> buffer_;
};
值得一提的是Shape
的析构函数()。由于我们通过
std::construct_at()
或者放置new
在字节缓冲区内手动创建了一个OwningModel
,因此我们也要负责显式调用析构函数。最简单和最优雅的方法是使用 C++17 算法std::destroy_at()
。或者,你也可以显式调用Concept
的析构函数。
Shape
的最后但至关重要的细节是模板化构造函数:
template< size_t Capacity = 32U, size_t Alignment = alignof(void*) >
class Shape
{
public:
template< typename ShapeT, typename DrawStrategy >
Shape( ShapeT shape, DrawStrategy drawer )
{
using Model = OwningModel<ShapeT,DrawStrategy>;
static_assert( sizeof(Model) <= Capacity, "Given type is too large" );
static_assert( alignof(Model) <= Alignment, "Given type is misaligned" );
std::construct_at( static_cast<Model*>(pimpl())
, std::move(shape), std::move(drawer) );
// or:
// auto* ptr =
// const_cast<void*>(static_cast<void const volatile*>(pimpl()));
// ::new (ptr) Model( std::move(shape), std::move(drawer) );
}
// ...
private:
// ...
};
在一对编译时检查确保所需的OwningModel
适合于类内缓冲区并遵守对齐限制之后,通过std::construct_at()
在类内缓冲区实例化了一个OwningModel
。
拿到这个实现后,我们现在适应并重新运行来自“指导原则 32:考虑用类型擦除替换继承层次结构”的性能基准测试。我们完全运行相同的基准测试,这次不在Shape
内分配动态内存,也不通过许多小的分配来碎片化内存。正如预期的那样,性能结果令人印象深刻(见表 8-2)。
表 8-2. 带 SBO 的类型擦除实现的性能结果
类型擦除实现 | GCC 11.1 | Clang 11.1 |
---|---|---|
面向对象的解决方案 | 1.5205 秒 | 1.1480 秒 |
std::function |
2.1782 秒 | 1.4884 秒 |
手动实现std::function |
1.6354 秒 | 1.4465 秒 |
经典策略 | 1.6372 秒 | 1.4046 秒 |
类型擦除 | 1.5298 秒 | 1.1561 秒 |
类型擦除(SBO) | 1.3591 秒 | 1.0348 秒 |
“哇,这速度真快。这…嗯,让我算一下…真是惊人,比最快的策略实现快大约 20%,甚至比面向对象的解决方案还快。” 的确如此。非常令人印象深刻,对吧?不过请记住,这些是我在我的系统上得到的数字。你的数字肯定会不同。但即使你的数字可能不同,总的来说,通过处理内存分配,有很大的性能优化潜力。
然而,尽管性能非常出色,我们失去了很多灵活性:只有小于或等于指定 Capacity
的 OwningModel
实例可以存储在 Shape
内。更大的模型被排除在外。这让我回到了我们可以根据给定形状的大小在类内缓冲区和动态内存之间进行切换的想法。现在您可以继续更新 Shape
的实现,以使用两种类型的内存。但是,在这一点上,指出我们最重要的设计原则之一可能是个好主意:关注点分离。与其将所有逻辑和功能挤入 Shape
类中,不如使用基于策略的设计更容易和(更)灵活,详见 “指南 19:使用策略隔离实现方式”。
template< typename StoragePolicy >
class Shape;
Shape
类模板被重写以接受 StoragePolicy
。通过此策略,您可以从外部指定类应如何获取内存。当然,您将完全遵循 SRP 和 OCP 原则。其中一种存储策略可以是 DynamicStorage
策略类:
#include <utility>
struct DynamicStorage
{
template< typename T, typename... Args >
T* create( Args&&... args ) const
{
return new T( std::forward<Args>( args )... );
}
template< typename T >
void destroy( T* ptr ) const noexcept
{
delete ptr;
}
};
如其名称所示,DynamicPolicy
将动态获取内存,例如通过 new
。或者,如果您有更强的要求,可以构建在 std::aligned_alloc()
或类似功能之上,以提供具有指定对齐方式的动态内存。与 DynamicStorage
类似,您可以提供一个 InClassStorage
策略:
#include <array>
#include <cstddef>
#include <memory>
#include <utility>
template< size_t Capacity, size_t Alignment >
struct InClassStorage
{
template< typename T, typename... Args >
T* create( Args&&... args ) const
{
static_assert( sizeof(T) <= Capacity, "The given type is too large" );
static_assert( alignof(T) <= Alignment, "The given type is misaligned" );
T* memory = const_cast<T*>(reinterpret_cast<T const*>(buffer_.data()));
return std::construct_at( memory, std::forward<Args>( args )... );
// or:
// void* const memory = static_cast<void*>(buffer_.data());
// return ::new (memory) T( std::forward<Args>( args )... );
}
template< typename T >
void destroy( T* ptr ) const noexcept
{
std::destroy_at(ptr);
// or: ptr->~T();
}
alignas(Alignment) std::array<std::byte,Capacity> buffer_;
};
所有这些策略类都提供相同的接口:一个 create()
函数用于实例化类型 T
的对象,以及一个 destroy()
函数用于执行必要的清理。Shape
类使用此接口触发构造和销毁,例如,在其模板化构造函数中()¹⁶ 和析构函数中(
):
template< typename StoragePolicy >
class Shape
{
public:
template< typename ShapeT >
Shape( ShapeT shape )
{
using Model = OwningModel<ShapeT>;
pimpl_ = policy_.template create<Model>( std::move(shape) ) 
}
~Shape() { policy_.destroy( pimpl_ ); } 
// ... All other member functions, in particular the
// special members functions, are not shown
private:
// ...
[[no_unique_address]] StoragePolicy policy_{}; 
Concept* pimpl_{};
};
最后一个不应被忽视的细节是数据成员():
Shape
类现在存储给定 StoragePolicy
的实例,并且,请不要惊慌,它的 Concept
的原始指针。事实上,再次在我们自己的析构函数中手动销毁对象后,就不再需要存储 std::unique_ptr
。您还可能注意到存储策略上的 [[no_unique_address]]
属性。这是 C++20 的特性,允许您节省存储策略的内存。如果策略为空,编译器现在可以不为数据成员保留任何内存。没有这个属性,必须至少为 policy_
保留一个字节,但可能由于对齐限制而需要更多字节。
总结来说,SBO 是类型擦除实现中有效且最有趣的优化之一。因此,许多标准类型,如std::function
和std::any
,使用某种形式的 SBO。不幸的是,C++标准库规范并不要求使用 SBO。这就是为什么你只能希望使用 SBO;你不能依赖它。然而,由于性能如此重要,而 SBO 又扮演如此决定性的角色,已经有提案建议标准化inplace_function
和inplace_any
类型。时间将告诉我们这些是否会进入标准库。
函数调度的手动实现
“哇,这将会很有用。还有什么我可以做来改进我的类型擦除实现的性能吗?”你问道。哦,是的,你可以做更多的事情。这里还有第二个潜在的性能优化。这次我们尝试改进虚函数的性能。是的,我说的是由外部继承层次引入的虚函数。也就是外部多态设计模式。
“我们应该如何优化虚函数的性能?这难道不完全取决于编译器吗?”当然,你是对的。然而,我说的并不是调整后端、特定于编译器的实现细节,而是用更高效的方法替换虚函数。这确实是可能的。请记住,虚函数只是存储在虚函数表中的函数指针。每个至少有一个虚函数的类型都有一个这样的虚函数表。然而,每种类型只有一个虚函数表。换句话说,这个表不是存储在每个实例中的。因此,为了将虚函数表与该类型的每个实例连接起来,该类存储一个额外的隐藏数据成员,我们通常称之为vptr
,它是指向虚函数表的原始指针。
当你调用虚函数时,首先通过vptr
访问虚函数表。一旦进入,你可以从虚函数表中获取相应的函数指针并调用它。因此,总体而言,虚函数调用涉及两次间接引用:vptr
和实际函数的指针。因此,粗略地说,虚函数调用比常规的非内联函数调用昂贵大约两倍。
这两次间接引用为我们提供了优化的机会:事实上,我们可以将间接引用的数量减少到一次。为了实现这一点,我们将采用一种通常很有效的优化策略:我们将通过在Shape
类中存储虚函数指针来手动实现虚分发。下面的代码片段已经让你对细节有了相当好的了解:
//---- <Shape.h> ----------------
#include <cstddef>
#include <memory>
class Shape
{
public:
// ...
private:
// ...
template< typename ShapeT
, typename DrawStrategy >
struct OwningModel 
{
OwningModel( ShapeT value, DrawStrategy drawer )
: shape_( std::move(value) )
, drawer_( std::move(drawer) )
{}
ShapeT shape_;
DrawStrategy drawer_;
};
using DestroyOperation = void(void*); 
using DrawOperation = void(void*); 
using CloneOperation = void*(void*); 
std::unique_ptr<void,DestroyOperation*> pimpl_; 
DrawOperation* draw_ { nullptr }; 
CloneOperation* clone_{ nullptr }; 
};
由于我们替换了所有虚函数,甚至包括虚析构函数,因此不再需要Concept
基类。因此,外部层次结构仅减少到OwningModel
类模板(),它仍然充当特定类型形状(
ShapeT
)和DrawStrategy
的存储。不过,它们命运相同:所有虚函数都被移除。唯一剩下的细节是构造函数和数据成员。
虚函数被手动函数指针替代。由于函数指针的语法不是最令人愉悦的,我们为了方便添加了一些函数类型别名:¹⁷ DestroyOperation
代表原来的虚析构函数(),
DrawOperation
代表原来的虚draw()
函数(),
CloneOperation
代表原来的虚clone()
函数()。
DestroyOperation
用于配置pimpl_
数据成员的Deleter
()(是的,作为策略)。后两者,
DrawOperation
和CloneOperation
,用于两个额外的函数指针数据成员,draw_
和clone_
(和
)。
“哦不,void*
!这不是一种过时和超级危险的做法吗?” 你惊呼道。好吧,我承认没有解释的情况下看起来非常可疑。不过,请跟我走,我保证一切都会完全没问题并且类型安全。现在使这一切生效的关键在于这些函数指针的初始化。它们在Shape
类的模板化构造函数中初始化:
//---- <Shape.h> ----------------
// ...
class Shape
{
public:
template< typename ShapeT
, typename DrawStrategy >
Shape( ShapeT shape, DrawStrategy drawer )
: pimpl_( 
new OwningModel<ShapeT,DrawStrategy>( std::move(shape)
, std::move(drawer) )
, []( void* shapeBytes ){ 
using Model = OwningModel<ShapeT,DrawStrategy>;
auto* const model = static_cast<Model*>(shapeBytes); 
delete model; 
} )
, draw_( 
[]( void* shapeBytes ){
using Model = OwningModel<ShapeT,DrawStrategy>;
auto* const model = static_cast<Model*>(shapeBytes);
(*model->drawer_)( model->shape_ );
} )
, clone_( 
[]( void* shapeBytes ) -> void* {
using Model = OwningModel<ShapeT,DrawStrategy>;
auto* const model = static_cast<Model*>(shapeBytes);
return new Model( *model );
} )
{}
// ...
private:
// ... };
让我们专注于pimpl_
数据成员。它通过指向新实例化的OwningModel
的指针()和一个无状态 lambda 表达式(
)进行初始化。你可能记得无状态 lambda 可以隐式转换为函数指针。这个语言保证是我们利用的优势:我们直接将 lambda 作为
unique_ptr
构造函数的删除器传递,强制编译器将其隐式转换为DestroyOperation*
,从而将 lambda 函数绑定到std::unique_ptr
。
“好的,我明白了:lambda 可以用来初始化函数指针。但是它是如何工作的?它做了什么?”请记住,我们也要记得我们是在模板化构造函数内创建此 lambda。这意味着在这一点上,我们完全了解传递的ShapeT
和DrawStrategy
的实际类型。因此,lambda 是使用对OwningModel
的实际类型已经知晓并存储在pimpl_
中的知识生成的。最终它将通过void*
即某个OwningModel
的地址来调用。但是,基于它对OwningModel
实际类型的知识,它首先可以执行从void*
到OwningModel<ShapeT,DrawStrategy>*
的static_cast
()。虽然在大多数其他上下文中,这种转换可能会被怀疑,很可能是一次野猜测,但在这个上下文中它是完全类型安全的:我们可以确定
OwningModel
的正确类型。因此,我们可以使用生成的指针来触发正确的清理行为()。
初始化draw_
和clone_
数据成员非常类似(和
)。唯一的区别当然是,由 lambda 执行的动作不同:它们分别执行绘制形状和创建模型副本的正确操作。
我知道,这可能需要一些时间来消化。但我们几乎完成了;唯一缺少的细节是特殊成员函数。对于析构函数和两个移动操作,我们可以再次请求编译器生成默认的。然而,我们必须自己处理复制构造函数和复制赋值运算符:
//---- <Shape.h> ----------------
// ...
class Shape
{
public:
// ...
Shape( Shape const& other )
: pimpl_( clone_( other.pimpl_.get() ), other.pimpl_.get_deleter() )
, draw_ ( other.draw_ )
, clone_( other.clone_ )
{}
Shape& operator=( Shape const& other )
{
// Copy-and-Swap Idiom
using std::swap;
Shape copy( other );
swap( pimpl_, copy.pimpl_ );
swap( draw_, copy.draw_ );
swap( clone_, copy.clone_ );
return *this;
}
~Shape() = default;
Shape( Shape&& ) = default;
Shape& operator=( Shape&& ) = default;
private:
// ...
};
这是我们需要做的一切,我们已经准备好尝试这个实现。所以让我们将这个实现放到测试中去。我们再次更新来自“指南 32:考虑用类型擦除替换继承层次结构”的基准,并用我们手动实现的虚函数运行它。我甚至将手动虚函数分发与先前讨论的 SBO 结合起来。表 8-3 显示了性能结果。
表 8-3。使用手动虚函数分发的类型擦除实现的性能结果
类型擦除实现 | GCC 11.1 | Clang 11.1 |
---|---|---|
面向对象的解决方案 | 1.5205 s | 1.1480 s |
std::function |
2.1782 s | 1.4884 s |
std::function 的手动实现 |
1.6354 s | 1.4465 s |
经典策略 | 1.6372 s | 1.4046 s |
类型擦除 | 1.5298 s | 1.1561 s |
类型擦除(SBO) | 1.3591 s | 1.0348 s |
类型擦除(手动虚函数分发) | 1.1476 s | 1.1599 s |
类型擦除(SBO + 手动虚函数分发) | 1.2538 s | 1.2212 s |
对于 GCC,手动虚拟调度的性能提升是非常显著的。在我的系统上,我将运行时间降低到了 1.1476 秒,这相比于基本的未优化的类型擦除实现提升了 25%。然而,Clang 并没有显示出与基本未优化实现相比的任何改进。虽然这可能有点令人失望,但运行时当然仍然是显著的。
不幸的是,SBO 和手动虚拟调度的组合并没有带来更好的性能。虽然在 GCC 中与纯 SBO 方法相比略有改进(这可能对于没有动态内存的环境来说很有趣),但在 Clang 中,这种组合并不如你希望的那样有效。
总结来说,优化类型擦除实现的性能潜力很大。如果你之前对类型擦除持怀疑态度,这种性能提升应该会激励你自行调查。虽然这很神奇,无疑非常令人兴奋,但重要的是要记住这是从哪里来的:只有将虚拟行为的关注点分离,并将行为封装到值类型中,我们才能获得这些优化机会。如果我们只有指向基类的指针,是无法实现这一点的。
指导原则 34:注意拥有类型擦除包装的设置成本
在 “指导原则 32:考虑用类型擦除替换继承层次结构” 和 “指导原则 33:了解类型擦除的优化潜力” 中,我指导您穿越了基本类型擦除实现的实现细节的丛林。是的,那很艰难,但绝对值得:您以更强大、更明智的方式脱颖而出,并在您的工具箱中得到了一个新的、高效且强耦合的设计模式。太棒了!
然而,我们必须回到复杂的问题中。我看到你在翻白眼,但还有更多内容。我必须承认:我撒了谎。至少有点。不是因为给你讲了错误的事情,而是因为有所遗漏。有一个关于类型擦除的缺点你应该知道。一个很大的缺点。可能你一点也不喜欢。叹气。
拥有类型擦除包装的设置成本
假设 Shape
再次是一个基类,而 Circle
是许多派生类之一。那么将 Circle
传递给期望 Shape const&
的函数将会很容易且廉价():
#include <cstdlib>
class Shape { /*...*/ }; // Classic base class
class Circle : public Shape { /*...*/ }; // Deriving class
void useShape( Shape const& shape )
{
shape.draw( /*...*/ );
}
int main()
{
Circle circle{ 3.14 };
// Automatic and cheap conversion from 'Circle const&' to 'Shape const&'
useShape( circle ); 
return EXIT_SUCCESS;
}
尽管类型擦除 Shape
抽象有点不同(例如,它总是需要一个绘图策略),但这种转换仍然是可能的:
#include <cstdlib>
class Circle { /*...*/ }; // Nonpolymorphic geometric primitive
class Shape { /*...*/ }; // Type erasure wrapper class as shown before
void useShape( Shape const& shape )
{
draw(shape);
}
int main()
{
Circle circle{ 3.14 };
auto drawStrategy = []( Circle const& c ){ /*...*/ };
// Creates a temporary 'Shape' object, involving
// a copy operation and a memory allocation
useShape( { circle, drawStrategy } ); 
return EXIT_SUCCESS;
}
不幸的是,它现在不再廉价。相反,根据我们之前的实现,包括基本实现和优化实现,调用useShape()
函数将涉及几个可能昂贵的操作()。
-
要将
Circle
转换为Shape
,编译器使用非explicit
的模板化Shape
构造函数创建一个临时Shape
。 -
构造函数的调用导致给定形状的复制操作(对于
Circle
而言不昂贵,但对于其他形状可能昂贵),以及给定的绘制策略(如果策略是无状态的,基本上是免费的,但根据对象内部存储的内容可能昂贵)。 -
在
Shape
构造函数内部,创建一个新的形状模型,涉及内存分配(隐藏在Shape
构造函数中的std::make_unique()
调用中,绝对昂贵)。 -
临时(rvalue)
Shape
以对useShape()
函数的const
引用传递。
需要指出的重要一点是,这不是我们Shape
实现的特定问题。例如,如果您将std::function
用作函数参数,也会遇到同样的问题:
#include <cstdlib>
#include <functional>
int compute( int i, int j, std::function<int(int,int)> op )
{
return op( i, j );
}
int main()
{
int const i = 17;
int const j = 10;
int const sum = compute( i, j, offset=15 {
return x + y + offset;
} );
return EXIT_SUCCESS;
}
在这个例子中,给定的 lambda 被转换为std::function
实例。这种转换涉及复制操作,并可能涉及内存分配。这完全取决于给定可调用对象的大小以及std::function
的实现。因此,std::function
是一种不同类型的抽象,与std::string_view
和std::span
不同。std::string_view
和std::span
是非拥有的抽象,由于它们仅由指向第一个元素的指针和大小组成,因此复制起来很廉价。由于这两种类型执行浅复制,它们非常适合作为函数参数。另一方面,std::function
是一种拥有抽象,执行深复制。因此,它不是作为函数参数使用的完美类型。不幸的是,我们的Shape
实现也是如此。¹⁸
“哦,我不喜欢这个。一点也不。太糟糕了!我要退钱!” 你大声说道。我不得不同意,这可能是你的代码库中一个严重的问题。然而,你理解到底层问题在于Shape
类的拥有语义:基于其值语义背景,我们当前的Shape
实现总是会创建给定形状的副本并始终拥有该副本。虽然这完全符合“Guideline 22: Prefer Value Semantics over Reference Semantics”中讨论的所有好处,但在这种情况下,它导致了一个非常不幸的性能惩罚。然而,请保持冷静——我们可以做些什么:针对这样的情况,我们可以提供一个非拥有类型擦除实现。
简单的非拥有类型擦除实现
一般来说,基于值语义的类型擦除实现非常优雅,完全符合现代 C++ 的精神。然而,性能也很重要。有时性能如此重要,以至于你可能不关心值语义的部分,而只关心类型擦除提供的抽象。在这种情况下,你可能想要使用一个非拥有的类型擦除实现,尽管这会将你带回引用语义的领域。
这里有一个好消息,如果你只需要一个简单的类型擦除包装器,一个代表基类引用的包装器,它是非拥有的并且可以简单地复制,那么所需的代码就非常简单。这一点特别明显,因为你已经看到了如何在 “第 33 条指南:了解类型擦除的优化潜力” 中手动实现虚拟调度。使用这种技术,一个简单的、非拥有的类型擦除实现只是几行代码的事情:
//---- <Shape.h> ----------------
#include <memory>
class ShapeConstRef
{
public:
template< typename ShapeT, typename DrawStrategy >
ShapeConstRef( ShapeT& shape, DrawStrategy& drawer ) 
: shape_{ std::addressof(shape) }
, drawer_{ std::addressof(drawer) }
, draw_{ []( void const* shapeBytes, void const* drawerBytes ){
auto const* shape = static_cast<ShapeT const*>(shapeBytes);
auto const* drawer = static_cast<DrawStrategy const*>(drawerBytes);
(*drawer)( *shape );
} }
{}
private:
friend void draw( ShapeConstRef const& shape )
{
shape.draw_( shape.shape_, shape.drawer_ );
}
using DrawOperation = void( void const*,void const* );
void const* shape_{ nullptr }; 
void const* drawer_{ nullptr }; 
DrawOperation* draw_{ nullptr }; 
};
如其名,ShapeConstRef
类表示对 const
形状类型的引用。它不是存储给定形状的副本,而是以 void*
的形式仅保存一个指向它的指针()。此外,它还保存了一个指向关联的
DrawStrategy
的 void*
(),作为第三个数据成员,还保存了手动实现的虚拟
draw()
函数的函数指针()(见 “第 33 条指南:了解类型擦除的优化潜力”)。
ShapeConstRef
接受它的两个参数,形状和绘制策略,两者可能是 cv 限定的,通过引用到非 const
()。¹⁹ 在这种形式下,不可能将右值传递给构造函数,这可以防止任何与临时值有关的生命周期问题。不幸的是,这并不能保护你免受所有可能的左值生命周期问题的影响,但仍然提供了一个非常合理的保护。²⁰ 如果你想允许右值,你应该重新考虑。如果你真的、真的 愿意冒着与临时值生命周期问题的风险,那么你可以简单地通过引用到
const
来接受参数。只是记住,这个建议不是我给的!
就是这样。这是完整的非拥有实现。它高效、简短、简单,如果您不需要存储任何相关数据或策略对象,甚至可以更加简短和简单。有了这个功能,您现在可以创建廉价的形状抽象。这在下面的代码示例中通过useShapeConstRef()
函数进行了演示。这个函数使您能够使用ShapeConstRef
作为函数参数,以便简单地使用任何可能的形状(Circle
、Square
等)和任何可能的绘制实现。在main()
函数中,我们通过具体的形状和具体的绘制策略(在本例中是 lambda)调用了useShapeConstRef()
():
//---- <Main.cpp> ----------------
#include <Circle.h>
#include <Shape.h>
#include <cstdlib>
void useShapeConstRef( ShapeConstRef shape )
{
draw( shape );
}
int main()
{
// Create a circle as one representative of a concrete shape type
Circle circle{ 3.14 };
// Create a drawing strategy in the form of a lambda
auto drawer = []( Circle const& c ){ /*...*/ };
// Draw the circle directly via the 'ShapeConstRef' abstraction
useShapeConstRef( { circle, drawer } ); 
return EXIT_SUCCESS;
}
这个调用触发了期望的效果,特别是在没有任何内存分配或昂贵的复制操作的情况下,只是通过将多态行为包装在给定形状的指针集合和绘制策略周围来实现。
更强大的非拥有类型擦除实现
大多数情况下,这个简单的非拥有类型擦除实现应该足够并满足所有您的需求。然而,有时,可能不够。有时,您可能对稍微不同形式的Shape
引用感兴趣:
#include <Cirlce.h>
#include <Shape.h>
#include <cstdlib>
int main()
{
// Create a circle as one representative of a concrete shape type
Circle circle{ 3.14 };
// Create a drawing strategy in the form of a lambda
auto drawer = []( Circle const& c ){ /*...*/ };
// Combine the shape and the drawing strategy in a 'Shape' abstraction
Shape shape1( circle, drawer );
// Draw the shape
draw( shape1 );
// Create a reference to the shape
// Works already, but the shape reference will store a pointer
// to the 'shape1' instance instead of a pointer to the 'circle'.
ShapeConstRef shaperef( shape1 ); 
// Draw via the shape reference, resulting in the same output
// This works, but only by means of two indirections!
draw( shaperef ); 
// Create a deep copy of the shape via the shape reference
// This is _not_ possible with the simple nonowning implementation!
// With the simple implementation, this creates a copy of the 'shaperef'
// instance. 'shape2' itself would act as a reference and there would be
// three indirections... sigh.
Shape shape2( shaperef ); 
// Drawing the copy will again result in the same output
draw( shape2 );
return EXIT_SUCCESS;
}
假设您有一个类型擦除的circle
称为shape1
,您可能希望将此Shape
实例转换为ShapeConstRef
()。通过当前的实现,这是可以的,但
shaperef
实例将会持有指向shape1
实例的指针,而不是指向circle
的指针。因此,任何对shaperef
的使用都会导致两次间接引用(一次通过ShapeConstRef
,一次通过Shape
抽象) ()。此外,您可能还希望将
ShapeConstRef
实例转换为Shape
实例 ()。在这种情况下,您可能期望创建基础
Circle
的完整副本,并且生成的Shape
抽象包含和表示此副本。然而,通过当前的实现,Shape
将创建ShapeConstRef
实例的副本,从而引入第三次间接引用。哎。
如果你需要在拥有和非拥有的类型擦除包装器之间进行更高效的交互,并且在将非拥有的包装器复制到拥有的包装器时需要真正的复制,那么我可以为你提供一个可行的解决方案。不幸的是,这比之前的实现更为复杂,但幸运的是它并不过于复杂。该解决方案基于“指导原则 32: 考虑用类型擦除替换继承层次结构”中的基本类型擦除实现,其中包括ShapeConcept
和OnwingShapeModel
类在detail
命名空间中,以及Shape
类型擦除包装器。您会看到,它只需要进行一些添加,这些添加您之前都已经看过了。
第一个添加发生在ShapeConcept
基类中:
//---- <Shape.h> ----------------
#include <memory>
#include <utility>
namespace detail {
class ShapeConcept
{
public:
// ...
virtual void clone( ShapeConcept* memory ) const = 0; 
};
// ...
} // namespace detail
ShapeConcept
类扩展了第二个clone()
函数()。该函数不是返回相应模型的新实例化副本,而是传递需要创建新模型的内存位置的地址。
第二个添加是一个新的模型类,NonOwningShapeModel
:
//---- <Shape.h> ----------------
// ...
namespace detail {
// ...
template< typename ShapeT
, typename DrawStrategy >
class NonOwningShapeModel : public ShapeConcept
{
public:
NonOwningShapeModel( ShapeT& shape, DrawStrategy& drawer )
: shape_{ std::addressof(shape) }
, drawer_{ std::addressof(drawer) }
{}
void draw() const override { (*drawer_)(*shape_); } 
std::unique_ptr<ShapeConcept> clone() const override 
{
using Model = OwningShapeModel<ShapeT,DrawStrategy>;
return std::make_unique<Model>( *shape_, *drawer_ );
}
void clone( ShapeConcept* memory ) const override 
{
std::construct_at( static_cast<NonOwningShapeModel*>(memory), *this );
// or:
// auto* ptr =
// const_cast<void*>(static_cast<void const volatile*>(memory));
// ::new (ptr) NonOwningShapeModel( *this );
}
private:
ShapeT* shape_{ nullptr }; 
DrawStrategy* drawer_{ nullptr }; 
};
// ...
} // namespace detail
NonOwningShapeModel
与OwningShapeModel
实现非常相似,但正如其名称所示,它不存储给定形状和策略的副本。相反,它仅存储指针( 和
)。因此,该类表示
OwningShapeModel
类的引用语义版本。此外,NonOwningShapeModel
需要重写ShapeConcept
类的纯虚函数:draw()
再次将绘图请求转发给给定的绘图策略(),而
clone()
函数执行复制。第一个clone()
函数通过创建一个新的OwningShapeModel
并复制存储的形状和绘图策略来实现()。第二个
clone()
函数通过std::construct_at()
在指定地址创建一个新的NonOwningShapeModel
()。
此外,OwningShapeModel
类需要提供新clone()
函数的实现:
//---- <Shape.h> ----------------
// ...
namespace detail {
template< typename ShapeT
, typename DrawStrategy >
class OwningShapeModel : public ShapeConcept
{
public:
// ...
void clone( ShapeConcept* memory ) const 
{
using Model = NonOwningShapeModel<ShapeT const,DrawStrategy const>;
std::construct_at( static_cast<Model*>(memory), shape_, drawer_ );
// or:
// auto* ptr =
// const_cast<void*>(static_cast<void const volatile*>(memory));
// ::new (ptr) Model( shape_, drawer_ );
}
};
// ...
} // namespace detail
OwningShapeModel
中的clone()
函数的实现与NonOwningShapeModel
类中的实现类似,通过std::construct_at()
创建一个NonOwningShapeModel
的新实例()。
下一个添加是相应的包装类,充当外部层次结构 ShapeConcept
和 NonOwningShapeModel
的包装器。该包装器应承担与 Shape
类相同的职责(即 NonOwningShapeModel
类模板的实例化和所有指针处理的封装),但仅应表示对 const
具体形状的引用,而不是副本。这个包装器再次以 ShapeConstRef
类的形式给出:
//---- <Shape.h> ----------------
#include <array>
#include <cstddef>
#include <memory>
// ...
class ShapeConstRef
{
public:
// ...
private:
// ...
// Expected size of a model instantiation:
// sizeof(ShapeT*) + sizeof(DrawStrategy*) + sizeof(vptr)
static constexpr size_t MODEL_SIZE = 3U*sizeof(void*); 
alignas(void*) std::array<std::byte,MODEL_SIZE> raw_; 
};
正如您将看到的,ShapeConstRef
类与 Shape
类非常相似,但存在一些重要的区别。第一个值得注意的细节是以正确对齐的 std::byte
数组形式使用 raw_
存储()。这表明
ShapeConstRef
不会动态分配内存,而是牢固地依赖于类内存。然而,在这种情况下,这是很容易实现的,因为我们可以预测所需的 NonOwningShapeModel
的大小等于三个指针的大小(假设虚函数表指针 vptr
与其他任何指针具有相同的大小)()。
ShapeConstRef
的 private
部分还包含一些成员函数:
//---- <Shape.h> ----------------
// ...
class ShapeConstRef
{
public:
// ...
private:
friend void draw( ShapeConstRef const& shape )
{
shape.pimpl()->draw();
}
ShapeConcept* pimpl() 
{
return reinterpret_cast<ShapeConcept*>( raw_.data() );
}
ShapeConcept const* pimpl() const 
{
return reinterpret_cast<ShapeConcept const*>( raw_.data() );
}
// ... };
我们还添加了一个作为隐藏 friend
的 draw()
函数,并且就像在 “指南 33:了解类型擦除的优化潜力” 中的 SBO 实现一样,我们添加了一对 pimpl()
函数( 和
)。这将使我们能够方便地使用类内
std::byte
数组。
每个类型擦除实现的签名函数的第二个值得注意的细节是模板化构造函数:
//---- <Shape.h> ----------------
// ...
class ShapeConstRef
{
public:
// Type 'ShapeT' and 'DrawStrategy' are possibly cv qualified;
// lvalue references prevent references to rvalues
template< typename ShapeT
, typename DrawStrategy >
ShapeConstRef( ShapeT& shape
, DrawStrategy& drawer ) 
{
using Model =
detail::NonOwningShapeModel<ShapeT const,DrawStrategy const>; 
static_assert( sizeof(Model) == MODEL_SIZE, "Invalid size detected" ); 
static_assert( alignof(Model) == alignof(void*), "Misaligned detected" );
std::construct_at( static_cast<Model*>(pimpl()), shape_, drawer_ ); 
// or:
// auto* ptr =
// const_cast<void*>(static_cast<void const volatile*>(pimpl()));
// ::new (ptr) Model( shape_, drawer_ );
}
// ...
private:
// ... };
再次,您可以选择接受非const
引用参数以防止临时对象的生命周期问题(非常推荐!)()。或者,您可以接受
const
引用参数,这样可以传递右值,但会面临临时对象的生命周期问题风险。在构造函数内部,我们再次首先使用所需模型的便捷类型别名(),然后检查模型的实际大小和对齐方式(
)。如果它不符合预期的
MODEL_SIZE
或指针对齐要求,我们将创建编译时错误。然后,我们通过 std::construct_at()
在类内存中构造新模型():
//---- <Shape.h> ----------------
// ...
class ShapeConstRef
{
public:
// ...
ShapeConstRef( Shape& other ) { other.pimpl_->clone( pimpl() ); } 
ShapeConstRef( Shape const& other ) { other.pimpl_->clone( pimpl() ); }
ShapeConstRef( ShapeConstRef const& other )
{
other.pimpl()->clone( pimpl() );
}
ShapeConstRef& operator=( ShapeConstRef const& other )
{
// Copy-and-swap idiom
ShapeConstRef copy( other );
raw_.swap( copy.raw_ );
return *this;
}
~ShapeConstRef()
{
std::destroy_at( pimpl() );
// or: pimpl()->~ShapeConcept();
}
// Move operations explicitly not declared 
private:
// ... };
除了模板化的ShapeConstRef
构造函数之外,ShapeConstRef
还提供了两个构造函数,以便从Shape
实例转换为Shape
实例()。虽然这些并非严格要求,因为我们也可以为
Shape
创建一个NonOwningShapeModel
的实例,但这些构造函数直接为相应的底层形状类型创建了一个NonOwningShapeModel
,从而减少了一个间接性,这有助于提高性能。请注意,要使这些构造函数起作用,ShapeConstRef
需要成为Shape
类的friend
。不过,不用担心,这是友谊的一个很好的例子:Shape
和ShapeConstRef
真正是一对,共同工作,甚至在同一个头文件中提供。
最后一个值得注意的细节是,这两个移动操作既没有显式声明也没有删除()。由于我们已经显式定义了这两个复制操作,编译器既不创建也不删除这两个移动操作,因此它们不存在。完全不存在的意思是这两个函数在重载解析中从不参与。是的,这与显式删除它们是不同的:如果它们被删除,它们将参与重载解析,如果被选择,将导致编译错误。但是这两个函数不存在时,当您尝试移动
ShapeConstRef
时,将使用复制操作而不是移动操作,因为后者是廉价和高效的,由于ShapeConstRef
只表示一个引用。因此,这个类有意实现了三法则。
我们即将结束。最后一个细节是Shape
类中的另一个构造函数的添加:
//---- <Shape.h> ----------------
// ...
class Shape
{
public:
// ...
Shape( ShapeConstRef const& other )
: pimpl_{ other.pimpl()->clone() }
{}
private:
// ...
}
通过这个构造函数,Shape
的一个实例创建了传递的ShapeConstRef
实例中存储的形状的深拷贝。如果没有这个构造函数,Shape
将存储ShapeConstRef
实例的副本,因此本质上也充当一个引用。
总结一下,无论是简单的非所有权实现还是更复杂的实现,都能够为您提供类型擦除设计模式的所有设计优势,但同时将您拉回到引用语义的领域,伴随着所有其缺陷。因此,要利用这种非所有权形式的类型擦除的优势,但也要注意通常的生命周期问题。将其视为std::string_view
和std::span
的同等级别。所有这些都是非常有用的函数参数工具,但不要用它们来长期存储任何东西,比如作为数据成员的形式。生命周期相关问题的危险性实在是太高了。
¹ 是的,我认为手动使用std::unique_ptr
是手动管理生命周期。但当然,如果我们不利用 RAII 的力量,情况可能会更糟。
² “类型擦除”这个术语的意义非常多样化,在不同的编程语言中用于许多不同的事情。即使在 C++社区内部,此术语也被用于多种目的:您可能听说过它被用来表示void*
、指向基类的指针和std::variant
。在软件设计的背景下,我认为这是一个非常不幸的问题。我将在本指南的末尾解决这个问题。
³ Sean Parent,《Inheritance Is the Base Class of Evil》,GoingNative 2013,YouTube。
⁴ Kevlin Henney,《Valued Conversions》,C++ Report,2000 年 7-8 月,CiteSeer。
⁵ 欲了解std::function
的简介,请参阅“指南 23:偏好基于值的策略和命令实现”。
⁶ 在这个示例实现中,ShapeConcept
和OwningShapeModel
的命名空间放置纯粹是一个实现细节。然而,正如您将在“指南 34:注意拥有类型擦除包装器的设置成本”中看到的,这个选择非常方便。或者,这两个类可以作为嵌套类实现。您将在“指南 33:了解类型擦除的优化潜力”中看到这方面的示例。
⁷ 有关基于std::function
实现的非侵入式运行时多态的详细内容,请参阅“指南 31:使用外部多态实现非侵入式运行时多态”。
⁸ 非常感谢 Arthur O’Dwyer 提供此示例。
⁹ 再次强调,请不要认为这些性能数字是绝对的真相。这些是在我的机器和我的实现上的性能结果。您的结果肯定会有所不同。然而,重要的是,类型擦除的性能表现非常出色,如果我们考虑到许多优化选项,它可能会表现得更好(参见“指南 33:了解类型擦除的优化潜力”)。
¹⁰ Eric Niebler 在推特,2020 年 6 月 19 日。
¹¹ 欲了解std::variant
的简介,请参阅“指南 17:考虑使用 std::variant 实现访问者”。
¹² 不过,您应该避免过度深入,就像摩利亚的矮人挖得太深时发生的事情一样……
¹³ 或者,您可以使用字节数组,例如,std::byte[Capacity]
或std::aligned_storage
。std::array
的优点在于它使您能够复制缓冲区(如果适用的话!)。
¹⁴ 请注意,默认参数Capacity
和Alignment
的选择是合理的,但仍然是任意的。当然,您可以使用最适合预期实际类型属性的不同默认值。
¹⁵ 您可能以前没有见过放置new
。如果是这样,请放心,这种形式的new
不执行任何内存分配,而仅调用构造函数以在指定地址创建对象。唯一的语法区别是您提供了一个额外的指针参数给new
。
¹⁶ 作为提醒,由于您可能不经常看到这种语法:构造函数中的template
关键字是必需的,因为我们试图在一个依赖名称(其含义依赖于模板参数的名称)上调用一个函数模板。因此,您必须向编译器明确表明以下内容是模板参数列表的开始,而不是小于比较。
¹⁷ 有些人认为函数指针是 C++的最佳功能。在他的闪电演讲中,“C++的最佳功能”,James McNellis 展示了它们的语法美感和巨大的灵活性。但请不要过于认真,而是把它作为 C++缺陷的一种幽默演示。
¹⁸ 在撰写本文时,有一个关于std::function_ref
类型的活跃提案,这是std::function
的非拥有版本。
¹⁹ 术语cv qualified指的是const
和volatile
限定符。
²⁰ 关于左值和右值的提醒,请参阅 Nicolai Josuttis 的关于移动语义的书籍:C++ Move Semantics - The Complete Guide。
第九章:装饰器设计模式
本章专注于另一个经典设计模式:装饰器设计模式。多年来,装饰器已被证明是在组合和重用不同实现时最有用的设计模式之一。因此,它被广泛使用,甚至用于 C++ 标准库功能的最令人印象深刻的重塑之一也不足为奇。本章的主要目标是让你对为什么以及何时装饰器是设计软件的明智选择有一个很好的理解。此外,我还将向你展示现代化、更基于价值的装饰器形式。
在“指南 35:使用装饰器以分层方式添加定制化”中,我们将深入探讨装饰器设计模式的设计方面。你将看到它何时是正确的设计选择,以及通过使用它可以获得哪些好处。此外,你还将了解与其他设计模式的区别以及它的潜在缺点。
在“指南 36:理解运行时和编译时抽象之间的权衡”中,我们将查看装饰器设计模式的另外两个实现。虽然这两个实现都牢固地根植于值语义的领域,但第一个基于静态多态性,而第二个基于动态多态性。尽管它们都有相同的意图并因此实现了装饰器,但这两者的对比将让你感受到设计模式空间的广阔。
指南 35:使用装饰器以分层方式添加定制化
自从你通过基于策略设计模式的解决方案解决了团队 2D 图形工具的设计问题(记得“指南 19:使用策略来隔离事务处理的方式”),你作为设计模式专家的声誉已经传遍了公司。因此,其他团队寻求你的指导并不令人意外。一天,公司商品管理系统的两位开发者来到你的办公室寻求帮助。
你的同事们的设计问题
两位开发者团队正处理许多不同的Item
(参见 Figure 9-1)。所有这些项目都有一个共同点:它们都有一个price()
标签。两位开发者试图通过 C++ 商品商店中的两个项目来解释他们的问题:代表 C++ 书籍的类(CppBook
类)和代表 C++ 会议票的类(ConferenceTicket
类)。
图 9-1. 初始Item
继承层次结构
当开发者们勾勒出他们的问题时,你开始理解到,他们的问题似乎是修改价格的多种不同方式。最初,他们告诉你,他们只需考虑税费。因此,Item
基类配备了一个protected
数据成员来表示税率:
//---- <Money.h> ----------------
class Money { /*...*/ };
Money operator*( Money money, double factor );
Money operator+( Money lhs, Money rhs );
//---- <Item.h> ----------------
#include <Money.h>
class Item
{
public:
virtual ~Item() = default;
virtual Money price() const = 0;
// ...
protected:
double taxRate_;
};
这似乎在一段时间内运行良好,直到有一天,他们被要求同时考虑不同的折扣率。显然,为了重构现有大量的类和它们的多种不同项目,这需要很多努力。你很容易想象到,这是必要的,因为所有的派生类都在访问protected
数据成员。“是的,你应该总是为变化而设计……” 你心里想着。¹
他们继续承认他们不幸的错误设计。当然,他们本应更好地封装Item
基类中的税率。然而,随着这一认识的到来,他们理解到通过在基类中使用数据成员来表示价格修饰符时,任何新的价格修饰符总是会是一种侵入性操作,并且总是会直接影响Item
类。因此,他们开始思考如何避免未来的重构,并如何实现轻松添加新修饰符。“这才是正确的方式!” 你暗自想着。不幸的是,他们首先想到的方法是通过继承层次结构来分离不同类型的价格修饰符(见图 9-2)。
图 9-2. 扩展的Item
继承层次结构
而不是封装税费和折扣值在基类内部,这些修饰符被分解到派生类中,执行所需的价格调整。“哦哦……” 你开始思考。显然,你的表情已经透露出你对这个想法并不特别喜欢,所以他们很快告诉你,他们已经放弃了这个想法。显然,他们已经意识到这会导致更多问题:这种解决方案将快速导致类型的爆炸,并且功能的复用性很差。不幸的是,大量代码会重复,因为对于每个具体的Item
,税费和折扣的代码都必须重复。然而,最麻烦的是处理既受税费影响又受某种折扣影响的Item
:他们既不喜欢提供处理两者的类的方法,也不想在继承层次中引入另一层(见图 9-3)。
图 9-3. 问题的Item
继承层次结构
显然,令他们惊讶的是,他们无法通过直接继承的方式在基类或派生类中处理价格调整器。然而,在你有机会评论关注分离之前,他们解释说他们最近听说了你的策略解决方案。最终给了他们一个正确重构问题的想法(见图 9-4)。
通过将价格调整器提取到一个单独的层次结构中,并通过构造时配置Items
来使用PriceStrategy
,他们最终找到了一个有效的解决方案,可以非侵入性地添加新的价格调整器,这将节省大量的重构工作。“好吧,这就是关注分离和优先组合而不是继承的好处”,你心里想着。² 然后你问道:“这太棒了,我真的为你感到高兴。一切似乎都运行正常,你自己找到了解决方案!你到底为什么在这里?”
图 9-4. 基于策略的Item
继承层次结构
他们告诉你,你的策略解决方案迄今为止是他们所知道的最佳方法(包括感激的表情)。然而,他们承认他们对这种方法并不完全满意。从他们的角度来看,仍然存在两个问题,当然,他们希望你有办法解决。他们看到的第一个问题是,即使没有价格调整器,每个Item
实例仍然需要一个策略类。虽然他们同意这可以通过某种形式的null object来解决,但他们觉得应该有一个更简单的解决方案:³
class PriceStrategy
{
public:
virtual ~PriceStrategy() = default;
virtual Money update( Money price ) const = 0;
// ...
};
class NullPriceStrategy : public PriceStrategy
{
public:
Money update( Money price ) const override { return price; }
};
他们面临的第二个问题似乎更难解决一些。显然,他们有兴趣将不同类型的调整器(例如,Discount
和Tax
)组合到DiscountAndTax
中。不幸的是,他们在当前实现中存在一些代码重复。例如,Tax
和DiscountAndTax
类都包含与税收相关的计算。目前,只有两种调整器,可以采用合理的解决方案来处理重复,但他们预计在添加更多调整器和任意组合时会遇到问题。因此,他们想知道是否有另一种更好的解决方案来处理不同类型的价格调整器。
这确实是一个有趣的问题,你很高兴抽出时间帮助他们。他们绝对是正确的:策略设计模式并不适合这个问题。虽然策略是一个很好的解决方案,可以消除对函数完整实现细节的依赖,并优雅地处理不同的实现,但它并不容易组合和重用不同的实现。试图这样做很快会导致一个不可取的复杂策略继承层次结构。
他们对于他们的问题所需的东西看起来更像是一种层次形式的策略,这种形式解耦了不同的价格修饰符,同时也允许非常灵活的组合。因此,成功的关键之一是一致地应用关注点分离原则:在像 DiscountAndTax
类这样的刚性手工编码组合将是禁止的。然而,解决方案还应该是非侵入性的,以使他们能够随时实施新的想法,而无需修改现有的代码。最后,也不应该通过某种人为的空对象来处理默认情况。相反,更合理的方法是坚持采用组合而非继承,并通过包装器的形式实现价格修饰符。有了这个认识,你开始微笑。是的,正是为这个目的设计的合适设计模式:你的两位客人所需的是装饰者设计模式的实现。
装饰者设计模式解析
装饰者设计模式也源自 GoF 的书籍。其主要关注点是通过组合灵活地组合不同功能的能力:
装饰者设计模式
意图:“动态地为对象附加额外的责任。装饰者提供了一种灵活的替代方案,用于通过组合扩展功能,而不是通过子类化。”⁴
图 9-5 显示了给定 Item
问题的 UML 图。与以往一样,Item
基类代表了所有可能的物品的抽象。另一方面,派生的 CppBook
类则充当了 Item
不同实现的代表。在这个层次结构中,存在的问题是难以为现有的 price()
函数添加新的修饰符。在装饰者设计模式中,将这种添加新“责任”的行为识别为变化点,并以 DecoratedItem
类的形式提取出来。这个类是 Item
基类的一个单独特殊实现,表示对任何给定物品的增加责任。一方面,DecoratedItem
派生自 Item
,因此必须遵循 Item
抽象的所有期望行为(参见“指导方针 6:遵循抽象的预期行为”)。另一方面,它也包含一个 Item
(通过组合或聚合方式)。由于这个原因,DecoratedItem
充当了每个物品的包装器,可能是自身扩展功能的包装器。因此,它为修饰符的分层应用提供了基础。Discounted
类和 Taxed
类代表了两种可能的修饰符,分别表示特定物品的折扣和某种税收。⁵
图 9-5. 装饰者设计模式的 UML 表示
通过引入DecoratedItem
类并分离需要更改的方面,您遵循 SRP 原则。通过分离这个关注点,从而允许轻松添加新的价格修改器,您也遵循开闭原则(OCP)。由于DecoratedItem
类的层次递归性质以及轻松重用和组合不同修改器的能力,您还遵循不要重复自己(DRY)原则的建议。最后但同样重要的是,由于装饰者的包装方法,无需以空对象的形式定义任何默认行为。任何不需要修改器的Item
都可以直接使用。
图 9-6 展示了装饰者设计模式的依赖图。在此图中,Item
类位于架构的最高层。所有其他类都依赖于它,包括位于下一级的DecoratedItem
类。当然,这不是必须的:如果Item
和DecoratedItem
都在同一架构级别引入,那也是完全可以接受的。然而,这个示例表明,随时随地都可以引入新的装饰者,而无需修改现有代码。Item
的具体类型实现在架构的最低级别。请注意,这些项之间没有依赖关系:包括Discounted
在内的所有项可以独立地随时引入,并且由于装饰者的结构,可以灵活和任意地组合。
图 9-6. 装饰者设计模式的依赖图
经典的装饰者设计模式实现
让我们通过给定的Item
示例来看一下完整的 GoF 风格装饰者设计模式的实现:
//---- <Item.h> ----------------
#include <Money.h>
class Item
{
public:
virtual ~Item() = default;
virtual Money price() const = 0;
};
Item
基类代表所有可能的物品的抽象。唯一的要求由纯虚拟price()
函数定义,可用于查询给定物品的价格。DecoratedItem
类代表Item
类的一种可能的实现():
//---- <DecoratedItem.h> ----------------
#include <Item.h>
#include <memory>
#include <stdexcept>
#include <utility>
class DecoratedItem : public Item 
{
public:
explicit DecoratedItem( std::unique_ptr<Item> item ) 
: item_( std::move(item) )
{
if( !item_ ) {
throw std::invalid_argument( "Invalid item" );
}
}
protected:
Item& item() { return *item_; } 
Item const& item() const { return *item_; }
private:
std::unique_ptr<Item> item_; 
};
DecoratedItem
从Item
类派生,但也包含一个item_
()。这个
item_
是通过构造函数指定的,该构造函数接受任何非空的std::unique_ptr
指向另一个Item
()。请注意,这个
DecoratedItem
类仍然是抽象的,因为纯虚拟price()
函数尚未定义。DecoratedItem
仅提供必要的功能来存储一个Item
并通过protected
成员函数访问该Item
()。
使用这两个类,可以实现具体的Item
:
//---- <CppBook.h> ----------------
#include <Item.h>
#include <string>
#include <utility>
class CppBook : public Item 
{
public:
CppBook( std::string title, Money price )
: title_{ std::move(title) }
, price_{ price }
{}
std::string const& title() const { return title_; }
Money price() const override { return price_; }
private:
std::string title_{};
Money price_{};
};
//---- <ConferenceTicket.h> ----------------
#include <Item.h>
#include <string>
#include <utility>
class ConferenceTicket : public Item 
{
public:
ConferenceTicket( std::string name, Money price )
: name_{ std::move(name) }
, price_{ price }
{}
std::string const& name() const { return name_; }
Money price() const override { return price_; }
private:
std::string name_{};
Money price_{};
};
CppBook
和 ConferenceTicket
类表示可能的具体 Item
实现 ( 和
)。C++ 书籍由书籍标题表示,而 C++ 大会则由会议名称表示。最重要的是,这两个类都重写了
price()
函数,返回指定的 price_
。
CppBook
和 ConferenceTicket
都不考虑任何形式的税收或折扣。但显然,这两种 Item
都可能受到这两者的影响。这些价格修饰器通过 Discounted
和 Taxed
类来实现:
//---- <Discounted.h> ----------------
#include <DecoratedItem.h>
class Discounted : public DecoratedItem
{
public:
Discounted( double discount, std::unique_ptr<Item> item ) 
: DecoratedItem( std::move(item) )
, factor_( 1.0 - discount )
{
if( !std::isfinite(discount) || discount < 0.0 || discount > 1.0 ) {
throw std::invalid_argument( "Invalid discount" );
}
}
Money price() const override
{
return item().price() * factor_; 
}
private:
double factor_;
};
Discounted
类 () 通过向
Item
的 std::unique_ptr
和折扣值传递初始化。该折扣值由范围为 0.0 到 1.0 的双精度值表示。虽然给定的 Item
立即传递给 DecoratedItem
基类,但给定的折扣值用于计算折扣 factor_
。此因素用于在 price()
函数的实现中修改给定项目的价格 ()。这可以是像
CppBook
或 ConferenceTicket
这样的特定项,也可以是任何像 Discounted
这样的装饰器,其再次修改另一个 Item
的价格。因此,price()
函数是完全利用装饰器的层次结构的关键点。
//---- <Taxed.h> ----------------
#include <DecoratedItem.h>
class Taxed : public DecoratedItem
{
public:
Taxed( double taxRate, std::unique_ptr<Item> item ) 
: DecoratedItem( std::move(item) )
, factor_( 1.0 + taxRate )
{
if( !std::isfinite(taxRate) || taxRate < 0.0 ) {
throw std::invalid_argument( "Invalid tax" );
}
}
Money price() const override
{
return item().price() * factor_;
}
private:
double factor_;
};
Taxed
类与 Discounted
类非常相似。主要区别在于构造函数中对与税相关的因素的评估 ()。同样,这个因素在
price()
函数中用于修改包装的 Item
的价格。
所有这些功能都集成在 main()
函数中:
#include <ConferenceTicket.h>
#include <CppBook.h>
#include <Discounted.h>
#include <Taxed.h>
#include <cstdlib>
#include <memory>
int main()
{
// 7% tax: 19*1.07 = 20.33
std::unique_ptr<Item> item1( 
std::make_unique<Taxed>( 0.07,
std::make_unique<CppBook>( "Effective C++", 19.0 ) ) );
// 20% discount, 19% tax: (999*0.8)*1.19 = 951.05
std::unique_ptr<Item> item2( 
std::make_unique<Taxed>( 0.19,
std::make_unique<Discounted>( 0.2,
std::make_unique<ConferenceTicket>( "CppCon", 999.0 ) ) ) );
Money const totalPrice1 = item1->price(); // Results in 20.33
Money const totalPrice2 = item2->price(); // Results in 951.05
// ...
return EXIT_SUCCESS;
}
作为第一个 Item
,我们创建了一个 CppBook
。假设这本书需要缴纳 7% 的税款,这是通过在该项目周围包装一个 Taxed
装饰器来应用的。因此,结果的 item1
表示一个征税的 C++ 书籍 ()。作为第二个
Item
,我们创建了一个 ConferenceTicket
实例,代表 CppCon。我们很幸运地获得了早鸟票,这意味着我们享有 20% 的折扣。这个折扣通过 Discounted
类包装在 ConferenceTicket
实例周围。门票也需缴纳 19% 的税款,这与之前一样,通过 Taxed
装饰器应用。因此,结果的 item2
表示一个打折和征税的 C++ 大会门票 ()。
第二个装饰器示例
另一个展示装饰器设计模式优势的令人印象深刻的例子可以在 STL 分配器的 C++17 重制版中找到。由于分配器的实现基于装饰器,可以创建任意复杂的分配器层次结构,以满足甚至最特殊的内存需求。例如,考虑以下使用 std::pmr::monotonic_buffer_resource
的例子()。
#include <array>
#include <cstddef>
#include <cstdlib>
#include <memory_resource>
#include <string>
#include <vector>
int main()
{
std::array<std::byte,1000> raw; // Note: not initialized!
std::pmr::monotonic_buffer_resource
buffer{ raw.data(), raw.size(), std::pmr::null_memory_resource() }; 
std::pmr::vector<std::pmr::string> strings{ &buffer };
strings.emplace_back( "String longer than what SSO can handle" );
strings.emplace_back( "Another long string that goes beyond SSO" );
strings.emplace_back( "A third long string that cannot be handled by SSO" );
// ...
return EXIT_SUCCESS;
}
std::pmr::monotonic_buffer_resource
是 std::pmr
命名空间中几种可用分配器之一。在本例中,它配置为当 strings
向量请求内存时,仅分发给定字节数组 raw
的块。无法处理的内存请求(例如 buffer
内存不足)将通过抛出 std::bad_alloc
异常来处理。这种行为是在构造过程中通过传递 std::pmr::null_memory_resource
指定的。然而,std::pmr::monotonic_buffer_resource
还有许多其他可能的应用场景。例如,还可以基于动态内存构建,并通过 std::pmr::new_delete_resource()
使用 new
和 delete
重新分配额外的内存块()。
// ...
int main()
{
std::pmr::monotonic_buffer_resource
buffer{ std::pmr::new_delete_resource() }; 
// ... }
这种分配器的灵活性和分层配置是通过装饰器设计模式实现的。std::pmr::monotonic_buffer_resource
派生自 std::pmr::memory_resource
基类,同时还充当另一个派生自 std::pmr::memory_resource
的分配器的包装器。在 std::pmr::monotonic_buffer_resource
的构造过程中指定了用于在 buffer
内存不足时使用的上游分配器。
然而,最令人印象深刻的是,你可以轻松而非侵入式地定制分配策略。例如,这可能会让你能够以不同于对小块内存请求的方式处理大块内存的请求。你所需做的就是提供你自己的定制分配器。考虑以下 CustomAllocator
的草图:
//---- <CustomAllocator.h> ----------------
#include <cstdlib>
#include <memory_resource>
class CustomAllocator : public std::pmr::memory_resource 
{
public:
CustomAllocator( std::pmr::memory_resource* upstream ) 
: upstream_{ upstream }
{}
private:
void* do_allocate( size_t bytes, size_t alignment ) override; 
void do_deallocate( void* ptr, [[maybe_unused]] size_t bytes, 
[[maybe_unused]] size_t alignment ) override;
bool do_is_equal(
std::pmr::memory_resource const& other ) const noexcept override; 
std::pmr::memory_resource* upstream_{}; 
};
要被认定为 C++17 分配器,CustomAllocator
类需要继承自 std::pmr::memory_resource
类,该类代表了所有 C++17 分配器的要求()。巧合的是,
CustomAllocator
还拥有一个指向 std::pmr::memory_resource
的指针(),这个指针是通过其构造函数初始化的(
)。
C++17 分配器的要求集包括虚拟函数 do_allocate()
、do_deallocate()
和 do_is_equal()
。do_allocate()
函数负责获取内存,可能通过其上游分配器()实现,而
do_deallocate()
函数在需要归还内存时调用()。最后,
do_is_equal()
函数在需要检查两个分配器是否相等时调用()。⁶
只需引入 CustomAllocator
而无需更改任何其他代码,特别是标准库中的代码,新的分配器类型就可以轻松地插入到 std::pmr::monotonic_buffer_resource
和 std::pmr::new_delete_resource()
之间(),从而允许您非侵入性地扩展分配行为。
// ... #include <CustomAllocator.h>
int main()
{
CustomAllocator custom_allocator{ std::pmr::new_delete_resource() };
std::pmr::monotonic_buffer_resource buffer{ &custom_allocator }; 
// ... }
装饰者、适配器和策略之间的比较
在名称为 Decorator 和 Adapter 的两种设计模式中,它们听起来似乎有相似的目的。然而,仔细检查后,这两种模式非常不同,几乎没有任何关联。适配器设计模式的意图是将给定接口适应并转换为预期接口。它不关心添加任何功能,而只关心将一组函数映射到另一组函数(另见 “指南 24:使用适配器来标准化接口”)。另一方面,装饰者设计模式保留了给定接口,并且根本不关心改变它。相反,它提供了添加职责、扩展和定制现有函数集的能力。
策略设计模式更类似于装饰者模式。这两种模式都提供了定制功能的能力。然而,它们各自适用于不同的应用场景,因此提供了不同的优势。策略设计模式专注于消除对特定功能实现细节的依赖,并使您能够从外部定义这些细节。因此,从这个角度来看,它代表了这个功能的核心——“内核”。这种形式使其特别适合表示不同的实现并在它们之间切换(参见 “指南 19:使用策略来隔离做事的方式”)。相比之下,装饰者设计模式专注于消除可附加实现之间的依赖关系。由于其包装形式,装饰者表示功能的“皮肤”。⁷ 在这种形式下,它特别适合组合不同的实现,从而增强和扩展功能,而不是替换或在实现之间切换。
显然,策略模式和装饰器模式各有其独特的优势,应根据具体情况选择。然而,也可以结合这两种设计模式,以获得双赢。例如,可以实现Item
,使用策略设计模式,并通过装饰器提供更精细化的策略配置:
class PriceStrategy
{
public:
virtual ~PriceStrategy() = default;
virtual Money update( Money price ) const = 0;
// ...
};
class DecoratedPriceStrategy : public PriceStrategy
{
public:
// ...
private:
std::unique_ptr<PriceStrategy> priceModifier_;
};
class DiscountedPriceStrategy : public DecoratedPriceStrategy
{
public:
Money update( Money price ) const override;
// ...
};
如果已经有了策略的实现,这种设计模式的组合尤其有趣:虽然策略是侵入性的,并且需要修改类,但非侵入性地添加类似DecoratedPriceStrategy
的装饰器是完全可能的。当然,是否选择这种解决方案取决于具体情况。
分析装饰器设计模式的不足之处
装饰器设计模式以其能够层次化地扩展和定制行为的能力,显然是设计模式目录中最有价值和灵活的模式之一。然而,尽管它有利之处,但也存在一些缺点。首先且最重要的是,装饰器的灵活性是有代价的:在给定层次结构中的每一级都会增加一级间接性。作为具体例子,在Item
层次结构的面向对象实现中,这种间接性以每个装饰器的虚函数调用形式呈现。因此,广泛使用装饰器可能会带来潜在的显著性能开销。是否这种潜在的性能损失构成问题,取决于具体情况。您将需要使用基准测试来决定,装饰器的灵活性和结构方面的优势是否超过了性能问题。
另一个缺点是可能以荒谬的方式组合装饰器的潜在危险。例如,很容易在一个Taxed
装饰器外围再包裹一个Taxed
装饰器,或者在已经被税的Item
上应用Discounted
。这两种情况都会让政府开心,但实际上不应该发生,因此应该通过设计避免。Scott Meyers 的通用设计原则很好地表达了这一理念:⁸
使接口易于正确使用,难以错误使用。
因此,装饰器的巨大灵活性非凡,但也可能具有危险性(当然取决于具体场景)。由于在这种场景中,税收似乎扮演了一个特殊角色,因此不将其视为装饰器处理,而是通过策略设计模式进行分离显得非常合理:
//---- <TaxStrategy.h> ----------------
#include <Money.h>
class TaxStrategy 
{
public:
virtual ~TaxStrategy() = default;
virtual Money applyTax( Money price ) const = 0;
// ... };
//---- <TaxedItem.h> ----------------
#include <Money.h>
#include <TaxStrategy.h>
#include <memory>
class TaxedItem
{
public:
explicit TaxedItem( std::unique_ptr<Item> item
, std::unique_ptr<TaxStrategy> taxer ) 
: item_( std::move(item) )
, taxer_( std::move(taxer) )
{
// Check for a valid item and tax strategy
}
Money netPrice() const // Price without taxes 
{
return price();
}
Money grossPrice() const // Price including taxes 
{
return taxer_.applyTax( item_.price() );
}
private:
std::unique_ptr<Item> item_;
std::unique_ptr<TaxStrategy> taxer_;
};
TaxStrategy
类表示将税收应用于 Item
的多种不同方式()。这样的
TaxStrategy
与 TaxedItem
类中的 Item
结合在一起()。请注意,
TaxedItem
本身并不是 Item
,因此不能通过另一个 Item
进行装饰。因此,它充当一种终止装饰器,只能作为最后一个装饰器应用。它也不提供 price()
函数,而是提供 netPrice()
()和
grossPrice()
()函数,以便查询包括税费在内的价格和包装
Item
的原始价格。⁹
另一个可能出现的问题是基于引用语义的装饰器设计模式实现:包括大量指针,包括 nullptr
检查和悬空指针的危险,通过 std::unique_ptr
和 std::make_unique()
进行显式生命周期管理,以及许多小的手动内存分配。然而,幸运的是,你还有一招在手,可以展示如何基于值语义实现装饰器(请参阅下面的指南)。
总结一下,装饰器设计模式是基本设计模式之一,尽管存在一些缺点,但将证明是你工具箱中非常有价值的补充。只需确保你不要对装饰器过于激动并开始将其用于一切。毕竟,对于每种模式,合理使用和过度使用之间有一条细微的界限。
指南 36:理解运行时和编译时抽象之间的权衡
在 “指导原则 35:使用装饰器分层添加定制” 中,我向您介绍了装饰器设计模式,并希望您能够将此设计模式添加到您的工具箱中。然而,到目前为止,我仅通过经典的面向对象实现来说明装饰器,并且再次未遵循 “指导原则 22:更喜欢值语义而不是引用语义” 的建议。因此,我假设您迫不及待地想要看到如何基于值语义实现装饰器,现在是展示两种可能方法的时候了。是的,两种 方法:我将通过展示两种非常不同的实现来弥补之前的推迟。两者都坚定地基于值语义,但在比较中,它们几乎处于设计空间的对立面。第一种方法将是基于静态多态性的实现,这使您能够利用您可能拥有的所有编译时信息,而第二种方法则更倾向于利用动态多态性的运行时优势。这两种方法各有其优点,当然也有其特有的缺点。因此,这些示例将很好地展示给您可供选择的设计选择的广泛性。
一种基于值的编译时装饰器
让我们从基于静态多态性的装饰器实现开始。"我假设这将再次非常依赖于模板,对吗?" 你问道。是的,我将使用模板作为主要的抽象机制,是的,我将使用 C++20 的概念甚至是转发引用。但不,我会尽量避免过度使用模板。相反,主要的焦点仍然在装饰器设计模式的设计方面以及使其易于添加新种类的装饰器和新种类的常规项目。其中一种项目是 ConferenceTicket
类:
//---- <ConferenceTicket.h> ----------------
#include <Money.h>
#include <string>
#include <utility>
class ConferenceTicket
{
public:
ConferenceTicket( std::string name, Money price )
: name_{ std::move(name) }
, price_{ price }
{}
std::string const& name() const { return name_; }
Money price() const { return price_; }
private:
std::string name_;
Money price_;
};
ConferenceTicket
完美地实现了值类型的期望:没有涉及基类,也没有虚函数。这表明项目不再通过指向基类的指针进行装饰,而是通过组合或者直接的非public
继承。两个示例是Discounted
和Taxed
类的以下实现:
//---- <PricedItem.h> ----------------
#include <Money.h>
template< typename T >
concept PricedItem = 
requires ( T item ) {
{ item.price() } -> std::same_as<Money>;
};
//---- <Discounted.h> ----------------
#include <Money.h>
#include <PricedItem.h>
#include <utility>
template< double discount, PricedItem Item >
class Discounted // Using composition 
{
public:
template< typename... Args >
explicit Discounted( Args&&... args )
: item_{ std::forward<Args>(args)... }
{}
Money price() const {
return item_.price() * ( 1.0 - discount );
}
private:
Item item_;
};
//---- <Taxed.h> ----------------
#include <Money.h>
#include <PricedItem.h>
#include <utility>
template< double taxRate, PricedItem Item >
class Taxed : private Item // Using inheritance 
{
public:
template< typename... Args >
explicit Taxed( Args&&... args )
: Item{ std::forward<Args>(args)... }
{}
Money price() const {
return Item::price() * ( 1.0 + taxRate );
}
};
Discounted
() 和
Taxed
() 都是其他类型的
Item
的装饰器:Discounted
类代表给定物品的某种折扣,而 Taxed
类代表某种类型的税。然而,这次它们都以类模板的形式实现。第一个模板参数分别指定折扣和税率,第二个模板参数指定被装饰的 Item
的类型。¹⁰
尤为重要的是,第二个模板参数的PricedItem
约束()。该约束表示语义要求集,即期望的行为。由于此约束,您只能提供具有
price()
成员函数的类型。使用任何其他类型将立即导致编译错误。因此,PricedItem
在经典 Decorator 实现中与Item
基类的作用相同,如“Guideline 35: Use Decorators to Add Customization Hierarchically”所示。出于同样的原因,它还代表基于单一职责原则 (SRP) 的关注点分离。此外,如果此约束由架构中的某个高级别拥有,则您以及其他任何人都能在任何较低级别上添加新类型的项目和新类型的 Decorators。此功能完美地满足开闭原则 (OCP),并且由于抽象的适当拥有,还满足依赖反转原则 (DIP)(参见图 9-7)。¹¹
图 9-7. 编译时 Decorator 的依赖图
Discounted
和Taxed
类模板非常相似,除了它们处理装饰的Item
的方式不同:Discounted
类模板将Item
存储为数据成员,因此遵循“Guideline 20: Favor Composition over Inheritance”,而Taxed
类模板私有继承给定的Item
类。这两种方法都是可行的,并且各有其优势,但应考虑采用Discounted
类模板的组合方式,因为这是更常见的方式。如“Guideline 24: Use Adapters to Standardize Interfaces”所述,只有五个理由支持非public
继承而不是组合(其中一些非常罕见):
-
如果您必须重写虚函数
-
如果您需要访问
protected
成员函数 -
如果你需要调整类型以在另一个基类之前构建
-
如果你需要共享一个通用的虚基类或重写虚基类的构造函数
-
如果你能从空基类优化 (EBO)中获得显著优势
可能是,对于大量适配器,EBO可能是倾向于继承的一个理由,但您应确保您的选择有数值支持(例如通过代表性基准)。
有了这三个类,您就能够指定一个打八折的ConferenceTicket
,并且税率为 15%:
#include <ConferenceTicket.h>
#include <Discounted.h>
#include <Taxed.h>
#include <cstdlib>
int main()
{
// 20% discount, 15% tax: (499*0.8)*1.15 = 459.08
Taxed<0.15,Discounted<0.2,ConferenceTicket>> item{ "Core C++", 499.0 };
Money const totalPrice = item.price(); // Results in 459.08
// ...
return EXIT_SUCCESS;
}
这种编译时方法的最大优势在于显著的性能提升:由于没有指针间接性,并且由于内联的可能性,编译器能够全力优化生成的代码。此外,生成的代码可能要短得多,没有任何样板代码臃肿,因此更易读。
“你能具体一点描述性能结果吗?在 C++中,开发者们常常争论 1%的性能差异并称其为显著。所以严肃地说:编译时方法到底快了多少?” 我明白了,你似乎很了解 C++社区对性能的热情。好吧,只要你再次保证不认为我的结果是最终答案,而只是一个例子,我们也不将此比较演变成一项性能研究,我可以给你展示一些数字。但在我这样做之前,请让我简要概述一下我将使用的基准测试:我将经典面向对象实现与“指导方针 35:使用装饰器进行分层自定义”中描述的编译时版本进行比较。当然,有任意数量的装饰器组合,但我将限制在以下四种物品类型:¹²
using DiscountedConferenceTicket = Discounted<0.2,ConferenceTicket>;
using TaxedConferenceTicket = Taxed<0.19,ConferenceTicket>;
using TaxedDiscountedConferenceTicket =
Taxed<0.19,Discounted<0.2,ConferenceTicket>>;
using DiscountedTaxedConferenceTicket =
Discounted<0.2,Taxed<0.19,ConferenceTicket>>;
由于在编译时解决方案中,这四种类型没有一个共同的基类,我用具体的std::vector
填充这四种类型。相比之下,在经典运行时解决方案中,我使用一个包含std::unique_ptr<Item>
的单个std::vector
。总体上,我为这两种解决方案分别创建了 10,000 个具有随机价格的物品,并调用std::accumulate()
函数 5,000 次来计算所有物品的总价格。
在了解了这些背景信息后,让我们来看看性能结果(表 9-1)。同样地,我将结果归一化到运行时实现的性能。
表 9-1. 编译时装饰器实现的性能结果(归一化性能)
GCC 11.1 | Clang 11.1 | |
---|---|---|
经典装饰器 | 1.0 | 1.0 |
编译时装饰器 | 0.078067 | 0.080313 |
如前所述,编译时解决方案的性能显著快于运行时解决方案:对于 GCC 和 Clang,仅需大约运行解决方案的 8% 的时间,因此比运行解决方案快一个数量级。我知道,这听起来很惊人。然而,虽然编译时解决方案的性能非凡,但它带来了几个潜在的严重限制:由于完全依赖于模板,没有剩余的运行时灵活性。由于即使折扣和税率也是通过模板参数实现的,因此每个不同的税率都需要创建一个新类型。这可能导致较长的编译时间和生成的代码(即更大的可执行文件)。此外,所有类模板很可能驻留在头文件中,这再次增加了编译时间,并可能透露出更多的实现细节。更重要的是,实现细节的更改是广泛可见的,可能导致大规模的重新编译。然而,最具限制性的因素似乎是,只有在所有信息在编译时都是可用的情况下,才能以这种形式使用解决方案。因此,您可能只能为少数特殊情况达到这种性能水平。
基于值的运行时装饰器
由于编译时装饰器可能快但在运行时非常不灵活,让我们将注意力转向第二个基于值的装饰器实现。通过这种实现,我们将回到动态多态的领域,以其所有的运行时灵活性。
现在你已经了解了装饰者设计模式,你意识到我们需要能够轻松添加新的类型:新的 Item
种类以及新的价格修改器。因此,选择将装饰器实现从“Guideline 35: Use Decorators to Add Customization Hierarchically”转换为基于值语义的实现的设计模式是类型擦除。¹³ 下面的 Item
类实现了一个拥有类型擦除包装器的价格项目示例:
//---- <Item.h> ----------------
#include <Money.h>
#include <memory>
#include <utility>
class Item
{
public:
// ...
private:
struct Concept 
{
virtual ~Concept() = default;
virtual Money price() const = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
};
template< typename T >
struct Model : public Concept 
{
explicit Model( T const& item ) : item_( item ) {}
explicit Model( T&& item ) : item_( std::move(item) ) {}
Money price() const override
{
return item_.price();
}
std::unique_ptr<Concept> clone() const override
{
return std::make_unique<Model<T>>(*this);
}
T item_;
};
std::unique_ptr<Concept> pimpl_;
};
在这个实现中,Item
类在其 private
部分定义了一个嵌套的 Concept
基类()。如往常一样,
Concept
基类代表了被包装类型的要求集合(即期望的行为),这些要求由 price()
和 clone()
成员函数来表达。这些要求由嵌套的 Model
类模板来实现()。
Model
通过将调用转发到存储的 item_
数据成员的 price()
成员函数来实现 price()
函数,并通过创建存储项目的副本来实现 clone()
函数。
Item
类的 public
部分应该看起来很熟悉:
//---- <Item.h> ----------------
// ...
class Item
{
public:
template< typename T >
Item( T item ) 
: pimpl_( std::make_unique<Model<T>>( std::move(item) ) )
{}
Item( Item const& item ) : pimpl_( item.pimpl_->clone() ) {}
Item& operator=( Item const& item )
{
pimpl_ = item.pimpl_->clone();
return *this;
}
~Item() = default;
Item( Item&& ) = default;
Item& operator=( Item&& item ) = default;
Money price() const { return pimpl_->price(); } 
private:
// ... };
除了常规的5 法则实现外,该类再次配备了一个模板构造函数,接受各种项()。最后但同样重要的是,该类提供了一个
price()
成员函数,模仿了所有项的预期接口()。
有了这个包装器类,您可以轻松地添加新的项:不需要对现有代码进行任何侵入性修改,也不需要使用基类。任何提供price()
成员函数且可复制的类都可以工作。幸运的是,这包括我们编译时装饰器实现中的ConferenceTicket
类,它提供了我们需要的一切,并且坚定地基于值语义。不幸的是,对于Discounted
和Taxed
类来说并非如此,因为它们期望装饰的项以模板参数的形式提供。因此,我们为类型擦除上下文重新实现了Discounted
和Taxed
:
//---- <Discounted.h> ----------------
#include <Item.h>
#include <utility>
class Discounted
{
public:
Discounted( double discount, Item item )
: item_( std::move(item) )
, factor_( 1.0 - discount )
{}
Money price() const
{
return item_.price() * factor_;
}
private:
Item item_;
double factor_;
};
//---- <Taxed.h> ----------------
#include <Item.h>
#include <utility>
class Taxed
{
public:
Taxed( double taxRate, Item item )
: item_( std::move(item) )
, factor_( 1.0 + taxRate )
{}
Money price() const
{
return item_.price() * factor_;
}
private:
Item item_;
double factor_;
};
特别有趣的是,这两个类都没有从任何基类派生,但却完美实现了装饰器设计模式。一方面,它们实现了Item
包装器所需的操作,以使其计算为一个项(特别是price()
成员函数和复制构造函数),但另一方面,它们拥有一个Item
。因此,它们都能让您任意组合装饰器,正如下面的main()
函数所示:
#include <ConferenceTicket.h>
#include <Discounted.h>
#include <Taxed.h>
int main()
{
// 20% discount, 15% tax: (499*0.8)*1.15 = 459.08
Item item(Taxed(0.19, Discounted(0.2, ConferenceTicket{"Core C++",499.0})));
Money const totalPrice = item.price();
// ...
return EXIT_SUCCESS;
}
“哇,这太美妙了:没有指针,没有手动分配,感觉非常自然和直观。但与此同时,它又极其灵活。这也太美好了吧,一定有问题。性能如何?”你说。好吧,你听起来像是期待性能彻底崩溃。那么让我们对这个解决方案进行基准测试。当然,我使用的是与装饰器编译时版本相同的基准测试,只是增加了基于类型擦除的第三种解决方案。性能数字显示在表 9-2 中。
表 9-2. 类型擦除装饰器实现的性能结果(性能标准化)
GCC 11.1 | Clang 11.1 | |
---|---|---|
经典装饰器 | 1.0 | 1.0 |
编译时装饰器 | 0.078067 | 0.080313 |
类型擦除装饰器 | 0.997510 | 0.971875 |
如您所见,性能不比经典运行时解决方案差。实际上,性能甚至似乎略好一些,尽管这是多次运行的平均值,但不要过分强调这一点。然而,请记住,有多种选项可以改进类型擦除解决方案的性能,正如在“指南 33:注意类型擦除的优化潜力”中展示的那样。
虽然性能可能不是运行时解决方案的主要优势(至少与编译时解决方案相比),但在运行时灵活性方面确实表现出色。例如,可以在运行时决定将任何Item
用另一个装饰器包装起来(基于用户输入,基于计算结果等)。当然,这将再次产生一个Item
,它与许多其他Item
一起可以存储在单个容器中。这确实给你带来了巨大的运行时灵活性。
另一个优势是更容易在源文件中隐藏实现细节。虽然这可能导致运行时性能损失,但可能会带来更好的编译时间。最重要的是:对隐藏代码的任何修改不会影响任何其他代码,因此可以节省大量重新编译的时间,因为实现细节更加封装。
总结一下,编译时和运行时解决方案都是基于价值的,并导致更简单、更可理解的用户代码。然而,它们也各有优缺点:运行时方法提供了更多的灵活性,而编译时方法在性能方面占据主导地位。实际情况中,你很少会使用纯编译时或运行时方法,但你经常会发现自己处于这两个极端之间。确保了解你的选择:权衡它们并找到一个完美结合两者优势的折中方案,以及符合你特定情况的解决方案。
¹ 请记住“指南 2:为变更而设计”和核心指南 C.133:“避免使用protected
数据。”
² 参见“指南 20:优先选择组合而非继承”,讨论为什么许多设计模式更倾向于组合而不是继承。
³ 空对象代表一个具有中性(空)行为的对象。因此,它可以被视为策略实现的默认对象。
⁴ Erich Gamma 等人,《设计模式:可复用面向对象软件的元素》。
⁵ 你可能会想知道这是否是处理税务问题的最合理方法。不,不幸的是,这不是。首先,像往常一样,现实比这个简单的教育示例复杂得多,其次,因为这种形式很容易错误地应用税收。关于第一点我无能为力(我只是一个普通人),但我将在本指南末尾详细讨论第二点。
⁶ 如果你对不完整的实现感到困惑:这里的重点完全在于如何设计分配器,而不是如何实现分配器。想要深入了解如何实现一个 C++17 分配器,请参阅尼古拉·约苏提斯的《C++17 - 完全指南》。
⁷ 策略作为对象的核心,装饰器作为皮肤的隐喻源自《设计模式》一书。
⁸ 斯科特·迈尔斯,《Effective C++》,第三版(Addison-Wesley,2005 年)。
⁹ 如果你认为原始的price()
函数应该重命名为netPrice()
以反映其真实目的,那么我同意。
¹⁰ 请注意,自 C++20 起,只能使用浮点值作为非类型模板参数(NTTPs)。或者,您可以将折扣和税率存储为数据成员的形式。
¹¹ 或者,特别是如果你还不能使用 C++20 概念,这是使用奇异递归模板模式(CRTP)的一个机会;参见“指南 26:使用 CRTP 引入静态类型类别”。
¹² 为了避免税务局的拜访,我应明确声明,我意识到Discounted<0.2,Taxed<0.19,ConferenceTicket>>
类的可疑性质(另请参阅“指南 35:使用装饰器以分层方式添加定制”末尾的潜在问题列表)。为自己辩护:这是装饰器的一个明显排列组合,非常适合这个基准测试。
¹³ 想要全面了解类型擦除,请参阅第八章,特别是“指南 32:考虑用类型擦除替换继承层次结构”。
第十章:单例模式
在本章中,我们将看一下臭名昭著的Singleton模式。我知道,你可能已经对 Singleton 有所了解,并且可能已经对它有很强的看法。甚至可能认为 Singleton 是反模式,因此你可能会想我是如何鼓起勇气将它包含在这本书中的。嗯,我知道 Singleton 并不特别受欢迎,在许多圈子里它的声誉相当不好,特别是因为 Singleton 的全局特性。然而,从这个角度来看,了解到 C++标准库中有几个类似“Singleton”的实例可能会令人非常惊讶。真的!而且,老实说,它们的工作效果非常好!因此,我们应该认真讨论什么是Singleton,什么时候Singleton 适用,以及如何正确处理 Singleton。
在“Guideline 37: Treat Singleton as an Implementation Pattern, Not a Design Pattern”中,我将解释 Singleton 模式,并通过一个非常常用的实现方式,即所谓的Meyers' Singleton,来演示它的工作原理。然而,我也会强烈主张不将 Singleton 视为设计模式,而是作为实现模式。
在“Guideline 38: Design Singletons for Change and Testability”中,我们接受事实,有时我们需要一个解决方案来表示代码中的少数全局方面。这正是 Singleton 模式经常用于的地方。这也意味着我们面对 Singleton 的常见问题:全局状态;许多强的人为依赖关系;以及受到限制的可变性和可测试性。虽然这些听起来都是避免 Singleton 的极好理由,但我将向你展示,通过合理的软件设计,你可以将 Singleton 的好处与出色的可变性和可测试性结合起来。
Guideline 37: 将 Singleton 视为实现模式,而不是设计模式
让我首先解决问题的关键点:
Singleton 并不是一个设计模式。
如果你之前没有听说过 Singleton,那么这可能完全没有任何意义,但请跟着我。我承诺很快会解释 Singleton。如果你之前听说过Singleton,那么我假设你要么在赞同中点头,脸上露出同情的表情,“我知道”的样子,要么是完全震惊,一开始不知道该说什么。“但为什么不是呢?”你最终敢于问。“它不是《设计模式》一书中的原始设计模式之一吗?”是的,你是对的:Singleton 是《设计模式》一书中记录的 23 种原始模式之一。截至撰写本文时,维基百科将其称为设计模式,甚至在史蒂夫·麦康奈尔的畅销书《代码大全》中也列为设计模式。¹尽管如此,它仍然不是设计模式,因为它没有设计模式的特性。让我解释一下。
解释了单例模式
有时候,您可能希望确保某个特定类只有一个,确切地只有一个实例。换句话说,您面临的是一种高地兰德(Highlander)情况:“只能有一个。”² 这在系统范围的数据库、唯一的记录器、系统时钟、系统配置或者简而言之任何不应该多次实例化的类中都是合理的,因为它们代表的是仅存在一次的东西。这就是单例模式的意图。
单例模式
意图:“确保一个类只有一个实例,并提供一个全局访问点。”³
Gang of Four 通过图 10-1 中的 UML 图表现了这一意图,其中引入了 instance()
函数作为访问唯一实例的全局访问点。
图 10-1. 单例 模式的 UML 表示
有多种方法可以将实例化的数量限制为恰好一个。其中最有用且因此最常用的单例形式之一是梅耶斯单例。⁴ 下面的 Database
类是作为梅耶斯单例实现的:
//---- <Database.h> ----------------
class Database final
{
public:
static Database& instance() 
{
static Database db; // The one, unique instance
return db;
}
bool write( /*some arguments*/ );
bool read( /*some arguments*/ ) const;
// ... More database-specific functionality
// ... Potentially access to data members
private:
Database() {} 
Database( Database const& ) = delete;
Database& operator=( Database const& ) = delete;
Database( Database&& ) = delete;
Database& operator=( Database&& ) = delete;
// ... Potentially some data members };
梅耶斯单例围绕着只能通过 public
、static
的 instance()
函数来访问 Database
类的单个实例这一事实进行。
#include <Database.h>
#include <cstdlib>
int main()
{
// First access, database object is created
Database& db1 = Database::instance();
// ...
// Second access, returns a reference to the same object
Database& db2 = Database::instance();
assert( &db1 == &db2 );
return EXIT_SUCCESS;
}
实际上,这个函数是获取 Database
的唯一方法:所有可能用于创建、复制或移动实例的功能都要么在 private
部分声明,要么显式地 delete
掉。⁵ 尽管这看起来相当直接,但一个实现细节特别有趣:注意默认构造函数是显式定义的,而不是 default
ed ()。原因在于,如果它是
default
ed,在 C++17 之前,可以通过空大括号创建一个空的 Database
实例,即通过值初始化:
#include <cstdlib>
class Database
{
public:
// ... As before
private:
Database() = default; // Compiler generated default constructor
// ... As before
};
int main()
{
Database db; // Does not compile: Default initialization
Database db{}; // Works, since value initialization results in aggregate
// initialization, because Database is an aggregate type
return EXIT_SUCCESS;
}
到 C++17,Database
类被视为一种聚合类型,这意味着可以通过聚合初始化来执行值初始化。聚合初始化忽略了默认构造函数,包括它是private
的事实,并简单地执行对象的零初始化。因此,值初始化使您仍然可以创建一个实例。然而,如果您提供了默认构造函数,则该类不再被视为聚合类型,这会阻止聚合初始化。⁶
instance()
函数是基于静态局部变量实现的。这意味着第一次控制通过声明时,变量以线程安全的方式初始化,并且在所有后续调用中跳过初始化。⁷ 在每次调用时,第一次和所有后续调用中,函数都会返回静态局部变量的引用。
已被证明
单例模式不管理或减少依赖关系
现在,想象一种可能的单例模式实现,让我们回到我声称单例模式不是设计模式的论断上。首先,让我们回顾一下设计模式的属性,我在“指南 11:理解设计模式的目的”中定义过:
引入一个抽象
-
有一个名称
-
Database
类的其余部分基本上就是你从代表数据库的类所期望的:有一些与数据库相关的public
函数(例如write()
和read()
),可能会有一些数据成员,包括访问函数。换句话说,除了instance()
成员函数和特殊成员之外,Database
只是一个普通的类。 -
具有一个意图
-
一个设计模式:
单例模式绝对有一个名称,并且它确实有一个意图。毫无疑问。我还会声称多年来已经证明了它的有效性(尽管可能会有怀疑的声音指出单例模式相当臭名昭著)。但是,单例模式没有任何形式的抽象:没有基类,没有模板参数,什么都没有。单例模式本身不代表抽象,也不引入抽象。事实上,它不关心代码的结构或实体之间的互动和依赖关系,因此它不旨在管理或减少依赖关系。⁸ 然而,这正是我定义的软件设计的一个组成部分。相反,单例模式专注于将实例化次数限制为一次。因此,单例模式不是设计模式,而仅仅是一种实现模式。
“那么为什么它在许多重要来源中被列为设计模式呢?”你问道。这是一个公平而好的问题。可能有三个答案。首先,在其他编程语言中,特别是在每个类都可以自动表示抽象的语言中,情况可能会有所不同。虽然我承认这一点,但我仍然相信单例模式的意图主要针对实现细节,而不是依赖关系和解耦。
其次,单例模式是非常常见的(尽管经常被误用),因此它绝对算得上是一种模式。由于单例模式存在于许多不同的编程语言中,看起来它并不只是 C++编程语言的一种习语。因此,把它称为设计模式似乎是合理的。这一系列论据可能对你来说听起来有道理,但我觉得它没有区分软件设计和实现细节的能力。这就是为什么在“Guideline 11: Understand the Purpose of Design Patterns”中,我引入了术语实现模式,以区分不同种类的与语言无关的模式,如单例模式。⁹
而且第三,我相信我们仍在理解软件设计和设计模式的过程中。对软件设计没有共同的定义。因此,我在“Guideline 1: Understand the Importance of Software Design”中提出了一个定义。设计模式也没有共同的定义。这就是为什么我在“Guideline 11: Understand the Purpose of Design Patterns”中提出了一个定义。我坚信我们必须更多地讨论软件设计和模式,以达成对必要术语的共识,尤其是在 C++中。
总之,你不应该使用单例模式来解耦软件实体。因此,尽管它在著名的 GoF 书籍中有描述,或者在Code Complete中,甚至在Wikipedia上列为设计模式,但它并不起到设计模式的作用。单例模式仅仅处理实现细节,因此你应该将其视为一种实现模式。
Guideline 38: 设计单例模式以便于变更和可测试性
单例模式确实是一个相当臭名昭著的模式:有很多声音认为单例模式是代码中的一般问题,是一种反模式,是危险的,甚至是邪恶的。因此,有很多建议避免使用这种模式,其中包括Core Guideline I.3:¹⁰
避免使用单例模式。
人们对单例模式不喜欢的一个主要原因是,它经常导致人为的依赖性并阻碍可测试性。因此,它与本书中两条最重要且最一般的指导原则相抵触:“指导原则 2:为变更设计”和“指导原则 4:为可测试性设计”。从这个角度来看,单例确实在代码中表现为一个问题,应该避免使用。然而,尽管有各种各样的善意警告,该模式仍然被许多开发人员坚持使用。这其中的原因是多方面的,但主要与两个事实有关:首先,有时候(我们可以认同有时候),表达某些东西只存在一次并且应该在代码中为许多实体提供服务是可取的。其次,有时候单例似乎是正确的解决方案,因为确实存在全局性方面需要表示。
因此,让我们做以下事情:与其争论单例模式总是不好和邪恶的,不如专注于那些我们需要在程序中表示全局方面的少数情况,并讨论如何正确表示这一方面,同时设计以支持变更和可测试性。
单例表示全局状态
单例通常用于表示程序中逻辑上和/或物理上仅存在一次且应该被许多其他类和函数使用的实体。¹¹ 常见的例子包括系统范围的数据库、日志记录器、时钟或配置。这些例子,包括术语系统范围,说明了这些实体的性质:它们通常代表全局可用的功能或数据,即全局状态。从这个角度来看,单例模式似乎是有道理的:通过防止所有人创建新实例,并强制所有人使用唯一的实例,您可以保证在所有使用实体中对这个全局状态的统一和一致访问。
然而,这种对全局状态的表述和引入解释了为什么单例通常被认为是一个问题。正如迈克尔·费瑟斯所表达的:¹²
单例模式是人们用来创建全局变量的机制之一。总的来说,全局变量是一个坏主意,原因有几个。其中一个原因是不透明性。
全局变量确实是一个坏主意,尤其是因为一个重要原因:术语变量表明我们正在讨论可变的全局状态。这种类型的状态确实可能会导致很多麻烦。明确地说,可变的全局状态是不被赞同的(总体上,特别是在多线程环境中),因为它难以控制访问,成本高昂,且可能难以保证正确性。此外,全局(可变)状态的阅读和写入访问通常在某些函数内部发生,这些函数根据其接口不会透露它使用全局状态的事实。最后但同样重要的是,如果您有几个全局变量,它们的生命周期彼此依赖,并且分布在几个编译单元中,您可能会面临静态初始化顺序混乱(SIOF)的问题。¹³ 显然,尽可能避免全局状态是有益的。¹⁴
然而,全局状态的问题是一个我们不能通过避免单例模式来解决的问题。这是一个普遍存在的问题,与任何特定的模式无关。例如,单例模式的相同问题也存在于单态模式,它强制执行单一的全局状态,但允许任意数量的实例化。¹⁵ 因此,相反地,单例模式可以通过限制对全局状态的访问来帮助处理全局状态。例如,正如 Miško Hevery 在他 2008 年的文章中解释的那样,提供单向数据流到或从某个全局状态的单例是可接受的:¹⁶ 实现记录器的单例只允许您写入数据,而不允许读取。代表系统范围配置或时钟的单例只允许您读取数据,而不允许写入,从而表示全局的常量。限制为单向数据流有助于避免许多与全局状态相关的常见问题。或者用 Miško Hevery 的话来说(我强调):¹⁷
适当使用“全局”或半全局状态可以极大地简化应用程序的设计[...]。
单例阻碍了可变性和可测试性
全局状态是单例的固有问题。然而,即使我们感到用单例来表示全局状态是合理的,也会有严重的后果:使用单例的函数依赖于表示的全局数据,因此变得更难更改和测试。为了更好地理解这一点,让我们重新审视来自“指南 37:将单例视为实现模式而非设计模式”的Database
单例,它现在被一些任意类,即Widget
和Gadget
,积极使用:
//---- <Widget.h> ----------------
#include <Database.h>
class Widget
{
public:
void doSomething( /*some arguments*/ )
{
// ...
Database::instance().read( /*some arguments*/ );
// ...
}
};
//---- <Gadget.h> ----------------
#include <Database.h>
class Gadget
{
public:
void doSomething( /*some arguments*/ )
{
// ...
Database::instance().write( /*some arguments*/ );
// ...
}
};
Widget
和Gadget
都需要访问系统范围的Database
。因此,它们调用Database::instance()
函数,随后调用read()
和write()
函数。
由于它们使用Database
并且依赖它,我们希望它们位于Database
单例的下层架构水平以下。这是因为,正如你从“准则 2:为变更而设计”中记得的那样,只有当所有的依赖箭头都指向高层次时,我们才能称之为一个适当的架构(参见图 10-2)。
图 10-2. 作为Singleton实现的Database
的期望依赖图
尽管这种依赖结构可能是可取的,不幸的是,它只是一个幻觉:Database
类并不是一个抽象,而是一个具体的实现,代表着对一个非常具体数据库的依赖!因此,真实的依赖结构是倒置的,类似于图 10-3。
实际的依赖结构完全不符合依赖倒置原则(DIP)(参见“准则 9:注意抽象物的所有权”):所有的依赖箭头都指向了更低层次。换句话说,当前没有软件架构!
图 10-3. 作为Singleton实现的Database
的实际依赖图
由于Database
是一个具体类而不是抽象,从所有代码到Database
类的具体实现细节和设计选择都存在强烈甚至是不可见的依赖。这可能——在最坏的情况下——包括对供应商特定细节的依赖,在整个代码中都变得可见,并且后续的更改变得极其困难甚至不可能。由于这个原因,代码变得更加难以改变。
同样要考虑这种依赖性对测试的严重影响。所有使用依赖于Database
单例的函数的测试都会依赖于这个单例。这意味着,例如,对于每一个使用Widget::doSomething()
函数的测试,你都必须提供唯一的Database
类。不幸的是,但也很简单的原因是,这些函数都没有提供替换Database
为其他内容的方法:无论是存根、模拟还是伪造都不行。¹⁸ 它们都把Database
单例当成了它们的闪亮宝贝。因此,可测试性受到严重影响,编写测试变得如此之难,以至于你可能会干脆不写测试。¹⁹
这个例子确实展示了单例模式的常见问题,以及它们引入的不幸人工依赖。这些依赖使系统更加僵化和刻板,从而更难以更改和测试。当然,这是不应该的。相反,应该轻松地用另一个数据库实现替换数据库实现,并且应该轻松地测试使用数据库的功能。正因为这些原因,我们必须确保Database
成为真正的实现细节,在适当架构的低级别上。²⁰
“但请稍等一下,您刚才说如果Database
只是一个实现细节,那就没有架构了,对吗?”是的,我说过。但现在我们无能为力:Database
单例并不代表任何抽象,也不能让我们完全处理依赖关系。单例模式并不是一个设计模式。因此,为了消除对Database
类的依赖并使架构正常工作,我们必须通过引入抽象并使用真正的设计模式来设计以便于变更和可测试性。为了实现这一点,让我们看一个例子,展示一个处理全局方面的良好方式,使用 C++标准库中的单例。
反转单例上的依赖关系
我回到了设计模式的真正埃尔多拉多,我曾多次用它来展示不同的设计模式:C++17 多态内存资源。
#include <array>
#include <cstddef>
#include <cstdlib>
#include <memory_resource>
#include <string>
#include <vector>
// ...
int main()
{
std::array<std::byte,1000> raw; // Note: not initialized!
std::pmr::monotonic_buffer_resource
buffer{ raw.data(), raw.size(), std::pmr::null_memory_resource() }; 
std::pmr::vector<std::pmr::string> strings{ &buffer };
// ...
return EXIT_SUCCESS;
}
在这个例子中,我们配置了std::pmr::monotonic_buffer_resource
,称为buffer
,只能使用给定的std::array
raw
中包含的静态内存()。如果这段内存用完,
buffer
将尝试通过其上游分配器获取新的内存,我们指定的是std::pmr::null_memory_resource()
。通过这个分配器分配将永远不会返回任何内存,而总是失败并抛出std::bad_alloc()
异常。因此,buffer
被限制在raw
提供的 1,000 字节内。
当你应该立即记住并认识到这是装饰器设计模式的一个例子时,它也充当了单例模式的一个例子:std::pmr::null_memory_resource()
函数每次调用时返回指向相同分配器的指针,因此作为std::pmr::null_memory_resource
的唯一实例的单一访问点。因此,返回的分配器充当单例。尽管这个单例不提供单向数据流(毕竟,我们既可以分配内存,也可以将其归还),但单例仍然感觉像一个合理的选择,因为它代表了一种全局状态:内存。
特别重要的是要注意,这个单例模式并不使您依赖于分配器的具体实现细节。恰恰相反:std::pmr::null_memory_resource()
函数返回一个指向std::pmr::memory_resource
的指针。这个类代表了所有种类分配器的基类(至少在 C++17 领域内),因此它充当了一个抽象。然而,std::pmr::null_memory_resource()
代表了一个具体的分配器选择,这是我们现在依赖的。虽然这个功能在标准库中,我们倾向于不认为它是一个依赖,但一般来说,它确实是:我们没有提供一个替换特定实现的机会。
如果我们将对std::pmr::null_memory_resource()
的调用替换为对std::pmr::get_default_resource()
的调用(),情况就会改变:
#include <memory_resource>
// ...
int main()
{
// ...
std::pmr::monotonic_buffer_resource
buffer{ raw.data(), raw.size(), std::pmr::get_default_resource() }; 
// ...
return EXIT_SUCCESS;
}
std::pmr::get_default_resource()
函数同样返回指向std::pmr::memory_resource
的指针,它代表了系统默认分配器的抽象。默认情况下,返回的分配器由std::new_delete_resource()
函数返回。但令人惊讶的是,可以通过std::pmr::set_default_resource()
函数自定义此默认行为:
namespace std::pmr {
memory_resource* set_default_resource(memory_resource* r) noexcept;
} // namespace std::pmr
利用此函数,我们可以将std::pmr::null_memory_resource()
定义为新的系统默认分配器():
// ...
int main()
{
// ...
std::pmr::set_default_resource( std::pmr::null_memory_resource() ); 
std::pmr::monotonic_buffer_resource
buffer{ raw.data(), raw.size(), std::pmr::get_default_resource() };
// ...
return EXIT_SUCCESS;
}
使用std::pmr::set_default_resource()
,您可以定制系统范围的分配器。换句话说,此函数使您能够注入对这个分配器的依赖。这让您想起了什么吗?这听起来很熟悉吗?我非常希望这让您思考到另一个重要的设计模式……鼓声……没错:策略设计模式。²¹
实际上,这是一种策略。使用这种设计模式是一个很好的选择,因为它对架构有着惊人的影响。虽然std::pmr::memory_resource
代表了所有可能的分配器的抽象,因此可以位于架构的高层,但任何具体的分配器实现,包括所有(供应商)特定的实现细节,可以位于架构的最低层。作为演示,考虑CustomAllocator
类的草图:
//---- <CustomAllocator.h> ----------------
#include <memory_resource>
class CustomAllocator : public std::pmr::memory_resource
{
public:
// There is no need to enforce a single instance
CustomAllocator( /*...*/ );
// No explicitly declared copy or move operations
private:
void* do_allocate( size_t bytes, size_t alignment ) override;
void do_deallocate( void* ptr, size_t bytes,
size_t alignment ) override;
bool do_is_equal(
std::pmr::memory_resource const& other ) const noexcept override;
// ...
};
注意,CustomAllocator
是公开继承自std::pmr::memory_resource
,以符合 C++17 的分配器要求。因此,您可以使用std::pmr::set_default_resource()
函数将CustomAllocator
实例设为新的系统默认分配器():
#include <CustomAllocator.h>
int main()
{
// ...
CustomAllocator custom_allocator{ /*...*/ };
std::pmr::set_default_resource( &custom_allocator ); 
// ... }
虽然std::pmr::memory_resource
基类位于架构的最高级别,但CustomAllocator
在最低级别逻辑引入(见 Figure 10-4)。因此,策略模式导致依赖反转(参见“指南 9:注意抽象所有权”):尽管分配器是单例,尽管表示全局状态,但你依赖的是抽象而不是具体实现细节。
图 10-4. 通过std::pmr::memory_resource
抽象实现的依赖反转
顺便说一句,值得一提的是,通过这种方法,你可以轻松地避免对全局初始化顺序的任何依赖(即 SIOF),因为你可以通过在单个编译单元中在堆栈上创建所有单例来明确管理初始化顺序:
int main()
{
// The one and only system-wide clock has no lifetime dependencies.
// Thus it is created first
SystemClock clock{ /*...*/ };
// The one and only system-wide configuration depends on the clock.
SystemConfiguration config{ &clock, /*...*/ };
// ...
}
应用策略设计模式
根据之前的例子,你现在应该有一个修复我们Database
示例的想法了。作为提醒,目标是保持Database
类作为默认的数据库实现,但将其作为实现细节,即删除对具体实现的所有依赖。你所需要做的就是应用策略设计模式,引入一个抽象,以及一个全局访问点和依赖注入的高级架构。这将使任何人(我真的是指任何人,因为你还要遵循开闭原则(OCP);参见“指南 5:设计以便扩展”)能够在最低级别引入自定义数据库实现(包括具体实现以及测试桩、模拟或伪造)。
因此,让我们引入以下PersistenceInterface
抽象():
//---- <PersistenceInterface.h> ----------------
class PersistenceInterface 
{
public:
virtual ~PersistenceInterface() = default;
bool read( /*some arguments*/ ) const 
{
return do_read( /*...*/ );
}
bool write( /*some arguments*/ ) 
{
return do_write( /*...*/ );
}
// ... More database specific functionality
private:
virtual bool do_read( /*some arguments*/ ) const = 0; 
virtual bool do_write( /*some arguments*/ ) = 0; 
};
PersistenceInterface* get_persistence_interface(); 
void set_persistence_interface( PersistenceInterface* persistence ); 
// Declaration of the one 'instance' variable extern PersistenceInterface* instance; 
PersistenceInterface
基类为所有可能的数据库实现提供接口。例如,它引入了read()
和write()
函数,根据std::pmr::memory_resource
类的示例分为public
接口部分和private
实现部分(和
)。²² 当然,实际情况下,它可能会引入几个更多的特定于数据库的函数,但让
read()
和write()
在本示例中足够了。
除了PersistenceInterface
,还将引入一个名为get_persistence_interface()
的全局访问点(),以及一个用于依赖注入的函数
set_persistence_interface()
()。这两个函数允许访问和设置全局持久性系统(
)。
Database
类现在从PersistenceInterface
基类继承,并实现了所需的接口(希望遵守里斯科夫替换原则(LSP);见“Guideline 6: Adhere to the Expected Behavior of Abstractions”):
//---- <Database.h> ----------------
class Database : public PersistenceInterface
{
public:
// ... Potentially access to data members
// Make the class immobile by deleting the copy and move operations
Database( Database const& ) = delete;
Database& operator=( Database const& ) = delete;
Database( Database&& ) = delete;
Database& operator=( Database&& ) = delete;
private:
bool do_read( /*some arguments*/ ) const override;
bool do_write( /*some arguments*/ ) override;
// ... More database-specific functionality
// ... Potentially some data members
};
在我们的特定设置中,Database
类代表默认的数据库实现。我们需要在未通过set_persistence_interface()
函数指定其他持久性系统时创建数据库的默认实例。然而,若在创建Database
之前已将任何其他持久性系统建立为全系统数据库,则不得创建实例,因为这会导致不必要和不幸的开销。通过实现具有两个静态局部变量和立即调用的 Lambda 表达式(IILE)的get_persistence_interface()
函数,实现了此行为():
//---- <PersistenceInterface.cpp> ----------------
#include <Database.h>
// Definition of the one 'instance' variable PersistenceInterface* instance = nullptr;
PersistenceInterface* get_persistence_interface()
{
// Local object, initialized by an
// 'Immediately Invoked Lambda Expression (IILE)'
static bool init = [](){ 
if( !instance ) {
static Database db;
instance = &db;
}
return true; // or false, as the actual value does not matter.
}(); // Note the '()' after the lambda expression. This invokes the lambda.
return instance;
}
void set_persistence_interface( PersistenceInterface* persistence )
{
instance = persistence;
}
当执行流程第一次进入get_persistence_interface()
函数时,静态局部变量init
被初始化。若此时instance
已被设置,则不会创建Database
。然而,若未设置,Database
实例将作为另一个静态局部变量在 lambda 内创建,并绑定到instance
变量:
#include <PersistenceInterface.h>
#include <cstdlib>
int main()
{
// First access, database object is created
PersistenceInterface* persistence = get_persistence_interface();
// ...
return EXIT_SUCCESS;
}
此实现实现了期望的效果:Database
成为实现细节,其他代码不依赖它,可以随时通过自定义数据库实现进行更改(参见图 10-5)。因此,尽管Database
是单例,但它不引入依赖关系,并且可以轻松更改和替换以进行测试目的。
图 10-5. 重构后的非Singleton Database
的依赖图
“哇,这是一个很棒的解决方案。我打赌我可以在自己的代码库中的几个地方使用它!”你说道,面带印象深刻和感激的表情。“但我看到一个潜在的问题:由于我必须从一个接口类继承,这是一个侵入性的解决方案。如果我不能更改给定的单例类,我该怎么办?”嗯,在这种情况下,你有两个非侵入式的设计模式可供选择。要么你已经有一个继承层次结构,那么你可以引入一个适配器来包装给定的单例(参见“指导原则 24:使用适配器标准化接口”),要么你还没有现成的继承层次结构,那么你可以将外部多态设计模式充分利用(参见“指导原则 31:使用外部多态实现非侵入式运行时多态”)。
“好的,但我看到另一个更严重的问题:这段代码真的是线程安全的吗?”老实说,不,它不是。举个可能出现问题的例子:可能会发生在第一次调用get_persistence_interface()
时(由于设置Database
实例而可能需要一些时间),此时调用了set_persistence_interface()
。在这种情况下,要么Database
被徒劳地创建,要么set_persistence_interface()
的调用被丢失。然而,或许令人惊讶的是,我们不需要解决这个问题。原因在于:记住instance
代表全局状态。如果我们假设set_persistence_interface()
可以从代码的任何地方随时调用,通常我们不能期望在调用set_persistence_interface()
之后,调用get_persistence_interface()
会返回已设置的值。因此,在代码的任何地方调用set_persistence_interface()
函数就像是在某人脚下掀地毯一样。这可以与对任何 lvalue 调用std::move()
相比较:
template< typename T >
void f( T& value )
{
// ...
T other = std::move(value); // Very bad move (literally)!
// ...
}
从这个角度来看,set_persistence_interface()
函数应该在程序的最开始或单个测试的开始时使用,而不是任意使用。
“我们不应该确保set_persistence_interface()
函数只能调用一次吗?”你问道。我们当然可以这样做,但这会人为地限制其用于测试目的:我们将无法在每次测试的开始重置持久化系统。
向本地依赖注入迈进
“好的,我明白了。最后一个问题:由于这个解决方案涉及可以更改的全局状态,是否使用更直接和更本地的依赖注入到低级类会更好呢?考虑一下对Widget
类的以下修改,它在构造时就获得了其依赖项:”
//---- <Widget.h> ----------------
#include <PersistenceInterface.h>
class Widget
{
public:
Widget( PersistenceInterface* persistence ) // Dependency injection
: persistence_(persistence)
{}
void doSomething( /*some arguments*/ )
{
// ...
persistence_->read( /*some arguments*/ );
// ...
}
private:
PersistenceInterface* persistence_{};
};
我完全同意你的观点。这可能是解决全局状态问题的下一步。然而,在分析这种方法之前,请记住,这个想法只是一个选择,因为我们已经反转了依赖关系。通过在我们架构的高层引入抽象,我们突然有了选择,并且可以讨论替代方案。因此,第一步,也是最重要的一步,是正确管理依赖关系。但回到你的建议:我真的很喜欢这种方法。Widget
类的接口变得更加“诚实”,清楚地显示出其所有依赖项。由于依赖关系通过构造函数参数传递,依赖注入变得更加直观和自然。
或者,你可以直接将依赖项传递给Widget::doSomething()
函数:
//---- <Widget.h> ----------------
#include <PersistenceInterface.h>
class Widget
{
public:
void doSomething( PersistenceInterface* persistence, /*some arguments*/ )
{
// ...
persistence->read( /*some arguments*/ );
// ...
}
};
虽然这种方法对于成员函数可能不是最佳选择,但对于自由函数而言,这可能是唯一的选择。此外,通过明确声明其依赖项,函数变得更加“诚实”。
然而,直接依赖注入也有其缺点:在大型调用堆栈中,这种方法可能很快变得笨拙。通过多层软件堆栈传递依赖项,以便在需要它们的地方使用它们,既不方便也不直观。此外,特别是在存在多个单例模式的情况下,这种解决方案很快变得复杂:通过多层函数调用传递PersistenceInterface
、Allocator
和系统级Configuration
,只是为了能够在最低级别上使用它们,确实不是最优雅的方法。因此,您可能希望结合提供全局访问点和局部依赖注入的思想,例如通过引入包装器函数:
//---- <Widget.h> ----------------
#include <PersistenceInterface.h>
class Widget
{
public:
void doSomething( /*some arguments*/ ) 
{
doSomething( get_persistence_interface(), /*some arguments*/ );
}
void doSomething( PersistenceInterface* persistence, /*some arguments*/ ) 
{
// ...
persistence->read( /*some arguments*/ );
// ...
}
};
虽然我们仍然提供之前的doSomething()
函数(),但现在我们额外提供了一个重载版本,它接受一个
PersistenceInterface
作为函数参数()。第二个函数完成所有工作,而第一个函数现在仅充当一个包装器,注入了全局设置的
PersistenceInterface
。在这种组合中,可以进行本地决策并局部注入所需的依赖,但同时不必通过多层函数调用传递依赖项。
但是,说实话,虽然这些解决方案在这个数据库示例以及内存管理的上下文中可能非常有效,但它可能并不是每一个单例问题的正确方法。因此,不要认为这是唯一可能的解决方案。毕竟,这取决于具体情况。然而,它是软件设计的一般过程的一个很好的例子:识别引起变化或依赖关系的方面,然后通过提取合适的抽象来分离关注点。根据您的意图,您只需应用一个设计模式。因此,请考虑相应地命名您的解决方案,并留下您的推理痕迹供其他人参考。
总之,单例模式确实不是那些光彩夺目的设计模式之一。它带来了太多的缺点,尤其是全局状态的通常缺陷。但是,尽管有许多负面因素,如果明智使用,单例模式在某些情况下仍然可以成为代表代码中少数全局方面的正确解决方案。如果确实如此,请优先选择具有单向数据流的单例,并通过反转依赖关系和使用策略设计模式实现依赖注入,设计您的单例以实现可更改性和可测试性。
¹ Steve McConnell,《代码大全:软件构建实用手册》,第 2 版(Microsoft Press,2004)。
² “There can be only one” 是 1986 年电影《高地人》(链接),由克里斯托弗·兰伯特主演的标语。
³ Erich Gamma 等人,《设计模式:可复用面向对象软件的基础》。
⁴ Meyers 的单例模式详见 Scott Meyers 的《Effective C++》第 4 条。
⁵ 我知道显式处理复制和移动赋值运算符似乎有些过度,但这让我有机会提醒您关于五则法则。
⁶ 在 C++20 中,此行为已更改,因为任何用户声明的构造函数现在足以使类型非聚合。
⁷ 精确地说,为了避免投诉,如果静态局部变量是零或常量初始化的,则可以在进入函数之前进行初始化。在我们的示例中,该变量确实是在第一次通过时创建的。
⁸ 实际上,单例的天真实现本身就创建了大量的人为依赖关系;参见“指导方针 38:为变更和可测试性设计单例”。
⁹ 不详细展开,我认为还有几个所谓的“设计模式”属于实现模式类别,例如Monostate模式、Memento模式和RAII idiom,维基百科将其列为设计模式。虽然在 C++之外的语言中可能有意义,但 RAII 的目的绝对不是减少依赖关系,而是自动化清理并封装责任。
¹⁰ Peter Muldoon 的 CppCon 2020 演讲“退休单例模式:替代方案具体建议”也给出了许多在代码库中处理单例模式的有用技术。
¹¹ 如果单例模式用于其他任何目的,你应该非常怀疑,并认为这是对单例模式的误用。
¹² Michael Feathers,《与遗留代码的有效工作》。
¹³ 我知道的 SIOF 的最佳总结由 Jonathan Müller 在他名为“Meeting C++ 2020”的演讲中给出。
¹⁴ “全局变量是坏事,明白了吗?”如 Guy Davidson 和 Kate Gregory 在Beautiful C++: 30 Core Guidelines for Writing Clean, Safe, and Fast Code(Addison-Wesley)中所述。
¹⁵ 至我所知,单态模式首次出现在 1996 年 9 月号的C++ Report的文章“单态类:一个的力量”中,由 Steve Ball 和 John Crawford 提到(参见 Stanley B. Lippmann 编辑的More C++ Gems(Cambridge University Press))。Martin Reddy 的API Design for C++(Morgan Kaufmann)中也有描述。与 Singleton 不同,Monostate 允许类型的任意数量实例,但确保所有实例只有一个状态。因此,该模式不应与std::monostate
混淆,后者用作std::variant
中的行为良好的空替代品。
¹⁶ Miško Hevery 在 2008 年 8 月的博客“单例模式的根本原因”,The Testability Explorer中提到。
¹⁷ 同上。
¹⁸ 关于不同类型的测试替身的解释,请参阅 Martin Fowler 的文章“Mocks Aren’t Stubs”。如何在 C++中使用这些示例,请参阅 Jeff Langr 的Modern C++ Programming with Test-Driven Development。
¹⁹ 但我确信,即使很难,你也不会被阻止写测试。
²⁰ 这也是 Robert C. Martin 在Clean Architecture中的一个强有力论据。
²¹ 对于设计模式专家,我应明确指出,std::pmr::get_default_resource()
函数本身实现了另一种设计模式的意图:Facade 设计模式。不幸的是,在本书中我未详细讨论 Facade。
²² 将公共接口和私有实现分离是模板方法设计模式的一个例子。不幸的是,在本书中我无法详细介绍这种设计模式的许多好处。
第十一章:最后的指导原则
还有一个指导原则,一个我可以给予你的建议。所以在这里:最后的指导原则。
指导原则 39:继续学习设计模式
“就这样?这就是你所能给予的全部吗?来吧,还有那么多其他的设计模式。我们几乎没有涉及到表面!”你说。好吧,老实说,你完全正确;我无法对此做出任何补充。但是为了辩护,我原本计划介绍更多的模式,直到现实打击我:在一本有 400 页的书中,你可以容纳的信息是有限的。但不要担心:在这 400 页中,我已经带领你走过了任何设计中最重要的建议,这些建议将在您的软件开发生涯中的任何地方、任何时候都需要:
最小化依赖关系
处理依赖关系是软件设计的核心。无论您编写什么类型的软件,如果您真正希望使其持久,您将不得不处理依赖关系:必要的依赖关系,但主要是人为的依赖关系。当然,您的主要目标是减少依赖关系,甚至希望将其最小化。为了实现这一目标,您将不可避免地涉及设计模式。
分离关注点
这可能是您从本书中学到的最重要、最核心的设计指导原则。分离关注点,您的软件结构将变得更加清晰,更容易理解、更易于修改和测试。所有的设计模式,毫无例外,都为您提供了一种分离关注点的方式。模式之间的主要区别在于它们分离关注点的方式,它们的意图。尽管设计模式在结构上可能相似,但它们的意图始终是独特的。
更倾向于组合而非继承
虽然继承是一个强大的特性,但许多设计模式的真正优势源于构建在组合之上。例如,策略设计模式,这是一个被广泛应用的模式(希望现在这一点已经显而易见),主要是基于组合来分离关注点,但同时也为您提供了使用继承来扩展功能的选项。桥接、适配器、装饰器、外部多态性和类型擦除也是如此。
更倾向于非侵入性设计
真正的灵活性和可扩展性是当不需要修改现有代码而只需添加新代码时才会出现。因此,任何非侵入性的设计都优于侵入性修改现有代码的设计。因此,装饰器、适配器、外部多态性和类型擦除等设计模式是您设计模式工具箱中如此宝贵的补充。
更倾向于值语义而非引用语义
为了保持代码简单、易于理解,并远离空指针、悬空指针、生命周期依赖等问题,您应该更倾向于使用值而不是指针和引用。而 C++是一个非常适合这个目的的语言,因为 C++严肃对待值语义。它允许您作为开发者在值语义的领域过上幸福的生活。令人惊讶的是,正如我们在std::variant
和类型擦除中看到的,这种哲学不一定会带来负面的性能影响,甚至可能提高性能。
除了这些关于软件设计的一般建议,您已经对设计模式的目的有了深入的了解。现在您知道什么是设计模式了。
设计模式:
-
有一个名称
-
承载一种意图
-
引入一种抽象
-
已被证明
拥有这些信息,您将不再受到一些关于某些实现细节是设计模式的虚假说法的影响(在我的职业生涯中多次遇到),例如智能指针(std::unique_ptr
、std::shared_ptr
等)或工厂函数(如std::make_unique()
)是设计模式的实现。此外,您现在熟悉几种最重要和最有用的设计模式,这将一次又一次地证明其用处:
Visitor
要在一组封闭类型上扩展操作,请考虑访问者设计模式(可能通过std::variant
实现)。
Strategy
要配置行为并从外部“注入”它,请选择策略设计模式(又称基于策略的设计)。
Command
要从不同类型的操作中抽象出来,可能是可撤销操作,请使用命令设计模式。
Observer
要观察实体状态变化,请选择观察者设计模式。
Adapter
要非侵入地将一个接口适配到另一个接口,请使用适配器设计模式。
CRTP
要获得无虚函数的静态抽象(并且目前你还不能使用 C++20 的概念),那么应用 CRTP 设计模式。CRTP 可能也会被证明对创建编译时混入类非常有用。
Bridge
要隐藏实现细节并减少物理依赖,利用桥接设计模式。
Prototype
要创建虚拟副本,原型设计模式是正确的选择。
External Polymorphism
要通过添加外部多态行为来促进松散耦合,请记住外部多态设计模式。
Type Erasure
要结合值语义的优势实现外部多态性,考虑使用类型擦除设计模式。
Decorator
要非侵入地向对象添加责任,请选择装饰器设计模式的益处。
然而,设计模式还有更多。非常多!还有许多重要且有用的设计模式。因此,你应该继续学习设计模式。而且有两种方法可以做到这一点。首先是了解更多模式:了解它们的意图,以及与其他设计模式相比的相似之处和差异。此外,不要忘记设计模式关注的是依赖结构,而不是实现细节。其次,你还应该更好地理解每个模式,并体验它们的优势和不足。为此,要密切关注你所工作的代码库中使用的设计模式。我向你保证,你会找到许多设计模式:任何试图管理和减少依赖关系的尝试都很可能是设计模式的证明。所以是的,设计模式无处不在!
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
2022-06-18 ApacheCN 校对活动参与手册
2022-06-18 # ApacheCN 校对活动参与手册