C--设计模式的现实世界实现-全-
C# 设计模式的现实世界实现(全)
原文:
zh.annas-archive.org/md5/11e871544054de0c7d4c4740ac456492译者:飞龙
前言
这是一本关于设计模式的书籍,它是在 C#编程语言的背景下编写的。我知道你可能正在想什么。这究竟是什么意思呢?好吧,我可以告诉你,但那样就会破坏这本书的剩余部分,相信我,那部分内容非常精彩!
设计模式是一套针对软件问题中经常出现的最佳实践,我们可以学会识别它们,并立即知道如何解决它们。这些模式中发现的重复问题的解决方案已经使用了数十年,并且已被证明是有效的。
由于模式无处不在,它们也成为了开发者的战斗语言。这个想法来源于流行的电视和电影系列《星际迷航》。在《星际迷航》中,被称为克林贡人的战士种族有两种语言。他们在克林贡幼儿园学习的是常规的克林贡语,而在战斗中使用的则是简化的版本。短语“装载鱼雷管 1 和 2 并发射全弹”可以缩减为一到两个词。所有克林贡人都知道这个短语的意思,他们之所以能赢得战斗,是因为他们比语言能力受限的对手快了几秒钟。同样,你可以说“就使用装饰者模式。”任何研究过模式的开发者都会知道接下来该做什么。
模式并不特定于 C#语言。然而,为了有效地学习模式,你需要一种实现语言。关于模式的原始书籍是由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 共同撰写的,他们合称为四人帮,简称GoF。他们的书《设计模式:可复用面向对象软件元素》通常被称为GoF 书。
GoF(设计模式:可复用面向对象软件元素)这本书是在 1994 年出版的,这使得它成为了一个技术上的恐龙。虽然它可能已经过时了,但书中解释的模式仍然非常相关。我对 GoF 书的批评是它采用了非常学术的格式,而且它的实现语言是你现在很少见到的。实现语言是一个重要的细节。GoF 书涵盖了 23 个模式,这与肯德基原味秘制配方中使用的香料和草药数量相同。这不会是巧合。这本书并没有涵盖所有 23 个模式,这很好,因为太多的炸鸡对你的健康并不好。我已经专注于你在大多数你参与的项目中每天都会用到的模式。我在书的末尾的第八章中简要地介绍了剩余的部分。
有些书籍试图使用伪代码来使其通用。我认为这样的书籍对大多数人来说没有用。如果你像我一样,你在这方面可能也是这样,你希望看到一个易于阅读、不太聪明且基于现实世界的代码示例。那些试图用“类 A继承自类 B,而类 B依赖于类 C”之类的短语教你模式的书籍和博客网站过于模糊,无法使用。同样令人烦恼的是那些试图用 20 种语言展示模式的书籍和网站。它们通常对这 20 种语言都做得很差,而不是专注于只做好一种。我只使用 C#和统一建模语言(UML)。
如果你之前从未听说过 UML,不要让它吓到你。UML 是一种用于创建图表的约定。有 14 种 UML 图表类型。我只使用一种图表类型:类图。我包括了一个入门指南在附录 2,如果你对 UML 不熟悉,这将有所帮助。
这本书是关于现实世界的,或者至少是一个非常接近的复制品。我写这本书使用了在真实软件项目中使用的相同技术。书中包含了解决真实商业问题的实际代码。还包括了设计错误,以及解决问题的深思熟虑的方法。
学术书籍的另一个问题是它们冗长且难以阅读。我努力将枯燥的主题变得不无聊。我意识到没有人故意写一本无聊的书。不幸的是,很多人做到了。我认为这种无聊的主要原因在于许多科技作者看待写书任务的方式。我认为许多作者写书是为了证明自己有多聪明。这些书往往非常学术化。它们旨在给其他学者留下深刻印象。那很好!世界需要学者。大多数开发者并不是专业的学者。我敢说,很多开发者从未正式上过计算机科学课程。我写这本书的目的不是证明我有多聪明或有多能干。我的妻子会告诉你,我总是过分地自我贬低。相反,我的目的是帮助你越过那堵阻碍你提升编程水平的墙。我必须独自攀登这堵墙,对我来说,这并不容易。然而,如果我能做到,你也能,在我的帮助下。
与枯燥的学术处理方式不同,这本书讲述了一个在现实世界中可能发生的故事。故事中涉及的科学幻想元素恰到好处,使其“仅仅是一个故事”。然而,故事中的情况非常真实,即使你在该领域工作了几年,你也会认出它们。
关于这一点,我想澄清几点。这本书中的故事是虚构的。虽然我是一个出色的软件工程师,但我不是机器人学家或机械工程师,也不是合格的自行车修理工。在即将到来的故事中的几个点上,如果你对这些主题很熟悉,你可能需要暂时放下你的怀疑。
这本书面向的对象
这本书是为任何想要成为更好的软件开发者的人而写的。我希望我可以说得简单一些,但可能不行。让我再补充几点,关于谁会从这本书中受益。
简单的回答是,我称之为中级开发者。这些开发者拥有几年的 C#经验,并且对面向对象编程的基本原则非常熟悉。理想情况下,你已经看过一些 UML 类图。
这本书的另一个受益者是正在学习 C#的学生。如果你对基本面向对象概念,如继承和组合,感到稍微舒适,并且熟悉集成开发环境(IDE),我希望你阅读这本书。当然,中级开发者可能会更容易一些,但学习模式和 SOLID 原则会给你打下坚实的基础。你可能会避免养成一些坏习惯,或者纠正你已经学到的那些。
我也鼓励你阅读这本书,如果你是大学或代码训练营的应届毕业生。如果你没有做过很多 C#工作,但你在 Java、C++、Python 或 JavaScript 等其他语言中工作过,我也邀请你阅读这本书。我在这本书的附录 1中包含了一段关于 C#和面向对象编程概念的详细入门,希望能给你一些帮助。
我最想接触的一个群体。
我特别希望鼓励像我这样的自学开发者。这个群体的人往往只学习绝对必要的知识,作为生存当前冲刺的手段。如果你的老师是 YouTube 和博客圈,那么你很可能会迅速识别出第一章中发现的反模式,因为你现在可能已经犯过与软件工程相关的每一个错误。我只知道这一点,因为我犯过。因此,我知道你从阅读这本书中受益最大。
正如我说的,这本书是为任何想要成为更好的软件开发者的人而写的。我想我应该坚持这一点。
这本书涵盖的内容
第一章,你的意大利面盘上有一大团泥巴:在我们深入研究模式之前,让我们先探讨一下为什么我们需要它们。软件开发的世界非常混乱,但不必如此。混乱源于我们工作中的一系列退化力量,你一定会认识到的。
第二章,为 C#中模式在实际现实世界应用中的准备:为了克服第一章中提到的退化力量,你必须提高你的水平。这一章提出了一些规则和原则。如果你能坚持这些规则,你将拥有使用设计模式达到最大效果所需的纪律。
第三章,运用创建型模式进行创新:现在你已经充分准备,本章介绍了我们的故事。它涵盖了设计模式,旨在使你的类实例化更加健壮和灵活。阅读本章后,你将不会再以同样的方式看待new关键字。
第四章,用结构模式加固你的代码:本章介绍了你可以用来为你的类结构化以实现最大灵活性同时遵守在第二章中提到的 SOLID 原则的技术。
第五章,通过应用行为模式处理问题代码:你有算法吗?你需要一组灵活的模式来最大化其效果和灵活性。你需要行为模式。
第六章,在编码前使用模式进行设计:在本章中,我们考虑了在 IDE 中编写第一行代码之前使用模式来设计我们的代码的方法。在我们的故事中发生了一些不幸的事件后,我们发现我们的公司急剧且迅速地改变方向。我们需要一个新的产品设计,而且我们需要它在上周!让我们先在 UML 中绘制我们的设计!这可以节省大量的时间和精力,并防止某些自以为是的老板告诉我们发货原型。
第七章,除了打字别无他法 – 实现轮椅项目:在上一个章节中,我们提出了一套优雅的设计图。在本章中,我们将进行打字。你将实现本书中早期学到的相同模式,但这次,你将在一个真实的项目中使用它们。
第八章,你已经了解了一些模式。接下来是什么?:到目前为止,我们已经愉快地学习了很多模式,但这只是冰山一角。模式无处不在!它们不仅限于面向对象实践。在本章中,我们涵盖了我们在故事中没有涉及到的 GoF 模式。
第九章,附录 1 – C#面向对象原则简要回顾:本附录是为那些刚接触 C#或可能有一段时间没有使用它,或者是从其他语言过来的读者设计的。
第十章,附录 2 – 统一建模语言入门:统一建模语言是软件开发者使用的文档规范。它定义了本书中使用的模式设计图的架构。虽然 UML 有 14 种不同的图类型,但我们实际上只使用类图。大多数关于模式的演示都有两个图。我画了一个通用的图,以及一个反映项目代码的第二张图。本附录展示了图中使用的规范。
为了最大限度地利用这本书
为了充分利用这本书,你应该熟悉 C#。你需要能够熟练使用三种流行的 IDE 之一:Visual Studio、Rider 或 Visual Studio Code。你还应该了解基本的面向对象编程原则,如抽象、继承、封装和组合。
我在这本书中并没有花很多时间来介绍如何使用你的集成开发环境(IDE)。然而,我确实包括了附录 1,它涵盖了如何创建项目,以防你有些生疏。这本书的设计并不是要逐步引导你通过一系列项目。示例项目中的代码并不重要。我们关注的是代码的结构,而不是类的内容。
这本书中的所有项目都是命令行或库项目。我们不会处理任何前端或用户界面代码。这样做是为了减少项目中的噪音水平。我希望你专注于类的结构,而不是它们内部的内容,甚至不是程序真正在做什么。
我使用 Windows 10 创建了这本书中的代码。如果你想跟随书中的代码,你可能会使用 macOS 或 Linux。然而,我没有明确涵盖那些操作系统,也没有在其他操作系统上测试示例代码。
如果你打算与本书的英雄一起编码,你需要设置一个合适的 IDE 和 .NET Core 6 或更高版本。我使用 Rider 作为我的 IDE,但我在 Visual Studio 2022 和 Visual Studio Code 中验证了代码。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| C# 10 | Windows |
| .NET Core 6 | Windows |
| Rider、Visual Studio 或 Visual Studio Code | Windows |
如果你使用的是这本书的数字版,我们建议你亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助你避免与代码复制粘贴相关的任何潜在错误。
我强烈建议你亲手输入书中的代码。通过这样做,你会从打字中学习更多,通过犯错误,然后自己修复它们。
下载示例代码文件
你可以从 GitHub 下载这本书的示例代码文件,链接为 github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns。请注意,GitHub 不允许我们在 C# 中使用 # 字符,所以仓库的名称有些误导。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有来自我们丰富的图书和视频目录的其他代码包,可在 github.com/PacktPublishing/ 获取。查看它们!
这本书有一个由作者开发的配套网站。你可以在 csharppatterns.dev 找到它。
下载图片
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/3KWzG。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Kitty 首先为构建者将要生产的内容创建一个抽象——即产品。她创建了一个名为IBicycleProduct的接口。”
代码块设置如下:
public interface IBicycleProduct
{
public IFrame Frame { get; set; }
public ISuspension Suspension { get; set; }
public IHandlebars Handlebars { get; set; }
public IDrivetrain Drivetrain { get; set; }
public ISeat Seat { get; set; }
public IBrakes Brakes { get; set; }
}
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
namespace WheelchairProject;
public abstract class WheelchairComponent
{
任何命令行输入或输出都如下所示:
$ dotnet build
$ dotnet run hillcrest
小贴士或重要提示
看起来是这样的。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果你对本书的任何方面有疑问,请通过客户关怀@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
读完《C#设计模式的实际应用》后,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

第一部分:模式的介绍(意大利面)和反模式(反意大利面)
我们将在第一部分中描述模式是什么,它们从何而来,以及它们是如何工作的概述,以及为什么你想学习它们。
本部分涵盖了以下章节:
-
第一章,你的意大利面盘上有一大团泥巴
-
第二章,为 C#中模式的应用做准备
第一章:您的意大利面盘上有一大团泥巴
欢迎来到可能是您在岗位上最后一天。您的项目即将被取消。您的客户很生气。您的老板快要疯了。您的老板的老板正在安提瓜,但当她下周回来时,可能有人要丢官帽。没有方法可以美化这个事实。您可能想要更新您的简历,并复习一下您的算法,以便为即将到来的求职做好准备。
我们是如何走到这一步的?我们本来有一个计划。硬件架构很简单。最初的几版发布都进行得非常顺利,我们的用户都很满意。我们的客户甚至提出了一整套新的功能请求,并签署了合同延期。我们是如何发现自己站在必然且突然的灾难边缘的?
我们发现自己所处的这种情况远非独特。根据许多学术报告,六分之五的软件项目都被取消了。其他项目由于进度落后或超支而失败。软件项目很难。没有所谓的简单程序。实际上没有哪个项目可以在一周内完成,然后发货,就结束了。事情不是这样运作的。这种现象是软件行业特有的。设计桥梁的结构工程师在桥梁对公众开放交通时基本上就完成了。电气工程师在面包板上设计和测试电路,然后将这些设计转交给他人进行制造。像我的祖父这样的航空工程师,他们为比奇飞机设计动力装置(即发动机),通常设计并原型化发动机,但并没有做太多。其他人负责制造发动机,将其安装在飞机上,其他人负责维护发动机。
相比之下,软件工程师必须在持续交付环境中设计、构建、测试,并且经常维护他们开发的系统。许多项目永远不会“完成”。我过去 9 年一直在同一个软件项目上工作。我们当然没有构建一个具有完美架构的完美项目,但项目已经持续了下来。新的功能被开发出来,并且发现了错误并得到了修复。
什么使得那些持续运行多年的项目与大量被取消的项目有所不同?虽然这种情况可能以许多方式发生,但我们将专注于我们软件的设计和架构。我将从它经常出错的地方开始。遵循本书的标题,我们将在本章中花一些时间讨论一系列的反模式。虽然我还没有直接介绍模式的概念,但我怀疑您可以对它们以及它们的对立面做出有根据的猜测。模式,目前来说,是一个正式解释的、抽象的、最佳实践解决方案,用于解决常见的开发需求。反模式是一个正式的例子,说明了您不应该做什么。模式可以说是好的。反模式无疑是坏的。
本章将介绍一些最常见的反模式,包括以下内容:
-
煤气管系统
-
大泥球
-
金锤
一旦我们了解了一些反模式,在后面的章节中,我们将重点关注旨在对抗和纠正反模式已经或可能很快占据主导地位的情况的原则和模式。在 第二章 中,为 C#中模式在现实世界中的应用做准备,我将为你与模式的工作做好准备。软件开发是一个奇怪的行业,我们每个人都有不同的道路来到这里。我本人是自学成才的。我 12 岁时就开始了。关于计算机编程的唯一书籍可以在 Radio Shack 购买。大约有十几本。我们没有像 Packt Publishing 这样在市场上用关于软件开发各个方面的迷人而有用的书籍来吸引人的资源。1991 年,我大学毕业那年,计算机科学学位将专注于使用 FORTRAN 为主机开发软件,这与我现在的工作相去甚远。我在 1987 年上的主机编程课程是最后一个使用穿孔卡的课程。如果你不确定它们是什么,去查查。我会等你。你回来了吗?你害怕了吗?我也是。重点是,像我这样的人还有很多,他们出于必要性学习编程,而且是非正式地学习的。
现在有很多受过大学教育的软件开发者,但并非所有软件开发课程都是一样的。计算机科学课程侧重于数学理论和算法开发等元素,但只教授很少的实践内容。软件工程课程、训练营和贸易学校则更侧重于工程,在那里你学习如何构建软件,而较少关注理论。无论你从哪里开始,第二章 的目标是确保你理解在运用模式时所需的最重要形式化工程概念。模式是通过一系列规则创建的,第二章 讲述了这些规则。
在 第三章、第四章 和 第五章 中,我们将使用故事格式认真介绍模式。我这样做是为了创造一种与我自己阅读的一些更严厉的学术设计模式处理方式截然不同的学习体验。我为这本书选定的模式来自可能是软件行业模式的开创性工作,即 Eric Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的 Design Patterns: Elements of Reusable Object-Oriented Software。这四位作者共同被称为 四人帮 (GoF),他们所写的书通常被称为 The GoF book 或简称为 GoF。The GoF book 包含 23 个模式,分为三个类别:
-
创建模式处理对象的创建,而不仅仅是使用 new 关键字。
-
结构模式处理你如何结构化你的类以最大化灵活性、减少紧密耦合并帮助你关注可重用性。
-
行为模式处理对象之间如何交互。
我将在第三章中涵盖以下创建模式:
-
简单工厂(技术上不是一个模式)
-
工厂方法模式
-
抽象工厂模式
-
建造者模式
-
对象池模式
-
单例模式
在结构模式的领域内,在第四章中,我将涵盖以下内容:
-
装饰者模式
-
外观模式
-
组合模式
-
桥接模式
实际的模式覆盖将在第五章中通过这一组行为模式结束:
-
命令模式
-
迭代器模式
-
观察者模式
-
策略模式
再次指出,这本书的设计是为了关注现实世界的软件开发。在现实世界中,我们并不总是完美地遵循在第二章中提出的规则。许多书籍展示了一个完美的体验。专家作者总是将他们的所有示例第一次尝试就展示为完美的。我不会这样做,因为这不符合现实。当我们进入第三章、第四章和第五章时,我们会发现自己面临实际实践中会遇到的“陷阱”。没有回避的办法。即使你完美地执行这些章节中的模式和策略,也没有人能预测未来。在第六章中,我们的故事中出现了一个转折,我们有机会重新思考我们之前所做的一切。在第六章中,我们将基于我们迄今为止学到的模式设计一个新的系统。第六章完全是关于创建设计和计划。在第七章中,我们实施这个计划。
我不会涵盖 GoF 所涵盖的所有模式。相反,我将专注于你作为在现实世界中工作的 C# .NET 软件开发者最可能需要的模式。我已经根据流行度、有用性和复杂性选择了我的模式列表。那些在野外不常看到且复杂的模式已被从正文的主文中省略。话虽如此,我在第八章中回顾了那些我没有涵盖的模式,并给出了通常的建议,告诉你接下来该怎么做。
本书假设您有几年使用 C#的经验。除了我的日常工作外,我在过去 25 年里一直在大学教授软件开发。我现在在南方卫理公会大学的全栈代码训练营教授。我教授的一些课程侧重于 C#,而其他课程则不是。在 SMU,我们教授 JavaScript。如果您没有最近的 C#经验,或者可能完全没有,我在书的末尾添加了附录 1。它旨在为您提供足够的 C#语言导向,以便使本书的其余部分有用。关于模式的真实情况是它们是语言无关的。它们适用于任何面向对象的语言,我甚至看到一些模式被强行应用到非面向对象的语言中,并且有争议的成功程度。
技术要求
本章提供了一些代码示例。大多数书籍总是提供一些示例代码,这些代码对于你在创建项目时遵循是有意义的。本章中的代码故意很糟糕。您不一定需要通过创建项目来跟随,但如果您愿意,欢迎这样做。
如果是这样,您将需要以下内容:
-
运行 Windows 操作系统的计算机。我使用的是 Windows 10。由于项目是简单的命令行项目,我相当确信这里的一切也应在 Mac 或 Linux 上工作,但我还没有在这些操作系统上测试过这些项目。
-
一个受支持的 IDE,例如 Visual Studio、JetBrains Rider 或带有 C#扩展的 Visual Studio Code。我使用的是 Rider 2021.3.3。
-
一些版本的.NET SDK。再次强调,项目足够简单,我们的代码不应该依赖于任何特定版本。我恰好使用的是.NET Core 6 SDK,我的代码的语法可能反映了这一点。
-
一个 SQL Server 实例和基本的 SQL 知识。我想重申,本章中的代码旨在成为可丢弃代码的现实示例。C#和 SQL Server 就像花生酱和果酱一样搭配,这增加了现实感。一些读者可能不习惯在 SQL Server 中工作,尤其是没有使用Entity Framework(EF)进行展示的情况下。这本书中唯一提到数据库的地方就是这里。如果您没有数据库经验,请不要担心。示例的真正目的是阅读而不是尝试。如果您想尝试,任何版本的 SQL Server 都应该可以工作。我将使用 SQL Server 2019。
您可以在 GitHub 上找到本章的代码文件,链接为github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns/tree/main/chapter-1/。
没有战斗计划能在与敌人首次接触后幸存
有句老话:“如果你没有计划,你就是在计划失败”。只有最业余的人才会在没有至少考虑项目应该如何结构的情况下,首先使用项目 IDE 进行项目。典型的第一步可能包括草拟一个包和对象结构,或者设计一个将持久化我们软件所使用数据的关系数据库的结构。那些有过几个项目经验的人甚至可能会用统一建模语言(UML)绘制一些图表。
我们首先从一组用户故事开始,将我们的代码塑造成表面上满足我们面前要求的样子。很快,我们就进入了敏捷的节奏。我们实现了速度!我们创建了一个功能,向客户展示,获取反馈,修改,并持续交付。通常,麻烦就是这样开始的。
我们遇到的第一大反模式,即烟囱系统,来源于该主题的开创性书籍,布朗等人所著的《反模式》(AntiPatterns),我在本章末尾将其列为推荐阅读。
烟囱系统
从前,在几乎任何工业化社会中,人们使用铸铁的大肚子炉子来供暖和烹饪。这些炉子以煤或木材为燃料。随着时间的推移,炉子的排气口,即烟囱,因为它是从炉子中伸出的管道,会积累腐蚀性沉积物,导致烟囱泄漏。在狭小的封闭空间内,燃烧炉子的烟雾可能具有致命的风险。
这里展示的是真正的烟囱的样子:

图 1.1 – 带有烟囱的炉子。
烟囱需要持续的维护以防止窒息。这通常由炉子的所有者来完成,他们不太可能是炉子维修专业人士。炉子的维修使用的是随手可得的工具和材料。这导致了非常随意的修补工作,而不是使用原始设备制造商(OEM)级材料和适当的工具完成的干净、经过深思熟虑的维修。
现在,想想这如何与一个软件项目相关。初始发布是精心设计的,实现与设计完美匹配。在软件维护期间,自然趋势是快速修复问题,并尽快发布修补版本。就像我们业余的炉子维修一样,我们对软件设计和实现的漏洞的分析是草率的和不完整的。有压力要快速解决这个问题。每个人都看着你。应用程序每分钟“宕机”都会给公司造成损失,你还有可能失去“本月员工”的停车位。
这发生在每个人身上,而且每个人通常都会屈服于人性的弱点。然后,你实施你能想到的最快、最简单的事情——它完成了,补丁已经发布出去。危机结束了。
或者是吗?咚咚咚!小型临时修复随着时间的推移会产生负面的累积效应,这被称为技术债务,就像管道上的腐蚀性沉积物一样。你如何判断你正在工作的系统是否是管道式系统?让我们探讨以下内容:
-
管道式系统由于其本质上是单一的。很难将数据输入或输出到这种系统中,并且将这种方式的软件集成到更大的企业架构中既繁琐又不可能。
-
管道式系统非常脆弱。当你对这些小型的临时修复进行操作时,通常会发现修复会破坏应用程序的其他部分。通常,这种破坏直到修复后的版本发布后才会被发现。
-
随着新的业务需求的产生,管道式系统难以轻松扩展。当项目开始时,你会得到一组需求。你构建满足这些需求的软件。发布后,会要求添加一个你无法预见的全新功能。你意识到,没有重新设计整个应用程序就无法实现这个功能。任何时候你都有可能因为想要把“洗澡水连同婴儿一起倒掉”而从头开始,那么你就是在处理一个管道式系统。
-
基于组件架构构建的管道式系统通常无法与其他企业应用程序共享这些组件。项目之间的代码复用程度非常低。
-
管道式系统通常出现在人员流动率高的项目中。这很合理。你开始一份新工作,取代了最后一位开发者,你感到压力要快速让某物工作以向你的新老板证明雇佣你不是一个大错误。你尽力拼凑一些东西来解决问题。你对现有的架构或过去尝试过(可能失败)的东西一无所知。现在考虑一下,在每次新员工开始日期之间有几个月的时间,进行两三次进一步的重新人员配备。
-
当开发团队使用新的或陌生的技术、技术栈或语言时,通常会指示管道式系统。鉴于存在快速生产东西的压力,同时团队还必须使用他们以前从未使用过的工具和语言,这会导致相同的模式:只是让某物工作并发布。你也会在初创公司、企业收购和合并中遇到管道式系统,原因相同。
这听起来熟悉吗?当然,我们不是在谈论你曾经编写过的任何东西!这难道不是让你想起了你看到别人编写的代码吗?也许是你的竞争对手?也许是你的学生?即使你承认编写过烟囱系统,也不要自责。这现在是软件开发中最流行的模式。有时,烟囱系统是可以接受的。记住,并非每个物理建筑都需要由有凹槽的象牙柱支撑,而且关于将软件推向市场并在以后再处理其他问题的论点是很有道理的。然而,如果你的目标是构建 10 年或更长时间后仍然有用和有利可图的软件,请继续阅读。我们将很快用功能性强、模块化、结构良好的系统替换这些烟囱系统。
《大泥球》
大概在同一时间,布朗等人正在撰写关于反模式的书籍时,另一支研究团队也在进行类似的工作。他们工作的成果被命名为《大泥球》,由福特和约德(1997 年)所著,我在本章末尾的“进一步阅读”部分列出了这本书。正是他们的工作启发了本章的标题。
《大泥球》反模式与我们关于烟囱系统的概述惊人地相似。然而,作者们更深入地探讨了这些系统是如何合理地形成的。
它们通常从废弃代码开始。我认为这很容易理解。这是你花几个小时甚至几周时间敲出来的代码,作为粗糙的原型。它向你和可能的项目利益相关者证明,你面前的问题是可以解决的。它甚至可能足够好,可以向客户展示。这就是陷阱出现的地方。原型足够好可以发布,所以,在你老板的要求下,你发布了它。我们将在本章的“废弃代码示例”部分通过故意构建一个足够好可以发货但不足以扩展的原型来模拟这一点。这个粗糙的原型将完成我们所要求的一切。在这个虚构的软件项目发布第一个版本后,我们就会发现自己面临构建大泥球时的第二个因素:零散增长。项目经理可能会贬义地称之为“范围蔓延”。我曾是一名咨询软件工程师和公司软件工程师。我可以告诉你,从项目管理角度来看,将范围蔓延视为负面的事情是错误的。虽然从计划和计费的角度来看,初始发布后出现的新需求是令人沮丧的,但新需求的出现是成功系统的标志。我最强烈的建议是,你开始每个项目时都要有它将非常成功的想法。这听起来可能过于乐观,但实际上,如果你发布了废弃代码,这是最坏的情况。
零散的增长导致了一种被称为保持运行的策略。再次强调,这不需要太多解释。当发现错误和新功能时,你只需修复有问题的部分,使程序满足新的需求集。顺便说一句,我们下周就需要完成这项工作。
在第二次发布之后,保持运行变成了你的日常工作描述。如果你的程序真的非常成功,你将开始雇佣人来帮助你保持运行,这自然会放大问题和技术债务,因为项目继续增长。
再次强调,这听起来与我们阐述的管道系统非常相似。福特和约德更详细地考虑了导致我们不幸混乱状况的力量。力量,就像自然界一样,是外部因素,你很少能控制。这些力量包括以下内容:
-
时间
-
成本
-
经验
-
技能
-
可见性
-
复杂性
-
变化
-
规模
让我们更详细地谈谈每个方面。
时间
你可能没有足够的时间来认真考虑你目前所做的架构选择的长期影响。时间也限制了你的项目,限制了你可以用分配的资源完成的事情。大多数开发人员和项目经理试图通过增加他们的估计来解决这个问题。在我的经验中,帕金森定律是正确的:那些时间估计被增加,甚至翻倍的项目,通常会扩展到填满或超过分配的时间。
成本
大多数项目没有无限的预算。那些有无限预算的是开源项目,它们根本没有任何货币预算,而是用志愿者的时间来代替,这本身也是一种成本。架构很昂贵。拥有知识和经验来开发良好架构的人很少,尽管由于你正在阅读这本书,他们稍微不那么难以接触。他们倾向于获得更高的薪水,而创建和维护适当架构的努力所涉及的成本在利益相关者、老板或客户的心中并不立即得到回报。
良好的架构需要时间,不仅需要开发人员和架构师的时间,还需要了解软件背后业务领域的领域专家的时间。领域专家很少致力于软件开发工作。他们有常规的工作,有真实的需求和项目外的截止日期。聘请每小时收费 250 美元的商业顾问正在消耗本可以收费的时间,但你诚实地讲,没有这种访问,你无法完成项目。
经验
软件开发者是软件开发方面的专家。他们很少在构建解决方案的业务领域拥有专业知识。例如,构建引用保险政策的系统的人很少曾是精算师,甚至调整员。在业务领域的经验不足使得软件建模成为一项试错的过程,这自然会影响到程序的架构。
技能
并非所有软件开发者都拥有相同水平的技能。有些人刚进入这个领域。有些人学习速度比其他人慢。有些人学会了使用一些“黄金锤子”(稍后会更详细地讨论)并且拒绝进一步提升技能。而且,在项目中总有一个超级明星,让其他人感觉自己像是在装模作样。
可视性
你无法看到正在运行程序内部。当然,你可以启动调试器,但普通人无法像检查一座办公楼等物理结构的架构那样检查你的代码架构。因此,架构常常被忽视。你的老板不太可能因为你惊人的抽象和接口结构给你一个丰厚的奖金。然而,他们会奖励你早点交付。这导致了一种非常人性化的、漫不经心的态度,对待你的代码结构。
复杂性
复杂的问题领域会产生混乱的架构。想象一下建模一组现代灯泡。这很简单。如瓦数、光输出(以流明为单位)和输入电压等属性会跃然纸上,仿佛它们是第二本能。现在,想象一下在 1878 年建模一个灯泡。你进入了未知领域。托马斯·爱迪生在 1879 年获得了他的第一个灯泡的专利,并且著名地引用说,他已经发现了两千种不制造灯泡的方法。如果领域复杂或未探索,你应该预期你的架构将面临坎坷。
变化
变化是唯一始终如一的事情。福特和约德写道,当我们设计架构时,它完全基于一个假设:一套关于未来的假设,我们期望对那个架构的更改和扩展仅限于我们迄今为止考虑的可能性领域。这听起来很好,但不可避免地会出现另一个真理:没有战斗计划能在与敌人首次接触后幸存。变更请求将以最不方便的形式出现,在最不方便的时间,而你的工作就是处理这些请求。对利益相关者来说,最容易的方法总是最令人愉悦的,但它正是导致大泥球(Big Ball of Mud)的原因。
规模
创建一个供 100 人使用的系统与创建一个每秒可以处理 10,000 个请求的系统是非常不同的问题。你编写代码的风格也不同。在一个小系统中,你高度依赖高性能算法的情况很少见,但在一个大型系统中,这是至关重要的。很少有项目从谷歌或亚马逊通常考虑的规模开始。成功的项目必须能够根据其成功程度进行扩展。
黄金锤子
另一个你应该学会识别的重要反模式通常是一些营销组织或你公司外部销售人员的产品。这种情况发生在某个杀手级应用、框架、基础设施组件或工具被展示为解决你所有软件开发问题的万能药。它切片,它切块,它制作法式炸薯条,并在加速代码执行的同时自动重构自己。
这种反模式被描述为金锤。请看 图 1.2 中的完整渲染的 CGI 表示:

图 1.2 – 当你有一把金锤时,一切都是钉子。
硅谷油销售员会拜访你,带你到一些高档的地方,并试图说服你,他们出售的数据库工具、平台或任何作为服务(WaaS)可以成为你公司软件的全部基础。考虑一下微软的 SQL Server。在最基本的情况下,SQL Server 是一个关系型数据库。它将你的数据存储在你可以查询的表中。相关数据表可以连接和筛选,允许理解 结构化查询语言(SQL)的开发者以任何格式或配置生成报告数据。这是每个关系型数据库工具都具备的常见功能,从 Microsoft Access 和 SQLite 到 Oracle 和 Microsoft SQL Server。由于 SQL 是一种标准化的语言,提供这种基本功能不过是 桌面赌注。仅为了我们相互理解,所有的双关语都是有意为之。
那么,微软怎么能期望为那些在开源产品如 MySQL 和 PostgreSQL 中可以免费获得的东西收费呢?当然,SQL Server 在市场上竞争对手较少的时候就已经开始发展了,但 SQL Server 仍然是今天最受欢迎的数据管理平台之一。这是因为 SQL Server 的价值贡献不仅仅局限于表格数据存储。随着产品多年的发展,新增了许多功能和辅助工具。您可以使用 SQL Server Analysis Services 以新颖和复杂的方式加载数据和分析数据。SQL Server Reporting Services 允许您使用 SQL 创建报告,然后将这些报告以图形方式呈现给可能需要它们的人,通过发送 PDF 格式的报告电子邮件。它还允许用户在服务器上访问报告,并可以无需了解 SQL 或访问底层代码的情况下对数据进行操作。
有支持的工作流程用于使用 R 和 Python 处理 AI 和机器学习项目,你可以用 C#编写一些代码,这些代码在数据库中处理,例如本地的存储过程。SQL Server Integration Services 允许你将数据导入并发布到各种不同的数据库、软件服务和行业格式。这导致你能够将你的软件和服务与你的商业伙伴和客户集成。
简而言之,如果你足够努力,你可能会写出一个相当大的应用程序,如果不是整个应用程序,仅使用 SQL Server 的生态系统。SQL Server 是金锤。现在每个问题看起来都像是可以用 SQL Server 解决的问题。我想指出的是,我并不是在诋毁 SQL Server。它是一套可靠且经济的工具集。我会在聚会上不遗余力地推荐它,我的建议总是受到欢迎。提醒自己:找到更好的聚会。我之所以挑剔 SQL Server,是因为我看到了这个特定工具发生的事情。如果你花太多时间阅读 SQL Server 的市场营销材料,你很容易得出同样的结论:SQL Server 是你所需要的所有东西。也许它确实是,但你应该在了解金锤反模式之后才做出这个决定,以免你最终陷入技术困境。
当一个开发者了解到他们之前不知道的一些技术时,金锤也会出现。他们使用了这项技术,他们喜欢它。他们因为快速或新颖的解决方案而得到了奖励。由于效果如此之好,并且他们已经努力将一项新技能添加到他们的技能集中,他们试图使用这个工具或技术来解决他们遇到的每一个问题。
曾经,我接管了一个陷入困境的项目。一个小团队的主程序员被解雇了,在他被替换后不久,他的大部分团队成员也离开了。抛开人际冲突不谈,我通过审查现有的代码库来了解新的项目和业务领域。
我四处询问,结果发现这个项目的原始团队成员都在一家咨询公司。该公司派了几位顶尖的开发者过来会面并收集需求。在看到客户自己用Visual Basic for Applications(VBA)在 Excel 中制作的原型后,顾问们得出结论,他们可以用真正的语言编写真正的代码,并在一个月内完成一个完全转换的程序。
两年过去了,没有可用的交付成果。顶尖的开发者要么大大低估了他们正在工作的原型,要么高估了自己的能力。我认为两者都有。大多数开发者都看不起 VBA。我必须承认,尽管我写过相当多的 VBA 代码,我以前也这样认为。顾问错误地认为 VBA 是简单的。他们认为用 VBA 编写的任何东西都只需要微不足道的努力就可以转换为像 C# 这样强大的语言,由同样强大的 .NET 框架和 SQL Server 支持。
几个月几乎没有进展后,咨询公司撤回了顶尖的开发者去从事其他工作,项目完全由初级开发者组成。
考虑到我们迄今为止已经讨论的反模式,你现在已经可以看到这个故事将走向何方。我在两年半没有可行的发布之后继承了这段代码。在我审查现有代码的过程中,我能够看到初级开发者遇到了某些工具或技术。这就像我正在观察树上的年轮:
.jpg)
图 1.3 – 新开发者发现黄金锤子的影响与他们的代码中的年轮相似。
你可以确切地知道他们是在哪里了解到在 SQL Server 中可以使用存储过程,因为从那时起,业务逻辑突然从代码中移出,进入了数据库。这通常是一个坏主意。这样做通常是因为你可以在不编译和发布新可执行文件的情况下更改业务规则,允许你进行小的或大的调整。这大约相当于在飞机以 1,261 节(约 1,453 英里/小时或 2,336 公里/小时)的速度在 30,000 英尺(9,144 米)高空飞行时对其引擎进行工作。
在其他地方,你可以看出他们读过一本关于模式的书籍,因为代码发生了变化。突然之间,一切都有了接口,并使用了工厂模式,我们将在第三章中稍后讨论。其中一些是好的。我可以看出他们在进步。然而,很多都是有人捡起新的锤子,用它来敲打周围的一切,使其变得有用。这主要因为从未给他们机会回过头来重构早期的工作。他们在不同时间使用了不同的技术。他们并不总是使用最适合工作的工具,但他们受到了我们之前讨论的力量的驱动。他们尽其所能,就像我们的烟囱修理工作一样。
一个废弃的代码示例
让我们看看一些临时代码。记住,临时代码是快速编写的,对架构的考虑很少。它足够用于发布,但不足以应对扩展。考虑一个旨在从流行的网络服务器中获取日志数据、随后分析和以 HTML 报告形式呈现关键信息的程序。您将分析来自 NGINX(发音为‘engine-ex’)的日志,它是目前使用最广泛的网络服务器程序之一。我通常在问题跟踪器中编写用户故事,但这次我将用 Markdown 文件代替,并将其包含在我的项目中,以便我有记录我的需求的记录:
As an IT administrator, I would like to be able to easily review weblog traffic by running a command that takes in the location on my computer of a log file from a server running NGINX. I would also like to store the data in a relational database table for future analysis.
GIVEN: I have a log file from NGINX on my computer at c:\temp\nginx-sample.log AND
GIVEN: I have opened a PowerShell terminal window in Windows 10 or later AND
GIVEN: The WebLogReporter program is listed within my computer's PATH environment variable.
THEN: I can run the WebLogReporter command, pass the location of the weblog and the path for the output HTML file.
GIVEN: The program runs without errors.
THEN: I am able to view the output HTML file in my favorite browser.
Acceptance Criteria:
* It's done when I can run the WebLogReporter program with no arguments and receive instructions.
* It's done when I can run the WebLogReporter program with two arguments, consisting of the first being a full path to the NGINX log file I wish to analyze and the second being the full path to the output HTML file I would like the program to produce, and I am able to view the output HTML file within my browser.
* It's done when all the log data are stored in a relational database table so I can query and analyze the data later.
您的团队决定使用 C# 和 SQL Server 来读取、解析和存储用于分析的数据。他们决定,尽管市面上有几种不错的模板系统,但团队中没有人使用过任何一种。时间紧迫,HTML 简单,所以我们只需编写自己的代码来将 SQL 语句的结果表示的结果转换为我们的结果。让我们开始吧!需求规定了一个控制台应用程序,因此我在我的 IDE 中创建项目时使用了这种项目类型。我不会向您展示如何创建项目。我假设您知道如何使用 Visual Studio 中的新项目选项创建控制台应用程序。
来自 NGINX 日志的输入数据如下所示:
127.0.0.1 - - [16/Jan/2022:04:09:51 +0000] "GET /api/get_pricing_info/B641F364-DB29-4241-A45B-7AF6146BC HTTP/1.1" 200 5442 "-" "python-requests/2.25.0"
127.0.0.1 - - [16/Jan/2022:04:09:52 +0000] "GET /api/get_inventory/B641F364-DB29-4241-A45B-7AF6146BC HTTP/1.1" 200 3007 "-" "python-requests/2.25.0"
127.0.0.1 - - [16/Jan/2022:04:09:52 +0000] "GET /api/get_product_details/B641F364-DB29-4241-A45B-7AF6146BC HTTP/1.1" 200 3572 "-" "python-requests/2.25.0"
当您在 Visual Studio 中创建控制台应用程序项目时,它会创建一个名为 Program.cs 的文件。我们目前不会对 Program.cs 做任何事情。我将首先创建一个新的类文件来表示日志条目。我将称它为 NginxLogEntry。我在我的样本数据中可以看到有一个日期字段,所以我将需要国际化,因为渲染日期需要文化信息。因此,让我们通过全球化包的 using 语句、命名空间和类来设置基本设置。Visual Studio 喜欢使用内部访问修饰符标记类。叫我老派吧。我总是将它们更改为 public,假设这是合适的,在这种情况下,确实如此:
using System.Globalization;
namespace WebLogReporter
{
public class NginxLogEntry
{
//TODO: the rest of the code will go here
}
}
基本设置完成之后,让我们设置我们的成员变量。除了几个构造函数之外,这基本上就是我们所需要的,因为这个类的设计是为了表示日志中的行条目。
我们感兴趣的字段在先前的数据样本中可以直观识别:
-
ServerIPAddress表示从其中获取日志的网络服务器的 IP 地址。 -
RequestDateTime表示日志中每个请求的日期和时间。 -
Verb表示 HTTP 的verb或request方法。我们将支持四种,尽管还有更多可用。 -
Route表示请求的路径。我们的样本来自 RESTful API。 -
ResponseCode表示请求的 HTTP 响应代码。成功的代码在 200 和 300 范围内。不成功的代码在 400 和 500 范围内。 -
SizeInBytes表示请求返回的数据的大小。 -
RequestingAgent代表用于发起请求的 HTTP 代理。这通常是指使用的网络浏览器,但在我们样本中的所有情况下,它都是用 Python 3 编写的客户端,使用了流行的requests库。
除了我们的字段外,我还将从一个enum开始,用于存储 HTTP 方法的四个可接受值,我将其称为HTTPVerbs。其余的用简单的自动属性表示:
public enum HTTPVerbs { GET, POST, PUT, DELETE }
public string ServerIPAddress { get; set; }
public DateTime RequestDateTime { get; set; }
public HTTPVerbs Verb { get; set; }
public string Route { get; set; }
public int ResponseCode { get; set; }
public int SizeInBytes { get; set; }
public string RequestingAgent { get; set; }
现在我已经设置了枚举和属性,我将创建几个构造函数。我希望有一个构造函数允许我传入一个日志行。构造函数将解析该行,并返回一个包含日志行的完整类。以下是第一个构造函数的顶部代码:
public NginxLogEntry(String LogLine)
{
首先,我会使用.Split()方法将传入的日志行分割成一个字符串数组,这是string类的一部分:
var parts = LogLine.Split(' ');
在开发过程中,我遇到了一些边缘情况。有时,日志行没有 12 个字段,正如我所预期的。为了解决这个问题,我添加了一个条件,用于检测包含少于 12 个部分的日志行。这种情况很少发生,但一旦发生,我希望将它们发送到控制台,以便我可以看到发生了什么。这类事情你可能想要移除。在这里,我正在拥抱我内心的管道开发者,所以我会保留它:
if(parts.Length < 12)
{
Console.WriteLine(LogLine);
}
现在,让我们根据分割来拆分这一行。从分割数组中挑选出服务器 IP 地址作为第一个元素很容易:
ServerIPAddress = parts[0];
我们不关心位置 1 和 2 上的那两个破折号。我们可以在第三个位置看到日期。处理日期总是比平均的根管手术更有趣。想想看,为了将日期格式化并解析成我们知道最终可以与数据库代码一起工作的格式,需要做多少工作。幸运的是,C#处理这个问题得心应手。我们提取日期部分,并使用自定义日期格式解析器。我并不关心用地区格式表达日期,所以我会使用InvariantCulture作为日期解析的第二个参数:
var rawDateTime = parts[3].Split(' ')[0].Substring(1).Trim();
RequestDateTime = DateTime.ParseExact(rawDateTime, "dd/MMM/yyyy:HH:mm:ss", CultureInfo.InvariantCulture);
接下来,我们开始解析 HTTP 动词。它需要符合我们在类顶部定义的enum。我开始提取相关的单词,并通过快速修剪确保它是干净的。然后,我将它转换为枚举类型。我可能应该使用tryParse(),但我没有。如果没有这样做,它仍然可以与输入样本一起工作,这就是那种让我们最终陷入管道监狱的思考方式:
var rawVerb = parts[5].Trim().Substring(1);
Verb = (HTTPVerbs)Enum.Parse(typeof(HTTPVerbs), rawVerb);
Route值、ResponseCode值和SizeInBytes值只是根据它们的位位置获取的。在后两种情况下,我使用了int.parse()将它们转换为整数:
Route = parts[6].Trim();
ResponseCode = int.Parse(parts[8].Trim());
SizeInBytes = int.Parse(parts[9].Trim());
最后,我需要RequestingAgent。样本数据有一些讨厌的双引号,我不想捕获它们,所以我将使用string.replace()方法将它们替换为null,从而有效地去除它们:
RequestingAgent = parts[11].Replace("\"", null);
}
我现在有一个非常有用的构造函数,它可以自动为我解析行。太棒了!
我的第二个构造函数更符合标准。我想通过简单地传递所有相关数据元素来创建NginxLogEntry:
public NginxLogEntry(string serverIPAddress, DateTime
requestDateTime, string verb, string route, int
responseCode, int sizeInBytes, string requestingAgent)
{
RequestDateTime = requestDateTime;
Verb = (HTTPVerbs)Enum.Parse(typeof(HTTPVerbs),
verb);
Route = route;
ResponseCode = responseCode;
SizeInBytes = sizeInBytes;
RequestingAgent = requestingAgent;
}
}
}
这个类开始时,就像所有类一样——从属性定义开始。我们有一个要求将日志数据存储在 SQL Server 中。为此,我在运行 SQL Server 2019 的笔记本电脑上创建了一个数据库。如果你没有 SQL Server 的经验,不用担心。这是唯一提到它的地方。你不需要 SQL 知识来处理这本书中的模式。我创建了一个名为WebLogEntries的新数据库,然后创建了一个与我的对象结构相匹配的表。创建表的数据定义语言(DDL)如下所示:
CREATE TABLE [dbo].WebLogEntries NOT NULL,
[ServerIPAddress] varchar NULL,
[RequestDateTime] [datetime] NULL,
[Verb] varchar NULL,
[Route] varchar NULL,
[ResponseCode] [int] NULL,
[SizeInBytes] [int] NULL,
[RequestingAgent] varchar NULL,
[DateEntered] [datetime] NOT NULL,
CONSTRAINT [PK_WebLogEntries] PRIMARY KEY CLUSTERED
(
[id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,
IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON,
ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY =
OFF) ON [PRIMARY]
) ON [PRIMARY]
如你所见,我添加了一个无处不在的自增主键字段,简单地称为id。我还添加了一个字段来跟踪记录何时被输入,并将其默认值设置为 SQL Server 的GETDATE()函数,该函数返回服务器上的当前日期。
让我们继续编写使用 SQL Server 读取和写入数据的代码。我认为大多数人会使用SQLServerStorage。如果你在跟着做,别忘了通过NuGet添加Systems.Data包。
和之前一样,我会从依赖项开始:
using System;
using System.Collections.Generic;
using System.Text;
using System.Data.SqlClient;
接下来,我将设置这个类:
namespace WebLogReporter
{
public class SQLServerStorage
{
//TODO: the rest of the code goes here
}
}
与我们之前创建的数据类不同,这个类完全是关于方法的。我将创建的第一个方法将使用直接连接将数据存储在数据库中。如果你只使用过 EF,并且理解 SQL(你应该理解),我强烈建议你尝试这种风格,并测试它与你的常规 EF 驱动代码的速度。你将看到巨大的差异,尤其是在规模上。我现在要从我的比喻肥皂箱上下来,回到创建StoreLogLine方法。它接受我们刚刚编写的NginxLogEntry类作为其唯一输入:
public void StoreLogLine(NginxLogEntry entry)
{
接下来,让我们连接到数据库。我使用using语法来做这件事。如果你之前没有使用过它(看看我做了什么?),它非常方便,因为它会处理你创建的任何东西的及时关闭和销毁。在这种情况下,我正在创建一个数据库连接。即使在临时代码中,也有一些事情你绝对不应该做,比如打开一个资源连接却未能关闭它。这真是太无礼了!这一行设置了我的连接。我还建议设置一个强大的数据库密码。像往常一样,我可以以临时代码为借口。到目前为止,我可能已经重复了比当地政府告诉你们戴口罩的次数还要多。而且就像你的当地政府一样,这可能不是你最后一次听到它:
using (SqlConnection con = new
SqlConnection("Server=Localhost;Database=
WebLogReporter;User
Id=SA;Password=P@ssw0rd;"))
{
接下来,我将构建我的StringBuilder类,它是System.Text的一部分:
var sql = new StringBuilder("INSERT INTO
[dbo].[WebLogEntries] (ServerIPAddress,
RequestDateTime, Verb, Route, ResponseCode,
SizeInBytes, RequestingAgent) VALUES (");
sql.Append("'" + entry.ServerIPAddress + "',");
sql.Append("'" + entry.RequestDateTime + "', ");
sql.Append("'" + entry.Verb + "', ");
sql.Append("'" + entry.Route + "', ");
sql.Append(entry.ResponseCode.ToString() + ", ");
sql.Append(entry.SizeInBytes.ToString() + ", ");
sql.Append("'" + entry.RequestingAgent + "')");
接下来,让我们打开连接,然后执行我们的 SQL 语句:
con.Open();
using(SqlCommand cmd = con.CreateCommand())
{
cmd.CommandText = sql.ToString();
cmd.CommandType = System.Data.CommandType.Text;
cmd.ExecuteNonQuery();
}
}
}
太棒了!既然我们现在正在写入数据,那么也应该读取它。否则,我们的类将非常糟糕。或者,也许它不会这么糟糕?在我输入下一个方法签名的同时,我会让你思考这个问题:
public List<NginxLogEntry> RetrieveLogLines()
{
read 方法将返回一个 NginxLogEntry 实例的列表。这就是为什么我们在 NginxLogEntry 类中提前创建了第二个构造函数。我将首先实例化一个空列表,用作返回值。之后,我将创建一个非常简单的 SQL 语句,从数据库中读取所有记录:
var logLines = new List<NginxLogEntry>();
var sql = "SELECT * FROM WebLogEntries";
使用之前相同的 using 语法,我将打开一个连接并读取记录:
using (SqlConnection con = new
SqlConnection("Server=Localhost;Database=
WebLogReporter;User Id=SA;Password=P@ssw0rd;"))
{
SqlCommand cmd = new SqlCommand(sql, con);
con.Open();
SqlDataReader reader = cmd.ExecuteReader();
执行了 select 语句后,我将使用一个读取器逐行获取数据,并为每条记录实例化一个 NginxLogEntry 类。由于它应该是原型代码,我依赖于数据集中的位置来检索数据。这并不罕见,但它相当脆弱。表的重构将使这段代码在以后失效。但这是废弃代码!看到了吗?我告诉过你会再听到它:
while (reader.Read())
{
var serverIPAddress = reader.GetString(1);
var requestDateTime = reader.GetDateTime(2);
var verb = reader.GetString(3);
var route = reader.GetString(4);
var responseCode = reader.GetInt32(5);
var sizeInBytes = reader.GetInt32(6);
var requestingAgent = reader.GetString(7);
var line = new NginxLogEntry(serverIPAddress,
requestDateTime, verb, route,
responseCode, sizeInBytes,
requestingAgent);
现在我已经使用表中的数据构建了对象,我将它添加到我的 logLines 列表中,并返回这个列表。using 语句处理了我创建的所有数据库资源的关闭:
logLines.Add(line);
}
}
return logLines;
}
}
}
总结一下,这个类有两个方法。第一个,StoreLogLine,接受 NginxLogEntry 类的一个实例,并将数据转换为与我们的表结构兼容的 SQL 语句。然后我们执行 insert 操作。由于我使用了 using 语法来打开数据库连接,当我们离开方法的作用域时,该连接会自动关闭。
第二个操作是相反的。RetrieveLogLines 执行一个选择语句,从表中检索所有数据,并将其转换为 NginxLogEntry 对象的列表。方法结束时返回这个列表。
最后一个组件是输出组件。这个类叫做 Report。它的任务是将数据库请求的记录转换为 HTML 表格,然后写入文件。
我将设置类依赖关系,并以通常的方式开始类设置:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WebLogReporter
{
public class Report
{
//TODO: the rest of your code goes here
}
接下来,我将添加生成报告的方法:
public void GenerateReport(string OutputPath)
{
现在,我将使用我们之前创建的 SQLServerStorage 类:
var database = new SQLServerStorage();
var logLines = database.RetrieveLogLines();
我有数据。现在,我将使用另一个 StringBuilder 生成 HTML。这是表格代码,因为这本书绝对不是关于前端设计的:
var output = new
StringBuilder("<html><head><title>Web
Log Report</title></head><body>");
output.Append("<table><tr><th>Request
Date</th><th>Verb</th><th>Route</th>
<th>Code</th><th>Size</th><th>Agent
</th></tr>");
foreach (var logLine in logLines)
{
output.Append("<tr>");
output.Append("<td>" +
logLine.RequestDateTime.ToString() + "</td>");
output.Append("<td>" + logLine.Verb + "</td>");
output.Append("<td>" + logLine.Route + "</td>");
output.Append("<td>" +
logLine.ResponseCode.ToString() + "</td>");
output.Append("<td>" +
logLine.SizeInBytes.ToString() + "</td>");
output.Append("<td>" +
logLine.RequestingAgent.ToString() + "</td>");
output.Append("</tr>");
}
output.Append("</table></body></html>");
最后,我们有一个美妙的 C# 单行代码来输出文件,以便在您最喜欢的浏览器中查看:
File.WriteAllText(OutputPath, output.ToString());
}
}
}
它可能看起来很丑,但它确实有效。我再说一遍,因为我可以。这是废弃代码!我在编写废弃代码时提倡的一个技巧是让它变得如此丑陋,以至于任何正常人都不会把自己的名字放在上面。我想我已经做到了。我只是使用字符串构建器创建我的 HTML。没有空格或格式。它基本上是一个压缩的 HTML 文件,当然,这是一个预期特性,绝不是因为懒惰。
在我们将这个宝贝放好之前,还有最后一件事要做。我们需要编辑 Visual Studio 为项目入口点创建的Program.cs文件。这个文件将所有其他部分粘合在一起。大多数 C# IDE 的最新版本在Program.cs文件中生成控制台应用程序的入口点。这不是什么新鲜事。新鲜的是这个文件的格式。新的格式缺少我们迄今为止在从头创建的类中看到的常规构造函数和类设置。在幕后,编译器正在为我们生成这些定义,但这使得Program.cs看起来与其他所有内容都不同。它不是展示所有常规样板,而是直接进入正题。
我们将首先使用我们刚刚创建的WebLogReporter类:
using WebLogReporter;
我们将进行一次例行且最小的测试,以确保从命令行传递了正确的参数数量。我们需要日志文件的路径和输出路径。如果您没有传递正确的参数数量,我们将给出一些命令行提示,然后以非零代码退出,以防这是自动化序列的一部分。我知道,这是废弃的代码,但我也不是野蛮人:
if (args.Length < 2)
{
Console.WriteLine("You must supply a path to the log file
you want to parse as well as a path for the output.");
Console.WriteLine(@"For example: WebLogReporter
c:\temp\nginx-sample.log c:\temp\report.xhtml");
Environment.Exit(1);
}
现在,我们检查日志输入文件是否存在。如果不存在,我们向用户表达我们的失望,并再次以非零代码退出:
if (!File.Exists(args[0]))
{
Console.WriteLine("The path " + args[0] + " is not a
valid log file.");
Environment.Exit(1);
}
如果他们能走到这一步,我们假设一切顺利,我们可以开始工作了:
var logFileName = args[0];
var outputFile = args[1];
Console.WriteLine("Processing log: " + logFileName);
int lineCounter = 0;
我们实例化SQLServerStorageClass,这样我们就可以在读取记录时将其存储:
var database = new SQLServerStorage();
现在,我们打开输入日志文件,使用foreach循环逐行读取,并使用NginxLogEntry中的解析构造函数创建一个NginxLogEntry对象。然后我们将它传递给我们的数据库类。如果我们遇到有问题的一行,我们将输出一条消息,说明问题发生的位置,以便我们稍后可以审查:
foreach(string line in
System.IO.File.ReadLines(logFileName))
{
lineCounter++;
try
{
var logLine = new NginxLogEntry(line);
database.StoreLogLine(logLine);
}
catch
{
Console.WriteLine("Problem on line " + lineCounter);
}
}
我们已经解析了日志数据并将其写入数据库。剩下要做的就是使用Report类来输出我们的 HTML:
var report = new Report();
report.GenerateReport(outputFile);
Console.WriteLine("Processed " + lineCounter + " log
lines.");
总结一下,Program.cs文件包含主程序。当前的 C#版本允许我们省略项目主文件中的常规类定义。
首先,我们检查用户是否输入了两个参数。这几乎不是一个坚不可摧的检查,但对于演示来说已经足够好了。
接下来,在确保输入日志文件是合法路径之后,我们打开文件,逐行读取,并将每一行保存到数据库中。
一旦我们读取了所有行,我们就从数据库中读取数据,并使用报告对象将其转换为 HTML。
您的程序已经完成;您向客户演示了它,他们非常高兴!一周后,客户的老板和您的老板共进午餐,然后出现了一个新的需求,表示客户现在希望支持两种其他 Web 服务器日志格式:Apache 和 IIS。他们还希望从以下几种不同的格式中选择输出:
-
HTML(我们已交付)
-
Adobe PDFs
-
Markdown
-
JavaScript 对象表示法 (JSON)
最后一种格式 JSON 的目的在于它允许外部客户端将数据摄入其他系统进行进一步分析,例如捕捉随时间变化的趋势数据。
虽然这些对需求的简洁描述在我们构建程序的实际扩展时几乎不是我们想要的,但它们足以让你开始思考。
你会怎么做?
我们是否建立了一个烟囱系统?如果不是,它有可能演变成一个吗?在继续阅读之前,请暂停一下,思考这个问题。
我认为我们已经建立了一个烟囱系统。原因如下:
-
我们的所有类都是单功能的,并且直接与网络服务器日志格式耦合
-
我们的软件直接与 SQL Server 耦合
-
我们的输出到 HTML 是唯一可能的输出,因为我们没有创建一个接口或结构来抽象输出操作和格式
你可能认为,在我们创建第一个版本时,第二套需求是未知的。这是准确的。你还可以进一步捍卫你的观点,声称你不是先知,没有方法你能知道你需要根据第二套需求扩展程序。你是对的。没有人是先知的。但尽管如此,你知道,至少因为你已经阅读了这一章,任何成功的程序都必须支持扩展。这是因为你知道你的第一次迭代现在已经产生了对第二次迭代的要求,而这总是意味着需求的变化和增加。我们永远不知道需求会如何变化,但我们确实知道它们会变化。
模式如何帮助?
这些因素无疑是你们现在职业生活的一部分。也许只有其中的一小部分在起作用。如果你在软件开发领域待的时间足够长,你无疑会在某个时候遇到它们所有。记住,我们迄今为止所讨论的一切都是反模式。所有这些负面能量都需要一个平衡。你甚至可能想说是需要平衡力量。而不是成为一个黑暗的绝地武士,也许一些不那么激进的方法会更好。你可以学会使用模式来平衡,最终战胜反模式和创造并使他们得以存在的力量。
我认为现在是时候正式引入我迄今为止留给你们想象的概念了。我可以提供自己对模式的定义,但我更愿意站在巨人的肩膀上。从 1947 年 Grace Hopper 在哈佛大学的 Mark II 上记录下第一个错误以来(见图 1.4),程序员和计算机科学家一直在反复面对同样的问题。我们每次都会变得稍微聪明一点,并将我们所做的事情记录下来。如果你从七十年间通过艰苦的试验和错误所获得的提炼经验和知识来看,从早期的战争先驱到最近的毕业生,你最终会得到一套模式,这些模式是对反复出现的问题的解决方案的描述。

图 1.4 – 首个计算机故障实际上是一只(蛾)爬进了哈佛大学 Mark II 计算机的继电器中
模式的想法起源于建筑领域,即传统建筑,与建筑的设计和创造有关。1977 年,克里斯托弗·亚历山大记录了一种模式语言,旨在为城镇建设的最佳实践奠定基础。这本书描述了 253 个模式,作为建筑设计的典范。这本书将一切分解为对象。我发现,即使将建筑模式的经典著作改编为软件模式的解释,语言也没有发生变化。我将这本书描述为描述现实世界中对象的语言及其如何塑造空间和对象以实现和谐的合成。正如电视剧和电影系列《星际迷航》中 Vulcans 的格言一样,模式语言的目标是无限多样性,通过无限组合表达。亚历山大本人将模式描述为对经常出现的问题的解决方案,每个从业者都可能认识它们。然后他以一种不直接关联任何实现的方式描述解决方案。以这种方式保持灵活性,相同的解决方案可以在数百万个项目中以及数百万种略有不同的方式中使用。
让我们将焦点从建筑世界转移到软件架构领域。我们回顾了著名的 GoF。他们将设计模式定义为对重复出现的问题的抽象,它指出了设计结构的主要元素,专注于创建可重用面向对象软件的思想。
模式将成为我们用来克服那些在最高级的公司机构和中小企业中最神圣的殿堂中盛行的反模式和黑暗力量的武器。
你准备好与黑暗作战了吗?卷起袖子,让我们开始工作吧!
摘要
本章通过定义反模式开始了我们对模式的讨论。
我们了解到,如果我们只从满足需求的角度设计软件,我们将构建一个难以扩展的系统。这些系统被称为烟囱系统,因为随着时间的推移,它们在结构上会像烧煤炉的排气孔一样退化。你不可避免地会达到一个点,在这个点上,维护和扩展这样的系统是不切实际的。没有人愿意成为告诉老板你需要六个月没有新发布,以便你可以重建公司现金牛产品的那个人的角色。使用模式进行设计将帮助你避免这些类型的陷阱。
我们还了解了一个类似的反模式,称为大泥球。福特和约德描述了我们都在日常工作中认识到的普遍力量:时间、成本、经验、技能、可见性、复杂性、变化和规模。这些力量阻碍了我们最初编写好代码的能力。即使我们可以在第一个版本中编写出好代码,这些力量也会随着时间的推移侵蚀系统,就像一条小溪最终形成大峡谷一样。
我们看到了一个废弃代码的例子,这就是大多数项目诞生的过程。我们现在知道,仅仅清理废弃代码然后发货是一种不良的做法。花时间正确地设计项目并假设最坏的情况:你的软件会取得巨大的成功。如果是这样,你可以绝对地依赖那些你甚至在编写废弃原型时都没有想象到的功能请求和新需求。
模式可以被看作是一种定义常见软件设计元素的“语言”,结合了一个可以以许多不同方式实现的抽象解决方案。它们并不依赖于特定的语言或技术栈。随着你学习使用模式,你的软件将因为更坚实的基础而变得更加健壮。你的项目将通过提供未来功能和扩展的途径来支持你不可避免的成功,这些途径是你开始项目时无法想象的。
在下一章中,我将使用 C#编程语言为你准备模式之旅。我将涵盖一些与面向对象编程相关的一些流行惯用和做法。特别是,请注意 SOLID 方法论的展示,因为它是成功模式实施的基础。
如果你是一个新手,或者可能是在使用过其他面向对象语言后返回 C#,我想要你参考书末的附录 1。我为那些对学习模式感兴趣但专注于其他语言(如 JavaScript 或 Python)的学生和同事写了这个附录。模式是语言无关的。在不考虑语言的情况下学习这些模式是可能的。然而,我相信我们都是通过实践来学习的,这意味着你需要一种实现语言。编程语言的好处是它们基本上都是一样的。它们都使用变量、对象、方法、集合和循环。
在附录 1中,我将介绍 C#语言的面向对象特性。我暗示了我的想法,即该领域的较新手开发者最有可能在合理掌握语言的同时,却仍然坚持使用“黄金锤子”并用 C#来制作烟囱式软件。我有许多学生从我这学习 JavaScript,并且在我的强烈鼓励下,他们希望通过学习 C#来继续他们的旅程。鉴于两种语言在继承和常见结构(如对象和类)之间的不同工作方式,你无疑会察觉到我的包容性愿望。我最初写这个附录是为了成为第二章,但我不想承担 C#群体大喊“无聊”的费用。即使你是 C#的老手,我也鼓励你浏览这一章。你可能会发现我解释事物的方式与其他许多作者略有不同,当然与学术教科书相比更是如此。
问题
-
用你自己的话来说,什么是模式?什么是反模式?
-
你在自己的工作中见过哪些反模式?
-
什么是烟囱式系统?你能从你自己的作品中指出一个例子吗?别担心,我不会说的。
-
你能想到一个你使用“黄金锤子”的时刻吗?这把锤子是什么,你认为是钉子的是什么?
进一步阅读
-
Alexander, C. (1977). 《模式语言:城镇、建筑、建造》。牛津大学出版社
-
Brown, W. H., Malveau, R. C., McCormick, H.W. S., & Mowbray, T. J. (1998). 《反模式:危机中的软件、架构和项目的重构》。John Wiley & Sons, Inc.
-
Foote, B., & Yoder, J. (1997). 大泥球. 程序设计模式语言,第 4 卷,第 654–692 页。来自
www.laputan.org/pub/foote/mud.pdf -
Gamma, E., Helm, R., Johnson, R., Vlissides, J., & Patterns, D. (1995). 《可重用面向对象软件元素》(第 99 卷). 马萨诸塞州雷丁:Addison-Wesley
-
Johnson, J. (1995). 《创造混沌》。美国程序员,1995 年 7 月
-
Nazeer, H. (2020). 《模式语言:城镇、建筑、建造(评论)》. 建筑与规划研究杂志,第 29 卷,第二期
第二章:为 C#中模式的实际现实世界应用做准备
成为预备者
你可能会想,因为我出生在俄克拉荷马州,住在德克萨斯州,我可能是那些在树林里四处奔跑,为即将到来的世界末日做准备的那些疯狂的人之一。嘿,让我们尽量把我的个人生活排除在外!然而,当我告诉你我想让你成为一个预备者时,这和阴谋论或末日言论无关。相反,这与你职业生活中可能发生的根本性转变有关。与大多数现实世界预备者所关心的激进政治转变不同,我们希望的是完全有益的转变,目的是让我们在技艺上更加出色。本章旨在为你准备使用模式。如果你正在处理的代码不符合以下条件,那么依赖模式来改进你的代码是没有意义的:
-
具有高度的组织性
-
以最小风险进行结构化修改
-
可测试性
-
可衡量性
因此,你需要做好准备。想象一下,你正在打磨一块木头上的粗糙部分,然后再上漆。模式就是油漆;如果你在上漆之前打磨,所有的粗糙部分都会突出出来。
将本章视为解释你公司代码现状的原因。如果你在微软、谷歌、苹果、Meta、推特或亚马逊工作,你的代码可能状况良好。你需要明白,高质量的代码库不是偶然出现的,它也并非对困扰物理宇宙的相同混乱力量免疫。熵并不仅限于遥远星云中热力学的关注。它一直在你的代码中发生,你必须学会识别它,并勤奋地修复或抵御它。
如果你在世界其他 99.99%的公司中工作,这些公司通常将软件开发作为业务的一部分,可能存在一些未被认识到的改进机会。让我们看看本章我们将涵盖的内容:
-
意大利面比千层面更比意大利肉丸——用意大利面解释软件演变
-
基本原则——编写干净的代码
-
使用 SOLID 原则创建可维护的系统
-
衡量质量
当你读到本章的结尾时,你将能够做到以下几件事情:
-
识别一些拙劣构建的代码的常见隐喻,以及典范代码。
-
阐述良好的基本编码习惯作为你项目成功和代码应用的关键。模式可以使你的代码变得更好,但前提是它是干净的代码。
-
将 SOLID 原则应用于你的代码和设计过程。
-
识别最常见的软件度量标准,以便我们了解何时我们的代码更有可能是好的而不是坏的。
技术要求
本章将展示一些代码示例。它们旨在展示我们将要讨论的一些概念。我不确定它们是否足够吸引人,让你想要自己重新创建项目,但欢迎你这样做。如果你决定尝试任何这些内容,你需要以下这些:
-
运行 Windows 操作系统的计算机。我正在使用 Windows 10。
-
支持的 IDE,例如带有 C# 扩展的 Visual Studio 2022、JetBrains Rider 或 Visual Studio Code。
-
.NET Core 6 SDK.
意大利面 < 千层面 < 饺子 – 用意大利面解释软件演化
任何准备之旅的第一部分涉及确保你的食物供应。现实世界的准备者喜欢意大利面,因为它便于携带且无需冷藏。所以,让我们从意大利面开始。
第一章 的标题是 为什么你的意大利面盘上有一个大泥球? 我从未提到过意大利面。我不需要。它是一个如此明显的描述性隐喻,用来形容混乱的混乱,可能不需要进一步的讨论。实际上,意大利面代码在 Big Ball of Mud 反模式的原版出版物中就有提及。许多开发者没有看到的是,意大利面代码是一个症状,而不是疾病本身。真正的疾病是反模式。你的身体可能同时受到多种感染的侵袭。同样,你的代码也可能同时受到多种反模式的侵害。你可以通过决定你的代码在意大利面谱系中的位置来衡量你代码的健康状况。
意大利面代码
意大利面代码被描述为混乱、组织不良、难以跟随和难以维护。意大利面越多,情况越糟糕。一旦出现意大利面代码,它往往会迅速扩散。我们也在上一章中将代码架构与物理建筑的架构进行了比较。犯罪学领域有一个著名的原理,称为破碎窗户理论。该理论假设犯罪的外在迹象、反社会行为、公民不服从或明显的城市衰败会鼓励进一步的犯罪,尤其是破坏行为。一栋有一扇破碎窗户的建筑很快就会所有窗户都破碎,因为很明显你可以扔石头而不受任何后果。谁不喜欢打破窗户呢?微软经常这样做。
你的代码也是如此。如果你允许一扇破碎的窗户,你的代码就不再干净。你需要立即纠正这种情况,否则不久你的代码库中就只剩下你盘上的大泥球了。
可能到目前为止,快速行动已经得到了回报。可能它让你能够将软件提前推向市场,领先于任何竞争对手。可能你的老板和利益相关者对你的初始发布速度和短周期周转时间印象深刻。所有这些都感觉很好。实际上发生的是,你正在因为砸自己的窗户而得到奖励。你正在牺牲长期利润以换取短期收益。
你需要完全退出意大利面生产业务。
千层面代码
从意大利面代码到千层面代码的逻辑步骤。大多数对千层面代码的描述都说它是意大利面的面向对象版本。我并不完全同意。千层面本身由许多与意大利面相同的成分组成。它仍然是面条、番茄酱、香料,通常还有肉类或蔬菜。区别在于它的组织方式。千层面不是通过在勺子上旋转来驯服的数百个相互交织的混乱面条结构,而是使用大面条作为离散层之间的边界。许多层由与其他层相同的东西组成。
在软件领域,分层代码将意大利面式的代码整理成许多小的类。将千层面作为软件的隐喻出现在 1982 年左右,当时行业正开始转向新的硬件架构。我们正从大型铁块式的单体主机转向所谓的客户端-服务器架构。当时的单体主机需要类似于几个小型欧洲国家 GDP 总和的财务投入。另一方面,对于资金合理充足的小型企业来说,客户端-服务器系统是负担得起的。这些系统由一个强大的服务器组成,为多个客户端提供服务,其形式是较不强大的计算机。这听起来熟悉吗?这正是互联网所使用的相同模式。不同之处在于,网络技术现在已经普及,指数级地更好、更快、更可靠。剧透一下:云计算不是一个新概念。它只是对在他人计算机上完成的工作的一种营销术语。
值得注意的是,20 世纪 80 年代还带给我们对面向对象编程的首次尝试。我怀疑当时詹姆斯·高斯林甚至还没有想到 Java,C#的发明者安德斯·海尔斯伯格当时还在上大学。然而,Bjarne Stroustrup 在 1979 年,在 AT&T 贝尔实验室工作时,开始研究C++。C++基于 C 语言。C 是一种严格的过程式语言,旨在控制电信交换网络。Stroustrup 通过将面向对象通过类的方式附加到这种语言上,重新构想了这个语言。
C++在 1985 年由斯特劳斯特普发布了一本关于该语言的书后,向世界发布。那些当时还没有出生的人现在明白,80 年代带给我们的不仅仅是发带、麦当娜的模仿者、《功夫小子》和山谷女孩。它带来了一种全新的思考软件和设计软件的方式。我们在做的时候看起来很棒,这仅仅是一个巧合。
向客户端-服务器硬件转移的需要促使软件设计实践发生变化,因为我们考虑哪些部分应该在服务器上运行,哪些应该在客户端运行。我们开始看到我们现在称之为关注点分离的东西。当做得好的时候,关注点分离是一个非常不错的想法。当做得不好的时候,它会产生类似千层面的问题。
千层面通常不会一开始就是千层面。一位软件开发者深思熟虑地设计了一个分层架构,将整个系统的组件分离成称为层的层级。以下是一个常见的四层架构设计示例:

图 2.1 – 常见的四层架构
这正是我们在现代移动应用中看到的这种架构。表示层是您从应用商店下载的应用程序,它在您的手机上运行,而其余的层级则在云中无形地运行。
由于分离存在于硬件级别,因此考虑以一组层来构建软件,这些层反映了运行程序的系统,是有意义的。表示层是视图,或与用户交互的程序部分。业务规则层实现了驱动表示层显示的逻辑。它从负责存储和检索数据的持久层获取信息。其中一些可能以文件形式存储或在内存中,但无疑一些数据将在数据库的最低级别。层与层之间的所有通信都应该通过它上面的每一层或下面的每一层进行,具体取决于数据流的方向。随着这些层变得越来越复杂,功能开始从预期的层渗出到其他层是很常见的事情。
假设你需要快速修复 UI 中显示方式的问题。也许你想要以某种方式向管理员用户显示某些内容,而以另一种方式向普通用户显示。实现这种更改的正确方式可能是更改业务规则层中的一个规则,但直接将某些内容直接粘贴到 UI 中更容易、更快。你刚刚打破了一个窗户。这没什么大不了的;你告诉自己你以后会修复它。
在其他情况下,千层面可能是由过度修正造成的。以一个在 C 这样的过程式语言中编写意大利面代码的程序员为例,然后给他们提供一个面向对象的编程语言,如 C++或 C#。现在,再加上一本关于模式的书。程序员的自然倾向可能与你第一次意识到可以在PowerPoint中使用不同字体时的倾向相同!一个字体很好;因此,你电脑上的所有 25 个字体都必须更好!自然地,你开始创建尽可能使用尽可能多字体的演示文稿。模式是一个惊人的工具。它们旨在引导你走向干净的架构,就像字体,如果使用得当,应该为演示文稿增添强调、清晰度和美学。模式并不是为了成为监狱。
拉斯 agna 问题不仅限于大型、多服务程序。你也会在较小的程序中看到它,以继承层的形式出现。继承是面向对象编程的基础特性。如果一个语言不支持继承,它就不在俱乐部里,而且它不能自称是面向对象的。考虑一下项目在第一章,“你的意大利面盘上有一个大泥球”,在几次需求收集、编码和发布迭代后可能看起来像什么。我在这里提出的用例是纯粹的虚构,实际上并不与 NGINX 日志结构的发布历史相关。我需要一个可信的复杂系列来帮助使我的例子有效。
假设第一章中的代码代表第一次发布。客户非常高兴,并开始提出新的需求,因为软件在现实世界的不同商业用户中得到了应用。以下可能是一些新的需求清单:
-
一些使用该程序的专业 IT 人员指出,虽然它与最新的网络服务器软件版本配合得很好,但在与较旧版本一起工作时会出现问题,因为日志结构在过去几年中已经改变了几次。我们需要支持最后主要修订版的日志格式,以及最新的格式。
-
基于亚洲的用户指出,他们的日志文件中的文本格式不受支持,因为他们的文本文件使用双字节格式编码。我们需要支持国际文本格式。
-
另一个重要的 IT 团队甚至不能使用你的软件,因为他们并不完全使用 NGINX。他们也使用 Apache,并且他们指出他们也有相同的限制。日志格式随着时间的推移而改变,所以他们需要考虑几种格式,并且他们也需要对单字节和双字节文本实现的支持。
让我重申我之前的免责声明。虽然我对网络服务器日志有所了解,但绝对不是网络服务器日志爱好者。作为提醒,这是一个虚构的警示故事。我们需求的任何与实际软件需求相似之处纯属巧合。我进一步承诺,根据伪证罪的法律责任,不会在同样虚构的生产过程中伤害任何可爱而蓬松的动物。我的律师想让我添加更多内容,但我的编辑想让我继续前进,而且我早就知道我的编辑总是对的。
接下来,假设原始代码中没有使用任何继承,这可能是因为我们刚开始接触 C#,并没有完全理解如何使用继承。现在,假设我们从除了 Packt 以外的出版社买了一本书。这本书是由一位从未在商业环境中发布过软件的大学教授所写。这是很常见的。他们从未听到过一位像神一样拥有对他们生计绝对控制力的秃头老板告诉他们清理原型并在下周发货。他们从未面临过由营销部门不合理规定的截止日期。简而言之,这本书将由一位不从事实际软件开发工作的作者所写。
这种作者可能会展示一个关于 C#继承模型的模糊、过于学术的画面,以及结构化代码的学术上完美的方式。他们基于可用的学术文献综述进行估计,这些综述也是由从未在实地工作过的大学教授所写。你年轻、敏锐、灵活,但容易受影响,可能会被作者的所有学术资历说服,做出以下类似的东西:

图 2.2 – 垃圾桶火灾旁边火车出轨的 UML 等效图(这是过度继承的一个例子)
这是一条用统一建模语言(UML)表示的构建不良的继承链。如果你之前从未见过 UML 类图,你应该跳转到书的末尾的附录 2。那里有如何绘制和解释这些图的概述。
图中表示的继承链比必要的更深,可以通过使用接口等工具和组合等技术更干净地实现设计目标。你也在目睹类膨胀的开始。
当你需要添加越来越多的类来支持新的需求时,就会发生类膨胀。这使我们陷入了经典的烟囱式设计。
过多的继承层,尤其是当属于一个类的功能随着时间的推移慢慢渗入其他类时,会产生千层面。
如果我们还有另一个美味且令人满意的意面隐喻,但对我们所创造的东西的维护者来说不那么有害于工作与生活的平衡,那会怎么样呢……
Ravioli – 意面代码的极致
虽然每个人都喜欢意大利面和千层面,但你不想它们出现在你的代码中。另一方面,Ravioli 是我们向往的东西。
当我们制作 Ravioli 时,它仍然由制作意大利面和千层面所用的相同成分组成。再次强调,区别在于材料的配置。现在面条形成了一个围绕美味内部的完整边界。面条内的肉或奶酪被完全封装,内容只有在被消费时才会暴露。可能还有美味的酱汁在外面,将整个菜肴融合成一个既不混乱也不过于密集的整体。
值得注意的是,塑造和填充面条所需的努力远远超过简单地煮一撮直面条。制作 Ravioli,就像编写优秀的面向对象代码一样,需要耐心和努力。如果你还记得之前用来描述面向对象设计的基石,Ravioli 的理念是意面面向对象设计的典范:
-
封装用于限制对对象状态的访问。
-
抽象指的是一个类仅用软件所需的细节级别来模拟现实世界中的对象。如果我们编写需要
Person类来模拟人的软件,Person类在病历应用中的建模方式将与电话簿应用中的建模方式不同。 -
继承允许在父类中共享类之间的公共结构,从而消除了在类之间重复复制相同代码的需要。
-
多态允许类设计者将抽象的细节推迟到具体类中实现。例如,一个抽象的
Vehicle类可能有一个Go()方法。对于具体的类,如汽车、船、滑板、潜艇或飞机,Go()方法的工作方式会有很大不同。多态允许我们在具体级别创建适当的实现,但在抽象级别定义它。
我们需要就封装进行更多讨论,特别是针对 C#。在设计良好的面向对象代码中,对象是封装的。这意味着对象的状态受到严密保护。只有实例应该被允许改变其自身的内部状态。
相比之下,结构不良的代码允许对象直接改变其他对象的状态。在 C#中,自动实现的属性使得这一点变得非常容易,因为属性并不比字段更好。如果每个属性都是公共的,并且没有任何规则,就像自动实现的属性那样,任何对象都可以改变任何其他对象的状态。我们可以通过一个长的继承链来加剧这个问题,其中每一层的每个属性都是公共的、内部的或受保护的。在这种情况下,任何层的任何对象都可以在继承链的上下方向施加状态变化。这使得跟踪每个对象状态的变化及其发生的条件变得困难。层与层之间状态变化的泄露正是 lasagna 的定义。
如果你的代码类似于 ravioli,每个对象的每个实例都是一个完全封装的类,具有有限的继承。它是自己状态的主宰,并且细致入微地完全控制着那个状态。由于这些对象都具有这些特征,你可以轻松利用组合来构建复杂对象,而不是仅仅依赖继承来定义对象的行为。
组合是一种将对象构建成其他对象的技术。当我们使用接口来定义这些对象如何组合在一起时,组合效果最佳。接口定义了对象的结构。把它想象成一个机械插座,比如灯座。只要它适合,你可以把任何灯泡插入那个插座。使其适合的是插座尺寸的定义。C#中的接口可以定义一组属性和方法,对象预期应该具有这些属性和方法。任何符合规格的对象都可以像灯泡插入插座一样被使用。
想象一组设计用来模拟汽车的类。有很多不同种类的汽车。有跑车、家庭轿车、微型面包车,以及我个人的最爱,吉普车。所有这些汽车都非常不同。我们试图提出一个继承链来将它们全部联系在一起,形成一个家族等级。但我怀疑,如果你将设计限制在严格的继承上,你最终会得到数百个类。结果代码将是一盘混乱、泥泞的意大利面。
一个更好的想法是使用组合。我们可以保留我们的抽象汽车模型,但不是创建子类,而是可以添加接口来定义汽车的特征。例如,我们可以为每个组件创建一个定义汽车的接口。例如,我们可以创建一个定义发动机的接口。
我们也可以创建一个定义传输的接口。家庭轿车、高端跑车和 4x4 越野车的传输之间存在着巨大的差异。然而,我们可以定义一组通用的属性和方法。只要遵循所需的接口,我可以建模的任何传输对象都可以适配到我的汽车对象中。
如果我必须用一句话总结面向对象开发者的工作,并且只允许我说一句话,我会说我们的工作始终确保没有任何对象应该被允许进入一个无效状态。我将在下一节中扩展这个想法。
基本原则——编写干净代码
本章介绍主题的主要目的是设定一个边界。你可以掌握这本书中的所有模式以及更多,但如果你的软件编写得不好,过于聪明,结构混乱,或者难以维护,那么世界上所有的模式都无法帮助你。让我们设定一些边界。我将提出一些创建“干净代码”的指导方针。你可以就细节进行争论。只要你有自己疯狂风格的方法,我们对于制表符与空格的看法不同,我一点也不介意。让我们画一些大家都可能同意的大致轮廓。
干净代码具有以下特点:
-
对认知负荷有限的人类易于阅读
-
风格一致
-
以适当的注释进行文档化
你应该编写人类可读的代码
我觉得这并不像它应该的那样明显。我们编写代码是为了让机器编译和执行,这些机器并不关心我们编写代码时所使用的表达性。编写代码就是编写语言。人类语言是富有表现力的,人类也是。我们可以用我们的书面语言创建一封关于库存短缺的商业电子邮件,一份关于皮肤肌炎诊断后潜在患者结果的医学报告,一篇关于分子生物学的硕士论文,一部长篇浪漫小说,甚至是一首俳句。你的计算机语言也是有限的词汇量中的富有表现力的。C# 有 79 个关键字。凭借这 79 个关键字,你可以创建从下载电子邮件的简短程序到整个操作系统的任何东西。它是一种强大且富有表现力的语言。只要你的代码对其他人来说是可读的,你就需要集中精力。我一直相信黄金法则在这里适用:己所不欲,勿施于人.你应该以你希望别人如何对待你的同样的尊重来对待每个人。你应该编写其他人扫描和理解所需努力最少的代码。
这从你命名类、变量、方法和用于创建你的软件的其他元素的方式开始。如果这些名称代表了这些元素的使用意图,你就朝着创建更易读和更易于维护的代码迈出了一步。以下是一些最佳实践的基本要素:
-
为你的元素命名,使其使用目的直观。
this.pn并不直观。你可能能想到它可能代表的一打事物。如果我们使用了this.phoneNumber,你就不需要猜测了。 -
创建可搜索的名称。将
MAX_FILES_PER_UPLOAD定义为常量,使得在代码中查找它变得容易,尤其是如果你使用的是索引你的代码的 IDE。 -
将过时的编码留给老一辈的人。除非你非常(我们如何优雅地说这个?)经验丰富,否则你很可能不会做这样的事情。很久以前,在这个星系中,我们学会了使用编码来创建变量名。我说的不是编程代码;我是指匈牙利命名法中的编码。那是在我们还没有类型检查 IDE 的时候。这让我想起了加里森·凯勒的 Lake Wobegon 播客,他在那里讲述了一个更简单时代的传说。我们使用简单的控制台编辑器,如 vi 和 emacs,并且我们喜欢这种方式。我们甚至可以像手被绑在一起一样使用 Notepad。当时,我们没有 Roslyn 在我们身后监督并指出我们的错误。我们需要一种方法来告诉我们变量中使用了哪种数据类型,所以我们可能会将变量命名为
intAge或iAge。正如我说的,你可能不再这样做,除非你是这样被教导的。如果你是这样被教导的,请停止这样做。谢谢。 -
不要使用成员前缀或后缀。过去,通常会在成员变量前加上前缀
m_,例如。与编码一样,这类事情并不需要,因为我们有好的集成开发环境(IDE),而且你的类应该短小且功能明确,这样就不需要前缀。此外,过了一段时间后,人们在浏览你的代码时往往会忽略它们,因为它们不再具有意义。我见过大学教授长时间这样教。我可能就是其中之一。我不为此感到自豪,但那已经是很久以前的事情了,自从那时起我就已经有所进步。我仍然观察到的例外是,通常用初始下划线命名私有字段。我认为大多数人这样做是为了让他们能够给字段起一个明显的名字,以匹配他们打算公开的字段名。 -
在你的代码中使用词性。你的对象是名词。它们代表人、地点和事物。按照这样的方式命名它们。
Person是一个很好的抽象类名称,它可能会在Student和Professor中扩展。包含动词作为名称一部分的类名,例如名为ParseLogLines的类,作为类名来说会让人困惑。将类命名为LogLineParser更清晰,因为它听起来像是一个事物而不是一个动作。在类内部,方法是动词,所以按照这种方式命名它们。ParseLogLines完全可以作为方法名。如果你注意这些细节,你的代码最终会像正常句子一样阅读,尽管带有一些奇怪但可以理解的标点符号。 -
不要重复自己(DRY)。我的意思是不要重复编写相同的代码。也不要重复任何东西,永远不要重复你已经写过的内容。真是抱歉,我经常看到人们在匆忙中这样做。他们可能在另一个项目或当前项目的另一部分中有一些代码,但编写的方式不利于重用,所以他们将其复制粘贴到不同的程序或同一程序的不同部分。这是一个破损的窗户。很快,你的代码将不再遵循 DRY 原则;它将比章鱼肚脐眼还要湿,而且这种事情会无处不在。
-
移除死代码。这是我的一大烦恼。有一次,我作为一名 Java 开发者工作。我知道,我那时年轻,需要钱。我对这份工作相当陌生,我们有一个数据库函数没有按预期写入数据库。我修改了方法并运行了测试。结果相同。最终,我决定做一个愚蠢、荒谬的大改动。没有任何变化。我正在处理一个名为
WriteInventoryPartToDatabase的方法。好吧,这个名字听起来很合适。它似乎是一个寻找问题的明显地方。大约一个小时后,我意识到我正在处理的是旧代码,而我真正需要的方法已经被移动到另一个类中。移动方法的开发者保留了旧方法以备不时之需,但后来从未清理过。好吧,人们。这就是版本控制系统存在的原因。你可以随时回退。不要偷懒。如果你删除或移除了某些内容,不要只是更改调用方法中的内容。移除死代码部分,否则其他人可能会在一条死胡同上浪费大量时间。 -
格式化你的代码以便人类阅读。以下这种代码应该让你感到厌恶:
var l = 1; var O = 0; if (O == l) { O++; } else { l = O * (l + 1); }
我们的 IDE 已经非常擅长默认使用帮助我们检测零和 O 大写字母、小写 L 和大写 1 之间差异的字体。开启字体连字符等选项可以提供更多帮助。连字符字体是改进后的字体,可以显示一组更具表现力的字符。例如,看看 CaskaydiaCove Nerd 字体中!=的渲染方式来自nerfonts.com。它显示为≠,这是你高中数学老师展示的方式:

图 2.3 – 开启字体连字符的 IDE 显示!=
你可以在你的集成开发环境(IDE)中开启这些功能。在 Visual Studio 中,你只需将字体设置为支持连字符的字体:

图 2.4 – 在 Visual Studio 中,只需在“选项”窗口中将字体设置为支持连字符的字体,例如 CaskaydiaCove Nerd Font Mono,你可以在 nerdfonts.com 免费找到它
在 JetBrains Rider 中,你有一个实际的设置:

图 2.5 – JetBrains Rider IDE 字体设置允许您特别开启和关闭连字符
如果你更喜欢 Visual Studio Code,你需要编辑你的 settings.json 文件:
"editor.fontFamily": "CaskaydiaCove Nerd Font, monospace",
"editor.fontLigatures": true,
IDE 的常规配色也有帮助,除非你在阅读以灰度打印的书本 😊。
之前的要点是编写供人类消费的代码的指南。这些提示将帮助我们在进入下一节时。
建立和执行风格和一致性
你应该使用一套约定来一致地命名代码元素,以及应用一致的编码风格。如果你做得好,你将无法分辨出你的代码在哪里结束,别人的代码在哪里开始。我不会花时间去推荐编码约定,因为这些在业界已经确立。一些这样的约定已经内置到 IDE 中,例如花括号总是换行,对于其他部分,你可以使用自动化工具,如 JetBrains 的 ReSharper,Prettier 用于 Visual Studio Code,或者开源项目 StyleCop。所有这些都有工具允许你在 IDE 中添加风格强制,以及在你将代码提交到 持续集成 (CI) 服务器时运行检查。那些没有使用适当样式的马大哈会失败构建,给团队中的其他人一个机会帮助他们看到错误。
限制认知负荷
问题不在于编写糟糕的代码难以理解。问题在于它难以阅读。多年来,我注意到遗憾的是,我不再阅读整段散文了。当然,我很少读完全没有代码的书。多年来,我的大脑已经适应了阅读代码,所以我扫描。如果你也阅读代码有一段时间了,你可能也在这样做,即使你没有意识到。记住这一点,努力使你的代码易于阅读。如果你只需看一眼就能想“好的,明白了。下一个?”,那么它就很容易阅读和理解。相反,你必须盯着一些代码几秒钟或几分钟,才能在你的大脑中解码它。弄清楚它的含义和作用需要明显且不舒服的时间。心理学家将这称为认知负荷。
尝试这样做:
var lastIndexedValue = 1;
var oldValue = 0;
if(oldValue == lastIndexedValue)
{
oldValue++;
}
else
{
lastIndexedValue = oldValue * (lastIndexedValue++);
}
这样更好。不再需要思考。保留它,等你真正需要的时候再用。
简洁是更糟的
说到认知负荷,我知道很多开发者喜欢简洁的语法。让我给你举个例子。我在 Stack Overflow 上找到了一个完美的例子,在 stackoverflow.com/questions/7103979/nested-ternary-operators/7104091#7104091:
_viewModel.PhoneDefault = user == null ? "" :
(string.IsNullOrEmpty(user.PhoneDay) ?
(string.IsNullOrEmpty(user.PhoneEvening) ?
(string.IsNullOrEmpty(user.Mobile) ? "" :
user.Mobile) :
user.PhoneEvening) :
user.PhoneDay);
它如此简短,以至于无法扫描。你可能需要坐下来几分钟才能弄清楚它做什么。一些开发者认为以这种方式编写代码使他们看起来比其他人聪明。事实并非如此。这就像一个英语作家过分谄媚和顺从地使用长词。你明白了吗,或者你的眼睛是不是扫了几次这句话?我可以说,“这就像一个英语作家故意迎合过度使用长词的倾向。”优秀的作家可以写出让受过大学教育的人群理解的文章,也就是说,他们的同行。伟大的作家可以用同样的内容让一群有才华的六年级学生理解。这需要与简洁写作一样多的集中精力,但你可以用你的代码做到这一点,所有负责维护的人都会为此感谢你。
让我们重构它:
if(user == null)
{
_viewModel.PhoneDefault = string.Empty;
}
if(!string.IsNullOrEmpty(user.PhoneDay))
{
_viewModel.PhoneDefault = user.PhoneDay;
}
if(!string.IsNullOrEmpty(user.PhoneEvening))
{
_viewModel.PhoneDefault = user.PhoneEvening;
}
if(!string.IsNullOrEmpty(user.Mobile))
{
_viewModel.PhoneDefault = user.Mobile;
}
嘭!欢迎来到最不吸引人的代码。如果你把它发到 Reddit 上,你最好的希望是在一长串恶评中偶尔得到一个“嗯”的评论,而这些评论并不局限于你的代码。话虽如此,现在你可以扫描它了,管理设置默认电话号码的规则相当明显。这是因为虽然代码简短是坏事,但我们至少使用了明显的对象和属性名称来表示正确的意图。
注释,但不要过度
注释你的代码是好的。我读过一些学术论文,它们提出了多达三分之一的代码行用于注释的坚实论据。问题是这种做法危险地接近编写文档,而程序员最讨厌的事情莫过于直接与用户互动。好消息是——如果你使用我之前强调的想法编写干净代码,你可以减少注释,因为你的代码本身已经很容易阅读。
我认为一个合适的平衡点是通过注释简要重申需求来实现的。如果你的需求已经按照应有的方式记录在在线系统中,你甚至可以链接到该需求。
我还会对任何不明显的东西进行注释,比如我为什么以某种方式编写某段代码的动机。这是一个有用的注释:我这样做是因为我们的供应商要求数据以这种格式存储。 这样,我的团队在我之后进来,看到他们认为需要重构的东西,但最终破坏了符合客户要求的代码。
当注释过多时,注释就会变得糟糕。除非你正在写一本书,或者教授初学者阅读代码,否则逐行注释是愚蠢的。
使用 SOLID 原则创建可维护的系统
SOLID 是指面向对象设计(OOD)的前五个原则:
-
单一职责原则
-
开放-封闭原则
-
Liskov 替换原则
-
接口隔离原则
-
依赖倒置原则
遵循这些原则将允许你创建健壮、可扩展和可维护的系统。遵守这些原则为你使用模式做好了充分的准备,因为许多模式都是基于或引用这些原则的。
单一职责原则
每个方法都应该只做一件事。每个类都应该代表一件事。我们称这个想法为单一职责原则(SRP)。如果你在对象内部有一个方法做了很多事,而没有调用外部方法,那么你的方法就做得太多了,存在变成被称为神函数的反模式例子的风险。这些都是大杂烩,难以消化的意大利面。有一次,我收到一位同事的绝望短信。她的程序崩溃了。她不知道为什么。我检查了一下。整个程序都在一个文件里,打印出来超过 20 页长。整个程序有九个函数。我关闭了项目,推迟了审查,等到我可以坐下来认真处理的时候再进行。我高度怀疑她忽略了 SRP。
让我们看看一个过度设计的例子:
public void doesTooMuch()
{
StreamReader sr = new StreamReader("C:\\Sample.txt");
var line = sr.ReadLine();
我们开始了一个明显会做很多事的功能。我们首先在我们的电脑硬盘上打开一个文本文件,并读取第一行。接下来,让我们逐行读取文件中的每一行,并将每一行文本发送到一个虚构的在线服务,该服务将文本翻译成另一种语言。
为了有效,句子需要文本被修剪,全部大写,并且其中不能有分号,因为服务的作者曾经被 SQL 注入攻击过一次,他发誓再也不这样做了:
while (line != null)
{
Console.WriteLine(line);
var processedLine = line.Trim();
processedLine = line.ToUpper();
processedLine.Replace(";", ""); // no sql injection
好的,文本准备好了。让我们发送它:
var url = "https://fake-translation-
service.com/translate";
var httpRequest =
(HttpWebRequest)WebRequest.Create(url);
httpRequest.Method = "POST";
var data = "{\"input\":\"" + processedLine + "\"}";
using (var streamWriter = new
StreamWriter(httpRequest.GetRequestStream()))
{
streamWriter.Write(data);
}
我们已经传输了数据;让我们解析响应并将其打印到控制台:
var httpResponse =
(HttpWebResponse)httpRequest.GetResponse();
using (var streamReader = new
StreamReader(httpResponse.GetResponseStream()))
{
var result = streamReader.ReadToEnd();
Console.WriteLine("Translates to " + result);
}
读取下一行,清洗,重复,直到我们到达文件的末尾:
line = sr.ReadLine();
}
如果你打开了它,你应该关闭它,所以让我们清理一下:
sr.Close();
}
你能在这里看到问题吗?我们有一个方法执行了多项操作:
-
我们打开一个文件。
-
我们处理每一行。
-
我们将信息传输到服务端。
-
我们处理结果。
这些都应该被分离到它们自己的方法中。当你这样做时,你可以在其他上下文中重用这些方法来解决其他问题。读取文件是通用的。这是你经常要做的事情。同样,清理你的输入字符串也是。同样,向RESTful端点发送也是。你不能重用doesTooMuch()方法。它太具体于一个实现点。在第三章,“用创建型模式发挥创意”中,我们将摘掉训练轮子,开始学习模式。模式与这种类型的神函数完全不相容。
开放封闭原则
类应该对扩展开放,但对修改封闭。这被称为开放封闭原则(OCP)。这对于已经投入生产的软件尤其如此。你已经有一组编写良好、完全测试的生产类。乱动连接会引入破坏东西的风险。当新代码可以作为现有代码的扩展来编写时,风险就限制在扩展上了。
让我们通过查看违反 OCP 的代码示例来分析一下。我们将创建一个简单的实用工具,用于计算一组几何形状的面积总和。对于我们的初始版本,我们将支持圆形和正方形。我将这样表示Circle库:
public class Circle
{
public double Area { get; }
public Circle(double radius)
{
Area = Math.PI * (radius * radius);
}
}
我已经设置了一个只读的Area属性。没有理由允许某人直接设置该属性。相反,我使用构造函数强制你在实例化时定义圆的半径。这防止了你将Area设置为任何你想要的东西,这可能会导致对象进入无效状态。
当你实例化时,你传入半径,这是我们找到圆面积所需的所有信息。古老的π•r²,或者常数π乘以半径的平方。面积会自动设置。
我们可以用类似的方法处理正方形。我们只需要知道一边的长度:
public class Square
{
public double Area { get; }
public Square(double lengthOfOneSide)
{
Area = lengthOfOneSide * lengthOfOneSide;
}
}
如前所述,我们将area属性作为只读属性提供,并使用构造函数在实例化时自动设置面积,通过将lengthOfOneSide参数自乘来实现。
现在,我有两个类来表示我的形状,每个类在实例化时都会自动设置area属性。我需要的只是一个将它们粘合在一起的类:
public class AreaCalculator
{
private double _area { get; set; }
public double Area { get { return _area; } }
public void AddShape(Square square)
{
_area += square.Area;
}
public void AddShape(Circle circle)
{
_area += circle.Area;
}
}
这里,我们有一个名为AreaCalculator的类,它有一个area属性。在这个例子中,我选择创建一个后端变量,以便更容易地保持运行总账。每次使用两次重载的AddShape方法添加正方形或圆形时,它都会将实例化时计算的面积添加到总和中。
我可以用以下代码测试其功能:
var areaCalculator1 = new AreaCalculator();
areaCalculator1.AddShape(new Square(5d));
areaCalculator1.AddShape(new Square(25.3452d));
areaCalculator1.AddShape(new Circle(2342.093d));
Console.WriteLine("The total area of the shapes is " + areaCalculator1.Area);
这看起来相当不错!现在就发货吧!一切顺利,直到我们高兴的客户回来要求支持更多形状。这难道不难吗?也许他们只是想要一个矩形。我需要做的就是创建一个Rectangle类,然后修改AreaCalculator类以添加另一个构造函数。
如果我这样做,我就违反了 OCP,因为每次我们得到新的形状要求时,都必须直接更改AreaCalculator类。我应该设计它,以便你可以传递任何具有area属性的任何东西,这样我就永远不需要再次更改AreaCalculator了。让我们修复它。
我将创建一个接口来定义我对Area属性的要求:
public interface IShapeWithArea
{
public double Area { get; }
}
现在,让我们修改形状类以实现接口。这很简单,因为它们都已经以符合接口要求的方式公开了area属性。
首先,让我们看看圆形:
public class OCPCircle : IShapeWithArea
{
public double Area { get; }
public OCPCircle(double radius)
{
Area = Math.PI * (radius * radius);
}
}
我将类名重命名为 OCPCircle,这样我就不会弄混它们了。现在,让我们来做正方形:
public class OCPSquare : IShapeWithArea
{
public double Area { get; }
public OCPSquare(double lengthOfSide) { Area =
lengthOfSide * lengthOfSide; }
}
接下来,我将修改 AreaCalculator 类,使其在 AddShape 方法上使用接口作为其类型:
public class OCPAreaCalculator
{
private double _area { get; set; }
public double Area { get { return _area; } }
public void AddShape(IShapeWithArea shape)
{
_area += shape.Area;
}
}
太棒了!现在,我们再也不需要修改这个类中的这个方法了。它是可扩展的,但不可修改。为了满足任何新形状的要求,我只需要添加一个新的类来实现 IShapeWithArea 接口。
我将添加一个实现相同 IShapeWithArea 接口的矩形类:
public class OCPRectangle : IShapeWithArea
{
public double Area { get; }
public OCPRectangle(double width, double height)
{
Area = width * height;
}
}
无论如何客户接下来会提出什么要求,添加对六边形、菱形或十二边形的支持只是简单地通过谷歌搜索该形状的面积公式,然后创建一个新的类来实现我们的接口。
利斯科夫替换原则
在 1988 年,芭芭拉·利斯科夫在一场名为 数据抽象和层次结构 的会议上发表了主题演讲,其中她介绍了后来被称为 利斯科夫替换原则(LSP)的概念。
这个原则指出,任何子类的对象都应该是一个合适的、可以工作的其超类替代品。这实际上更多地依赖于继承。使用我刚刚展示的策略,即更多地依赖接口而不是继承,你不太可能违反 LSP。到现在,你无疑已经推测出我喜欢打破规则(问问任何人),所以让我们打破这个规则。
我正在努力实现的需求与上一个例子相似,但并不完全相同。在这里,我有一个计算矩形面积的要求——仅仅是矩形。为了使这可行,假设我没有在上一个例子中展示出最佳解决方案:
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
public double Area { get { return Width * Height; } }
}
我这次稍微不那么谨慎了。我使用了正常的自动实现属性来设置宽度和高度,而不是构造函数。如果我正在违反规则,我干脆完全放松。Area 属性得到了适当的封装,所以你在这里不会做出什么太过分的事情。
现在,让我们创建一个类来处理以与上一个例子中处理 OCP 的方式非常相似的方式来计算矩形的面积:
public class RectangleAreaCalculator
{
public double Area { get; set; }
public void AddShape(Rectangle rectangle)
{
Area += rectangle.Area;
}
}
这相当直接。在你创建这个类不到一个小时后,那位无处不在的爱出风头的老板告诉你有需求变更。你需要支持正方形。
你可能会想,没问题。正方形是矩形的一种。显然,我们可以通过继承来实现这一点。也许我们根本不需要做任何改变,但那位爱出风头的老板必须得到安抚。我之前已经强调了“是”这个术语,因为这个短语描述了继承,而且确实如此。正方形是矩形的一种。我们创建了这个类。由于我们正在使用继承,我们需要快速修改一下 Rectangle 类,以便它能够支持继承。我们将属性设置为虚拟的,这样我们就可以在子类中覆盖它们,如果需要的话:
public class Rectangle
{
public virtual double Width { get; set; }
public virtual double Height { get; set; }
public virtual double Area { get { return Width * Height; } }
}
关于正方形的事情是,它实际上是矩形的,但我们只需要一边的长度来计算面积。父类Rectangle需要两边的长度。突然,事情开始看起来有点难看。让我们在打字的同时思考:
public class Square : Rectangle
{
private double _lengthOfSide;
public override double Width {
get { return _lengthOfSide; }
set { _lengthOfSide = value; }
}
public override double Height {
get { return _lengthOfSide; }
set { _lengthOfSide = value; }
}
public override double Area {
get { return Width * Height; }
}
}
我有一个很棒的想法!我们可以修复它,以便我们有一个后备变量。每次你修改宽度或高度时,accessor方法只是简单地改变后备变量,该变量由Area属性的 getter 引用。由于宽度和高度都设置为相同的东西,乘以宽度乘以高度将给出正方形的面积。它不会弄乱整个矩形类需要两个值。现在,我想让你访问 YouTube 并搜索Guinness 啤酒商业广告精彩。挑选任何看起来滑稽的视频。我会等着。太棒了!这需要庆祝,也许甚至需要一杯饮料。它会起作用吗?当然会的。我今天会发货,感觉特别特别,就像我因为聪明而逃脱了什么淘气的事情一样。事实上,现在可能是一个去交税的好时机。
同时,在完全不同的部门,最近被尖头老板雇佣的新手开发者正在与一个类似的要求作斗争。自然,我们想要尽可能多地重用代码。新手拿走了你的代码,但做了完全出乎意料的事情。要求必须更改宽度和高度的数字。不管为什么。你知道上一个质疑尖头老板技术方向的真实性的新手发生了什么事吗?他们在某个又热又潮湿的地方的一个地下室隔间里,没有空调,正在尝试使用 Z80 汇编语言在穿孔卡片上加快冒泡排序算法。他们的休息室里没有咖啡机。他们甚至没有赫曼·米勒椅子。这简直是野蛮。你不想成为那样的人,对吧?
这里是新手的代码:
public class LiskovAreaCalculator
{
public double Area { get; set; }
public void AddShape(Rectangle rectangle)
{
rectangle.Width = 10;
rectangle.Height = 20;
if (rectangle.Area != 200)
{
throw new Exception("Bad area!");
}
else
{
Area = rectangle.Width * rectangle.Height;
}
}
}
棒吗?也许吧,但可能不是。某些要求迫使新手在超类中独立更改宽度和高度的值。对于设计用于此类事情的Rectangle超类来说,这不是问题。然而,当你尝试用正方形替换矩形时,这绝对会破坏正方形的实现,尽管正方形和矩形之间有明显的“是”关系。
如果你熟悉达尔文奖,你就会知道,每个获奖者在开始他们获得奖项的事情之前都会说出同样的话。在德克萨斯州,或者几乎任何南方州,事情是这样的:嘿,大家?拿着我的啤酒,看着这个。
我将给你展示一个 Liskov 替换的例子。
var test1 = new LiskovAreaCalculator();
var testRectangle = new Rectangle();
testRectangle.Width = 5d;
testRectangle.Height = 6d;
test1.AddShape(testRectangle);
// Don't forget the answer won't be 30 on purpose.
//It prints 200.
// That's not the problem.
Console.WriteLine("Area of test rectangle is " + testRectangle.Area);
到目前为止,一切顺利。一切正常工作。接下来的这部分是它开始出错的地方:
var testSquare = new Square();
testSquare.Width = 5d;
test1.AddShape(testSquare);
Console.WriteLine("Area of test square is " + testRectangle.Area);
如果我们将一个正方形传递给AddShape,我们得到的答案不是 200。它变成了 400,这导致抛出了错误。
由于我们勇敢的新手需要更改base类以独立修改值,我们打破了 LSP(Liskov 替换原则),它指出我们必须能够用子类Square替换父类Rectangle。虽然我们在Square类中的巧妙覆盖看起来很聪明,但这并不适用于所有情况。你可能会说我们试图将方钉塞入圆孔。然后,你可能会重新考虑,要求找回那杯啤酒,并赞赏地凝视着早期 OCP(开闭原则)解决方案的简洁性,该解决方案可能是解决这个问题的最佳方案。
你现在看到了一个违反 Liskov 替换原则的例子。毫无疑问,你想要看到它被正确地实现。我很乐意在后面的章节中满足你的愿望,这些章节充满了对 LSP 的引用。
接口隔离原则
没有类应该被迫实现它不使用的接口,也不应该依赖于它不使用的方法。你不讨厌这种情况吗?你被迫使用你不需要或想要的东西?毫无疑问,新手在 Liskov 替换问题上的失败就是这种感觉。然而,我们是程序员,不是心理学家,所以让我们继续前进,并扩展通过接口隔离原则(ISP)反对被迫使用我们不需要的东西的想法。
假设我们得到了一个新的需求,需要我们处理二维和三维形状,我们需要二维形状的面积和三维形状的体积。自然地,我们希望坚持使用有效的方法,并且 OCP(开闭原则)部分中提到的接口想法似乎效果很好,这也证明了它在 Liskov 场景中的有用性。
这次,秃头老板没有给我们一个新手,而是给我们一个经验丰富的老兵。不幸的是,这位经验丰富的老兵患有由“不是我自己发明的”综合症引起的心理性失明。也就是说,当他看到任何他个人没有发明的解决方案时,他会立刻认为该解决方案有缺陷,并使用从海事专业人士那里常听到的一些色彩丰富的语言来表达他对该解决方案的蔑视。简而言之,当他描述他未编写的代码时,就像一个水手在诅咒,尽管编写该代码的人可能就在几个隔间之外。鉴于这个人对尴尬一无所知,因为他是最聪明的人,他决定我们之前写的任何东西他都不需要,于是他独自一人开始了自己的行动。
他很匆忙,而且这项工作对他来说太简单了。他想回到同时解决旅行商问题和多体问题,并且运行时间为 O(log n)。你的愚蠢项目挡了他的路。他半心半意地输入以下内容:
public interface IPollutedShape
{
public double Width { get; set; }
public double Height { get; set; }
public double Depth { get; set; }
}
好吧,他不会称之为IPollutedShape。我为你取了这个名字。我在技术预销售领域工作了 3 年,我学到了一些让我印象深刻的东西。永远不要让真相或任何现实主义阻碍一个好故事。
你可以在不查看实现的情况下看到问题,对吧?如果我创建了一个扩展此接口的cube或cuboid类,那就没问题。它们都是三维形状,我们需要三个维度来计算体积。
然而,如果我们使用与二维形状相同的接口,我们就被迫有一个Depth属性,这在这样一个类中是没有位置的:
public class PollutedSquare : IPollutedShape
{
public double Width { get; set; }
public double Height { get; set; }
public double Depth { get; set; } //this is useless!
public double getArea()
{
return Width * Height;
}
}
哎呀!我们得到了这个额外的属性,它在那里什么也没做!这让我想起了那次我的阿姨琳达嘴里有菠菜。不管怎样,我们只制作了一个接口就节省了时间。我们的自大的超级程序员可以回去填补艾萨克·牛顿的空白。
我们现在违反了 ISP,因为我们强制使用了一个我们不需要的属性或方法。那个家伙没有注意到。有人问卡内基梅隆大学的研究生生活是怎样的,他最喜欢谈论的是他读研究生时的时光,当时他简化了自己的最佳作品,以便教授们能理解他的作业。这太完美了。趁他没注意,让我们来修复它。
显然,我们需要两个接口。与只能有一个超类的情况不同,你可以在一个类上实现多个接口。在 C#中,有一个更干净的方法。让我们为二维形状制作接口:
public interface ITwoDeeShape
{
public double Width { get; set; }
public double Height { get; set; }
}
然后,我们这样做:
public class SquareISP : ITwoDeeShape
{
public double Width { get; set; }
public double Height { get; set; }
public double getArea() { return Width * Height; }
}
这很简单。一个三维形状使用与二维形状相同的部件,但增加了深度。让我们来做这件事:
public interface IThreeDeeShape : ITwoDeeShape
{
public double Depth { get; set; }
}
C#允许在接口中使用继承。我使用了接口中定义的二维形状的属性,并将其扩展为需要表示深度的属性。现在,让我们输入cube类以获胜:
public class CubeISP : IThreeDeeShape
{
public double Width { get; set; }
public double Height { get; set; }
public double Depth { get; set; }
public double getVolume()
{
return Width * Height * Depth;
}
}
哇!我们通过只强制执行所需的内容而没有更多内容来遵守 ISP。如果你喜欢,堆叠多个接口没有问题。你还可以在你的接口中使用继承,就像我这样做的一样。
依赖倒置原则
当你第一次学习如何设计继承层次结构时,你会知道高级对象建立基础,而低级对象则在这个基础上形成依赖。依赖倒置原则(DIP)将这种关系颠倒过来,并形成了一种非常重要的实践——解耦的基础。我们在之前的 OCP 示例中看到了解耦的力量。我们学习了如何使用接口来防止两个具体类之间的强耦合。
我要提出一个警告。表面上看,依赖反转听起来与依赖注入(DI)相似。它也听起来与控制反转(IoC)相似。这些概念并不相同。鉴于我们刚刚讨论了 Liskov 替换,你应该明白它们是不可互换的。原因微妙但确实存在。我不想离题太远,所以我在本章末尾的进一步阅读部分留给你一个链接,在那里 Martin Fowler 解释了这三个看似同义的词之间的微妙差异。让我们将我们的焦点重新回到 DIP 上。这个原则指出两点:
A. 高层类或模块不应该依赖于低层类或模块,两者都应依赖于抽象。
B. 抽象不应该依赖于实现细节,而细节应该依赖于抽象。
对于我来说,这听起来就像武侠电影中少林僧人在脱掉上衣,平静地进入生死搏斗之前可能会说的话。让我们看看我们是否能够将其带到现实生活中。考虑一下你可能会如何使用面向对象原则来设计一个灯。一种可能的方式如下:
public class CoupledLamp
{
public bool IsLit { get; set; } = false;
}
有一个灯。我们需要一个按钮来开关它:
public class CoupledButton
{
public CoupledLamp Lamp { get; set; }
public void ToggleLamp()
{
if (Lamp.IsLit)
{
Console.WriteLine("The lamp is off");
}
else
{
Console.WriteLine("The lamp is on");
}
Lamp.IsLit = !Lamp.IsLit;
}
}
这个灯会工作。当你触发按钮内的ToggleLamp方法时,灯会按预期打开或关闭。然而,它违反了 DIP。灯是一个高级对象,而按钮是一个低级对象的想法感觉直观。但是,将灯放在按钮里似乎有些不对劲。你可能会认为它应该是相反的,就像现实生活中一样。你在这里的直觉导致违反了 DIP 的两个原则。高层对象在低层对象内部,而且一切都是具体的类,没有抽象的概念。我们该如何解决这个问题呢?
我们可以使用组合。记住,组合意味着从组件对象中构建或组合一个对象。这可能类似于以下内容:
public class ComposedButton
{
private bool _isOn = false;
public bool Toggle()
{
_isOn = !_isOn;
return _isOn;
}
}
这里是我们的按钮,里面没有灯。相反,按钮应该包含在灯中。换句话说,我们应该使用按钮来组合一个灯:
public class ComposedLamp
{
private bool _isLit;
public ComposedButton Button { get; }
public ComposedLamp(ComposedButton button)
{
Button = button;
}
public void ToggleLamp()
{
_isLit = Button.Toggle();
if (_isLit)
{
Console.WriteLine("The lamp is on");
}
else
{
Console.WriteLine("The lamp is off");
}
}
}
我们反转了依赖关系。按钮在灯内部,低级对象直接依赖于高级对象。我们已经满足了 DIP 的第一个原则。在我们达到少林级大师之前,我们还有更多的工作要做。按钮和灯仍然通过具体的对象实现紧密耦合。如果我们想用除了按钮以外的其他东西来打开灯呢?也许是一个运动探测器。也许是一些大家都在谈论的物联网(IoT)微控制器。实际上,这仍然是一个按钮,但它要复杂得多,其对象表示会比我们的按钮更复杂。我们需要一个关于按钮的抽象,它允许我们插入任何可以作为按钮工作的对象。让我们创建一个描述灯需要的接口:
public interface IToggleServer
{
bool ToggleOnOff();
}
然后,我们让按钮扩展接口:
public class DIPButton : IToggleServer
{
private bool _enabled = false;
public bool Enabled { get { return _enabled; } }
public bool ToggleOnOff()
{
_enabled = !_enabled;
return _enabled;
}
}
现在,我们重新布线灯:
public class DIPLamp
{
public IToggleServer DipoleSwitch { get; set; }
public DIPLamp(IToggleServer dipoleSwitch)
{ DipoleSwitch = dipoleSwitch; }
public void ToggleLamp()
{
if (DipoleSwitch.ToggleOnOff())
{
Console.WriteLine("The lamp is on");
}
else
{
Console.WriteLine("The lamp is off");
}
}
}
如往常一样,我稍微改变了一些名称,以便我们可以保持我们的例子一致。我将之前持有我们具体按钮的成员变量改名为更通用的DipoleSwitch,我认为它可以描述几乎任何实现二进制开/关功能的任何事物。
我们现在有一个灯,其中包含高级和低级对象,并且它们处于正确的方向。我们可以争论说,它们现在是正确的,而且我们最初是以错误的方式倒置的。有点像在美国南部,当有人给你提供加糖茶或不加糖茶时。根本就没有不加糖的茶。这并不是说你在里面加了糖然后再把它取出来。它在制作时就未加糖。措辞很奇怪,但我们仍然那样说。这就是我们的灯在按钮类内部开始时的样子。它工作得很好,每个人都知道你的意思,但这仍然是错误的。
我们还移除了具体的按钮对象,并用接口替换了它。我们本可以使用抽象类来满足第二个原则,但我仍然认为我们应该倾向于接口,因为它们更灵活。
在学习 DIP 的过程中,我们了解了解耦的含义。解耦是一种策略而不是一种模式,它可以在你的软件项目的许多不同层面上有许多用途。
超越开发组织衡量质量
在本章中,我们花了很多时间从软件工程的角度讨论质量代码。软件永远不会在真空中开发。它总是与致力于解决业务问题的商业专业人士一起开发。这意味着你的项目中的许多人可能不是开发者或工程师。到目前为止,我们所有的讨论都是围绕特定的工程实践展开的。然而,你需要意识到,这些观点不会得到你的以业务为导向的同事的认同。
对他们来说,“质量”通常被定义为“符合要求”。这意味着高质量代码是满足其要求的代码。我认为这是一个过于基本的定义。20 世纪 80 年代和 90 年代的 corporate quality movements,如 Deming 提倡的精益制造和零缺陷理念,已被有良心的管理者引入我们的领域。他们希望将 100%的精力集中在客户身上。他们很难与我们联系,因为他们对软件的了解仅限于用户看到的表面层。你的非工程师同事在谈论质量时会遇到困难,因为你正在使用不同的语言。
为了让大家达成共识,让我们考虑一些问题:
-
“高质量代码”这个术语对你来说意味着什么?
-
对你的老板来说,它意味着什么?
-
对你的老板的老板来说,它意味着什么?
-
对你的最终用户来说,它意味着什么?
-
对于你的供应商来说,这意味着什么,即那些提供驱动你工作的原始过程输入的商业专业人士?
在完成这个练习后,你很可能已经探索了广泛的视角。
代码审查
在一个期望一切都能立即获得的时代,强调重构作为一种常规练习变得越来越困难。尽快解决一个错误报告很容易,草率地做些事情,然后继续处理下一个错误。跑步者有“跑步者的快感”,指的是在跑步过程中某个时刻内啡肽的释放。我个人不知道。我只在被迫时才跑步。我认为程序员在将问题从“进行中”移至“完成”时对多巴胺的依赖有相似之处。同样,如果你有一个向上级报告的项目经理,总是以处理的问题数量来展示进度,而不是使用实际的质量指标并关注代码库的健康状况,这很诱人。
最佳实践是进行代码审查。
代码审查可能是维护健康代码库的最重要实践,这导致开发者快乐。在组织为了在激烈竞争中保留其开发人才而投入大量精力的时候,你将经历更少的已发布错误、更少的停机时间和更高的保留率。代码审查是投资个人贡献者的一种简单且成本效益高的方式。接受审查的开发者,通常由资深团队成员进行,将学会更好的技术和更多模式。资深开发者可以帮助他们的初级同事识别他们可能从培训中受益的领域,在课程开始时,通过分配一本关于模式的优秀书籍。希望你现在能处于推荐一本书籍的位置。
本书对你在软件开发领域的经验水平假设非常少。遵循这一原则,你可能现在正在想代码审查是什么。一个糟糕的代码审查是有人匆匆浏览你的代码,一两分钟后说,“嗯。看起来不错。”然后他们签字并回到他们之前的工作。代码审查需要像会议一样安排。如果代码审查是打扰了别人的工作,它将不会产生结果,也不会有效。
在进行代码审查时,有两个关键领域需要考虑:整体设计和功能。
整体设计
这本书最终是为了帮助你提高你对软件设计的理解和实践。从逻辑上讲,我会在审查中提到这一点。我不可避免地会问以下问题:
-
新代码如何融入生产代码的整体架构?
-
代码是否遵循 SOLID 原则、领域驱动设计、行为驱动设计或其他团队遵循的范式?
-
是否遵守了格式和风格约定?事物的名称是否遵循我们之前制定的指南?它们是否合理?是否易于阅读?是否具有自描述性?
-
代码是否符合本章前半部分的阅读性要求?
-
使用了哪些模式,它们是否合适?
-
代码是否在正确的位置?我们不希望有千层面!
-
新代码是否可以在其他地方重用?这可能需要将其移动。
-
新代码能否替换我们已有的东西?如果你的添加有 100 行代码可以替换 1000 行,那将是一个巨大的胜利!
-
现在我们有了这个新添加的功能,我能从当前代码中移除什么?当我能够移除代码时,我认为这是一件大事。如果这也能应用到美国税法上就好了!
-
代码是否过于巧妙?巧妙的代码需要通过理解它所需的认知负荷来付出努力。寻找使你的代码可扫描的机会。
-
除了可读性外,它是否充满了未被请求或指定的功能?它是否充满了非常不可能被需要的用例的代码?这被称为你不会需要它(YAGNI)。
使用“ ain't”这个词可能会激怒我的编辑、我的妻子、我的老板、我的牧师和我的六年级英语老师,但就是这样。大家处理好,好吧。在审查了整体设计之后,审查可以转向功能。
功能性
审查的重点应该是确保代码做了它应该做的事情。它还应该询问关于我们如何知道代码已经完成的问题,而不仅仅是当我们从进行中状态更改问题时所感受到的温暖感觉:
-
来自不知道代码的其他组的人能否查看这段代码并理解它?
-
自动单元和回归测试是否始终通过?什么?你没有测试?你需要测试!
-
对于新功能是否有文档?最终用户将如何知道如何使用这些新功能,或者甚至知道它们的存在?
-
异常是否得到了充分的沟通,以便向技术团队提供完整的错误报告,而不责怪或无谓地混淆用户?
-
这段新代码是否在你的安全中引入了漏洞?
-
你的行业是否有任何违反监管约束的情况?
-
你是否对性能进行了基准测试?我们能加快任何事情吗?
-
现在将此推向生产是否涉及任何风险?
你还没有想到什么?
这件事很难。你不能自己完成。你需要与其他开发者的代码审查。虽然这不是一个详尽的清单,但这些问题应该能帮助你朝着正确的方向前进。
摘要
这一章的全部内容都是关于准备你改变工作方式和思考创建软件的方式。学习模式是一件大事。它们通常鼓励对项目质量和可维护性进行重大改变。
我们从一些常见的隐喻开始,描述了软件项目典型的退化,即意大利面代码和千层面代码。意大利面代码代表一种混乱的结构。千层面代码代表一种分层结构,层与层之间存在泄漏的状态和功能。意大利馄饨代码代表最好的代码,因为它使用了意大利面和千层面相同的原料,但意大利馄饨的内容是完全封装的。
我们介绍了一些通常占据整本书的主题。SOLID 原则是大多数严肃编码组织的指南星,但我很少看到它们被教授。初学者和中级程序员通常过于专注于语言和语法,以至于没有发展出规划和管理架构所需的肌肉。
如果这是你的情况,SOLID 设计原则的五个原则是一个极好的起点,并且在深入研究模式之前应该理解。
我们以一些关于执行代码审查的要点结束。
你现在准备好开始一段冒险了。你已经准备好了所有的预备装备,并且准备好应对我可能向你抛出的任何挑战。在下一章中,我们将开始学习模式,从创建模式开始。
进一步阅读
-
Martin, R. C., & Martin, M. (2006.) 敏捷原则、模式和 C# 实践 (Robert C. Martin.) Prentice Hall PTR.
-
Martin, R. C. (2009.) Clean code: a handbook of agile software craftsmanship. Pearson Education.
-
Schubert, B (2013.) DIP in the Wild [博客文章]. 从
martinfowler.com/articles/dipInTheWild.xhtml#YouMeanDependencyInversionRight.获取。

第二部分:你需要现实世界中的模式
这些章节解释了四人帮(Gang of Four)创建的最有用和最受欢迎的模式。我们严格关注那些在现实世界项目中经常出现的模式——这些模式你绝对应该知道,以便将你的技艺和职业生涯提升到下一个层次。在每种情况下,我们将提供一个通用的 UML 图,然后是实际应用。通用模式图将被重新绘制以适应问题,然后我们将根据图示编写代码解决方案。
本部分涵盖了以下章节:
-
第三章, 用创建型模式激发创造力
-
第四章, 用结构型模式加固你的代码
-
第五章, 通过应用行为模式整理问题代码
第三章:创造性使用创建型模式
创建型模式处理对象的创建,这个过程我们称之为实例化。记住,对象是一个已经被实例化的类。对象只存在于运行程序中。它们是由称为类的蓝图构建的。由于 C#是一种静态语言,一旦对象被实例化,通常无法更改其结构,这意味着你应该使用最佳策略来创建你的对象。这就是我们将在本章讨论的内容。
即使你是 C#软件开发的new手(这可能是书中第一个仅依靠格式来搞笑的双关语),你也已经知道从类中实例化对象的最简单方法。你只需使用new关键字并调用类的构造函数:
var myConcreteClass = new ConcreteObjectThingy();
这就是实例化。你正在创建一个类的实例,这个类作为类转变为对象的点。在 C#中,像许多语言一样,我们使用new关键字与构造函数一起使用。构造函数是一个方法,其名称与类的名称匹配。它的唯一任务是创建并返回一个类的实例作为新对象。这就是为什么CreateObjectThingy()的末尾有一个括号的原因。
我们为什么还需要其他东西呢?记住这本书前面我们讨论的基础原则。我们不希望有一个由我们无法构建的许多对象类组成的管道系统,因为它们太具体、太具体,或者彼此之间耦合得太紧密。根据定义,new关键字创建一个具体对象。我们的类应该易于扩展但不易于修改。然而,在管道软件中,它们并不是管道软件,它们不是。遵循模式将帮助我们避免这些情况,并为我们提供一种创建复杂对象并易于理解和扩展的清晰方式。
在本章中,我们将涵盖以下主题:
-
初始设计
-
没有模式实现
-
简单工厂模式
-
工厂方法模式
-
构建者模式
-
对象池模式
-
单例模式
这些模式将通过简单的 C#命令行项目来展示,以保持象征性的信号与噪声比低。有许多书籍独立于任何编程语言教授模式。我认为这是一个错误。大多数人通过实践来学习,而要“实践”模式,你需要一个实现语言。没有那个,你只是在学习学术概念。我们专注于现实世界,我们使用 C#,但你在本书中学到的知识是 100%可移植的。当你了解这些模式时,请记住,它们不是语言特定的。你可以使用在这里获得的知识并将其应用于 Java、Python 或任何其他面向对象的语言。
技术要求
在整本书中,我假设你知道如何在你的首选集成开发环境(IDE)中创建新的 C#项目,所以我不会在设置和运行项目的机制上花费任何时间。如果你决定尝试这些,你需要以下内容:
-
运行 Windows 操作系统的计算机。我使用的是 Windows 10。由于项目是简单的命令行项目,我相当确信这里的一切也都可以在 Mac 或 Linux 上运行,但我还没有在这些操作系统上测试过这些项目。
-
支持的 IDE,如 Visual Studio、JetBrains Rider 或带有 C#扩展的 Visual Studio Code。我使用的是 Rider 2021.3.3。
-
.NET SDK 的某个版本。再次强调,项目足够简单,我们的代码不应该依赖于任何特定的版本。我恰好使用的是.NET Core 6 SDK。
-
你可以在 GitHub 上找到本章的完成项目文件,网址为
github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns/tree/main/chapter-3。
以下故事是虚构的
与任何在世或已故的人物的相似之处纯属巧合。
来认识凯蒂和菲比——这对出生于德克萨斯州的姐妹,她们都对骑自行车有着共同的热爱。菲比在德克萨斯州达拉斯的南方卫理公会大学学习工程学,这是姐妹俩的家乡。德克萨斯州是美国最南部的几个州之一。凯蒂在德克萨斯州西部的 Sul Ross 大学学习工业设计。纯粹是命运的安排,她们俩都在 MegaBikeCorp 获得了暑期实习机会,这是一家大型跨国自行车制造公司:

图 3.1:凯蒂(左)和菲比(右)在他们的机器人工厂中完全建造的创新自行车原型前摆姿势。
在 MegaBikeCorp 工作期间,姐妹俩有机会尝试几种不同类型的自行车。菲比住在大城市,偏爱公路自行车。公路自行车使用菱形车架,两个大而细的轮子,光滑的轮胎,以及丰富的变速器来帮助骑行者应对他们在许多城市经常遇到的陡峭的山丘:

图 3.2 – 凯蒂为她称为 Hillcrest 的公路自行车所做的 CAD 设计
骑行在公路自行车上的骑行者会向前倾斜,身体靠在低矮的车把上。这使得这些自行车速度很快,每年,来自世界各地的数百名骑行者都会参加一项名为环法自行车赛的著名赛事。在比赛中,选手们竞争,看谁是世界最快的。基蒂和菲比回忆起,她们的母亲,一个狂热的自行车爱好者,不允许在电视播放比赛时家里任何人说话。菲比也喜欢环法自行车赛,当她骑着她的公路自行车穿越达拉斯的街道时,她经常会渴望骑得更快。这需要一辆不同的自行车。
当菲比需要满足她对速度的需求时,她会骑躺式自行车。躺式自行车具有一个舒适的座椅,看起来更像带轮子的躺椅,你可以看到基蒂的设计在下图中:

图 3.3 – 基蒂为躺式自行车设计的 CAD 图。注意,车架的形状与大多数其他自行车(如插入图中所示的 Hillcrest)所使用的菱形形状不同。这些是制造过的最快的自行车之一。
骑行者会向后倾斜,并非常低地坐在自行车上。座椅被拉长,位于菲比下方。脚踏板被提升到前轮上方,以捕捉骑行者腿部完全伸展时的力量。这些自行车比传统的赛车更快,而且骑行长途时非常舒适。然而,由于全球范围内认可的赛车规则,它们从未被用于如环法自行车赛等赛事中。
相反,基蒂喜欢骑山地自行车,这种自行车设计用于越野骑行,就像吉普车一样。山地自行车,就像公路自行车一样,使用菱形车架,但它们有大的、多孔的轮胎和适合泥地的碟刹。骑行者坐得更直,这样他们可以观察并应对任何越野障碍。标准山地自行车通常在前轮有复杂的减震器,而更昂贵的自行车在后轮上有一个独立的减震器,以及前轮的减震器:

图 3.4 – 基蒂为她称为“Palo Duro Canyon Ranger”的创新山地自行车设计的 CAD 图。
契蒂也喜欢在镇上骑自行车,但她不喜欢公路自行车的难受骑行姿势。躺式自行车因为离地面太低,所以骑起来太危险,在德克萨斯州非常流行的那些驾驶大型卡车的牛仔们是看不到她的。相反,契蒂喜欢骑舒适的巡航车,有时也被称为老奶奶自行车。它们非常适合在镇上骑行。由于弯曲把手的设计,它们让她能够完全直立地坐在一个舒适的位置:

图 3.5 – 契蒂设计的舒适巡航自行车,有时也被称为老奶奶自行车。这些自行车非常适合在镇上通勤和需要舒适出行的游客。
巡航车配备了厚轮胎以增加稳定性,并且有链条防护罩和封闭式齿轮系统,以防止你的左腿裤子被链条磨坏,这通常会发生。这些自行车的设计是为了舒适而不是速度或越野能力。
两位姐妹骑遍了 MegaBikeCorp 生产的每一种自行车型号。菲比觉得 MegaBikeCorp 的工程创新水平令人鼓舞。契蒂觉得 MegaBike 的设计已经过时了。“他们已经生产了同样的四种自行车 30 年了,”契蒂说。姐妹俩决定她们可以做得更好,并决定制定一个行动计划。经过许多漫长的夜晚,大量的玉米卷饼,在线协作会议,自行车骑行,以及一些擦伤的膝盖,她们提出了四种自行车设计。但姐妹俩并没有止步于自行车设计。她们还提出了一个机器人制造系统的计划,该系统能够完全自动化自行车生产过程。她们一起成立了一家名为Bumble Bikes Incorporated的初创公司,这个名字来源于菲比最喜欢的童年玩具:一只她命名为Bumbles的填充大黄蜂。她们计划开设两个制造地点:一个在姐妹们的家乡德克萨斯州的达拉斯,另一个在契蒂的大学城阿尔派恩,德克萨斯州。这两个地区在地理上非常不同。
北德克萨斯州,尤其是达拉斯,是城市化和高度发达的地区。骑自行车对于许多北德克萨斯人来说是一种流行的运动和爱好。达拉斯地区的骑行者主要购买传统的公路自行车。令人惊讶的是,躺式自行车也非常受欢迎,尤其是在大型工程学院附近。你甚至可以在德克萨斯仪器公司 7 百万平方英尺(6.5 亿平方米)的校园里看到躺式自行车,德克萨斯仪器公司是一家位于达拉斯的大型半导体制造商。女孩们决定在达拉斯的工厂建造她们的公路自行车和躺式自行车设计。
在西德克萨斯州,阿尔派市是比格本国家公园以北的最后一块文明堡垒。极限山地自行车在奇索斯山脉盆地以及横穿沙漠的旧采矿和牛车路非常流行。阿尔派是一个小镇,没有也不需要任何大众交通服务。凯蒂的研究表明,市场上需要舒适型“巡航”自行车,这种自行车非常适合在镇上四处走动。凯蒂决定在阿尔派制造山地自行车和巡航车型。手握初步计划,菲比和凯蒂开始着手工作。
菲比负责研究用于制造自行车的机器人技术。凯蒂坐在她最喜欢的 C#集成开发环境中,开始编写将最终控制菲比机器人的控制软件。她知道她的设计,连同菲比的工程,将生产出行业有史以来最好的自行车。凯蒂计划他们的公司和运行的软件将取得巨大的成功,正如我们在第一章中学习的,“你的意大利面盘上有一大团泥巴”,这是最坏的情况。
凯蒂编写机器人控制软件代码的起点将是为非常通用的自行车对象建模基本类结构。这种建模将成为她整个公司未来的基础。第一次就很好地建模类将使凯蒂和菲比骑上通往成功的自行车之路!
让我们跟随姐妹俩凯蒂和菲比的冒险,她们着手构建自动化工厂的机器人控制系统。我们将关注创建型模式,正如其名称所示:控制对象实例化的模式。
初始设计
凯蒂知道她想要为她的软件建模自行车,并且她希望以最大化每个类别的灵活性来设计她的模型。创业计划是开发四种自行车,未来将扩展以包括异国情调的自行车和定制构建。
凯蒂打开她的 IDE,创建一个类库项目来存放她的类,因为她知道她可能会在几个不同的程序中使用这些类。她将类库命名为BumbleBikesLibrary,你可以在本章的示例代码中找到它。
她决定从一个抽象类开始,并使用继承来定义她打算最初设计和制造的四种自行车模型。图 3.6展示了她努力的结果。这组属性可以用来定义几乎任何类型的自行车:

图 3.6 – 抽象自行车模型。
让我们更详细地看看这些属性:
-
ModelName:模型在公司网站上的名称。 -
Year:自行车的型号年份。所有自行车设计应每两年更新一次,以防止设计像 MegaBikeCorp 的那样过时。 -
SerialNumber:每辆从生产线下来的自行车的唯一标识符。 -
Color:自行车的颜色。为了保持初始成本较低,Kitty 将为每个型号定义一个具有有限颜色集合的枚举。 -
Geometry:这指的是自行车的车架配置。所有初始制造运行的自行车都是直立或躺式几何形状。这也可以被移动到枚举中。 -
Suspension:这指的是自行车上使用的减震器类型。减震器在山地自行车上最为重要,但您也可以在公路自行车、躺式自行车和一些巡航车上找到它们。可以争论说,这个属性只适用于山地自行车子类,但 Kitty 了解“分析瘫痪”。她决定现在将其放入超类中,而不是试图在第一次尝试时就使模型完美。她可以在事情变得更加具体或如果她闻到有问题的代码结构时,稍后总是可以重构它。 -
BuildStatus:Kitty 知道她的机器人控制系统需要了解自行车制造过程的当前状态,因此她决定包括一个枚举属性来保存此信息。
几个属性可以表示为枚举,它是一个固定的集合。在过去的岁月里,我们使用“魔法数字”来表示有限列表。程序员会为每个值分配一个整数。例如,为了表示悬挂类型的有限列表,我们可能会倾向于将它们编号为 0 到 3,因为总共有四种可能性。这使得维护变得困难,因为每个人都需要记住每个数字的含义。0 是全悬挂吗?硬尾的代码是什么?后来,我们变得聪明起来,为所有事物定义了常量:
const int FULLSUSPENSION = 0;
const int FRONTSUSPENSION = 1;
const int SEATPOSTSUSPENSION = 2;
const int HARDTAIL = 3;
这更好,但它并不非常面向对象。您最终会得到数十行大写字母的丑陋常量定义。您可能会倾向于使用整数类型来模拟它们,因为您有机会分配一个任意的整数值。什么会阻止某人将其设置为 100,当潜在值应该是从 0 到 3 时?我们可以编写封装逻辑来强制我们的范围是 0 到 3。这都很好,但每次我们向阵容中添加一个新的悬挂时,我们都需要不断修改访问器逻辑。幸运的是,在 C# 中,我们在框的顶部有 <<Enumeration>> 标签。我们的自行车类使用四个枚举,如下所示:

图 3.7 – 定义了四个枚举来限制我们抽象自行车的几何形状、减震、颜色和当前制造状态的选项。枚举帮助我们保持对象状态干净、有效且易于阅读。
在进入模式之前,让我们先创建到目前为止所绘制的图形。首先,Kitty 将进行四个枚举:
-
SuspensionTypes -
BicycleGeometries -
BicyclePaintColors -
ManufacturingStatus
这里是 BicycleGeometries 枚举:
public enum BicycleGeometries
{
Upright, Recumbent
}
然后,她创建了一个用于减震类型的 enum:
public enum SuspensionTypes
{
Full, Front, Hardtail
}
接下来是油漆颜色:
public enum BicyclePaintColors
{
Black, Red, White, Blue
}
最后,Kitty 创建了一个用于制造状态代码的 enum:
public enum ManufacturingStatus
{
Specified, FrameManufactured, Painted, SuspensionMounted,
Complete
}
在处理完这些之后,让我们看看 Bicycle 基类是什么样的:
public abstract class Bicycle
{
protected string ModelName { get; init; }
private int Year { get; set; }
private string SerialNumber { get; }
protected BicyclePaintColors Color { get; init; }
protected BicycleGeometries Geometry { get; init; }
protected SuspensionTypes Suspension { get; init; }
private ManufacturingStatus BuildStatus { get; set; }
Kitty 创建了一个名为 Bicycle 的抽象类,就像她在 UML 模型中所做的那样。注意,图 3.6 中的类名被斜体标注,表示它是抽象的。接下来,她添加了属性。UML 模型故意省略了访问修饰符和类型,将这些留作程序员确定的实现细节。在这种情况下,就像许多初创项目一样,架构师和开发者是同一个人。
Kitty 将一些属性定义为受保护的,因为她打算从子类中操作它们。一些属性被标记为 private,因为适当地在更高层次上操作这些属性是合适的。
接下来,她转向构造函数,这是使用 new 关键字进行实例化时运行的函数:
public Bicycle()
{
ModelName = string.Empty;
SerialNumber = Guid.NewGuid().ToString();
Year = DateTime.Now.Year;
BuildStatus = ManufacturingStatus.Specified;
}
Kitty 在构造函数中为每个属性设置了默认值。目前,车型名称为空。她将在子类中更改它。序列号属性是一个生成的 GUID,这是一个保证始终唯一的字符串。Year 属性设置为当前年份,BuildStatus 设置为枚举中的第一个状态。
最后一步是添加 Build 方法。目前,Build 方法只是打印到控制台以显示逻辑是否正确工作。最终,这可以替换为 Phoebe 机器人系统的更复杂控制逻辑:
public void Build()
{
Console.WriteLine($"Manufacturing a {Geometry.ToString()} frame...");
BuildStatus = ManufacturingStatus.FrameManufactured;
PrintBuildStatus();
Console.WriteLine($"Painting the frame {Color.ToString()}");
BuildStatus = ManufacturingStatus.Painted;
PrintBuildStatus();
if (Suspension != SuspensionTypes.Hardtail)
{
Console.WriteLine($"Mounting the {Suspension.ToString()} suspension.");
BuildStatus = ManufacturingStatus.SuspensionMounted;
PrintBuildStatus();
}
Console.WriteLine("{0} {1} Bicycle serial number {2} manufacturing complete!", Year, ModelName, SerialNumber);
BuildStatus = ManufacturingStatus.Complete;
PrintBuildStatus();
}
抽象类已经完成!接下来,Kitty 需要创建他们打算制造的自行车的具体子类:
-
RoadBike -
MountainBike -
Recumbent -
Cruiser
首先,她创建了 RoadBike 类:
public class RoadBike : Bicycle
{
public RoadBike()
{
ModelName = "Hillcrest";
Suspension = SuspensionTypes.Hardtail;
Color = BicyclePaintColors.Blue;
Geometry = BicycleGeometries.Upright;
}
}
RoadBike 类从 Bicycle 类继承,构造函数设置了类的默认值。车型名为 Hillcrest,这个名字来自 Phoebe 大学校园西边的街道。公路自行车通常没有减震器,所以她将减震类型定义为 Hardtail。初始型号只有一种颜色,这款是蓝色的。由于这不是一款躺式自行车,所以几何形状设置为 Upright。
在处理完 Phoebe 最喜欢的公路自行车之后,Kitty 开始建模她最喜欢的——山地自行车:
public class MountainBike : Bicycle
{
public MountainBike()
{
ModelName = "Palo Duro Canyon Ranger";
Suspension = SuspensionTypes.Full;
Color = BicyclePaintColors.Black;
Geometry = BicycleGeometries.Upright;
}
}
奇蒂以德克萨斯州潘哈德尔的帕洛杜罗峡谷命名山地自行车。很少有人知道帕洛杜罗峡谷是美国第二大峡谷,仅次于大峡谷。帕洛杜罗峡谷是德克萨斯州一些最好的山地自行车骑行地。她希望设计有侵略性的外观,因此选择了黑色作为颜色。自然地,她不是一个会节省开支的人,因此她设计了全悬挂式设计——自行车前后都有减震器,以应对小径上可能出现的任何障碍。如前所述,由于它不是躺式,其几何形状被定义为直立。
现在,需要创建躺式自行车的类别:
public class Recumbent : Bicycle
{
public Recumbent()
{
ModelName = "Big Bend";
Suspension = SuspensionTypes.Front;
Color = BicyclePaintColors.White;
Geometry = BicycleGeometries.Recumbent;
}
}
奇蒂决定将这辆自行车命名为大弯。大弯地区是一个沙漠,有一个山脉,那里有许多山地自行车骑行机会。然而,有很长一段铺砌和不铺砌的道路,大致上是直的。躺式自行车在这些环境中表现良好,因为它们的设计允许骑行者更快、更远地骑行而不会感到疲劳。躺式自行车在拖曳拖车方面也相当不错。一个想要露营的人可以携带足够的水和补给。由于自行车的名字来源于铺砌和不铺砌的道路,奇蒂选择了前悬挂。躺式自行车已经有一个很好的座椅,所以后悬挂不会增加很多好处,而且会使自行车更贵。自然地,几何形状被设置为躺式。
最后一种自行车是休闲车:
public class Cruiser : Bicycle
{
public Cruiser()
{
ModelName = "Galveston Cruiser";
Suspension = SuspensionTypes.Hardtail;
Color = BicyclePaintColors.Red;
Geometry = BicycleGeometries.Upright;
}
}
奇蒂以她与家人在海边度假的美好回忆为名,将这辆自行车命名为加尔维斯顿休闲车。加尔维斯顿是墨西哥湾的一个中等城市。加尔维斯顿有海滩、商业和度假游轮的码头,以及一个充满活力的历史区,称为斯特兰德。斯特兰德充满了独一无二的商店、咖啡馆、酒吧、博物馆和为游客提供的娱乐活动。在斯特兰德的停车费用昂贵,而且往往很难找到停车位。除了阿尔派因不太为人所知的街道外,加尔维斯顿的斯特兰德是骑休闲车的完美场所。
没有模式实现
奇蒂正在一路高歌猛进!你知道那是怎么回事。她在一小时内就完成了枚举、基类和子类的编写。她真的在快速前进,而且她不想失去速度。奇蒂屈服于诱惑,为程序的最终实现编写了主入口点的代码:
using BumbleBikesLibrary;
const string errorText = "You must pass in mountainbike, cruiser, recumbent, or roadbike";
我们从命令行程序中获取一个参数,并使用它来确定要创建什么。如果传递了一个字符串,args的长度将大于零,我们可以做我们的事情。否则,我们可以责备我们愚蠢的用户,因为他们认为我们的软件能读懂他们的心思:
if(args.Length > 0)
{
剪切和规范化命令行输入是个好主意。这意味着我们忽略参数前后多余的空格。我们通过强制将所有内容转换为上档或小写来忽略大小写的情况,以便我们可以将输入与我们的预期值进行比较。比较无论用户输入的是 mountainbike、MOUNTAINBIKE 还是甚至 mOuNtAiNbIkE 都能正常工作:
var bicycleType = args[0].Trim().ToLower();
Bicycle bikeToBuild;
接下来是一个基于输入的 switch 语句。输入确定要构建的内容,并返回相应的类实例:
switch (bicycleType)
{
case "mountainbike":
bikeToBuild = new MountainBike();
break;
case "cruiser":
bikeToBuild = new Cruiser();
break;
case "recumbent":
bikeToBuild = new Recumbent();
break;
case "roadbike":
bikeToBuild = new RoadBike();
break;
如果用户传递了一个我们在 switch 中没有考虑到的参数,比如 MotorCycle 或 PeanutButter,我们编写并抛出一个异常:
default:
Console.WriteLine(errorText);
throw new Exception("Unknown bicycle type: " + bicycleType);
}
bikeToBuild.Build();
}
如果没有将参数传递到命令行程序中,我们显示一个错误消息,指示用户提供所需的参数:
else
{
Console.WriteLine(errorText);
}
实际上,基蒂正在使用命令行参数,并使用软件的主入口点 Program.cs 作为将实例化正确自行车类型的类。她试了一下,结果成功了!基蒂巧妙地利用了 Liskov 替换原则,通过使用抽象基类作为 bikeToBuild 变量的类型,这使得她可以根据她想要构建的自行车类型实例化适当的子类。如果她在 MegaBikeCorp 工作,她将无法做到这一点。她可能有一个爱挑剔的老板会告诉她清理代码并发货。幸运的是,她是个个体户,她内心深处的一个声音在抱怨,“你可以做得更好。”
基蒂的第一个实现可以在本章示例源代码中的 NoPattern 项目中找到。
简单工厂模式
基蒂决定对模式进行一些研究。她在大学时并没有计算机科学专业,只是在她的编码课程中听说过模式。基蒂四处寻找,并找到了一些关于称为 简单工厂模式 的内容的博客文章。太好了,她心想。这是她大学以来的第一个编码项目,而且她对自己的代码寄予厚望(看我在这里做了什么?),她决定以名字中的 simple 为起点。
根据博客文章,她只需要将实例化逻辑移动到自己的类中,这个类被称为 工厂类。文章中说,这样做是为了将实例化逻辑与主程序解耦。这应该让她更接近遵循开闭原则,并使她的代码更加灵活。
她回到她的集成开发环境(IDE),并添加了一个名为 SimpleBicycleFactory 的类,并将实例化逻辑移动到那里。逻辑与之前展示的相同:
public class SimpleBicycleFactory
{
public Bicycle CreateBicycle(string bicycleType)
{
Bicycle bikeToBuild;
switch (bicycleType)
{
case "mountainbike":
bikeToBuild = new MountainBike();
break;
case "cruiser":
bikeToBuild = new Cruiser();
break;
case "recumbent":
bikeToBuild = new Recumbent();
break;
case "roadbike":
bikeToBuild = new RoadBike();
break;
default:
throw new Exception("Unknown bicycle type: " + bicycleType);
}
return bikeToBuild;
}
}
然后,基蒂重构了她的 Program.cs 文件以使用简单工厂:
using SimpleFactoryExample;
const string errorText = "You must pass in mountainbike, cruiser, recumbent, or roadbike";
if (args.Length > 0)
{
var bicycleType = args[0].Trim().ToLower();
这里是不同的部分——基蒂使用 SimpleBicycleFactory 类而不是直接运行一个 switch 语句:
var bicycleFactory = new SimpleBicycleFactory();
var bikeToBuild = bicycleFactory.CreateBicycle(bicycleType);
bikeToBuild.Build();
}
else
{
Console.WriteLine(errorText);
}
根据定义,重构意味着你在不引入任何新功能的情况下改进代码的结构或性能。Kitty 通过使她的代码更加优雅实现了这一点。
当逻辑在Program.cs文件中是自由范围的,它就被锁定只能用于那个程序。Kitty 明智地意识到她和,毫无疑问,Phoebe 将想要创建其他可以使用创建逻辑的程序。将逻辑封装到类中是一个明显的改进。
在晚上关闭笔记本电脑之前,Kitty 注意到了社交媒体上她编程教授的一条帖子。她询问他最近怎么样,在聊天中,她提到了她的代码项目。教授说他很乐意看看,于是她给他发了一个 GitHub 链接。
Kitty 的教授将要审查的代码可以在本章示例源代码中的SimpleFactoryPattern项目中找到。
工厂方法模式
Kitty 的旧教授看了看代码,告诉她这个更新的代码是一个改进,但她没有使用任何模式。简单工厂被归类为编程习语。习语就像模式一样,它们经常出现。当你看到它们时,你会认出它们,但它们并没有完全解决常见问题。也许最著名的编程习语是 Kernighan 和 Ritchie 所著的《C 程序设计语言》一书中创造的,也被称为The K&R book。正是在这本书中,我们看到了我们的第一个Hello, World程序。Hello, World是一个习语。它通常是你学习新语言时尝试的前几行代码。它通过鼓励灵活性和代码重用,并没有解决任何工业级问题。
Kitty 意识到她曾在她选择的 C# IDE 中看到过这个习语,那就是 JetBrains Rider。当你创建一个控制台应用程序,就像她一直在做的那样,你首先看到的是这段代码,这是 IDE 作为项目起点的生成代码:
Console.WriteLine("Hello, World!");
我包括了一个Hello, World程序的例子。你可以在章节源代码中的HelloWorld项目中找到它。是的,我确实包括了一个Hello, World程序,因为我非常注重细节。现在,回到故事中。
第二天,Kitty 决定全面研究创建型模式。她想要正确地做这件事。阅读了一些资料后,Kitty 发现了几种被称为工厂模式的模式:
-
简单工厂,我们已经确定它不是一个模式,但它经常被误认为是。
-
工厂模式,它是对简单工厂的一个轻微改进。
-
工厂方法模式,它真正抽象了当你需要实例化多种对象类型时的创建过程。
-
抽象工厂模式,它目前似乎比必要的更复杂,因为它涉及到创建对象组。
凯蒂确定了她认为适合她问题的完美模式:工厂方法模式。她也认为讽刺的是,她的软件设计的起点是使用工厂模式,因为她正在模拟一个将来将成为物理工厂的东西。工厂模式之所以被称为工厂模式,是因为它们接受一组输入并产生一个具体的对象作为输出。简单的工厂在表面上完成了这个任务,但依赖于这种习语而不是真实模式有一些问题。习语不如它可能的那样灵活。Bumble Bikes Inc. 将有两个工厂地点制造不同类型的自行车。简单工厂可以创建任何自行车,但与此同时,它被锁定在制造所有四种。将这一点与真实工厂联系起来,你可以看到要求工厂制造每种类型的自行车可能是浪费的,而不是它将要制造的两种。
在我们的软件设计中,工厂不应了解它将要创建的内容。它应该足够灵活,可以制造任何类型的自行车。我们可以这样构建,以便我们可以创建一个能够生产所有子类子集的工厂。子类决定什么可以和应该被具体化。我们可以这样表示通用的工厂方法模式:

图 3.8 – 工厂方法模式的通用图。
基于数字,让我们回顾一下图中的每一部分:
-
工厂方法模式从定义公共行为或一组行为的接口开始。一般来说,使用接口比使用基类更灵活,因为在 C 中,你不受继承规则的限制。也就是说,在 C# 中,任何子类可能只有一个父类。不支持多重类继承。在接口的情况下,任何类都可以实现所需的不同接口。
-
当我们讨论工厂方法模式时,我们称工厂创建的对象为
products。这些是工厂将要生产的具体产品。它们都将实现公共产品接口。在实践中,你不需要坚持使用与前面图示相同的名称。 -
工厂方法有一个包含工厂方法的
Creator类。工厂方法被编码为返回Product接口,以便它可以返回实现该接口的任何产品。它不依赖于特定的抽象基类,正如凯蒂最初的重构那样。这些创建者都是抽象的,并意味着在具体的创建者子类中被覆盖。这就是我们自行车工厂所需的灵活性所在。 -
具体创建者提供实际的 concrete 类。所有你的创建逻辑都将在这里。
让我们记住凯蒂和菲比的计划细节。这个计划需要两个工厂——一个在达拉斯制造公路自行车和休闲自行车,另一个在阿尔卑斯山制造山地自行车和巡航自行车。凯蒂走向她的白板,并绘制了她版本的先前图表:

图 3.9 – 凯蒂的工厂方法设计理念的白色板草图。
这看起来相当不错!凯蒂决定将她抽象自行车类中的大部分内容移动到一个她称为 IBicycle 的接口中。这并不意味着她应该丢弃抽象类,但抽象类实现接口很容易。一旦她这样做,她就可以传递接口,这比使用基类更灵活。
抽象自行车基类除了实现 IBicycle 接口外不会改变。所有的自行车子类都不会有任何变化。
她确实需要添加一些创建类。她将需要创建一个抽象的 BicycleCreator 类,这个类将被她可能需要的尽可能多的具体创建类所继承。
这符合设计问题,因为我们需要模拟两个实际的工厂。一个被称为 DallasCreator,将制造公路自行车和休闲自行车,另一个被称为 AlpineCreator,将生产山地自行车和巡航自行车。
我们的设计对修改是封闭的。我们再也不需要与基类和接口打交道了。然而,设计对扩展也是开放的。随着产品线的未来扩展,我们可以继续添加工厂,每个工厂可以专门生产任何一组产品。只需创建 Bicycle 的新子类即可添加新自行车。
剩下的只有打字了。
凯蒂将 IBicycle 接口添加到她的 class 库中:
public interface IBicycle
{
public string ModelName { get; set; }
public int Year { get; }
public string SerialNumber { get; }
public BicycleGeometries Geometry { get; set; }
public BicyclePaintColors Color { get; set; }
public SuspensionTypes Suspension { get; set; }
public ManufacturingStatus BuildStatus { get; set; }
public void Build();
}
然后,她修改了 Bicycle 基类。在接口中定义非公共成员是不可能的。C# 通常要求接口中定义的属性和方法是 public。接口中的非公共成员没有意义。它们将容纳实现细节,而不是供公众消费的东西,这是接口的目的。底线是,如果我们想在 Bicycle 基类上要求任何东西,我们需要将访问修饰符更改为 public。我们通常希望避免在生产中发布后更改类。在这个阶段,我们还没有这样做。我们可以更改访问修饰符,或者避免在接口中定义这些元素,只保留 build 函数。凯蒂决定采用她已输入的更完整的接口版本:
public abstract class Bicycle : IBicycle
{
protected Bicycle()
{
ModelName = string.Empty; // will be filled in subclass // constructor
SerialNumber = new Guid().ToString();
Year = DateTime.Now.Year;
BuildStatus = ManufacturingStatus.Specified;
}
public string ModelName { get; set; }
public int Year { get; }
public string SerialNumber { get; }
public BicyclePaintColors Color { get; set; }
public BicycleGeometries Geometry { get; set; }
public SuspensionTypes Suspension { get; set; }
public ManufacturingStatus BuildStatus { get; set; }
接下来,我们需要我们的创建类。凯蒂从抽象类开始,她将称之为 BicycleCreator:
using BumbleBikesLibrary;
namespace FactoryMethodExample;
public abstract class BicycleCreator
{
public abstract IBicycle CreateProduct(string modelName);
}
接下来是两个具体的创建类,从 DallasCreator 开始:
using BumbleBikesLibrary;
namespace FactoryMethodExample;
public class DallasCreator : BicycleCreator
{
public override IBicycle CreateProduct(string modelName)
{
return modelName.ToLower() switch
{
"hillcrest" => new RoadBike(),
"big bend" => new Recumbent(),
_ => throw new Exception("Invalid bicycle model")
};
}
}
紧接着是 AlpineCreator 类:
using BumbleBikesLibrary;
namespace FactoryMethodExample;
public class AlpineCreator : BicycleCreator
{
public override IBicycle CreateProduct(string modelName)
{
return modelName.ToLower() switch
{
"palo duro canyon ranger" => new MountainBike(),
"galveston cruiser" => new Cruiser(),
_ => throw new Exception("Invalid bicycle model")
};
}
}
凯蒂需要一个快速测试,所以她将此代码添加到 Program.cs 中:
using FactoryMethodExample;
Console.WriteLine("Let's make some bicycles");
var dallasBicycleFactory = new DallasCreator();
var phoebesBike = dallasBicycleFactory.CreateProduct("HILLCREST");
phoebesBike.Build();
var alpineBicycleFactory = new AlpineCreator();
var kittysBike = alpineBicycleFactory.CreateProduct("PALO DURO CANYON RANGER");
kittysBike.Build();
现在是检验真伪的时刻。Kitty 在 IDE 中点击运行按钮,并坐到椅子的边缘,随着代码编译:

图 3.10 – 哇!它工作了!工厂方法模式正在 Kitty 的代码中运行!
Kitty 提交并推动了她的代码,你可以在本章源代码中的 FactoryMethodExample 项目中查看。别忘了 IBicycle 接口已被添加到 BumbleBikesLibrary 项目中。
抽象工厂模式
在 Kitty 使用工厂方法完成初步设计后,Phoebe 在 GitHub 上检查了 Kitty 的工作。Phoebe 已经完成了创建车架的工具,她正在努力完成其他一些制造自行车的部件。
“Kitty!,” Phoebe 说,“这段代码将允许我们创建自行车对象,但这有点太抽象了。自行车由许多不同的部件组成。” 经过长时间的讨论,两人决定专注于制造每种自行车类型的自行车框架和把手。其他部件,如轮子、轮胎、刹车和齿轮,可以在自行车初始生产中外包。
Phoebe 想到这些部件可以按系列制造。公路自行车使用下弯把手,而山地自行车使用平面把手设计。你不应该互换这些部件。公路自行车上的平面把手会创造一个新的自行车类别,称为 砾石自行车 或 混合型。我们目前不感兴趣改变我们的产品线。山地自行车上的下弯把手完全没有意义,而且很危险。对于物理自行车工厂来说,模仿软件模式是有意义的。姐妹们得出结论,更好的选择可能是使用 抽象工厂模式。
这是一个很多人做错的模式。一个常见的误解是抽象工厂模式仅仅涉及使你的工厂类抽象化。不是这样!抽象工厂模式旨在创建相关对象,并将这些对象从客户端对具体类型的依赖中解耦。
我们的设计包括四种自行车系列:
-
路自行车(希尔克里斯特)
-
山地自行车(帕洛杜罗峡谷管理员)
-
休闲自行车(大弯)
-
巡游自行车(加尔维斯顿巡游)
每种类型都有特定的车架类型,以及不同的把手设计。我们可以说我们将制造四种 系列 的自行车组件。当你遇到涉及相关对象系列的问题时,你应该自动考虑抽象工厂模式。
抽象工厂模式的第二个好处是它解耦了客户端对任何特定具体对象的依赖。让我们看看以下图表:

图 3.12 – 抽象工厂模式。
我从右到左绘制了这些部分,从客户端开始:
-
客户端是任何消耗由抽象工厂创建的对象的代码。在这里,我展示了一个客户端对象上的私有引用,指向一个抽象工厂作为属性。
-
客户端依赖于
AbstractFactory接口。此接口定义了两个方法。通常,我不在 UML 中放置返回类型,但在这个情况下,这确实非常重要。接口将引用一对抽象类。也许你现在开始看到这要去哪里了。由抽象工厂创建的最终产品将是一个继承自这些抽象类之一的具体类。 -
对于每种产品系列,都展示了两个具体的工厂。我们的需求有四个产品系列,但这个图仅展示了两个以保持简单。你可以根据需要添加任意多的具体工厂。
-
使用两个抽象类来定义两种类型的对象,这些对象独立于具体对象的家庭。
-
最终的具体产品继承自抽象产品。菲比和凯蒂可以在他们的过程中添加更多具体细节,因为他们不仅仅在制作自行车——他们还在制作把手。每个物理工厂都应该制作所需的框架和把手。
姐妹俩走向白板,并绘制出抽象工厂模式的设计。为了保持简单,她们将只绘制公路自行车和山地自行车:
![图 3.13 – 菲比和凯蒂使用抽象工厂模式的白板设计。(img/B18605_Figure_4.12.jpg)]
图 3.13 – 菲比和凯蒂使用抽象工厂模式的白板设计。
在这个程序中,客户端只是当你创建一个命令行项目时,你的 IDE 创建的 Program.cs 文件。
图表显示 Program.cs 并依赖于由 IBicycleFactory 创建的对象。注意箭头的不同。这些在 UML 中很重要。实线上的封闭箭头表示继承。虚线上的封闭箭头表示接口的实现。封闭线上的开放箭头表示关联。客户端依赖于 IBicycleFactory 后面的某个东西。Program.cs 类有一个私有字段来保存实现此接口的对象的实例,这可以是 RoadBicycleFactory 或 MountainBicycleFactory,如前图所示。
这里值得注意,菲比将 IBicycleFactory 绘制为一个字面接口。对接口的代码级理解可能是一个字面接口或一个抽象类,因为它们都可以用来定义对象必须采取的结构。
我们有实现IBicycleFactory接口的具体系列类,分别称为RoadBicycleFactory和MountainBicycleFactory。每个具体工厂负责创建一个对象家族。在我们的例子中,家族是Road和Mountain。RoadBicycleFactory可以根据依赖关系创建RoadBicycleFrame和RoadBicycleHandlebars,但你可以看到RoadBicycleFrame和RoadBicycleHandlebars分别继承自BicycleFrame和BicycleHandlebars抽象类。
当客户端请求自行车车架和一组把手时,它可以引用抽象类。由于 Liskov 替换原则,客户端不需要严格耦合到任何具体类。这使得我们的客户端非常灵活。随着我们的自行车家族阵容的变化,我们不需要修改客户端,因为客户端不知道工厂返回的是什么。客户端只知道它有名为CreateBicycleFrame和CreateBicycleHandlebars的方法。
Phoebe 在仓库中创建了一个分支。她知道你应该始终在分支上工作,于是开始编写代码。她从IBicycleFactory接口开始。
using BumbleBikesLibrary.BicycleComponents.BicycleFrame;
using BumbleBikesLibrary.BicycleComponents.Handlebars;
namespace BicycleAbstractFactoryExample;
public interface IBicycleFactory
{
public IFrame CreateBicycleFrame();
public IHandlebars CreateBicycleHandleBars();
}
注意前两个using语句。由于我们开始将自行车分解成组件,Kitty 和 Phoebe 决定将这些组件重构到BumbleBikesLibrary中的BicycleComponents命名空间内。这次重构与模式无关,只是为了让代码更加有序。你可以在 GitHub 仓库中找到本章的 BumbleBikesLibrary 中的组件。
姐妹俩按照书中的方法进行编码。所有内容都输入到接口中以保持灵活性。我们正在添加处理创建车架和把手的方法,这两个由接口指定的相关类。自然,我们可以添加更多。BicycleComponents命名空间中还有更多组件类。我们在这里只保留两个以保持简单。如果你想练习,看看你是否可以将其他组件,如座椅、传动系统和刹车,添加到模式代码中。
接下来,Kitty 和 Phoebe 需要在具体的工厂中工作。这些类只在这个项目中使用,所以你可以在 GitHub 上书籍示例源代码的BicycleAbstractFactoryExample项目中找到它们。记住,抽象可以指接口或抽象类。在这种情况下,抽象工厂的抽象部分就是这个接口。Kitty 根据IBicycleFactory接口编写了MountainBicycleFactory:
using BumbleBikesLibrary.BicycleComponents.BicycleFrame;
using BumbleBikesLibrary.BicycleComponents.Handlebars;
namespace BicycleAbstractFactoryExample;
public class MountainBicycleFactory : IBicycleFactory
{
public IFrame CreateBicycleFrame()
{
return new MountainBikeFrame();
}
public IHandlebars CreateBicycleHandleBars()
{
return new MountainBikeHandlebars();
}
}
具体工厂负责创建相对于我们创建的对象家族所需的具体对象。在这种情况下,Kitty 正在制作山地自行车的部件,所以所有返回的部件都是针对该家族的。可以添加更多的产品家族,而不需要修改抽象工厂。
下面是 Phoebe 为RoadBicycleFactory类编写的代码,它也扩展了IBicycleFactory接口:
using BumbleBikesLibrary.BicycleComponents.BicycleFrame;
using BumbleBikesLibrary.BicycleComponents.Handlebars;
namespace BicycleAbstractFactoryExample;
public class RoadBicycleFactory : IBicycleFactory
{
public IFrame CreateBicycleFrame()
{
return new RoadBikeFrame();
}
public IHandlebars CreateBicycleHandleBars()
{
return new RoadBikeHandlebars();
}
}
我们现在已经实现了抽象工厂模式,制造出两种可能的产品组件,属于两种可能的产品系列。让我们看看客户。菲比写道:
using BicycleAbstractFactoryExample;
Console.WriteLine("Let's make some bicycles!");
IBicycleFactory roadBikeFactory = new RoadBicycleFactory();
var frame = roadBikeFactory.CreateBicycleFrame();
var handlebars = roadBikeFactory.CreateBicycleHandleBars();
Console.WriteLine("We just made a road bike!");
Console.WriteLine(frame.ToString());
Console.WriteLine(handlebars.ToString());
我们使用RoadBikeFactory制造了一辆公路自行车!这里的关键是使用接口作为工厂类型。这样编码可以使更改工厂而不直接依赖于具体的工厂类成为可能。你可以看到控制台输出显示了操作的结果。菲比继续编写代码,使用MountainBikeFactory生成一辆山地自行车:
IBicycleFactory mountainBikeFactory = new MountainBicycleFactory();
frame = mountainBikeFactory.CreateBicycleFrame();
handlebars = mountainBikeFactory.CreateBicycleHandleBars();
Console.WriteLine("We just made a mountain bike!");
Console.WriteLine(frame.ToString());
Console.WriteLine(handlebars.ToString());
对于菲比和凯蒂可能梦想制造的任何新型自行车,都可以重复同样的过程。
建造者模式
菲比完成了抽象工厂的实现,然后回到设计自行车工厂的机器人部分。你有多少次认为某个工作或项目很简单,结果发现一旦你开始着手做,事情比你所意识到的要复杂得多?
菲比和凯蒂对工程、设计和软件开发都是新手。菲比对围绕建造自动化工厂的设计问题的独特理解随着时间的推移而巩固。她意识到制造自行车比她最初想象的要复杂。姐妹俩手工制作了原型。他们能够使用木材制作车架,并使用现成的部件制作其他部分。他们现在致力于使用轻质铝合金自行制作车架和把手。
菲比意识到车架是难点。其他组件,如车轮、刹车、传动系统以及把手,可以很容易地作为自动化过程的一部分内部制造。
自然,这增加了机器及其软件的复杂性。菲比给凯蒂打电话。
“嘿,妹妹,抽象工厂模式对你怎么样?”凯蒂问。
“这没问题,但我一直在想,”菲比回答说。
“哎呀。每次你这么做,爸爸的信用卡就会受到考验。你有什么想法?”凯蒂问。
菲比向凯蒂讲述了她用同一种铝合金制造所有部件的想法。“有些部件需要加固,但结果将是一辆比我们最初想象的更轻、更便宜的自行车,”菲比说。凯蒂喜欢这个主意。姐妹俩之前很兴奋,但现在更加激动。菲比说:“我会用爸爸的信用卡购买制造前几辆自行车所需的铝合金。”凯蒂回答说:“太好了。当你这么做的时候,我会把你的想法融入到控制软件中。你把分支合并到主分支上了,对吧?”
凯蒂从 GitHub 拉取了最新的代码并开始了一个新的分支。她开始认真思考如何完成编写一个过程,这个过程可以根据客户可能有的任何规格来构建一辆完整的自行车。姐妹们有一套四辆自行车,第一版有有限的选项。凯蒂不想把自己的思考和软件设计锁定在只构建那四辆自行车上。那会使其成为一个烟囱系统。凯蒂走向白板。她意识到她可能不得不放弃到目前为止在抽象工厂模式中取得的进展。不同的模式可能不仅仅是他们迄今为止所做工作的直接演变。她知道基本结构将保持不变。自行车总是需要车架、座椅、把手、轮子、刹车和传动系统。
新问题涉及到创建一个复杂对象。构建将需要多个步骤,而不是像早期设计中那样简单的 BuildBicycle 方法,该方法创建车架和把手。
沉思中,她的幻想被手机响亮的嗡嗡声打断。手机放在她的金属工作台上,震动表明她收到了一条短信,几乎把一小堆螺丝和一些工具从桌边弹了出去:

图 3.15 – 她检查屏幕。有一条来自她父亲的信息。
哎呀。凯蒂决定撕下那个众所周知的创可贴,给她的父亲打电话。也许她对最新进展的兴奋可以减轻打击。“爸爸会理解的,”她低声对自己说,好像她正在大声试图说服自己。
凯蒂和菲比的父亲是一位软件工程师。他几年前因为总是在社交媒体上谈论疯狂的阴谋论而失去了工作。他决定退休,在与他的大家庭发生争执后,凯蒂和菲比的父母搬到了美国南部俄克拉荷马州的一个小镇,这个小镇位于德克萨斯州边境以北,那里的道路没有铺路,互联网接入也不存在。这正是他喜欢的方式。凯蒂拨打了电话。
当他接电话时,他父亲的语言,我们可以说是丰富多彩的。这不是菲比第一次欠下账单。她曾经一天之内就欠下了超过一千美元。大多数少女都是因为旅行或购物而欠下账单。菲比的账单来自几家知名的工业供应商,一个模具车间和一个花生酱工厂。她从未坦白她在做什么,但不久之后,她进入了工程学院,那件事几乎被遗忘。
契蒂安抚了他的怒气,并告诉他正在发生的事情。“你应该看看 建造者模式,”他说。“*是的,我曾经写过一本关于模式的书籍,我记得这个。它用于使用一系列灵活的步骤来创建复杂对象。听起来这好像是你需要的。哦,还有,你们得还我人情!我想在公司里得到股份,还有你们两个建造的第一辆自行车。””
“当然,爸爸,”契蒂用她最甜美的声音说,这通常是当她想要冰淇淋但已经被告诉“不”时才会使用的。她决定不责备他没有告诉她他写过一本关于模式的书籍。当菲比 11 岁、契蒂 12 岁时,她的父亲就教她们编程。他多年来出版了许多书籍和视频。当然,他也写过一本关于模式的。在那个时刻,契蒂的电话变得静默。很难听到任何声音。她的父亲还在和她说话,但她只听到了几个词。关于平行时空和时间递归的事情。“他有时会说些很傻的话。他可能又重新看了《神秘博士》的老剧集,”她心想。电话挂断了。
契蒂耸了耸肩,开始研究建造者模式。她父亲的书籍已经绝版多年,但它却是一本国际畅销书,因此她能够在引用他的许多更近期的书籍中找到一些图表:

图 3.16 – 建造者模式由一个建造者接口、一个控制创建过程的导演和一个基于建造者接口的具体建造者来产生特定产品组成
让我们更详细地看看这个图表:
-
建造者模式有两个重要的部分。第一个是 IB。记住,这可以是一个字面接口或一个抽象类。我将坚持使用真正的接口以保持灵活性。
Builder接口定义了将在一组具体建造类中出现的所有方法。建造者模式中你总会找到的第二个部分是Director类。 -
创建了一个
Director 类,其中包含以逐步方式定义创建过程的逻辑。 -
一组具体建造类定义了可以创建的每种对象类型。
-
根据导演中包含的逻辑,不同的产品会从具体建造类中产生。
契蒂带着这些新知识回到她的白板上:

图 3.17 – 契蒂在白板上实现的建造者模式。
基蒂对这一款很兴奋。经过尝试了几种不同的模式后,她觉得这一款最能代表姐妹们想要达成的目标。你可以在本章 GitHub 仓库中的BicycleBuilderExample项目中找到这段代码。
基蒂首先为建造者将要生产的内容创建了一个抽象——即产品。她创建了一个名为IBicycleProduct的接口:
public interface IBicycleProduct
{
public IFrame Frame { get; set; }
public ISuspension Suspension { get; set; }
public IHandlebars Handlebars { get; set; }
public IDrivetrain Drivetrain { get; set; }
public ISeat Seat { get; set; }
public IBrakes Brakes { get; set; }
}
接口包含了制作完整自行车所需的一切。在这里,我们有一些设计上的奢侈。所有自行车都遵循相同的接口。我们不再需要考虑公路车或山地车。公路车只是一个部件的集合:
-
路车车架
-
硬尾悬挂(即完全没有悬挂)
-
路车把手(即下弯的曲线把手)
-
公路车传动系统(即正常长度的链条和一套 3 个前齿轮和 8 个后齿轮,总共 24 速)
-
刹车卡钳
-
标准的、便宜的、非常不舒服的座椅
菲比和基蒂在实习期间学习了有关座椅的知识。自行车制造商知道座椅是一个非常个人化的选择,如果给骑手选择,没有人会挑选相同的座椅。他们都会销售配备便宜、不舒服座椅的自行车,并提供单独的产品来升级座椅以满足骑手的偏好。这降低了自行车的制造成本,并让他们的经销商有机会加价,同时提供座椅安装服务。
基蒂为建造者编写了一个通用的自行车对象,称为BicycleProduct:
public class BicycleProduct : IBicycleProduct
{
public IFrame Frame { get; set; }
public ISuspension Suspension { get; set; }
public IHandlebars Handlebars { get; set; }
public IDrivetrain Drivetrain { get; set; }
public ISeat Seat { get; set; }
public IBrakes Brakes { get; set; }
public override string ToString()
{
var fullDescription = new StringBuilder("Here's your new bicycle:");
fullDescription.AppendLine(Frame.ToString());
fullDescription.AppendLine(Suspension.ToString());
fullDescription.AppendLine(Handlebars.ToString());
fullDescription.AppendLine(Drivetrain.ToString());
fullDescription.AppendLine(Seat.ToString());
fullDescription.AppendLine(Brakes.ToString());
return fullDescription.ToString();
}
}
这个类只是接口的一个实现,加上一个大的ToString()重写,我们将其用作示例代码的主体。
接下来,基蒂创建了IBicycleBuilder接口,它将定义自行车型号的各个建造者:
namespace BicycleBuilderExample;
public interface IBicycleBuilder
{
public void Reset();
public void BuildFrame();
public void BuildHandleBars();
public void BuildSeat();
public void BuildSuspension();
public void BuildDriveTrain();
public void BuildBrakes();
public IBicycleProduct GetProduct();
}
接口定义了builder类中必须包含的内容。拼图中的下一部分是Director类。Builder模式的经典实现总会有这样一个类。在我们的例子中,我们可以跳过这一部分,因为我们的所有自行车都符合相同的接口。导演可以创建任何东西,只要有一个建造者。导演的职责是根据所需的业务逻辑调用建造者的方法,并返回由建造者创建的产品。在我们的例子中,每个产品都有相同的属性集,建造者都有相同的方法。只需意识到,如果你的建造者没有遵循相同的接口,导演中可以有更多的逻辑来决定如何处理它们。导演的目的是以正确的顺序运行建造者的构建方法。有时,这需要比我们这里更多的逻辑:
public class Director
{
public Director(IBicycleBuilder builder)
{
Builder = builder;
}
private IBicycleBuilder Builder { get; set; }
Kitty 从一个私有字段开始,用来存储她将要与之工作的构建器引用。你可以传递任何扩展 IBicycleBuilder 接口的对象。构造函数设置了实际传递的构建器对象。接下来,她创建了一个允许我们更改构建器而不直接暴露它的方法:
public void ChangeBuilder(IBicycleBuilder builder)
{
Builder = builder;
}
最后,她根据她的 UML 图创建了一个 Make 方法。Make 方法的任务是按正确的顺序运行构建步骤。Kitty 让这个方法遵循机器人将使用的相同流程。他们将从车架开始,然后添加部件。没有人会想到从座椅或齿轮开始构建自行车。在这里,我们可以看到自行车的一个逻辑构建过程,它从更大、更重要的部件开始,然后是附加到较大部件上的较小部件:
public IBicycleProduct Make()
{
Builder.BuildFrame();
Builder.BuildHandleBars();
Builder.BuildSeat();
Builder.BuildSuspension();
Builder.BuildDriveTrain();
Builder.BuildBrakes();
return Builder.GetProduct();
}
}
接下来,Kitty 为山地自行车和公路自行车创建了具体的构建器。自然地,她也创建了其他自行车类型的代码,但在这里,我们将保持简短,这样你就不必在大量的代码中寻找模式。以下是基于 IBicycleBuilder 类的 RoadBikeBuilder 类:
public class RoadBikeBuilder : IBicycleBuilder
{
private BicycleProduct _bicycle;
public RoadBikeBuilder()
{
Reset();
}
public void Reset()
{
_bicycle = new BicycleProduct();
}
Kitty 创建了一个名为 _bicycle 的私有字段来存储由 Director 类构建的产品。我们有一个公共构造函数和一个 Reset() 方法,该方法将 _bicycle 字段设置为基于 BicycleProduct 类的新自行车:
public void BuildFrame()
{
_bicycle.Frame = new RoadBikeFrame();
}
public void BuildHandleBars()
{
_bicycle.Handlebars = new RoadBikeHandlebars();
}
public void BuildSeat()
{
_bicycle.Seat = new GenericSeat();
}
public void BuildSuspension()
{
_bicycle.Suspension = new HardTailSuspension();
}
public void BuildDriveTrain()
{
_bicycle.Drivetrain = new RoadDrivetrain();
}
public void BuildBrakes()
{
_bicycle.Brakes = new CaliperBrakes();
}
public IBicycleProduct GetProduct()
{
return _bicycle;
}
}
同样的构建器可以用于山地车、躺式自行车和休闲自行车。它们看起来一样,但自然地,它们使用适当的部件。构建器步骤(方法)了解构建所需的部件,而导演了解调用步骤(方法)所需的顺序。
用于调用这些构建器的客户端代码看起来像这样:
using BicycleBuilderExample;
Console.WriteLine("Let's make some bikes with the builder pattern!");
这里是所有辛勤工作的回报。你可以用三行代码构建任何自行车。首先,创建构建器。然后,如果你还没有,创建一个导演,并将你刚刚创建的构建器传递给它。然后,调用 Make() 方法。结果是完美构建的产品:
var roadBikeBuilder = new RoadBikeBuilder();
var director = new Director(roadBikeBuilder);
var roadBike = director.Make();
Console.WriteLine(roadBike.ToString());
你更愿意拥有一辆山地自行车吗?这很简单!
var mountainBikeBuilder = new MountainBikeBuilder();
director.ChangeBuilder(mountainBikeBuilder);
var mountainBike = director.Make();
Console.WriteLine(mountainBike.ToString());
我们可以同样容易地制作躺式自行车和休闲自行车,但为了节约树木,我将留给你的想象力。如果你像我一样想象力较弱,本章的示例代码包含了创建所有四种类型的构建器。
Kitty 对事情的结果感到非常高兴。她有一个对修改封闭但对扩展开放的软件架构。她明智地利用了接口和抽象类,因此 Kitty 准备迎接世界机器人自行车制造领域可能对她提出的下一个挑战。
对象池模式
回到工厂,菲比在机器人技术方面取得了进展。她开发了一种移动式机器人臂,用于在制造过程中处理焊接工作。她原本希望制造 30 个这样的臂,以实现最大化的工厂产量,但她的父亲的信用卡神秘地停止工作了。这让菲比感到困惑。难道她的姐姐告发了她,导致父亲阻止了卡片的进一步购买?“她不会那么做的!”菲比想,但当她这么说的时候,她立刻意识到这正是发生的事情。她考虑过指责她的姐姐,但最终决定专注于她父亲的话:
“一个优秀的工程师是在时间、材料、人力和预算的限制下,尽可能少抱怨地制造出最佳产品的那个人。”
最后这部分是最难的。大多数工程师会抱怨,“如果我有预算,我会要求……”或者“如果我有另一年的时间……”这是一件关于自尊的事情。菲比吞下了她的抱怨,决定她必须想出如何仅用她能建造的 10 个机器人臂来解决问题。她的臂设计看起来有点像这样:

图 3.18 – 菲比为处理自行车车架焊接的机器人臂设计。
由于臂是移动的,菲比决定可以在需要时让臂在焊接项目之间移动。当自行车通过生产线时,只有少数几辆在任何时候都需要焊接。菲比想,“拥有 30 个臂会很不错,但说实话,它们大部分时间都会处于闲置状态。这样会更好。我被迫更加高效。”
10 个臂的池子位于轨道上,它们可以随时移动到任何装配线。当需要焊接时,基蒂的软件会将臂移动到所需的位置。焊接完成后,臂可以返回池中等待下一次需要。如果需要在不同项目上使用多个焊接工,一个臂会从池中取出并完成工作,然后返回。只要我们一次不需要超过 10 个臂,我们就没问题。如果我们需要,第 11 个任务将不得不等待直到有一个臂可用。菲比对此感到高兴,因为创建臂的成本很高,而且她已经发现,她只需要几个。随着业务的扩张,她可以添加更多的臂到池中。
菲比无意中发现了对象池模式。这种模式用于创建计算成本高昂的对象,因此它们往往会将软件性能降低到极慢。最明显的例子是与关系数据库一起工作。这是每个软件开发者在某个时候都会做的事情,对我们中的许多人来说,这是一项我们每天都要依赖的技能。
实例化和连接到数据库在计算上代价高昂且耗时。当然,这可能只需要 100 毫秒,但大多数关系型数据库都是设计来每小时处理数百万次事务的。当你的软件在规模负载下运行时,100 毫秒就是一段很长的时间。每个具有商业可行性的数据库都有一个驱动程序,它会为你处理连接池。驱动程序创建了一个数据库连接池。就像机器人手臂一样,你的软件从池中获取连接,执行查询,然后当连接关闭时(这应该尽可能快地完成)而不是真正关闭,开放的连接会被返回到池中,并可供程序中的另一个进程使用。查看以下通用图示:

图 3.19 – 对象池模式创建了一个管理其他对象集合的对象。客户端从池中请求对象以使用它们,完成后释放它们。
有一个名为PooledObjects的私有集合,用于存储池中的对象。客户端在获得对对象池的访问权限后,可以使用GetPooledObject方法从池中请求对象。一旦完成,借用的对象就会被返回到池中。如果池为空,后续对GetInstance的请求将创建一个新对象,如果可能的话。如果不可能,实现通常会等待直到对象返回到池中。
菲比决定添加一个池来控制对她的有限数量机器人手臂的访问:

图 3.20 – 菲比为她装配线上的有限数量可用机器人手臂设计的对象池。
菲比为该图编写代码:
public class WeldingArmPool
{
private int _maxSize = 10;
菲比只有 10 台机器人手臂可用,但她知道将来会有更多。在这里她创建了一个私有变量并将其初始化为10。接下来,她创建了一个构造函数,但通常,构造函数只会处理创建初始化对象。UML 图包含一个Reset()方法的条目,它执行相同的功能。她将稍后编写它,但将其放在构造函数中。IDE 提出了抗议,但她知道当她完成时一切都会顺利:
public WeldingArmPool()
{
Reset();
}
对象池最重要的部分是某种类型的集合来存储池中的对象。菲比选择了WeldingArm对象的List。这将在我们之前提到的Reset()方法中初始化,尽管菲比还没有创建它:
private List<WeldingArm> Pool { get; set; }
菲比希望能够改变池的最大大小,这样她就能构建更多的机器人手臂。她可以增加池的大小,甚至在手臂因维护而离开池时减少它:
public int MaxSize
{
get => _maxSize;
set
{
_maxSize = value;
Reset();
}
}
菲比决定有一个方法来查看池中有多少手臂会很好:
public int ArmsAvailable => Pool.Count;
最后,我们期待已久的Reset()方法。这看起来像是你会在构造函数中放入的代码。菲比想要一种在需要时重置池的方法。由于 DRY(Don't Repeat Yourself)是一个好习惯,而且使用除new关键字之外的任何内容调用构造函数看起来都很奇怪且不自然,菲比将事情反过来,将逻辑放在这里,然后在需要的地方调用它,包括构造函数。这让我的人工智能开发环境(IDE)疯狂。它认为我有一个永不初始化的非空列表。这不是真的,但 IDE 的自动化功能还不够聪明,看不出来。代码本身初始化列表,然后根据MaxSize属性指示的数量填充它:
public void Reset()
{
Pool = new List<WeldingArm>();
for (var i = 0; i < MaxSize; i++) Pool.Add(new WeldingArm());
}
我们需要一种从池中获取手臂的方法。以下方法检查是否有可用的手臂,如果有,则检索池中的第一个手臂。如果没有手臂,我们抛出一个错误:
public WeldingArm GetArmFromPool()
{
if (ArmsAvailable > 0)
{
var returnArm = Pool[0];
Pool.RemoveAt(0);
return returnArm;
}
throw new Exception("You are out of arms. Return some to the pool and try again.");
}
在一个真实程序中,你可能只是返回一个新的对象,并承担性能损失。在这种情况下,我们实际上受到限制。我们可以实现一些并发代码,监视当手臂空闲时是否有可用的手臂,这指向一个新的工作,但这已经超出了模式演示的范围。菲比需要一种方法将她的手臂返回到池中。手臂存储其最后焊接的位置。为了避免混淆,菲比决定将其重置为零。这将表明手臂没有在工作,它位于池中,准备分配:
public void ReturnArmToPool(WeldingArm arm)
{
arm.CurrentPosition = 0; //not at any station
Pool.Add(arm);
}
}
总结一下,菲比在Program.cs中编写了一个小的测试程序:
Console.WriteLine("Here's a program that controls some welding robots from a pool of 10.");
var armPool = new WeldingArmPool
{
MaxSize = 10
};
var arm01 = armPool.GetArmFromPool();
arm01.MoveToStation(1);
if (arm01.DoWeld()) armPool.ReturnArmToPool(arm01);
作为一种工具,对象池可以极大地加快大多数软件的速度,因为通常,你只有在对象创建需要大量时间或资源时才会想到使用它。池预先创建对象,并希望以后不再需要。
“但是!”菲比大声对自己说,“这似乎最好能保证一次只有一个池在使用。”她是对的。关于机器人手臂,软件简单地创建更多实例是不合适的。从逻辑上讲,控制软件最终应该是多线程的。你不能有多个线程创建它们自己的池。在现实世界中,实例化额外的池不能神奇地生成更多资源。“如果只有一种方法可以确保对象池在程序运行期间只实例化一次,”菲比想。她本可以继续工作,但今天是玉米卷之夜,所以她关闭了笔记本电脑,准备晚餐。你可以在这个章节的源代码中的ObjectPoolExample项目中找到菲比对对象池模式的源代码。
单例模式
那天晚上,或许是被她的对象池奇点问题所启发,或许是受到玉米卷的影响,或许两者兼而有之,菲比做了一个奇怪的梦。她是一个穿着长流苏黑色法袍的法官,坐在法庭的高台上。一个审判正在进行。被告是辛·埃尔顿。他是一位穿着得体的中年绅士,安静地坐在他律师旁边的一个大而精致雕刻的橡木桌后。
法庭书记员清了清嗓子,对着麦克风平静地说,“被告辛·埃尔顿被指控模仿一个有益的设计模式,实际上却是一个反模式。”
陪审团中有一半人发出了集体吸气声。这声音来自法庭的后方,菲比这时才注意到。房间里充满了软件开发者,他们都穿着工装短裤、Birkenstock 凉鞋和 300 美元的复古金属乐队T 恤复制品。菲比敲响法槌,大声喊道,“法庭秩序!”她曾在电影中看到过这一幕,一直想这么做。随着陪审团安静下来,书记员没有抬头,继续从电脑屏幕上发言。“这些是非常严重的指控,埃尔顿先生;你将如何答辩?”
埃尔顿的律师,一个瘦弱的紧张男人,穿着廉价的蓝色西装,站起来,用尖锐的声音说,“无罪!”
书记员记录了答辩,审判开始了。检察官站起来发表开场白,他的南方口音缓慢而拖沓,让人联想到牛仔西部电影。他说,“尊敬的法官,我们打算证明,辛·埃尔顿并非像他自己所宣称的那样是一个模式,而是一个反模式。他公然是名为“金锤”的非法编码组织的成员。”检察官坐下,露出一个带有太多牙齿的狡猾微笑,这让人怀疑他是否是人类。你知道为什么鲨鱼从不攻击律师吗?职业礼节。
辩护方叫埃尔顿先生出庭作证,在审问他时,辩护方能够确立几个关键前提,旨在证明辛·埃尔顿是一个模式。
首先,这是一个广泛使用、受欢迎的模式,它被收录在四人帮这本书中。这本书被许多人视为不可动摇的,因为它被认为是软件设计模式的奠基之作。
“反对!”检察官大声喊道,同时用拳头猛击桌子。“尊敬的法官,受欢迎程度或被收录在单一书籍中并不足以证明这是一个模式。”
菲比轻轻地在法官台上敲了敲她的法槌,说道,“维持原判。辩护将继续进行,但你的立场很脆弱,律师。要成为一个模式,辛·埃尔顿必须解决许多软件开发者面临的一个共同问题。这不是一场受欢迎的竞赛。你必须做得更好。”
“是的,尊敬的法官。”律师脸红,显得有些慌乱,好像他希望这个案件能基于这个开场白被驳回。他继续审问埃尔顿先生。
“你能向法庭准确说明你解决了哪些问题吗?”
“有时候,”埃尔顿先生开始说,“你需要确保在程序运行期间任何时候只有一个类的实例。例如,你可能需要访问数据库,可能通过对象池,或者你可能需要访问文件或配置服务。在这些情况下,你应该只有一个实例来处理这些。该实例对程序的所有部分都是可用的,这让你能够紧密控制全局状态。这些问题我可以解决,而且我是在一个类中的一个地方解决的,而不是分散在代码的各个部分。”
在审判过程中某个时刻,法警带来了一块大白板,并要求埃尔顿先生画一幅自己的图。它看起来如下:

图 3.21 – 单例模式使用一个私有的构造函数(未显示)来创建一个对象(它自己)的实例,该实例存储在一个私有字段中。对该类调用 new 时,会检查是否已存在实例。如果存在,则返回现有对象。
辩护方继续并这样总结了其论点:
-
您的应用程序使用的共享资源,无论是数据库、文件、远程服务还是机械臂,都保证只有一个访问点。
-
程序的全局状态受到保护,因为只有一个访问点。
-
单例模式只初始化一次,因此只有在单例初始化时才会产生性能影响。
辩护律师以“辩护结束。”作为结论。
几乎无声的法庭突然爆发出一阵响亮的慢速高尔夫掌声。检察官站起来,整理了一下领带,贪婪地盯着陪审团。你能感觉到从他身上散发出的自信。那是一种电击般的感觉。当他说话时,几乎听起来就像他的南方口音故意更加明显。
“尊敬的法官,陪审团成员们,让我告诉你们为什么辩护方刚才说的每一件事都是一大堆胡言乱语!” “胡言乱语”这个词是法官菲比说的,她看到了事情的发展方向,用力敲击了法槌。她只做了几分钟的法官,可能再也不会做了,所以她绝对不允许那种语言出现在她的法庭上。女孩得有自己的标准。在警告了检察官后,这让书记员和辩护律师感到困惑,检察官叫了几位证人,他们提供了关于 Sing Elton 是一个反模式和黄金锤子的轶事证据。
一位软件工程经理作证说,辛·埃尔顿肯定是一个黄金锤子,因为在每一次他主持的面试中,应聘者总是声称他们研究过设计模式。当经理要求提供一个模式的例子时,唯一能被提到的模式就是单例模式。现在有很多类(你可能会认为只有一个例子)其实不需要实现为单例。每个人都使用它,因为它是最容易理解和记住的模式。
一家专注于窗户破碎修复的软件公司的一名软件工程师指出了最令人信服的论点:
-
根据广泛接受的原理,每个类都应该有一个职责;它应该只解决一个问题。讽刺的是,单例模式解决了两个问题。它确保只有一个类的实例存在,并且提供了一个访问某些共享或受限资源的单一入口点。
-
单例模式闻起来非常像全局变量。它不仅仅是一个变量,而是一个完整的类。在程序员的世界里,全局变量普遍受到谴责,因为它们被认为是不安全的。运行程序中任何给定对象的任何方法都可以修改全局状态,这通常会导致软件不稳定。对于设计用于线程或并发的软件,这有更严重的后果。
-
单例模式被认为促进了类之间的紧密耦合。你真的无法避免这一点,因为没有抽象单例这种东西。
-
单例的实现很难进行有效的单元测试。除了我们之前提到的紧密耦合问题,这本身就是测试的大忌,你无法模拟单例类。它是密封的,没有父类,因此使用 Liskov 替换作为测试工具是不可能的。单元测试应该是可隔离的,一个测试的效果不应该影响其他测试。有了单例的参与,它们很可能会受到影响。
检察官总结道:“所以,尊敬的法官,陪审团的女士们和先生们,辛·埃尔顿不过是一个卑鄙无耻的骗子。他没有任何模式,应该被剥夺这个头衔。”
画廊里爆发出一阵不满的哼声。菲比微笑,因为那是她再次敲响法槌的信号。她可以习惯这种生活。她重重地敲击了长凳,以至于产生的声音将她从梦中惊醒。这就像每个人至少做过一次的梦——那个你梦见自己在坠落,当你即将触地时突然醒来的梦。
“我再也不会吃玉米卷了!”菲比昏昏欲睡地喊道。她知道她在撒谎。她吃了一顿非玉米卷的早餐,然后回去工作了。她梦中的细节有一些合理的观点,但她觉得这是一个她真正需要单例模式的案例,考虑到实际存在的非常真实的限制。
菲比使用单例模式来模拟一个对象池,用单例来代表她在控制软件中受限的共享机器人手臂集合:

图 3.22 – 菲比已经将她的对象池重构为单例。现在,在她的运行程序中任何时候只能存在一个机器人臂池。
菲比转向她的 IDE,开始将她的机器人臂对象池重构为单例。这是一项相当简单的工作。首先,她重命名了类并将其密封,这样它就不能被扩展。如果类能够被扩展,它就会失去模式提供的保护。你可以开始看到为什么人们讨厌单例模式。它打破了我们一直钦佩的许多关于保持事物可扩展性的规则:
public sealed class WeldingArmPoolSingleton
{
为了使单例模式工作,它需要一个私有静态方法来持有单例实例。实际上,这是一个有自我引用的类,这有点奇怪。关键是只能有一个这样的实例,这就是为什么我们需要将字段设置为静态:
private static WeldingArmPoolSingleton _instance;
private int _maxSize = 10;
下一个你不太常见的怪癖是私有构造函数。没有公共的构造函数。就在这个时候,如果你的 IDE 配备了静态分析工具,它将开始抱怨。它会告诉你无法实例化对象。这是好事。这正是我们想要的。菲比保留了她的Reset()逻辑。她所做的只是将构造函数重命名,使其与类名匹配,并将访问修饰符更改为私有:
private WeldingArmPoolSingleton()
{
Reset();
}
单例模式最后一块拼图是一个静态方法,用于访问我们最初开始的实例属性。菲比使用 C#的属性语法来暴露它。当客户端程序第一次引用Instance属性时,获取器会检查是否已经存在一个实例。如果没有,它会创建一个并设置后备字段的_instance。如果_instance不是null,这意味着它已经被调用过一次,所以它只是返回已经存在的实例。由于它是一个静态字段,所有引用都指向内存中的同一位置。就这样——你得到了一个无法实例化两次的类:
public static WeldingArmPoolSingleton Instance
{
get
{
if (_instance == null) _instance = new WeldingArmPoolSingleton();
return _instance;
}
}
菲比的其余代码保持不变。你可以在本章源代码的SingletonExample项目中找到完整的重构。
摘要
在本章中,我们介绍了一个惯用方法,即简单工厂,以及四个模式——工厂方法模式、抽象工厂模式、对象池模式和单例模式。
所有这些模式都被归类为创建型模式。这意味着它们通过封装创建逻辑在一个比使用严格的具体对象和new关键字更灵活的结构中来控制对象的创建。
工厂方法模式是当人们听到“工厂模式”时首先想到的。使用它意味着将创建逻辑抽象成一个称为创建者的工厂类。创建者对象由一个接口定义以最大化灵活性。我们还为工厂生产的对象创建了一个接口。我们称之为产品。每个工厂创建者类负责程序中所有产品的一个子集。
抽象工厂模式涉及创建一系列自然搭配的对象。使用它意味着为多个创建者类创建一个抽象定义。每个创建者负责一个具体对象。
当您需要使用一系列复杂步骤来创建对象时,会使用建造者模式。使用它类似于抽象工厂模式,但您的建造者类由一个接口定义。建造者中的每个方法代表构建过程的一个步骤。可能会诱使您在每个建造者类中放置一个单独的方法来按顺序调用这些步骤。然而,这通常委托给一个Director类。建造者包含构建对象的函数,但Director类包含调用这些方法的顺序背后的逻辑。
对象池模式旨在帮助您管理那些由于实际约束而受限的对象,例如我们的机器人手臂,或者那些创建成本高昂的对象,例如数据库、网络服务或文件连接。其理念是在程序运行期间一次性支付创建成本,创建一个包含这些对象的列表,这些对象在程序运行期间保持可用。当需要其中一个对象时,它从池中取出,不再需要时再返回。这允许其他进程稍后使用它,而无需经历正常的实例化过程。
对象池模式可以有效地与单例模式结合使用。单例模式是有争议的,通常被认为是一个反模式,因为它不能扩展,并且它促进了紧密耦合。菲比能够将其与她的机器人手臂池结合使用,以确保她不会意外地创建多个机器人手臂池。她只有 10 个物理手臂可以工作,因此复制池可能会带来问题。
这些模式被展示为一种渐进。姐妹们从简单的工厂开始,迭代地工作到建造者模式。这并非有意为之——这只是事情发展的结果。您不应将这种渐进视为建造者模式比工厂方法或抽象工厂更好的指示。每个模式都有其位置,并且通常,模式可以像美酒和好牛排一样搭配使用。
在下一章中,我们将探讨结构模式,这些模式旨在优化您构建类层次结构的方式,以实现最大灵活性和完全实现开闭原则。
问题
回答以下问题以测试你对本章知识的掌握:
-
编程惯用法是什么,它们与模式有何不同?
-
除了
Hello, World之外,还有一些流行的编程惯用法吗? -
依赖于简单工厂惯用法有什么缺点?
-
单例模式是一个模式还是一个反模式?为什么?
-
当你处理创建一系列相关对象时,你应该使用哪种模式?
-
对于具有复杂构建过程的对象,你应该使用哪种模式?
-
建造者模式中哪个类负责以正确的顺序控制构建步骤的执行?
进一步阅读
要了解更多关于本章所涉及的主题,请查看以下资源:
-
Ritchie, D. M., Kernighan, B. W., & Lesk, M. E. (1988). 《C 程序设计语言》。Englewood Cliffs: Prentice Hall.
-
sites.google.com/site/steveyegge2/singleton-considered-stupid
第四章:用结构型模式加固您的代码
最近,我的妻子问了一个应该很容易回答的问题:“你小时候(比如 9 岁或 10 岁)有什么娱乐活动?”我不得不思考。当我 9 岁或 10 岁的时候,家里不可能有电脑,除非你住在有稳定高电压连续正弦波电源的军事掩体里。掩体还需要几千平方英尺的架空地板、工业级空调和稳定的清洁水源来用于 CPU 冷却。这并不是我们大多数朋友 9 岁或 10 岁时正常的居住环境。这个问题很难回答,因为当我 12 岁的时候,我就得到了我的第一台电脑。它是 Radio Shack TRS-80,配备了一个级别 1 的 8 位 Z-80 处理器,4K(即 4,000 字节——只是字节,不是千字节、兆字节或吉字节)的内存,一个分辨率为 128×48 像素的单色显示器,以及一个磁带机用于加载和存储程序和数据。这台新电脑占据了我醒着的每一刻,从那时起,我可能比愿意承认的更多地将我的生命奉献给了屏幕时间。然而,问题是:在我有电脑之前,我有什么娱乐活动?经过一分钟思考,我想起了我建造模型火箭。
就在我长大的那所房子附近,有一个爱好店,出售模型火箭套件以及引擎和发射器。在周六早上,我会翻遍妈妈的包,凑够 5 美元,然后走到店里买一个套件。一开始,它们是简单的“级别 1”套件,你可以在几小时内组装和发射。随着我越来越熟练,模型变得越来越复杂,比如降落伞,当火箭耗尽燃料并达到最高点时会展开。甚至有一枚火箭配备了一个单次使用的相机,可以在返回地球的路上拍摄空中照片。这些花哨的套件中的火箭看起来像《星球大战》电影中的宇宙飞船(当时只有一部),以及《银河战舰》。
火箭通常具有相似的结构。然而,随着它们变得更加复杂,组装它们并安全发射所需的指令也越来越多。这让我想起了我们接下来要介绍的模式集合。
结构型模式旨在帮助您将对象组装成更大、更复杂的结构,从而避免我们逐渐厌恶的管道式单体结构。结构型模式对于大型系统的作用,类似于创建型模式对于单个对象实例的作用。结构型模式帮助我们保持灵活性和效率。已经有许多结构型模式被记录下来,但本章重点介绍四个最重要的模式:
-
装饰者模式
-
外观模式
-
组合模式
-
桥接模式
与前几章一样,这些结构模式将在简单的命令行程序的环境中演示。这将限制您在更复杂(尽管可能更有趣)的桌面、Web 或游戏项目中遇到的噪音量。
本章假设您了解统一建模语言(UML)的基础知识。本书中所有模式都使用 UML 类图进行图示。如果您对 UML 是一个新概念,请参阅本书的附录 2。您不需要理解 UML 的 14 种图示类型中的所有内容。我只使用类图,因为这是我们模式工作所需的所有内容。
技术要求
在整本书中,我假设您知道如何在您喜欢的集成开发环境(IDE)中创建新的 C#项目。我在本章中不花时间讲解设置和运行项目的机制。然而,如果您需要有关 IDE 或如何设置项目的教程,请参阅本书的附录 1。如果您决定与我一起编写代码,您将需要以下内容:
-
运行 Windows 操作系统的计算机。我使用的是 Windows 10。由于项目是简单的命令行项目,我相当确信这些内容在 Mac 或 Linux 上也能工作,但我还没有在这些操作系统上测试过这些项目。
-
支持的 IDE,如 Visual Studio、JetBrains Rider 或带有 C#扩展的 Visual Studio Code。我使用的是 Rider 2021.3.3。
-
.NET SDK 的某个版本。再次强调,项目足够简单,我们的代码不应该依赖于任何特定版本。我正在使用.NET Core 6 SDK。
如果您需要代码,您可以在 GitHub 上找到本章的完整项目文件,网址为github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns/tree/main/chapter-4。
B2B(回到自行车)
在我们上一集中,姐妹 Kitty 和 Phoebe 决定开设自己的自行车工厂:Bumble Bikes。她们打算利用 Kitty 的专业知识,设计路上最创新的自行车。Phoebe 通过设计和构建机器人来利用她自己的工程技能。尽管姐妹俩都不是受过专业训练的软件开发人员,但她们的父亲,一位退休的时间旅行软件工程师,在她们很小的时候就教她们编程。姐妹们熟悉 IDE 的使用,但她们刚刚开始学习模式。因此,当前的编码任务是编写将运行自动化工厂的机器人控制软件。
机器人制造系统将Bicycle类的实例转换为实物自行车。女孩们已经掌握了创建型模式,并且决定使用 Builder 模式。Builder 模式将被用来创建所需的任何类型的自行车组件,并将这些组件组装成成品自行车。
装饰者模式
对于基蒂和菲比来说,周一早上是个忙碌的开始。周末,基蒂在她家附近的西德克萨斯州岩石沙漠的一些小径上骑了一辆原型山地自行车。她想要一个有挑战性的测试,所以她选择了美国国家公园大弯的 Black Gap Road。大弯的名字来源于里奥格兰德河的一个大弯,它形成了公园的边界,以及美国与墨西哥的南部边界。Black Gap Road 因其具有挑战性的小径而闻名。它有冲刷地、浅溪流和实际的缺口(道路因此得名)。缺口由火山岩形成的大山之间的狭窄通道组成。在缺口中间是一个约 3 英尺(约 1 米)高的悬崖,悬崖下方是下一部分。基蒂曾多次驾驶她的吉普车越过悬崖,但从未骑自行车。她误判了落差,结果背部着地。在喘过气来后,她重新骑上自行车完成了小径。菲比在起点等待基蒂用吉普车来接她。
周一早上,他们的电话开始响起。菲比花了一小时的时间在电话里和一个原材料供应商交谈。根据女孩们预测的原材料数量,供应商告诉她们需要在该供应商的外部网络(这是一个仅对大客户开放的私有网络)上设置账户。Bumble Bikes 可以在原材料上获得优惠价格。然而,为了利用这个价格优势,Bumble Bikes 必须承诺在其机器人制造系统和供应商的库存控制系统之间创建一个接口。菲比认为这是一个积极的信号,因为它意味着在短期内,她可以利用供应商的库存控制系统,而不必自己创建这个系统。唯一的缺点是菲比需要修改她的软件,特别是Bicycle类,以便向供应商的 API 提供通知。Bicycle类已经内部发布并投入使用。为了添加供应商所需的通知器而打开这个类将迫使她违反开放封闭原则,该原则指出:你不应该通过更改类来修改正在生产的代码。相反,她应该找到一种方法来扩展这个类。
同时,凯蒂正在用手机和一个拥有美国大量自行车经销商的公司总裁通话。这家公司希望成为美国 Bumble Bikes 的独家经销商。姐妹俩原本设想直接向客户销售,并与小型当地自行车店建立协议。她们对大型连锁店对其新兴产品线感兴趣感到惊讶。在与总裁长时间讨论后,凯蒂了解到与经销商做生意的一个要求是,每辆订购的自行车都需要一份车主手册,并且必须带有散布在手册中的每个经销商的详细信息。经销商所属的公司将提供大规模印刷系统,因此女孩们不需要为任何设备筹集资金。
命运就是这样,两人在各自通话的同一时间挂断了电话。
“你不会相信这个!”菲比惊呼。
“不,你不会相信这个!”凯蒂反驳道。
当他们讨论所发生的事情时,两人都很兴奋。周五,他们是一家小型自行车初创公司。到了下周一早上,他们已经定位为美国自行车市场的一个严肃的竞争对手。他们的梦想比他们想象的实现得更快。他们所需要做的只是对他们的代码进行一些修改。
“很简单!”菲比说,“我们只需更改 Bicycle 类,并添加新的属性和方法以满足我们的新要求。”
“别这么快,”凯蒂回答说,然后她继续说,“自行车类已经投入生产。改变它们与开闭原则相冲突。此外,并非每个自行车对象都需要新的手动打印行为。如果我们强迫每个子类都具有这种行为,我们就是在违反接口隔离原则。我们将会违反两个 SOLID 原则!”
“哦,是的,”菲比沮丧地说。两人进行了一些研究,发现了一个似乎可行的想法。如果他们能够创建一个包装或装饰 Bicycle 类的类,他们就可以创建一个具有新行为但不会破坏现有实现的扩展类。
装饰者模式允许你在不接触原始类的同时,向类添加属性和方法,同时仍然尊重任何具体实现。你可以在 图 4.1 中看到装饰者模式的通用绘图:

图 4.1:装饰者模式。
模式的各个部分在此进行了分类和解释:
-
IComponent是定义我们要包装的行为的接口。 -
ConcreteComponent是原始实现。在迄今为止提供的示例中,这将是从微控制器供应商的 API。 -
Decorator是一个抽象类,它通过接口持有对具体组件的引用。 -
ConcreteDecorator1是一个扩展装饰器类的具体类,但它添加了一些额外的状态定义,以属性或字段的形式出现。 -
ConcreteDecorator2通过添加额外的操作扩展了装饰器。
Kitty 和 Phoebe 需要向他们的Bicycle类添加两种不同的行为。一种行为将与原材料供应商的库存控制系统接口。另一种行为将允许在工厂打印定制的手册,并与自行车一起运送到美国各地的数百个经销商处。理想情况下,还应该有一种方法可以将这两种行为添加到他们的Bicycle对象中。换句话说,他们真的希望他们的装饰器可以堆叠。堆叠将允许他们潜在地修改所有Bicycle类以与原材料供应商的系统协同工作,但只需处理通过他们的经销商协议销售的自行车的打印手册。他们应该能够根据需要添加或省略这些行为。Kitty 走到白板前,最终确定了图 4.2中所示的结构:

图 4.2:Kitty 对装饰器模式的实现。
让我们来看看图中的类:
-
这是我们创建的第三章“使用创建型模式进行创意”,用来表示我们的自行车界面。这里没有任何变化。
-
这是实现接口的抽象
Bicycle类。这里也没有任何变化。实际上,接口和抽象类都没有变化。 -
这是抽象装饰器类。严格来说,类名中不一定需要包含单词装饰器。这里是为了清晰起见。请注意,它使用组合来包含一个
protected属性,该属性包含对实现IBicycle的类的引用。此对象由构造函数设置,同时实现IBicycle接口。乍一看,这似乎不太符合 DRY 原则。正如你将看到的,它会。当你看到代码时,这会变得有意义。 -
装饰器类。这里有两个:
DocumentedBicycle和NotifyingBicycle。你可以根据需要创建任意多个。你可以在实现中堆叠它们,使得可以有一个既带有手动打印机又带有通知器,或者两者都有或都没有的Bicycle对象。随着 Bumble Bikes 的扩展和新业务需求的实现,我们有可能添加并选择性地堆叠更多的装饰器,而不会干扰原始的自行车类本身。 -
IDocumentor和INotifier接口定义了装饰行为。将它们作为接口可以防止装饰器与具体实现紧密耦合。
装饰器用于在不修改原始类的情况下向对象添加新的属性和方法(或者如果你更喜欢,行为)。这允许你通过巧妙地扩展类来遵守开闭原则。在这种情况下,我们通过包装类来扩展类,而不是仅仅通过继承来扩展。
装饰一个类涉及三个步骤:
-
创建一个包含要装饰的类的
private成员的类。在我们的例子中,我们将装饰AbstractBicycle类。我们需要一个包含IBicycle类型的private属性的类,以及一个允许我们设置此属性的构造函数。 -
我们需要实现
IBicycle接口中已经存在的所有属性和方法。当我们为装饰器实现获取器、设置器和常规方法时,我们将它们传递给私有实例。实际上,我们已经包装了类,装饰器执行得与原始类完全一样。 -
我们添加装饰属性和方法。如果你打算堆叠装饰器,有一个可以链接它们的公共方法是很重要的。我们将使用
Build()方法。
让我们看看 Kitty 的代码实现。我们将从底部开始,从两个接口开始。她添加了 IDocumentor 接口。这允许在构建自行车时打印定制的经销商手册:
public interface IDocumentor
{
public void PrintManual();
}
然后她添加了 INotifier 接口,该接口定义了一个与原材料供应商的库存控制系统通信的功能:
public interface INotifier
{
public void Notify();
}
接下来,让我们看看 AbstractBicycleDecorator 类:
public abstract class AbstractBicycleDecorator : IBicycle
{
protected readonly IBicycle UndecoratedBicycle;
在这里,保存原始 IBicyle 对象的 protected 字段至关重要。这是我们将要装饰的原始、未装饰的对象实例:
protected AbstractBicycleDecorator(IBicycle bicycle)
{
UndecoratedBicycle = bicycle;
}
接下来,我们需要在装饰类中实现 IBicycle 接口。我们通过将一切传递给构造函数设置的私有未装饰对象来完成此操作。对装饰器类的每次调用都将传递给未装饰的实例:
public string ModelName
{
get => UndecoratedBicycle.ModelName;
set => UndecoratedBicycle.ModelName = value;
}
public int Year => UndecoratedBicycle.Year;
public string SerialNumber =>
UndecoratedBicycle.SerialNumber;
public BicycleGeometries Geometry
{
get => UndecoratedBicycle.Geometry;
set => UndecoratedBicycle.Geometry = value;
}
public BicyclePaintColors Color
{
get => UndecoratedBicycle.Color;
set => UndecoratedBicycle.Color = value;
}
public SuspensionTypes Suspension {
get => UndecoratedBicycle.Suspension;
set => UndecoratedBicycle.Suspension = value;
}
public ManufacturingStatus BuildStatus {
get => UndecoratedBicycle.BuildStatus;
set => UndecoratedBicycle.BuildStatus = value;
}
最后,我们将 Build() 函数实现为抽象的。在我们的原始 Bicycle 类中,这个类也是抽象的,我们在类中实现了这个方法。这是因为我们实际上正在改变自行车的构建方式。正如你将看到的,这个方法提供了堆叠我们的装饰器的方法:
public abstract void Build();
}
最后,让我们进入两个装饰器类。首先考虑 Kitty 打印手册的需求,因为她是大姐。至少,她总是这样争辩。在这里,DocumentedBicycle 类扩展了 AbstractBicycleDecorator:
public class DocumentedBicycle : AbstractBicycleDecorator
{
有一个 private 字段来保存 IDocumentor 对象:
private IDocumentor _documentor;
public DocumentedBicycle(IBicycle bicycle, ManualPrinter
printer) : base(bicycle)
{
_documentor = printer;
}
这里是实际的装饰。在构造函数中传递给任何对象的 Build() 方法被调用。然后,调用额外的装饰行为——在这种情况下是在装饰上调用 PrintManual() 方法:
public override void Build()
{
UndecoratedBicycle.Build();
_documentor.PrintManual();
}
}
Phoebe 要求通知供应商的库存控制系统是在其自己的装饰器类中实现的:
public class NotifyingBicycle : AbstractBicycleDecorator
{
private readonly INotifier _notifier;
public NotifyingBicycle(IBicycle bicycle, INotifier
notifier) : base(bicycle)
{
_notifier = notifier;
}
public override void Build()
{
UndecoratedBicycle.Build();
_notifier.Notify();
}
}
结构是相同的,当然,除了装饰部分。这次,我们传入了一个符合 INotifier 接口的对象。当调用原始未装饰对象的 Build() 方法时,我们调用相应的方法。
现在,我们需要一些具体的类来满足 INotifier 和 IDocumentor 接口。我们将保持这些类简单。IDocumentor 接口通过一个名为 ManualPrinter 的类来实现:
public class ManualPrinter : IDocumentor
{
public void PrintManual()
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("The manual is printing!");
Console.ResetColor();
}
}
PrintManual() 方法是我们添加的行为。对于这个示例来说,它所做的只是打印出 The manual is printing! 这一行。由于我们的控制台中有大量的文本,我选择让装饰器的输出为青色,以便更容易辨认。INotifier 接口的一个实现可能如下所示:
public class MaterialsInventoryNotifier : INotifier
{
public void Notify()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("The materials inventory control
system has been notified regarding the manufacture of
this bicycle.");
Console.ResetColor();
}
}
再次,我在输出中添加了一些颜色,以便更容易辨认。这次,它是黄色。我已经很久没有开玩笑,让我们用一些使用我们的装饰器类的代码来结束这个话题!
这些是示例项目文件中 Program.cs 文件的内容:
var regularRoadBike = new RoadBike(); //no decorators.
regularRoadBike.Build();
Console.WriteLine("+++++++++++++++++++++++++++++++++++++");
这是一个普通的、未装饰的 RoadBike 对象。无聊!我们之前见过这个!让我们用打印自定义手册的能力来装饰它:
var bikeManualPrinter = new ManualPrinter();
var documentedBike = new DocumentedBicycle(new RoadBike(),
bikeManualPrinter);
documentedBike.Build();
这次,我们实例化了 DocumentedBicycle,这是我们的装饰器。它需要一个未装饰的 RoadBike 和一个 ManualPrinter。当我们调用构建方法时,DocumentedBicycle 类调用 RoadBike 类的 Build() 方法。然后,它调用自己的 Build() 方法,添加新的打印手册行为。
如果你没有注意到,我在这里添加了一些分隔符,这样当我们运行示例时,就可以轻松地看到每个运行部分:
Console.WriteLine("+++++++++++++++++++++++++++++++++++++");
这真是一次愉快的经历!让我们再来一次!这次,让我们尝试 NotifyingBicycle 装饰器。它的工作方式相同。首先,我们创建一个 MaterialsInventoryNotifier 的实例,它体现了装饰器要添加到 RoadBike 中的新行为:
var manufacturingInventoryNotifier = new MaterialsInventory
Notifier();
接下来,我们实例化 NotifyingBicycle 类,传入一个新的 RoadBike 对象用于装饰,以及 manufacturingInventoryNotifier:
var notifierBike = new NotifyingBicycle(new RoadBike(),
manufacturingInventoryNotifier);
现在,我们调用装饰器的 Build() 方法:
notifierBike.Build();
Console.WriteLine("+++++++++++++++++++++++++++++++++++++");
记住这个工作原理:装饰器(NotifyingBicycle)有一个 Build() 方法。被装饰的 RoadBike 类也有一个 Build() 方法。装饰器调用 RoadBike 的 Build() 方法,生成一个 RoadBike 对象。然后,装饰器调用自己的 Build() 方法,添加通知行为。
装饰器的酷之处在于它们可以堆叠。对于压轴之作,我们将同时将两个装饰器放在 RoadBike 对象上:
var notifyingDocumentedBike = new NotifyingBicycle(new
DocumentedBicycle(new RoadBike(), bikeManualPrinter),
manufacturingInventoryNotifier);
notifyingDocumentedBike.Build();
哎呀!这太难读了。让我们把它拆分开来。在中间,你会找到一个新的未装饰的 RoadBike 类:
var notifyingDocumentedBike = new NotifyingBicycle(new
DocumentedBicycle(new RoadBike(), bikeManualPrinter),
manufacturingInventoryNotifier);
notifyingDocumentedBike.Build();
现在,从这里向外移动,你会找到我们使用这个新的 RoadBike 创建 DocumentedBicycle 的地方:
var notifyingDocumentedBike = new NotifyingBicycle(new
DocumentedBicycle(new RoadBike(), bikeManualPrinter),
manufacturingInventoryNotifier);
notifyingDocumentedBike.Build();
我们传递了 bikeManualPrinter。在现实生活中,小心以这种方式重复使用你的实例。我们之前使用了 bikeManualPrinter,现在我们将其传递给第二辆自行车。这些是通过引用传递的,这意味着如果你在 bikeManualPrinter 上更改任何属性,它将影响早期示例中的 documentedBike 的值以及我们现在构建的 notifyingDocumentedBike。
现在,让我们一直移动到最外层的构造器:
var notifyingDocumentedBike = new NotifyingBicycle(new
DocumentedBicycle(new RoadBike(), bikeManualPrinter),
manufacturingInventoryNotifier);
notifyingDocumentedBike.Build();
最外层的构造器使用新的 DocumentedBicycle 创建了一个 NotifyingBicycle 的实例,而 DocumentedBicycle 又使用了新的 RoadBike。在每一层,我们都传递了一个装饰行为。
用另一种方式来说,notifyingDocumentedBike 是使用新的 DocumentedBicycle 和 ManualPrinter 创建的。DocumentedBicycle 是使用新的 RoadBike 创建的。
你可以看到程序运行的输出。图 4.3 显示了 DocumentedBicycle 装饰器的运行,接着是 NotifyingBicycle 装饰器。最后的运行显示了在同一对象上成功运行的两个装饰器。代码指示了输出结果的彩色编码,但这本书并没有彩色印刷。或者也许它是有彩色的,你需要做一次眼科检查:

图 4.3:DecoratorExample 项目的 Program.cs 运行的输出结果(为了节省空间,未装饰的 RoadBike 的输出未显示)。
你可以理解为什么装饰模式是开发者中的热门选择。当你需要在不破坏现有实现的情况下向对象添加行为时,你可以使用它。装饰器可以用来创建可以按需堆叠或组合的业务逻辑层。例如,只有通过经销商网络销售的自行车需要 DocumentedBicycle 装饰器,但所有使用我们供应商的原始材料的自行车都需要 NotifyingBicycle 装饰器。如果我们使用来自另一个来源的原始材料制作自行车,而这些自行车不会通过经销商网络销售,那么我们就不需要任何装饰器。
你还可以使用装饰器来扩展那些难以或无法通过常规继承扩展的类。考虑一个密封的类,这意味着它不能通过继承来扩展。你仍然可以使用装饰器来扩展它!这可能会让你感觉自己像是一个亡命之徒。这是正常的,可能会让你得到一顶黑色牛仔帽,并学会演唱约翰尼·卡什创作的每一首歌。你已经得到了警告。
外观模式
“呃!” 菲比惊呼。现在时间是凌晨 4 点,在菲比的工坊里。她原本白色的实验服已被油污覆盖,她正踩着由工业车床散落的铝屑。她正在尝试制作她的一只机器人手臂。她有几个不同的设计,但这个是一个重型的模型,固定在了地板上。手臂有三种不同的型号。一只手臂装备了焊接机,用于焊接铝合金自行车框架,另一只装备了抛光机,用于在自行车喷漆后进行抛光处理,最后一只装备了夹具,用于在组装过程中固定自行车。菲比想要制作每种型号的手臂。由于预算限制,她只能负担起制作 10 只手臂。她决定手臂的最佳组合是三个焊接机、三个抛光机和四个夹具。在白板规划了这个流程后,她意识到她的工厂将无法满足姐妹们出色的营销活动带来的需求。仅 Kickstarter 项目就已经产生了数百个订单,而工厂甚至还没有开始运营!菲比正苦于想出让这个工厂运转的方法。
她的妹妹基蒂一直在做控制软件。在创建机器人之前,两人就软件的工作方式达成了共识,因为有时在硬件之前制作软件是实用的。软件设计灵活且易于更改。它可以驱动硬件设计,而硬件设计则不那么灵活,更改成本也更高。这是一个这样的例子。一时兴起,菲比打开了基蒂的 GitHub 仓库,切换到了基蒂为控制手臂设计对象结构的分支。
菲比发现了基蒂对装饰者模式的实现,这给了她一个灵感。她可以把机器人手臂的附件看作装饰者!这样她就可以制作 10 只手臂,但通过切换装饰者,手臂可以执行所需的任何动作。为了明确,菲比并不是在创建软件。她是从装饰者模式中汲取灵感,制作了一个带有可互换附件的机器人手臂,这样基本手臂就可以根据需要执行许多功能,而无需为每个任务构建新的手臂。

图 4.4:菲比从装饰者模式中获得了灵感。
这太棒了!然而,还有一个问题。每个附件将由不同供应商的组件制成,并且每个都将有不同的 API 来控制手臂的附件。
几周前,Kitty 离开了德克萨斯州阿尔卑斯小镇,她在 Sul Ross 大学完成工业设计学位。她驾驶着她明亮的黄色吉普 Wrangler 向东北方向开了八个小时,到达德克萨斯州的达拉斯,她的妹妹 Phoebe 正在南方卫理公会大学完成她的工程学学业。Kitty 来访的目的,除了达拉斯优越的大学夜生活外,是为了研究和寻找他们工厂机器人的潜在部件。他们已经能够获得一些基本的自行车制造需求。在他们父亲职业生涯的某个时刻,他在德克萨斯州沃斯堡的一家直升机工厂工作了几年。沃斯堡与达拉斯相邻,居民们将这个地区称为达拉斯-沃斯堡都会区。在他们父亲在直升机工厂的时期,他结识了许多可以帮助女孩们实现目标的联系人。他们需要的从机床到先进的计算机控制激光切割和制造的一切,只需一个电话即可获得。然而,电子设备却是一个不同的情况。女孩们决定使用现成的 商业现货 (COTS) 组件来开发机器人技术。
伺服电机是允许精确控制角或线性定位、速度和加速度的电动机。它们在开发机器人和人控制的工业机械中得到广泛应用。Bumble Bikes 的目标是拥有一个自动化的工厂。Kitty 开始研究微控制器。微控制器是小型计算机,允许 应用程序编程接口-(API-) 级别的交互来控制任何东西,包括伺服电机。伺服电机可能通过引脚连接器连接到计算机,有时被称为“帽子”,它们安装在微控制器的印制电路板上。达拉斯当地的电脑店在他们的超市大小的设施中有一个专门的区域,专门用于销售微控制器、伺服电机以及所有相关的电子设备。
一周后,他们已经制作了一份包含 Libre Office Calc 电子表格的机器人手臂及其三个不同附件的物料清单。将这些部件组装成一个电气的机器人手臂是基础性的。最难的部分是编写软件。女孩们为每个手臂附件找到了三个不同的微控制器和三个不同的 API:一个用于抓取器,一个用于焊接机,另一个用于缓冲器。
一个简单的解决方案是编写一个烟囱式应用程序,根据制造过程需要的任何逻辑直接调用 API。姐妹们最近在工厂模式上的经验使她们对这种策略持谨慎态度。快速简单的解决方案在长期内既不可持续也不可维护,而且他们正在考虑长期。他们希望一次就构建好机器人,而这些机器人如果得到良好的维护,理论上可以永远运行。
Kitty 的一位教授,查尔斯·德克斯特·沃德教授,曾教授一门名为《智能产品设计导论》的课程。这是一整个学期都在解决女孩们面临的问题:如何使用传感器和微控制器设计高效的自动化系统。沃德博士曾警告 Kitty 和她的学术同事们关于供应商锁定。Kitty 和 Phoebe 正在通过将一个激情项目转变为可以传给他们的孩子和孙子的企业来开始创业。现在购买的微控制器及其相关的 API 在长期或未来几年内保持不变是没有意义的。通过直接紧密耦合到当前的 API 上,姐妹们将信任他们今天使用的 API 开发者将像 Bumble Bikes 一样继续经营。这也假设 API 将继续根据其应用需求进行演变和维护。自然,这将会是一个非常天真的假设。
更安全的赌注是,每过几年市场上就会引入新的微控制器 API,并且来自不同的公司。方法签名以及 API 本身的调用方式将不同于今天。回想一下 20 年前远程 API 调用的常见技术。通用对象请求代理架构(CORBA)被简单对象访问协议(SOAP)所取代。SOAP 反过来又完全被表示状态转移(REST)所取代,这在网络开发和物联网(IoT)行业中很常见,但在 2022 年仍然处于起步阶段。我敢打赌,许多受过良好训练的软件开发者阅读这本书时,对 CORBA 或 SOAP 的了解很少,甚至没有,就像你的后代同事可能对 REST 有非常不同的看法。任何紧密耦合到任何 API 的系统,其寿命等于其紧密耦合组件中最短寿命。Kitty 已经深刻地吸取了这个教训。在软件中,Kitty 使用一个实现了执行抽象操作的机器人手臂的接口来表示控制臂。这些可以映射到 API 上。她正在使用外观模式。
在普通英语中,或者在这个案例中,法语中,“门面”意味着“面孔”。在建筑学(指有建筑物的,而不是软件)中,它指的是建筑物的正面。门面通常是华丽的,代表着设计师所说的吸引力。我能想到的最著名的门面之一是佛罗里达州奥兰多迪士尼世界的城堡,如图 4.5 所示。顺便说一句:亲爱的美国国税局,请接受我修改后的申报表,我的扣除项标记为“迪士尼研究之旅”。我向你保证,这纯粹是业务。

图 4.5:佛罗里达州奥兰多迪士尼世界城堡的前门面向外界呈现了一种华丽的外观。
在软件架构中,门面模式是相反的。它不是面向对象或 API 的华丽外观,而是一个简化的访问点。这个巧妙的模式可以通过将自己置于代码和所调用的 API 之间来隔离你的程序,从而避免供应商锁定。作为额外的奖励,门面还允许你简化 API 的接口,甚至通过仅公开重要的部分来简化多个 API 的接口。
在 Bumble Bikes 的情况下,Phoebe 和 Kitty 只需要允许机器人手臂附件移动以及调用允许焊接、抛光和抓取的特定 API 例程的基本功能。这些 API 之间可能有数千个公开的对象、方法和属性,但我们只需要其中的一小部分。同样,可能有十几个这样的 API,但从我们的代码角度来看,我们只调用一个库。API 甚至可以通过非明显的方式调用,例如 CORBA(希望不是),REST 或直接作为导入的组件依赖。门面将无缝地处理所有这些。当 API 在未来版本中更改或被不同供应商的不同 API 替换时,你只需要替换门面。底层代码保持不变。如果你使用了一个与数据库的对象关系映射器(ORM),那么它就相当于一个门面,因为它为你提供了简化对数据库的访问。这通常允许你更换数据库,比如从 Oracle 更改为 SQL Server,而无需更改你的代码。
这就是 Kitty 在软件中解决 API 问题的方法。她创建了一个接口,定义了机器人臂的行为。然后,她创建了一个装饰器,这是实现接口的代码。接着,她包装了 API 调用,成功地将抽象定义的行为映射到具体的 API 调用。通过以这种方式设计软件,Kitty 打破了对外部 API 的依赖,并由此防止了供应商锁定。任何 API 都可以按照 Kitty 的行为接口进行装饰或包装。当下一代微控制器 API 可用时,Kitty 只需要为新 API 编写一个包装器,使其符合她软件中封装的接口要求。机器人控制软件保持封闭状态,不进行修改。如果她将软件紧密耦合到微控制器的 API 上,情况就不会是这样。如果她那样做,API 的任何修订都需要部分重写,以及 Kitty 整个软件套件的严重测试和验证工作。你遇到过多少次,甚至执行过修复软件某一部分而影响到其他地方的情况?你是否曾说过,“那是不可能的!改变库 A 中的任何东西都不可能影响库 B 的操作!”?这表明程序是基于紧密耦合的操作构建的。改变任何东西都可能产生级联效应。系统越复杂,级联的某个阶段对整体系统产生有害影响的可能性就越大。这是通过使用 Façade 模式来避免的。虽然 Kitty 的程序对修改是封闭的,但她可以轻松地添加遵循她软件接口的新包装器。她只需要开发和测试新的软件,而不需要整个控制程序。整个程序通过解耦免受连锁失败的波及。Kitty 的工程笔记本使用 图 4.6 记录了她对这个模式的实现:

图 4.6:Kitty 的工程笔记本中关于她 Facade 模式实现的示意图。
模式实现的各个部分可以通过以下数字进行解释:
-
客户端使用
RobotArmFacade类代替对三个第三方 API 的直接引用,这些 API 分别由数字SomeOtherSubSystemOperation300()、SomeOtherSubsystemOperation400()和SomeOtherSubSystemOperation99()表示。这是我的巧妙提示,表明这些第三方 API 是庞大的,而不需要绘制一个填满整个页面的想象中的 API 方法图。我们的应用程序只需要这些方法中的一小部分,因此在门面中只暴露了我们需要的那些。 -
WelderAttachmentAPI代表一个第三方库,可能是一个 NuGet 包,用于控制 Phoebe 机器人臂上的焊接臂附件。 -
BuffingAPI代表一个第三方库,可能是一个 NuGet 包,用于控制抛光附件。 -
GrabbingAPI代表一个第三方库,可能是一个 NuGet 包,用于控制抓取附件。
注意,三个第三方类中的许多方法都是相似的。
WelderAttachmentAPI 提供了 MoveTo(int, int, int) 方法,允许控制软件在三维空间中定位焊接附件。BuffingAPI 使用 Quaternion,这是一个结合了角旋转的三维坐标。如果你曾经使用过使用 C# 作为其旗舰编程语言的 Unity3D 游戏引擎,你会了解并喜爱四元数,尽管它们的数学性质非常复杂。幸运的是,有了外观模式,你不需要完全理解内部工作原理,例如欧拉角和陀螺仪锁定概念,就可以使用它们。GrabbingAPI 提供了 SetLocation(int, int) 方法,使用两个坐标,因为该附件只需要在两个维度上移动。
所有三个 API 都有一个共同的功能,即移动机械臂到位置,但它们在实现或调用方式上并不相同。每个都有不同的方法签名。这是一个完美的外观模式用例,因为你可以公开一个单一的方法来控制移动,并根据使用的机械臂附件选择性地调用正确的 API 方法。
类似地,每个 API 都有一些激活其主要功能的方法:分别是 Weld()、Buff() 和 Grab()。再次强调,外观模式可以隐藏调用多个方法的复杂性。虽然它们似乎实现不同的目标,但我们可以在一个方法后面隐藏这种复杂性,这个方法根据所使用的附件类型调用正确的 API 方法。
让我们看看 RobotArmFaçade 类中的代码。首先,我们有一个 enum 定义了附件:
public enum ArmAttachments { Welder, Buffer, Grabber }
接下来是类本身及其组成部分变量:
public class RobotArmFacade
{
private readonly WelderAttachmentApi _welder;
private readonly BuffingApi _buffer;
private readonly GrabbingApi _grabber;
public ArmAttachments ActiveAttachment;
你可能会在这里注意到与装饰器模式的相似之处。我们在外观本身中有三个 API 的私有实例。外观类的任务将是将指令传递到正确的 API,并使用正确的格式。私有成员通过构造函数初始化:
public RobotArmFacade(WelderAttachmentApi welder,
BuffingApi buffer, GrabbingApi grabber)
{
_welder = welder;
_buffer = buffer;
_grabber = grabber;
ActiveAttachment = ArmAttachments.Welder;
}
ActiveAttachment 成员被任意设置为默认的焊接器。接下来,我们的外观将公开激活当前活动附件的方法。如果是焊接器,它将焊接;如果是抓取器,它将抓取;如果是抛光器,它将抛光,但按名称公开这些方法没有意义。外观使事情变得更简单。Kitty 选择调用外观方法 Actuate(),这个方法确定幕后实际调用的是什么:
public void Actuate()
{
switch (ActiveAttachment)
{
case ArmAttachments.Buffer:
_buffer.Buff();
break;
case ArmAttachments.Grabber:
_grabber.Grab();
break;
case ArmAttachments.Welder:
_welder.Weld();
break;
default:
throw new ArgumentOutOfRangeException();
}
}
最后,我们需要提供一个简单的方法来移动手臂和定位附件。这需要更多的思考,因为每个 API 方法都有不同的方法签名。凯蒂决定最明显的解决方案是选择最复杂的要求,即Quaternion。根据文档,四元数是.NET System.Numerics库的一部分。这个结构体包含四个值,W、X、Y 和 Z,每个都是Single类型。由于它是最复杂的要求,可以通过忽略我们不需要的四元数部分来使其为更简单的方法签名提供服务:
public void MoveTo(Quaternion quaternion)
{
var roundX = (int)Math.Round(quaternion.X, 0);
var roundY = (int)Math.Round(quaternion.Y, 0);
var roundZ = (int)Math.Round(quaternion.Z, 0);
两个 API,WelderAttachmentAPI和GrabbingAPI需要整数。首先,我们将四元数中的单个数字四舍五入为整数,如前述代码所示。然后,根据活动附件调用适当的 API:
switch (ActiveAttachment)
{
case ArmAttachments.Buffer:
_buffer.Position(quaternion);
break;
case ArmAttachments.Welder:
_welder.MoveTo(roundX, roundY, roundZ);
break;
case ArmAttachments.Grabber:
_grabber.SetLocation(roundX, roundY);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
缓冲区附件需要一个四元数,所以我们的代码只是传递原始参数。X、Y 和 Z 坐标可以通过四舍五入与另外两个方法签名兼容,忽略四元数中不需要的部分。
菲比决定她的装配线应该非常简单。机器人通常只需要担心它们在装配线上的位置,所以她的控制程序将在每种情况下都改变 X 坐标。作为进一步的控制措施,菲比决定创建一个控制程序,该程序精确知道她装配线每个站点的 X 坐标。
她的控制程序由以下代码组成:
const int numberOfAssemblyStations = 20;
const float consistentY = 52.0f;
const float consistentZ = 128.0f;
const float consistentW = 90.0f;
首先,我们看到一组常量。由于菲比的材料和财务限制,我们最多有十个机器人手臂。由于手臂可以组成一个团队工作,菲比发现她可以通过系统地移动机器人从站点到站点,并在需要时更换手臂附件,来供电 20 个站点。她仔细校准了她的设备,并找到了理想的 Y 和 Z 坐标。她将它们设置为常量,以及默认的旋转角度 90 度,这对于她的所有过程都适用。
接下来,菲比创建了一个数组来存储她 20 个站点的坐标,作为四元数:
var assemblyStations = new Quaternion
[numberOfAssemblyStations];
由于装配线实际上是一条直线,因此很容易沿着线的 X 轴均匀地间隔 25 英尺设置各个站点。然后,一个简单的循环可以预先填充表示装配线工作站的四元数数组:
for (var i = 0; i < numberOfAssemblyStations; i++)
{
var xPosition = i * 25.0f;
assemblyStations[i] = new Quaternion(xPosition,
consistentY, consistentZ, consistentW);
}
我们现在准备启动机器人舞蹈。
让我们实例化我们的RobotArmFacade,将附件设置为焊接机,并将其移动到站点零,这是四元数数组中的第一个位置。一旦到达那里,我们将告诉它使用外观上的Actuate()方法进行焊接:
Console.WriteLine("RobotArm 0: Robotic arm control system
activated!");
var robotArm0 = new RobotArmFacade(new
WelderAttachmentApi(), new BuffingApi(), new
GrabbingApi());
Console.WriteLine("Initializing welder function in arm 0");
robotArm0.ActiveAttachment = ArmAttachments.Welder;
robotArm0.MoveTo(assemblyStations[0]);
robotArm0.Actuate();
接下来,让我们将手臂移动到站点3,在那里我们需要一个缓冲区来平滑自行车框架上的金属挤出物。一旦手臂到位,我们将使用Actuate()方法进行抛光:
Console.WriteLine("Initializing buffer function in arm 0");
robotArm0.ActiveAttachment = ArmAttachments.Buffer;
robotArm0.MoveTo(assemblyStations[3]);
robotArm0.Actuate();
太棒了!现在需要手臂去抓取一个部件并在站点7上将其固定以进行喷漆:
Console.WriteLine("Initializing grabber function
in arm 0");
robotArm0.ActiveAttachment = ArmAttachments.Grabber;
robotArm0.MoveTo(assemblyStations[7]);
robotArm0.Actuate();
最初,我们需要处理来自三个不同供应商的三个不同的 API,以与三套不同的硬件协同工作。通过使用外观模式,我们能够处理所有三个 API 的共同接口,这使我们的大部分代码免受 API 更改的影响。当 API 发生变化时,我们可能需要更改外观,但不需要更改其他任何东西。
组合模式
菲比继续工作在电子设备上。然而,契蒂开始担心她设计中的一些基本考虑因素。最初,女孩们同意使用商业可用的组件,但菲比意识到他们可以自己制造所有需要的部件。这样,假设 Bumble Bikes 能够获得所有原材料,如铝合金、塑料和橡胶,他们就能更紧密地控制最终产品的成本、耐用性和重量。这些因素影响着从 Bumble Bikes 如何获取原材料到最终销售价格的一切。最终销售价格是通过加上商品销售成本来计算的,在这种情况下,就是制造、包装和交付自行车的成本。契蒂在她的 iPad 上有一些初步的电子表格。虽然很复杂,但她真的很想摆脱电子表格展示的二维思维,并找到一种更好的方式来表示制造自行车的成本。
契蒂打开她的背包,她的心沉了下去。她的 iPad,或者说它剩下的部分,散落在了工作台上,形成了一堆破碎的玻璃和锋利的塑料碎片。她想起了上周末在 Big Bend 的意外。她在 Black Gap Road 上 3 英尺(1 米)高的悬崖上判断失误,翻过了前轮把手,背部着地。装有 iPad 的背包帮她缓冲了落地时的冲击。所有东西都在那个 iPad 上!幸运的是,她的父亲已经将第一条家规深深地刻在了她的脑海中。“永远保护你的装备!”他会说。“我们用平板电脑和笔记本电脑谋生。其他人用它们玩游戏和看电影。这没什么不好,但我们用它们来还房贷,所以照顾好你的装备!”他通常会在一大早,当他不可避免地踩到某人遗忘在地板上的平板电脑、手机或电脑时,大声说出这个咒语。当他睡眼惺忪地沿着他每天早上带着咖啡因去厨房的路上,他会踩到这些东西。正因为这明智的智慧,平板电脑才得到了备份。当契蒂在达拉斯她最喜欢的电脑店购买它时,她选择了额外 99 美元的更换保证,涵盖了所有内容,包括意外损坏。新的 iPad 第二天就通过邮件寄到了,契蒂作为一个工业设计的学生,对她的新数字伙伴的拆箱感到非常兴奋。
尽管她对苹果产品有很多批评,比如无法升级或维修,但有一件事没有人能否认。它们是科技行业中任何产品中最酷的包装。这个包装本身就是一件工业艺术品:从比必要的纸板厚度更重的纸板到所有包装件如何配合以尽可能少地占用空间。
当凯蒂等待她的云备份恢复所有数据和应用程序时,一个闪电般的想法闪过她的脑海。苹果公司已经解决了她正在考虑的问题。这些包装件如何相互配合,其中一些位于其他包装件内部,这让她想起了树状结构。与平板电脑一起发货的还有充电器、USB-C 风格的电缆,以及一个包含精美印刷说明书的小盒子,而每个人都会把它扔掉。还有一个保修卡和一些其他宣传苹果对环境负责立场的印刷卡片,以及苹果自己的服务计划 AppleCare 的广告。iPad 本身被放置在一个涂层的纸板插套中,所有包装件中的部件都精确地组合在一起。充电器和电缆被放置在一个凹槽中,这个凹槽是为 iPad 本身挖出的。所有东西都涂上了塑料,这样在运输过程中就不会有任何损坏或划痕。这个复杂的包装,如图 4.7所示,以一种不会让我被起诉的方式,包含了与实际电子产品一样多的小型纸盒和纸板部件。自然,苹果公司的一名产品设计师可以精确地知道任何较小的盒子或包装件中包装的组件在任何时候的重量,以及每个复杂的模切纸板组件的制造成本。那位设计师可能为了寻找削减包装材料成本几分钱的方法而痛苦了数月,就像 iPad 的设计师们对重量、功率和热量问题感到担忧一样:

图 4.7:凯蒂的新平板电脑被精心包装。她意识到它可以被建模为树状结构,也许自行车的部件也可以用同样的方式建模。
到目前为止,Kitty 已经成功地为高级自行车组件,如车架,创建了类模型。她和 Phoebe 致力于自己制造所有自行车的想法,包括曲柄组。曲柄组由所有使你在踩踏板时推动自行车移动的部件组成。Bumble Bikes 打算在市场上将自己定位为比大型百货商店更高的水平,就其制造质量而言。这意味着每一克都会被经过训练的、习惯于购买自行车、丢弃大部分随车组件并替换为更好、更轻组件的复杂客户仔细检查。对于许多骑行者来说,曲柄组的重量和成本之间总是有一个完美的平衡。赛车社区愿意为轻几克的部件支付溢价,而休闲骑行者则希望购买更便宜的产品,并且不介意额外的重量。
就像盒子一样,曲柄组可以被建模为树,正如我们很快就会看到的。如果你不熟悉组成曲柄组的自行车部件,并且对此感到好奇,请参阅 Kitty 在图 4.8中的 CAD 图纸,她很友好地为我们图解了其中大部分:

图 4.8:典型自行车的曲柄组涉及可以嵌套在树状结构中的组件,以解决我们关于成本和重量的即时问题。
当你面对建模一个可以符合通用接口的对象树状结构时,你应该立刻想到组合模式。组合模式允许你组合一个对象树,然后像处理单个对象一样与该结构一起工作。树由容器和叶子组成。叶子是一个没有子元素的树元素。容器是一个包含其他叶子和容器在内的树元素。从图形上看,这类似于你电脑上的文件文件夹结构。文件是叶子,文件夹是容器:

图 4.9:你的电脑硬盘的文件结构被表示为一个由叶子(文件)和容器(文件夹)组成的树。
我们也可以用这种方式来模拟我们小组的一部分。对于那些对机械感兴趣的读者,我并不一定是在建议这些部分在物理世界中真的可以相互嵌套。我是在建议它们可以用这种方式来模拟,以解决关于重量和成本的问题。在这个模型中,曲柄组由底座组成,底座基本上是自行车框架底部的一个大洞,并装有轴承,一个轴穿过底座。轴连接到一组链轮。大多数自行车根据类型的不同,有一个、两个或三个链轮。公路自行车通常有两个:一个大链轮用于一般骑行,一个小链轮用于爬坡。在我们的模型中,小链轮被处理为叶子,而到目前为止所有其他组件都是容器。
链轮连接到曲柄臂,尽管它们可能看起来是两个独立的臂,但实际上它们是一个大部件。臂连接到左右踏板,踏板是叶子,也是我们树结构的末端。
组合模式允许你通过充分利用递归和多态性优雅地处理复杂的树结构。只是要小心,确保你处理的是具有非常通用接口的类。如果你必须强行将一大堆实际上并不搭配的类放在一起,你可能会引入代码异味。这个模式与已经使用的 Builder 模式配合得很好,因为 Builder 可以被用来组装树结构。组合模式的基本结构在 图 4.10 中显示:

图 4.10:组合模式。
让我们详细理解这个图:
-
Component类将实现一些接口,包含访问整体功能所需的方法。这里,我们称之为Run()。组件可以包含叶子和其他组件。 -
Leaf类代表树中不能包含其他内容的节点。 -
Composite对象允许使用组件和叶子创建和维护树结构。 -
客户端程序在需要时访问组合树,并且可以像对待简单和复杂对象一样对待它们。
我要重申,我们 不是 试图模拟自行车的物理结构。这个模型是一个成本模型,定义了组件组内的关系,而不是物理组装的顺序。我们感兴趣的领域是成本和重量,这些字段将形成一个接口,描述任何自行车组件的通用属性,无论其形式、构造或用途。
Kitty 的版本在 图 4.11 中如下所示:

图 4.11:Kitty 将图 4.x 中找到的基本模式的结构改变以适应她的需求。注意,这不是整个层次结构的图,只是模式的结构
这个模式的实现只需要一个抽象类和基于该抽象类的一群具体类。以下是在 Kitty 的抽象BicycleComponent类中的内容,它形成了模式所需的关键通用接口。我们需要两个私有属性来保存组件的weight和cost值:
public abstract class BicycleComponent
{
private float Weight { get; set; }
private float Cost { get; set; }
接下来,我们需要一个列表来持有任何子组件。Kitty 指定了一个接口来表示类型,而不是直接耦合到List<>类:
public IList<BicycleComponent> SubComponents;
这三个属性在构造函数中初始化,我们传递weight和cost作为浮点数。SubComponents列表初始化为一个空列表:
protected BicycleComponent(float weight, float cost)
{
SubComponents = new List<BicycleComponent>();
Weight = weight;
Cost = cost;
}
接下来,我们需要两个方法来显示组件及其任何子组件的重量和成本。这些方法可以利用递归来打印出整个树,这对于 Kitty 的成本分析很有用。我们只需要在容器上做这件事,而不是在叶子上,因为叶子会与其容器一起打印。我们通过检查SubComponents.Count来确定我们是否在处理叶子。如果它是零,我们就是在处理叶子,我们只需简单地返回。否则,我们循环并打印子组件的重量和成本:
public void DisplayWeight()
{
if (SubComponents.Count <= 0) return;
foreach (var component in SubComponents)
{
Console.WriteLine(component.GetType().Name + " weighs
" + component.Weight);
component.DisplayWeight();
}
}
在这里,我们用同样的方式处理成本:
public void DisplayCost()
{
if (SubComponents.Count <= 0) return;
foreach (var component in SubComponents)
{
Console.WriteLine(component.GetType().Name + " costs
$" + component.Cost + " USD");
component.DisplayCost();
}
}
}
组合模式的具体代码通常非常重复,您很快就会看到。事实上,Kitty 在这里做了一件在其他地方没有做过的事情。她创建了一个包含许多类的文件。她将文件命名为CompositeParticipants.cs,下面是代码的局部内容。她这样做是因为实际上它是一个继承自基类的非常简单的具体类的集合。如果您想看到整个类,请查阅章节示例代码项目中的 Kitty 的代码:
public class Pedal : BicycleComponent
{
public Pedal(float weight, float cost) : base(weight,
cost)
{
}
}
public class CrankArm : BicycleComponent
{
public CrankArm(float weight, float cost) : base(weight,
cost)
{
}
}
public class LargeChainRing : BicycleComponent
{
public LargeChainRing(float weight, float cost) :
base(weight, cost)
{
}
}
如您所见,这并不是什么火箭科学。每个部分都简单地被建模为抽象类的具体实现。我在这里展示了三个类。总共有七个类,除了类名不同,它们看起来都一样。
乍一看,这可能会显得有些奇怪,直到你看到客户端代码中Program.cs文件内如何构建组合的树。在该文件中,Kitty 从下往上构建树。这不是一个要求,但它确实使理解变得容易。树的底部的叶子是花瓣或踏板:
var leftPedal = new Pedal(234.14f, 11.32f);
var rightPedal = new Pedal(234.14f, 11.32f);
踏板连接到曲柄臂。我突然想起了那首老歌《Dem Bones》,其中脚趾骨连接到脚骨。脚骨连接到跟骨。歌曲继续到头骨,然后是歌曲创作者的召唤。在这里,踏板骨连接到曲柄臂骨,但它们并不是骨头:
var crankArm = new CrankArm(432.93f, 34.32f);
crankArm.SubComponents.Add(leftPedal);
crankArm.SubComponents.Add(rightPedal);
我们创建CrankArm的实例,然后将踏板添加到其SubComponents列表中。crankArm连接到largeChainRing。同样,smallChainRing也连接到它,它本身成为一个叶节点:
var largeChainRing = new LargeChainRing(57.0983f, 13.53f);
var smallChainRing = new SmallChainRing(52.57f, 11.33f);
largeChainRing.SubComponents.Add(smallChainRing);
largeChainRing.SubComponents.Add(crankArm);
大飞轮连接到轴上:
var shaft = new Shaft(82.03f, 19.55f); // can you dig it?
shaft.SubComponents.Add(largeChainRing);
轴穿过下轴杯:
var bottomBracket = new BottomBracket(284.834f, 11.51f);
bottomBracket.SubComponents.Add(shaft);
那是我们的曲柄组,但我会添加一个CrankSet的顶层实例,并将成本和重量设为零,因为曲柄组本身由其子组件组成:
var crankSet = new CrankSet(0f, 0f);
crankSet.SubComponents.Add(bottomBracket);
现在我们展示的神奇部分。我们将调用两个方法,并获取整个结构的递归细节:
Console.WriteLine(" ---------- Weights -----------------");
crankSet.DisplayWeight();
Console.WriteLine(" ------------ Cost ------------------");
crankSet.DisplayCost();
运行结果如图 4.12 所示:

图 4.12:我们的组合模式项目的运行结果。
组合模式用于您需要将层次结构作为树来处理时。该模式有效的主要要求是树中的每个节点都必须符合一个公共接口。如果这一点可以管理,您就可以使用这种模式以任何您可能需要的任何方式处理树。只要它们符合公共接口,您就可以向您的树中添加新的类类型。使用这种模式,您可以在遵守开闭原则的同时创建新的处理能力。递归和多态可以用来加速您的处理。客户端代码将节点和容器同等对待,因为它们具有公共结构,这实际上是难点所在。您必须找到一种方法使树中的所有内容都符合公共接口,这并不总是容易的。
桥接模式
桥接模式是一种结构型设计模式,它允许您将一个大类或一组密切相关的大类拆分为两个独立的层次:抽象和实现。Kitty 和 Phoebe 创建了一个 Kickstarter 页面来推广 Bumble Bikes 并衡量市场上的兴趣。支持者可以预览并预购 Bumble Bike 的旗舰山地车设计——Palo Duro Canyon Ranger。该项目受到了好评,但 Kickstarter 的支持者对自行车上颜色选择的缺乏表示不满。在原始设计中,女孩们故意限制了颜色选择,因为她们几乎在所有事情上都使用了继承。使用继承的问题正在成为一个明显的主题:它可能导致类的大量无序膨胀,如图 4.13 所示。你能想象支持每个自行车模型 20 种颜色,并扩展到 20 种自行车模型吗?那会有很多子类!

图 4.13:类膨胀听起来可能像是一个马克思主义政治概念,但当过度使用继承时(我们显然需要一个更好的方法来表示我们自行车支持的有限颜色集)就会成为一个问题。
解决这个问题的最简单方法可能是简单地在你基自行车类上创建一个属性来保存颜色。也许可以存储一个常见的颜色结构,如红绿蓝(RGB)或青色品红色黄色黑色(CMYK),这是打印机使用的颜色模型。如果你完全在软件中处理,并且需要在你用户的显卡或打印机支持的色域内表示颜色,这工作得很好。
在自行车制造等行业中,这种方法是不可行的,因为我们不仅仅要表示任何可能的光或漆的颜色。我们需要表示一组有限的油漆,这些油漆需要通过机器混合和涂装。这个现实层面的维度意味着颜色系统的工作有局限性。女孩们必须保持基础颜色和清漆的库存,并且必须考虑到他们涂装机械的安装和清洁成本。将每辆自行车限制为一种可用的颜色可以很好地处理所有这些问题,而不会出现任何设计问题。实际上,他们依靠通过将单一颜色作为业务需求来控制他们软件中的一个变量。结果证明,市场无法承受这种约束。竞争对手可以提供一系列颜色。菲比自己记得当她 9 岁时,她新自行车的最重要的方面就是它是粉红色的。她不在乎它来自哪里,或者车架上是否有花哨的标签。它必须是粉红色的。
当我们在本章早期讨论装饰者模式时,遇到的问题类似。我们添加了外部特性,例如铃铛和灯光,这些特性也可以用指数增长的子类树来表示。那么,为什么不用装饰者呢?也许,你可以把喷漆工作想象成一个装饰者。然而,装饰者被设计成可以堆叠的。我们可以在对象层次结构中堆叠铃铛、前大灯、尾灯、挡泥板、镜子,甚至自行车防盗警报器,以构建一个完美的自行车,而不需要修改抽象的自行车基类。将喷漆工作堆叠,甚至将铃铛或灯光堆叠在喷漆工作上,在概念上似乎不太合适。喷漆,从概念上讲,更多的是车身的一部分,而不是装饰它的东西。当学习到一个模式后,要小心不要把它当作金锤来使用。装饰者在这里并不适用,尽管不使用该模式也有类似的副作用。
当你在 GoF 书中阅读关于桥接模式的内容时,我总是鼓励你在可能的情况下查阅原始的学术来源,你会发现它用非常学术性的语言来描述。他们谈论的是抽象与其实现之间的桥梁。我们可以通过保持两者分离来独立地改变它们。我们有一个新的维度业务需求,它是我们自行车框架的组成部分,我们需要独立于抽象自行车来改变这个维度。当你想要独立地改变两个或更多维度,同时避免子类数量组合增加时,你需要桥接模式。
你可以在图 4.14中看到桥接模式的表示。我可能在视觉上玩了一点小花样,但这确实使得理解为什么这个模式被称为桥接变得容易:

图 4.14:桥接模式允许你独立于两个不同的维度,独立地改变你的对象结构两边的属性。
让我们通过数字来回顾我们图中桥接模式的元素:
-
这是访问抽象中功能性的客户端。
-
这是桥接模式中的抽象方面。这通常是你在开始时使用的类结构,以及直到你意识到你需要对额外的维度进行建模之前工作良好的结构。
-
对抽象的细化是具有意义且不会导致多维类结构失控的子类。我们通过四种不同类型的自行车来继承抽象自行车类,有几种细化方式。
-
实现接口位于桥的另一侧。这是你建模将独立于抽象变化的维度的位置。注意,连接这两个维度的桥本身是一个组合关系。抽象具有第二维度的实现。
-
这是基于接口的第二维度的具体实现。
-
那是一条鲨鱼。研究表明,如果你不使用桥接模式来解耦你的复杂类,你被鲨鱼攻击的可能性会增加 68.342%。即使你住在内陆,这也无关紧要。没有人知道为什么。这是科学!不要争论。
让我们看看凯蒂在图 4.15中应用的桥接模式图,它用于自行车涂装问题:

图 4.15:凯蒂对她的绘画问题应用桥接模式的白板演示。
“哇,哇,哇!等一下,别急!” 菲比一边跺着脚,一边大声说道。“你不能这么做!让这一切运作的唯一方法就是打破开闭原则!我们一直把这条规则视为神圣不可侵犯!” 基蒂茫然地看着黑板。菲比是对的。她通常都是对的,尤其是在她跺脚的时候,这是她从母亲那里继承来的一个特点。
“我们在抽象的自行车类中已经有了颜色属性,” 基蒂观察到。“但因为它是一个讨厌的枚举,我们无法扩展它,也无法子类化它,” 菲比补充道。“装饰器怎么样?” 基蒂问道。“也许吧,但看起来我们可能会得到比仅仅改变原始设计更多的代码和复杂性,” 菲比说。他们都不想放弃。接下来的几个小时里,他们翻阅了其他可能帮助他们解决问题的模式的文章。这里的问题是原始模型使用枚举来定义颜色。C#语言的限制是枚举不能被扩展。
如果他们子类化或装饰原始的IBicycle接口或抽象的Bicycle类,他们需要添加PaintJob属性,但他们将不得不保留现有的Color属性。继承不能给我们隐藏或删除过时代码的方法。用外观来掩盖它也不觉得合适。即使你用外观掩盖了未使用的成员,它也会改变你用来与自行车类交互的接口。
“天哪!我们 得改变自行车基类中的类型了!” 菲比愤怒地说。“天哪”是菲比训练自己用来代替不那么社会可接受的话的一个词,比如大多数人当脚趾被重重地踢到时说的那些话。他们像辩护律师试图为被指控犯有死刑的无辜者辩护一样,对这一认识进行了激烈的反驳。他们翻阅了软件工程书籍和关于模式的书籍。所有这些书籍的共同主题是,所有作者从未犯过设计错误。他们要么提供了诸如形状、圆形和正方形这样的简单示例,要么以过于假设的方式展示了模式——ClassA从ClassB继承,等等。这些示例不太有用,但它们在示例方面非常安全。GoF 书籍通过一个面向 Unix 用户的窗口软件项目展示了现实世界的用例,该项目主要针对使用 SmallTalk 的 Unix 用户,而 SmallTalk 在政府和非学术圈外并不广泛使用。
在一个深夜,姐妹们找到了一直逃避他们的清晰。在敏捷开发模式的世界中,两本最重要的书籍是 Robert Martin 所著的《敏捷软件开发:原则、模式与实践》,以及为 C#开发者重写的《敏捷软件开发:原则、模式与实践(C#版)》。GoF 书籍向世界介绍了正式的软件模式,但 Uncle Bob 的书籍向世界介绍了 SOLID 和敏捷原则,以及与之相结合的模式。别担心,它们并不是紧密耦合的。那将是一个可怕的递归噩梦。
如果你正在阅读这本书的电子书版本(假设有),请在接下来的三句话中拿出你的电子高亮笔。在纸质书上可以使用模拟高亮笔。
Uncle Bob 提醒我们,事先预见每一个设计考虑是不可能的。没有人是先知,这是我们在这本书的第一章中提到的一个观点。缺乏先见会导致通常用烟囱式解决方案解决的问题设计问题。
可能看起来我们正在放弃我们的开闭原则。你永远不应该放弃 SOLID 原则。但有时你必须为了更大的利益而打破它们。Martin 的书籍将此描述为承受一枪并意识到从错误中学习的机会。他还为你推理出,你应该考虑代码中可以以相同方式重构的每个元素,希望你只需要承受一枪。在现实世界中,随着软件的增长,你需要遵守良好的原则,但你不能被它们束缚。
模式可以帮助你避免需要更改基类,但有时你需要改变设计,因为不改变它会使一切变得更糟。同样重要的是,从软件中移除死代码的练习。这也需要打破 SOLID 原则。从教条的角度来看,移除死代码远比留下它要好。
在这个特定的情况下,改变我们实现属性的方式,使我们能够以符合 SOLID 原则精神的方式灵活地满足业务需求,而不受其束缚。这是一个你很少会做出的决定。将接口更改为使用新的IPaintJob接口,为整体设计增加了显著的业务价值。
我们现在有了建模自行车的途径,这是我们一直拥有的,以及一种商业敏感的建模喷漆工作方式。Kitty 和 Phoebe 现在可以向他们的客户提供各种颜色的自行车。我们已经从限制客户只能选择每辆自行车的一种颜色,转变为仅受油漆色域限制的选择。我们已经从软件对业务的限制转变为现实世界化学和机械的限制。我个人一直认为,业务应该定义软件,而不是反过来。
桥接模式将 Bicycle 类与一个同样复杂的模型紧密耦合的情况隔离开来。毫无疑问,随着时间的推移,涂装模型和自行车模型将继续变得更加复杂,但现在它们是在隔离的情况下发展的。
让我们看看桥接模式的代码。对于本书的示例代码,我将保留我们迄今为止所写的所有代码。在现实生活中,我会不情愿地修改 IBicycle 接口。为了本书的连贯性,我将把 Kitty 和 Phoebe 的代码放入一个稍微不同的格式。Phoebe 强烈反对,但编辑介入后,她最终屈服了。
我将首先创建一个新的接口来替代 IBicycle。在这里我所要做的就是移除那个令人讨厌的 Color 属性:
public interface ISimplifiedBicycle
{
public string ModelName { get; set; }
public int Year { get; }
public string SerialNumber { get; }
public BicycleGeometries Geometry { get; set; }
public SuspensionTypes Suspension { get; set; }
public ManufacturingStatus BuildStatus { get; set; }
public void Build();
}
我们正在构建的桥梁有两个方面。自行车方面由这个新的 ISimplifiedBicycle 接口表示,而实现方面用于独立地建模一个复杂对象,该对象描述了自行车可能被涂装的方式。
由于描述涂装工作的类和接口可能会被用于多个不同的项目中,Kitty 为她在 第三章 中创建的 BumbleBikesLibrary 添加了一个新的命名空间。她添加了一个名为 PaintableBicycle 的新文件夹。本章中展示的其余代码可以在 chapter-3/BumbleBikesLibrary/PaintableBicycle 中的库项目中找到。
对于后者,Phoebe 设计了一个她认为可以描述任何她的机器人可以涂装的东西的接口:
public interface IPaintJob
{
public string Name { get; set; }
public int Cyan { get; set; }
public int Magenta { get; set; }
public int Yellow { get; set; }
public int Black { get; set; }
public IPrimer Primer { get; set; }
public IPaintTopCoat TopCoat { get; set; }
public void ApplyPrimer();
public void ApplyPaint();
public void ApplyTopCoat();
}
系统中创建的每个涂装工作都将有一个可销售的名字,该名字存储在 Name 属性中。Kitty 想要使用的涂装系统基于传统印刷所使用的相同的 CMYK 颜色空间。她还打算使用一种她称之为 TopCoat 的涂装效果。由于这也可能稍后会被重用,它属于 Kitty 在 第三章 中创建的库。你可以在本书的示例代码中的 chapter-3/BumbleBikesLibrary/PaintableBicycle 找到它。这种涂装效果会使自行车的涂装呈现出美丽的亮光光泽,并保护涂装免受划痕和阳光照射。下面是一个描述涂装效果的接口:
public enum PaintTopCoatTypes { TopCoatClear, GlamorClear,
TurboClear, HigherSolidClear }
public interface IPaintTopCoat
{
public string Name { get; set; }
public PaintTopCoatTypes Type { get; set; }
}
“等等,Kitty!” 菲比大声说道。“我们为什么要使用另一个枚举?上次那只是麻烦的开始!”
Kitty 通过说,“我们短视地认为我们可以通过每辆自行车一种颜色来解决问题。但当涉及到底漆和油漆表面处理时,每种都有很少改变的分类。底漆的代码可能会被重用,所以 Kitty 把代码放在她创建的库中第三章。你可以在 chapter-3/BumbleBikesLibrary/PaintableBicycle 中找到它。PaintTopCoatTypes 枚举包含了几十年一直在使用的相同集合。制造商改进了产品的化学成分,但他们从未引入任何革命性的东西。底漆也是如此。”Kitty 提出了她的 Primer 接口:
public enum PrimerColors { Gray, White, Black }
public enum PrimerTypes {Epoxy, Urethane, Polyester,
AcidEtch, Enamel, Lacquer, MoistureCure}
public interface IPrimer
{
public string ManufacturerStockKeepingUnit { get; set; }
public PrimerTypes Type { get; set; }
public bool IsLowVoc { get; set; }
}
自然地,一个好的油漆工作从好的底漆开始。为了扩展 Kitty 的观点,这些底漆已经存在很长时间了。Kitty 在枚举中添加了比她可能使用的更多的内容,只是为了谨慎起见。女孩们找到了一些可能会使用的底漆,但大部分情况下,用于汽车喷漆的古老灰色底漆似乎效果最好。她有制造商的 isLovVoc 字段,这告诉她油漆是否被认为是低 false,我们希望在喷漆设备周围戴上呼吸器。
油漆工作模型的结构正在形成。与简单的颜色术语相比,它相对复杂。正如你所见,桥接模式确实帮了我们。桥接模式的主要目标是允许两个复杂对象结构独立于彼此开发和维护。自行车模型通过名称进行了许多交互,并成为越来越健壮的模型。现在,我们有了这个复杂的油漆工作模型。这两个需要一起使用,但有一天,自行车模型或油漆模型可能需要进行剧烈的变化。桥接,就像我们迄今为止研究的大多数模式一样,隔离了代码的一部分,同时使所有代码灵活且可重用。
为了处理我们针对桥接模式的用例,我将创建另一个从 ISimplifiedBicycle 继承的接口,称为 IPaintableBicycle。我们这样做是为了尽可能保持最大的灵活性:
public interface IPaintableBicycle : ISimplifiedBicycle
{
IPaintJob PaintJob { get; set; }
}
Kitty 和 Phoebe 有四种自行车设计需要实现这个接口。设置一个抽象类来实现这个接口是有意义的:
public abstract class PaintableBicycle : IPaintableBicycle
{
public string ModelName { get; set; }
public int Year { get; }
public string SerialNumber { get; }
public BicycleGeometries Geometry { get; set; }
public SuspensionTypes Suspension { get; set; }
public ManufacturingStatus BuildStatus { get; set; }
public IPaintJob PaintJob { get; set; }
Build() 方法也需要更新。Bicycle 类有一行打印自行车的颜色。我们仍然需要这样做,但不是从枚举中派生出来,我们将与油漆工作的名称一起工作:
public void Build()
{
Console.WriteLine($"Manufacturing a
{Geometry.ToString()} frame...");
BuildStatus = ManufacturingStatus.FrameManufactured;
PrintBuildStatus();
Console.WriteLine($"Painting the frame
{PaintJob.Name}");
我们还需要应用油漆工作的其他部分:
PaintJob.ApplyPrimer();
PaintJob.ApplyPaint();
PaintJob.ApplyTopCoat();
BuildStatus = ManufacturingStatus.Painted;
PrintBuildStatus();
类的其余部分可以保持不变。你可以在 GitHub 上找到该章节的完整代码。
新的喷漆系统超出了 Kickstarter 支持者的预期。Bumble Bikes 不仅能够支持每辆自行车的定制颜色,还能支持定制渐变喷漆工作。这在自行车行业中很少见,除了那些专门从事定制喷漆和组装的商店。
当你有两个或更多复杂且需要一起使用的类层次结构时,可以实施桥接模式。在核心上,桥接模式不过是组合。它之所以被认可为一个模式,是因为它成为了一种深思熟虑的解耦练习。它通常出现在设计阶段。你从一个类开始,随着设计的深入,它变得越来越复杂。理想情况下,当你建模这个类时,你会发现这种复杂性的增长,并在它变成代码之前将其解耦。在现实世界中,这种情况可能在项目进行了几次迭代甚至几年之后才会出现,你需要记住这种情况已经发生过。它还会再次发生。最终,它可能发生在你身上。有一个解决方案,那就是软件开发模式的核心和灵魂。
摘要
奇蒂和菲比对自行车产品和自动化制造过程及设备的建模正变得越来越复杂,随着他们学习和发明新的业务,这种情况也在不断发生。这就是它的运作方式,从外面看进去,我认为他们做得非常出色。对于一个软件开发者来说,成为软件设计和开发的专家是很正常的,但在理解他们被分配去解决的问题的业务问题上,却远没有那么精通。软件项目实际上是一个涉及开发者对业务及其客户和利益相关者需求的理解的演变过程。
在本章中,我们学习了几个结构模式,这些模式允许我们继续使我们的软件更加复杂,同时保持简单易维护和扩展。你也注意到了一个共同的主题:面向对象编程语言提供的继承和组合的基本工具,本身并不足以构建健壮的软件。这些结构模式允许我们最大限度地利用组合和继承的工具,而不会将我们的设计困入像意大利面一样的对象层次结构的泥潭中。
装饰器模式允许我们通过装饰或围绕原始类添加新功能来扩展现有类。装饰器可以像俄罗斯套娃一样堆叠,其中一个娃娃嵌套在另一个娃娃里面。

图 4.16:俄罗斯套娃(每个娃娃都嵌套在更大的一个里面,就像装饰器围绕基类或堆叠在其他装饰器周围一样)。
菲比掌握了外观模式(Façade pattern),该模式允许我们抽象并隔离我们的软件,使其免受复杂依赖的影响。该模式允许你通过统一暴露操作,即使它们在底层并不统一,也能将一个简单的界面放在复杂的 API 上。你也可以使用外观模式仅暴露复杂 API 或结构中对你实现重要的元素。如果将来第三方 API 发生重大变化,你可以在代码中无需紧密耦合的 API 调用的情况下替换外观。
凯蒂能够以树状结构对自行车的组集进行建模,这使她能够轻松地使用递归来找到组件的成本和重量,或者它们的组合。任何需要处理树的时候,你应该像凯蒂那样,考虑使用组合模式(Composite pattern)。
在解决了成本和重量分析问题后,女孩们联手应对 Bumble Bikes 系列中市场对更广泛颜色选择的需求。最初,凯蒂和菲比决定限制颜色选择,以使自行车建模工作更容易。然而,他们的 Kickstarter 活动显示了对更多颜色选择的强烈需求。当凯蒂试图使用继承来解决问题时,她发现自己面临着大量子类。对于每种自行车型号和每种颜色,类的数量失控增长。凯蒂通过使用桥接模式(Bridge pattern)独立地对自行车和油漆颜色这两个维度进行建模,解决了这个问题。他们甚至能够创建一个能够进行定制渐变油漆工作的系统,这让他们在 Kickstarter 的支持者中非常受欢迎。应用了桥接模式后,自行车和油漆系统可以独立增长,同时通过组合保持相关。
到目前为止,我们已经涵盖了三组模式中的两组。创建型模式帮助我们处理对象实例化。结构型模式帮助我们以比简单使用继承和组合更复杂的方式思考构建复杂对象的新方法。最后一组模式,将在下一章中介绍,是一组帮助您使用行为模式设计“表现良好”的类的模式。
问题
-
为什么装饰器模式有时被称为包装器?
-
你如何使用装饰器来扩展一个密封的类?
-
哪种模式最有效地解耦复杂对象结构,使它们能够分别成熟?
-
何时是考虑使用外观模式(Façade pattern)的最佳时机?
-
哪种模式允许你利用递归和多态性以及树状结构?
-
你是否遇到过必须违反 SOLID 原则的情况?你能想到任何避免在应用桥接模式时凯蒂和菲比解决方案的方法吗?
进一步阅读
-
马丁,罗伯特·C·,詹姆斯·纽柯克,和罗伯特·S·科斯。敏捷软件开发:原则、模式和惯例。第 2 卷。新泽西州上萨德尔河:普伦蒂斯·霍尔,2003 年。
-
Martin, Robert C., 和 Micah Martin. 《敏捷原则、模式和 C#实践(Robert C. Martin)》. Prentice Hall PTR, 2006.
-
本书配套网站为
www.csharppatterns.dev。请访问查看最新动态。
第五章:通过应用行为模式整理问题代码
你想找点不涉及代码的乐子吗?下次当你发现自己在一座高楼里,有电梯的时候,和三四个朋友一起坐电梯到顶层。这里有趣的部分是:让你们小组的每个人都面朝电梯的后方。当其他人上电梯时,他们几乎总是会跟随你的领导,面朝后方。这是因为人类行为遵循模式!有整个研究领域致力于这一事实,包括心理学、社会学,以及市场营销和人际关系应用的领域。
软件是人类发明,所以软件也可以被设计成遵循行为模式也就不足为奇了。行为模式是处理你类中实现的算法以及这些类如何交互和共享执行这些算法的责任的模式。
随着故事的发展,Kitty 和 Phoebe 将面临需要他们学习和实现四种最受欢迎的行为模式的挑战:
-
命令模式
-
迭代器模式
-
观察者模式
-
策略模式
在你跟随他们的旅程中,你将学习如何使用 C# 语言绘制和实现四种最受欢迎的行为模式。
技术要求
在这本书的整个过程中,我假设你知道如何在你的首选 集成开发环境(IDE)中创建新的 C#项目,所以我在章节本身中不会花费时间在设置和运行项目的机制上。这本书的附录 1 中有关于三个最受欢迎的 IDE 的简短教程。如果你决定跟随,你需要以下内容:
-
运行 Windows 操作系统的计算机。我使用的是 Windows 10。由于项目是简单的命令行项目,我相当确信这些内容在 Mac 或 Linux 上也能工作,但我还没有在这些操作系统上测试过这些项目。
-
一个支持的开发环境,例如 Visual Studio、JetBrains Rider 或 Visual Studio Code 并带有 C#扩展。我使用的是 Rider 2021.3.3。
-
一些版本的 .NET SDK。同样,项目足够简单,我们的代码不应该依赖于任何特定版本。我恰好使用的是 .NET Core 6 SDK,我的代码的语法可能反映了这一点。
您可以在 GitHub 上找到本章的完整项目文件,地址为github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns/tree/main/chapter-5。
同时,回到自行车工厂
“W00t!”菲比惊呼。基蒂吓了一跳,感到惊讶。她的妹妹不知怎么的,竟然用一种充满兴奋的方式发出了黑客俚语词汇,基蒂甚至能听到双-o 的音节被零所取代。“什么?”基蒂问道。菲比没有立即回答,于是基蒂抬头看,发现菲比正在围圈跳舞。穿着脏兮兮的工作服,把头发扎成马尾辫的菲比跳舞的样子并不罕见。基蒂意识到地板上有一排 10 个机器人手臂。这些手臂尽力模仿菲比的舞步。
这群没有编排的机械舞手臂,按照瓦加诺娃学院的标准来看并不令人印象深刻。然而,在这个由两名大四学生在废弃仓库里手工建造的机器人工厂的背景下,这却是一项惊人的成就。“太棒了!”基蒂笑着说道。姐妹俩手拉手围圈跳舞,几乎造成了一次大碰撞,因为机器人试图模仿她们的舞步。她们停止了跳舞,又笑了起来。“所以,我们完成了?”基蒂问道。“不,现在它们只是模仿它们看到的,”菲比回答道。“你说的‘看到’是什么意思?视觉从未是我们规格的一部分,”基蒂指出。“我知道,”菲比说。“我在楼上的一箱子里找到了布默的旧 Xbox。它上面连接了一个 Kinect,所以我把它清理干净,并将其连接到我们的测试客户端程序上。”布默是基蒂和菲比的表兄。他比基蒂大一岁,去年从拉斯维加斯大学毕业。他凭借电子竞技奖学金上大学,现在是一名职业选手。自然,他总是拥有最好的游戏装备。
基蒂记得 Kinect 是微软多年前作为其 Xbox 游戏平台的一部分出售的摄像头系统。它提供了一个基本的计算机视觉系统,能够识别人体形状。有一些游戏基于这样的想法:你可以站在摄像头前,游戏中的角色会根据他们“看到”你的移动方式来行动。自然,作为微软的技术,C#的 SDKs 很容易获得。
“真是个不错的主意!”凯蒂惊叹道。菲比喘着气继续说,“所以,我们从创世模式的工作中得到了一个良好的对象结构,也因为我们的结构模式,我们已经有一个工作系统的框架。”凯蒂反驳道,“确实如此,但我们还没有真正处理过驱动机器人的实际命令和控制系统。机器人可以凭借我们的测试程序独立运行。它们甚至可以自己制作自行车零件。”菲比以只有姐妹才能做到的方式完成了凯蒂的想法:“它们做不到的是一起工作!我昨晚又读了一些东西。结果发现有一整套模式可以使机器人协同工作。它们被称为行为模式!我认为我们可以用它们来控制我们的大多数系统,并编排机器人,使它们能够协同工作。”菲比走到白板前,解释了编写必要机器人控制系统的下一步步骤。
命令模式
到目前为止,菲比已经设计了两种不同的机器人臂模型。一套臂是大型并固定在地面上的。这些臂是固定的。第二套臂安装在轨道上,可以移动。这些臂主要用于将零件、材料和部分完成的自行车移动到不同的站点。一旦到达站点,较大的臂就会做大部分实际工作。
钉在地面的较大机械臂具有可互换的附件,这使得它们能够执行不同的任务。菲比根据装饰者模式设计了这种行为。记住,装饰者允许你在不直接修改现有类的情况下向其添加新行为。这是通过创建一个围绕原始类结构的新类来完成的,然后添加额外的行为。在这种情况下,装饰者是物理硬件。菲比对模式感到惊奇。她理解了与模式一起工作可能被视为一种设计哲学,就像是一种软件工程实践一样。菲比记得,模式是在软件工程仅由科学家在绝密军事实验室中进行的时候,由物理世界的建筑师设计的。
每个大型的固定式机械臂都可以安装不同的附件,用于焊接或用于抛光和打磨。每个机械臂都可以编程,以接收由较小的、可移动的轨道式机器人带来的材料。
菲比画出了她为凯蒂的想法,解释了她如何能够以灵活的方式模拟必要的机器人命令。
“凯蒂,你就这么想,”菲比开始说。“当我们在线订购衣服和鞋子时,我们挑选出我们想要购买的衣物。我们决定想要哪些连衣裙,以及它们的颜色和尺码。然后,我们告诉商店我们希望将衣物寄送到哪里。最后,我们向零售商提供付款详情,以及如果出现问题或疑问时如何联系我们的方式。订单是一个包含所有这些信息的结构。这正是我想做的。我想有一个可以发送命令的模式。命令应该包含机器人完成工作所需的所有信息。命令不应该与机器人的控制 API 紧密耦合。实际上,我应该能够向任何我们拥有的硬件发送命令。它并不特定于任何一台机器人,甚至不是它当时使用的附件。”
“我明白了,”凯蒂说。“所以,包含我们想要购买的衣物所有信息的订单可以同样容易地发送到世界上的任何一家商店。我们是命令的发送者。我们编译信息并发送给接收者。接收者并不特定于某一家商店,而且这个接收者可以是任何能够接收我们命令的设备。我们打包完成订单或命令所需的一切,然后由接收者来执行。”
“你明白了,妹妹!”菲比兴奋地说。她们都迫不及待地想要开始。菲比拿出了一张她在网上找到的图表,展示了模式的通用形式,如下所示:

图 5.1 – 命令模式。
让我们回顾一下模式的各个部分,它们已经适当编号:
-
Sender对象负责调用请求。例如,当菲比在线订购连衣裙时,网站正在收集必要的信息,并负责发送订单。 -
ICommand接口定义了一个用于执行命令的单个方法。发送者不创建命令。相反,它在构造函数中接收它,或者它可以作为一个属性设置。 -
Receiver类包含业务逻辑并执行实际工作。当菲比在线订购时,接收订单的商店就是接收者。业务逻辑可能因商店而异。每个商店可能都有不同的订单挑选、提取、结算和发货流程。逻辑由接收者决定。它接收包含它需要独立于发送者执行逻辑的命令。 -
具体的命令类实现了
ICommand接口,但同时也包含了一个指向接收器的引用,该接收器将执行命令,以及执行命令所需的任何属性或参数。 -
客户实例化具体的命令类(4)并传入或设置命令所需的任何参数或属性,包括接收器的一个实例。
应用命令模式
菲比在适应从通用示例中学到的内容时绘制了她的模式想法。以下是她绘制的图案:

图 5.2 – 菲比绘制的命令模式图。
基蒂研究了这张图几分钟。菲比密切地观察着她。基蒂在想法突然闪现之前,脸上总是会有一种表情。一分钟过后,她说道:“我明白了!”当她走到仓库的角落并开始工作时,她已经开始工作了。几个小时后,她完成了命令模式的实现。你可以在本章代码的 CommandExample 项目中查看这个实现,该项目位于本书的 GitHub 仓库中。
编写命令模式
基蒂首先创建了 ICommand 接口:
public interface ICommand
{
public void Execute();
}
接下来,她创建了一个具体命令,该命令实现了这个接口:
public class BuildFrameCommand : ICommand
{
private AssemblyLineReceiver _assemblyLineReceiver;
记住,命令对象拥有接收器执行意图所需的一切,除了业务逻辑。如果你还记得菲比的解释,它甚至包括一个 private 字段来保存接收器本身。命令是自包含的。除了接收器,它还需要关于它负责构建的自行车的信息。基蒂添加了对 BumbleBikesLibrary 的项目引用,我们在 第三章**,用创建型模式进行创新 中讨论过。我们扩展了库以包括使用桥接模式在 第三章**,用创建型模式进行创新 中创建的 IPaintableBicycle 接口。作为提醒,尽可能使用接口。具体对象应该尽可能晚地发挥作用。这使你的设计更加灵活,并尊重 SOLID 原则:
private IPaintableBicycle _bicycle;
接下来是一个构造函数,我们传递接收器和 IPaintableBicycle:
public BuildFrameCommand(AssemblyLineReceiver
assemblyLineReceiver, IPaintableBicycle bicycle)
{
_assemblyLineReceiver = assemblyLineReceiver;
_bicycle = bicycle;
}
最后,我们有 Execute() 方法,这是 ICommand 接口所要求的。它所做的只是运行接收器内包含的业务逻辑:
public void Execute()
{
_assemblyLineReceiver.DoBusinessLogic(_bicycle);
}
}
BuildFrameCommand 类已经完成。现在,我们需要一个发送者。如前所述,发送者中并没有太多的事情发生。它只需要一个命令,基蒂指定为 ICommand,以及一个执行命令的方法,基蒂指定为 DoCommand()。DoCommand() 方法执行命令。这看起来有点反直觉;你可能会认为接收器执行命令。确实如此,但不是直接执行。如果你直接编写代码来执行,你很可能会将发送者、接收器和命令逻辑紧密耦合在一起,这正是我们想要避免的:
public class Sender
{
private ICommand _command;
public Sender(ICommand command)
{
_command = command;
}
public void DoCommand()
{
_command.Execute();
}
}
基蒂完成了接收器的部分,她称之为 AssemblyLineReceiver。这个类背后的想法是它作为整个装配线的总控制。这是女孩们试图实现的整体编排的一部分。
AssemblyLineReceiver 类需要一个项目引用,指向我们在 第三章 《创意使用创建型模式》 中使用外观模式所做的工程。如您所回忆的那样,外观模式允许我们将众多复杂的 API 呈现为一个单一、易于使用的接触点。在这种情况下,机器人的 API 来自不同的制造商。不同机器人手臂附件(焊接机、缓冲器和抓取器)的微控制器都来自不同的制造商,具有不同的 API。在 第三章 《创意使用创建型模式》 中,Kitty 和 Phoebe 编写了一个外观模式,使其更容易使用,并隔离它们免受第三方代码未来变更带来的影响,这些变更将独立于 Bumble Bike 制造流程的业务需求而发展。
外观模式还包含了对地面安装的机器人线应该如何工作的封装。女孩们选择使用游戏行业通用的 struct 来表示装配线的空间布局。四元数 struct 可以在.NET 框架的 System.Numerics 库中找到。
AssemblyLineReceiver 类的第一部分只是设置了我们需要用于外观的内容:
public class AssemblyLineReceiver
{
private readonly RobotArmFacade _robotArmFacade;
private const int NumberOfAssemblyStations = 20;
private const float ConsistentY = 52.0f;
private const float ConsistentZ = 128.0f;
private const float ConsistentW = 90.0f;
private readonly Quaternion[] _assemblyStations;
四元数是一个复杂的概念。我并不是低估您的智慧;我只是在转述 Unity 3D 的文档,这是一个流行的 C#编写的视频游戏框架。在 Unity 的视频游戏工作中,你几乎无处不在都会遇到四元数。虚拟猫的位置和角度将使用四元数来定义。Unity 文档直接告诉你四元数是一个高级数学概念。简而言之,四元数是空间中三个点(用 X、Y 和 Z 表示)的组合,以及一个旋转向量(用 W 表示)。由于我们不需要在关于模式的书中深入研究四元数的内部结构,我们通过决定将其视为一条直线来简化了装配线的布局。因此,四元数中只有一个坐标在各个站点之间不同,对我们来说,那就是 X 坐标。其余的可以在工厂地面的标准高度(Y)和标准深度(Z)内保持不变。我们还将旋转保持为 90 度,以简化问题。总结来说,为了表示机器人在装配线上的位置,我们有四个坐标(X、Y、Z 和 W),但我们保持其中三个不变。只有 X 随着你在装配线一端到另一端移动而变化。
每个站点的装配位置都存储在一个四元数数组中。10 个手臂可以为 20 个站点提供服务。在接收器的构造函数中,我们通过用 20 个四元数填充 _assemblyStations 数组来设置我们的外观,以及每个站点的位置:
public AssemblyLineReceiver(RobotArmFacade
robotArmFacade)
{
_robotArmFacade = robotArmFacade;
_assemblyStations = new Quaternion
[NumberOfAssemblyStations];
for (var i = 0; i < NumberOfAssemblyStations; i++)
{
var xPosition = i * 25.0f;
_assemblyStations[i] = new Quaternion
(xPosition, ConsistentY, ConsistentZ,
ConsistentW);
}
}
这需要很多设置,但也是一个很好的例子,说明了模式如何结合使用。它们都是更大谜题的一部分。接下来,我们将看看有趣的部分:执行业务逻辑的代码。这看起来会很熟悉。
女孩们将这段代码放在了测试Program.cs文件中,这是我们在第四章**,使用结构化模式强化代码中看到的FacadeExample代码。逻辑本身并不重要。在这种情况下,它是一系列步骤,将自行车框架从工作站移动到工作站,在组装和喷漆过程中进行。关键点是这里实际的业务逻辑所在。它被独立于发送者。在我们之前的例子中,店主不希望 Phoebe 告诉他们如何最好地挑选、包装和运送她选择的连衣裙。业务逻辑不是由发送者驱动的。同样,我们也不希望在命令本身中包含逻辑。命令代表执行逻辑所需的一切。它是订单,而不是店员在执行订单。
在 Kitty 的程序中,DoBusinessLogic方法接收IPaintableBicycle对象(本质上是对自行车的订单)并使用外观来操控机器人制造自行车:
public void DoBusinessLogic(IPaintableBicycle bicycle)
{
// Now let's follow an abbreviated imaginary
// assembly of a bicycle frame by controlling a robot
// arm.
// grabber gets the frame parts and takes them to
// station 1
_robotArmFacade.ActiveAttachment =
ArmAttachments.Grabber;
_robotArmFacade.MoveTo(_assemblyStations[0]);
_robotArmFacade.Actuate();
_robotArmFacade.MoveTo(_assemblyStations[1]);
… see the rest in the sample code.
让我们继续到最后一部分,即客户端。
测试命令模式的代码
我不会展示 Kitty 的所有客户端代码,因为它们既广泛又复杂,而是只展示她为Program.cs中的逻辑编写的简单测试程序。
记住,创建命令是客户端的职责。在这个例子中,作为顾客,Phoebe 的任务是在网上挑选一件连衣裙,指定其颜色,并提供她的支付和运输详情。在这里,客户端指定要建造的自行车类型,并利用桥接模式。它还可以指定喷漆工作。在这里,我们选择了一种简单的黑色喷漆,这是我们山地自行车的标准喷漆:
var blackPaintJob = new BlackPaintJob();
var standardMountainBike = new PaintableMountainBike
(blackPaintJob);
我们需要访问机器人手臂外观的控制逻辑:
var robotArmFacade = new RobotArmFacade(new
WelderAttachmentApi(), new BuffingApi(), new
GrabbingApi());
我们必须创建一个命令,并传入完成命令所需的数据:
var cmd = new BuildFrameCommand(new AssemblyLineReceiver
(robotArmFacade), standardMountainBike);
最后,我们必须创建一个发送者对象,并通过在发送者上调用DoCommand方法来启动一切。正如承诺的那样,发送者启动了动作,但它本身并不执行任何操作。当 Phoebe 在网上选择一件连衣裙时,她提交订单。是接收者来完成工作:
var sender = new Sender(cmd);
sender.DoCommand();
命令模式是我们将要讨论的所有模式中最有用和最受欢迎的模式之一。《设计模式:可复用面向对象软件的基础》(GoF)一书以及其他许多书籍都讨论了它如何应用于桌面或 Web 应用程序的用户界面层。UI 中的命令可以从 UI 的几个不同部分创建并发送到程序的其它部分。一个简单的例子是在保存文件时——你通常在程序中有一个文件菜单选项。你可能还有一个带有保存按钮的菜单栏,以及一个如Ctrl/Command + S这样的快捷键组合。这些都是发送者。命令模式允许你将接收器逻辑封装在一个地方。
当你需要执行一个动作且希望将其与可能调用该逻辑的任何紧密耦合的事物隔离时,你可以使用命令模式。在现实世界中,命令的给予、接收和执行是一个任何当过父母并下达命令、孩子执行命令的人都会熟悉的模式。命令由发送者发起,由接收者执行。事件链代表了一种我们都能理解和识别的行为模式。
实际上,一个完整的自行车订单将包含许多这些命令。在这里,我们已经搭建了车架并对其进行了喷漆。凯蒂将使用命令构建控制组装过程的其余逻辑,所以我们让她去做这件事,而我们则探索更多的模式。
接下来,我们将讨论 C#中的另一个基本概念:集合。在讨论集合时,我们将关注一个你可能多次使用过,甚至可能不知道它是一个模式的模式:迭代器模式。
迭代器模式
凯蒂在控制系统中工作进展顺利,她大量使用了命令模式。然后,她遇到了一些障碍。凯蒂的命令接收器逻辑只是从客户订单系统(如直接销售和经销商)接收自行车订单,请求按照接收的顺序进行处理。因此,凯蒂和菲比注意到了速度的下降。尽管AssemblyLineReceiver中的算法已经优化,可以高效地生产自行车,但凯蒂没有考虑到喷漆过程。
从时间和金钱的角度来看,喷漆过程中最昂贵的部分是设置一切以便喷漆自行车。在每种自行车只能喷一种颜色的时候,这很容易。现在,Bumble Bikes 支持定制喷漆。由于在放置定制订单时必须彻底清洁和重置喷漆设备,姐妹们因定制工作而浪费了时间和金钱。喷漆系统必须混合所需的颜色,将其应用于定制订单,然后重置为更常见的颜色,如红色或黑色。这每天会发生多次,因此当收到定制工作请求时,它会阻碍其后的所有其他自行车。Phoebe 指出,在她父亲多年前做印刷工作时,他会根据墨水颜色要求分组他的工作,以最大限度地减少他必须清洁和重新上墨印刷机次数。姐妹们需要按喷漆工作类型分组订单,这样她们就可以批量完成所有定制工作,并且每天只需要清洁和重置一次。她们可以调整客户对定制喷漆请求的交货日期的期望,但这并不是不正常的零售情况。Kitty 需要找出如何高效灵活地分组喷漆订单。
Kitty 的第一个想法是采用foreach循环。虽然这样做很整洁,但在她希望她的软件可能有一天需要扩展的规模上可能会出现问题。
在 C#的工作中,foreach循环针对集合,并允许您遍历其元素——也就是说,您可以逐个处理集合中的每个项目。C#中的集合是强类型的,这意味着集合中的每个对象,例如一个List,都是同一类型。自行车的订单订单被存储在数据库中,并在一天中分批加载到List<BicycleOrder>中。BicycleOrder类的代码如下:
public class BicycleOrder
{
public Customer Customer { get; set; }
public IPaintableBicycle Bicycle { get; set; }
public BicycleOrder(Customer customer,
IPaintableBicycle bicycle)
{
Customer = customer;
Bicycle = bicycle;
}
}
C#在foreach循环中使用的标准迭代器是按添加顺序返回订单的。也就是说,它们操作foreach循环处理。Kitty 需要的是一个先返回所有常规喷漆订单,然后是定制喷漆订单的迭代器。简而言之,她需要一个自定义迭代器。C#中的迭代器遵循迭代器模式。这应该不会令人惊讶,尽管你可能不知道这是一个模式。以下图示使用 UML 展示了迭代器模式:

图 5.3 – 一个自定义迭代器遵循迭代器模式,其中 C#已经为您部分实现了。
让我们回顾一下模式的各个部分,它们已经被适当地编号:
-
IEnumerator接口是 .NET 框架的一部分,因此这次你不需要从头开始创建它。该接口需要一个名为Current的属性,它返回迭代中的当前元素。所需的MoveNext()方法是用于将迭代推进到下一个元素的机制。 -
IEnumerable是 C# 中另一个接口,因此,你同样不需要自己创建它。它需要一个名为GetEnumerator()的方法,这个方法提供了我们自定义枚举器的实例。 -
实现
IEnumerator接口的具体迭代器将在类的private字段中拥有集合。集合,以及通过扩展迭代器,使用泛型工作,如<T>所示。这意味着它们可以适应任何类型,包括你创建的任何类。该类有一个构造函数,它接受一个具体集合。通过这种方式,我们指的是在ConcreteCollection中实现的打算迭代的集合。 -
一个具体集合。
如所述,标准迭代器只是提供了一个遍历集合的方法。C# 中的每个集合——而且有很多——都有一个通用的迭代器,它按顺序遍历集合。任何时候你的业务逻辑需要你的迭代不是 FIFO(先进先出)时,最好将算法封装在自定义迭代器中。我们已达到这样的时刻。也许你的迭代器将具有在集合中移动的独创算法。一个虚构的例子可能是一个只迭代集合中奇数或偶数元素的迭代器。也许你需要一个按字母顺序迭代字符串的迭代器。Kitty 的问题是现实世界的问题——她需要在正常迭代之前根据油漆要求过滤和排序集合。
应用迭代器模式
让我们看看 Kitty 如何应用这个模式,如下面的图所示。请注意,IEnumerable 和 IEnumerator 是 .NET 框架的一部分,不需要实际实现:

图 5.4 – Kitty 实现的迭代器模式。
迭代器总是具有相同的部分。我们的具体集合称为 OrderCollection,它最终通过一个名为 IteratorAggregate 的抽象类实现了 C# 的 IEnumerable 接口。具体的迭代器是一个名为 PaintJobIterator 的类,它继承自一个将实现接口要求的抽象基类。PaintJobIterator 类包含根据油漆作业类型对集合进行排序的逻辑。自定义作业将最后完成,这样我们就可以立即发货标准订单自行车。我们的客户对等待额外一天以完成定制油漆作业是可以接受的,因此这些作业最后完成。
OrderCollection和PaintJobIterator类分别从IteratorAggregate和Iterator基类继承。这些类只是实现相应接口的抽象类。如果你打算在项目中创建多个自定义迭代器,它们是有用的。
编写迭代器模式
我们已经看到了BicycleOrder类的代码。这个类包含对另一个名为Customer的类的引用,这基本上是你所期望的。Kitty 使用了System.Net.Mail中的MailAddress类。其余的都是字符串:
public class Customer
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string CompanyName { get; set; }
public MailAddress Email { get; set; }
public string ShippingAddress { get; set; }
public string ShippingCity { get; set; }
public string ShippingState { get; set; }
public string ShippingZipCode { get; set; }
}
记住,IEnumerator和IEnumerable接口是.NET 框架中System.Collections的一部分,所以我们不需要编写这些代码。然而,Kitty 选择为它们创建抽象类。第一个在Iterator.cs文件中:
public abstract class Iterator : IEnumerator
{
object IEnumerator.Current => Current();
public abstract int Key();
public abstract bool MoveNext();
public abstract void Reset();
protected abstract object Current();
}
这是一个实现IEnumerator接口的抽象类。Kitty 在IteratorAggregate.cs文件中为IEnumerable接口创建了另一个。需要实现一个方法来符合该接口:
public abstract class IteratorAggregate : IEnumerable
{
public abstract IEnumerator GetEnumerator();
}
到目前为止,这只是一些样板代码。现在,让我们继续到好的部分。第一部分是 Kitty 自定义的集合,称为OrdersCollection。这只是一个List<BicycleOrder>的包装:
public class OrdersCollection : IteratorAggregate
{
public List<BicycleOrder> Orders { get; set; }
一个无参数的构造函数确保我们从空列表开始:
public OrdersCollection()
{
Orders = new List<BicycleOrder>();
}
在这里,我们有一个简单的传递到List的Add方法。小测验:这看起来像我们已经覆盖过的另一个模式吗?可能是装饰者模式?
public void AddOrder(BicycleOrder order)
{
Orders.Add(order);
}
这里是神奇的部分。当我们实现我们的集合时,我们需要GetEnumerator方法返回我们的自定义迭代器,我们还没有编写它。我们正在覆盖前面提到的抽象IteratorAggregate类中的抽象方法,并且我们返回PaintOrderIterator,如下所示:
public override IEnumerator GetEnumerator()
{
return new PaintOrderIterator(this);
}
}
最后,我们有了这个模式的核心——实际的迭代器本身:
public class PaintOrderIterator : Iterator
{
这个类的大部分内容是实现IEnumerator接口中表达的要求,我们在前面看到的抽象Iterator类中表达这些要求。首先,我们可以看到一个private字段,它持有我们刚刚看到的OrdersCollection类的引用:
private readonly OrdersCollection _orders;
当迭代器在集合中移动时,我们需要跟踪其位置:
private int _position;
构造函数接受OrdersCollection并设置private字段,以及初始位置。初始位置从-1开始,因为我们还没有开始迭代,如果我们将其设置为0,我们将指示集合中的实际位置:
public PaintOrderIterator(OrdersCollection orders)
{
_orders = SeparateCustomPaintJobOrders(orders);
_position = -1;
}
我们可能需要一种方法来获取私有_position字段。我们将使用只读的Key()方法来完成此操作:
public override int Key()
{
return _position;
}
迭代器通过foreach循环被调用,该循环调用MoveNext()方法将迭代向前移动,直到达到集合的末尾:
public override bool MoveNext()
{
var updatedPosition = _position + 1;
if (updatedPosition < 0 || updatedPosition >=
_orders.Orders.Count) return false;
_position = updatedPosition;
return true;
}
接口要求必须有一种方法可以将迭代器位置重置到集合的开始位置:
public override void Reset()
{
_position = 0;
}
Current() 给我们集合中当前迭代的对象。不要与一分钟前我们看到的 Key() 方法混淆。Key() 给你数字索引或位置,而 Current() 给你键的位置的内容:
protected override object Current()
{
return _orders.Orders[_position];
}
这是我们的定制。我们在这里所做的只是迭代开始之前重新排序集合。Kitty 制作了两个列表。一个将包含 BicycleOrder 对象,其中 PaintJob 属性中的类是标准喷漆工作,而另一个将包含定制喷漆订单。当我们将它们分开后,简单地重新组合带有定制工作的列表即可:
private OrdersCollection SeparateCustomPaintJobOrders
(OrdersCollection orders)
{
var customPaintJobOrders = new List
<BicycleOrder>();
var standardPaintJobOrders = new List
<BicycleOrder>();
foreach (var order in orders.Orders)
{
var paintJob = order.Bicycle.PaintJob;
如果你还记得我们在桥接模式覆盖期间构建这个类的时候,标准单色喷漆工作是用 IPaintJob 接口表示的。同样,定制喷漆工作也是。我们的定制喷漆工作是在 CustomGradientPaintJob 类中定义的,它是 IPaintJob 的不同实现。Kitty 在 IPaintJob 接口和实际实现之间使用了一个抽象类。这个中间类被称为 CustomGradientPaintJob,这意味着我们可以检测喷漆工作的基类并根据情况采取行动:
bool isCustom = paintJob.GetType().IsSubclassOf
(typeof(CustomGradientPaintJob));
if(isCustom)
{
customPaintJobOrders.Add(order);
}
else
{
standardPaintJobOrders.Add(order);
}
}
现在我们有了常规喷漆订单列表和定制喷漆订单列表,我们可以替换原始 orders 列表的内容:
orders.Orders.Clear();
然后,我们可以将标准订单重新添加进去:
orders.Orders.AddRange(standardPaintJobOrders);
这后面跟着定制喷漆订单:
orders.Orders.AddRange(customPaintJobOrders);
return orders;
}
}
完成!现在,Kitty 需要一个快速程序来在 Program.cs 中进行测试。当我说是“快速”的时候,它只有几行长,因为我们需要在测试程序中创建所有部分,包括客户、自行车、订单列表以及通过 foreach 循环创建的自定义迭代器。记住,我们正在从不同的包中引入一些类。Customer 类使用 System.Net.Mail,它是 .NET 框架的一部分。自行车将来自 BumbleBikesLibrary.PaintableBicycles,而喷漆工作将来自 BumbleBikesLibrary.PaintableBicycle.CommonPaintJobs:
using System.Net.Mail;
using BumbleBikesLibrary.PaintableBicycle;
using BumbleBikesLibrary.PaintableBicycle.CommonPaintJobs;
using IteratorExample;
迭代器需要一些可以迭代的内容,所以让我们创建一个空的 OrdersCollection 类:
var orders = new OrdersCollection();
现在,我们需要一个客户。在现实生活中,可能会有几个,但为了我们的示例,我们只使用一个:
var dealership = new Customer
{
FirstName = "John",
LastName = "Galt",
CompanyName = "Atlas Cycling",
Email = new MailAddress("johngalt@whois.com"),
ShippingAddress = "123 Singleton Drive",
ShippingCity = "Dallas",
ShippingState = "Tx",
ShippingZipCode = "75248"
};
现在,我们需要自行车来放入订单。让我们不要浪费时间,制作一辆带有定制喷漆工作的自行车!这样,我们知道列表前面有一辆。当迭代器重新排序列表时,所有这些都应该在最后:
var amarilloPeacockPaintjob = new
AmarilloPeacockPaintJob();
var bicycle0 = new PaintableMountainBike
(amarilloPeacockPaintjob);
一旦你有一辆带有喷漆和客户的自行车,你就可以创建一个订单并将其添加到 orders 列表中:
var order0 = new BicycleOrder(dealership, bicycle0);
orders.AddOrder(order0);
接下来,让我们对一些标准喷漆工作做同样的事情,当我们在迭代时,这些工作将出现在列表的前面。首先,让我们添加一辆青绿色巡航自行车:
var turquoisePaintJob = new BluePaintJob();
var bicycle1 = new PaintableCruiser(turquoisePaintJob);
var order1 = new BicycleOrder(dealership, bicycle1);
orders.AddOrder(order1);
那么一辆白色公路自行车呢?
var whitePaintJob = new WhitePaintJob();
var bicycle2 = new PaintableRoadBike(whitePaintJob);
var order2 = new BicycleOrder(dealership, bicycle2);
orders.AddOrder(order2);
为了保持有趣,让我们再添加一辆定制自行车。这次,我们将添加一辆带有定制渐变喷漆的躺式自行车:
var bicycle3 = new PaintableRecumbent
(amarilloPeacockPaintjob);
var order3 = new BicycleOrder(dealership, bicycle3);
orders.AddOrder(order3);
这是一个标准的红色公路自行车:
var redPaintJob = new RedPaintJob();
var bicycle4 = new PaintableRoadBike(redPaintJob);
var order4 = new BicycleOrder(dealership, bicycle4);
orders.AddOrder(order4);
这应该足够进行有意义的测试。
尝试新的迭代器
现在,让我们尝试我们的迭代器。如果一切顺利,这应该看起来就像你在 C#中看到的任何迭代一样:
foreach (BicycleOrder order in orders)
{
Console.WriteLine(order.Bicycle.PaintJob.Name);
}
foreach循环几乎没有什么悬念,不是吗?这就是我们知道我们做得好的原因。你无法分辨出我们的自定义迭代器与 C#或.NET 中提供的任何常见迭代器有何不同。常规的foreach循环包含了提取我们的自定义迭代器并使用它通过MoveNext()和Current方法(我们具体类中拥有的方法)在集合中移动的机制。当 Kitty 运行测试程序时,她可以看到我们希望看到的结果:

图 5.5 – Kitty 对她自定义迭代器的测试运行。
就像 Kitty 学到的,迭代器模式是我们日常工作中最重要的模式之一。尽管有几个不同的部分,但它并不复杂。当你需要以不同于标准 FIFO 处理顺序的方式处理任何类型的集合时,你都可以使用这个模式。
你应该总是寻找机会使用这种模式。寻找…嘿,这让我想起了我们下一个模式!
观察者模式
我们最大的恐惧变成了现实。Bumble Bikes 变得如此受欢迎,以至于 Kitty 和 Phoebe 开始遇到物流问题。“别误会我,”Phoebe 说。“这是一个好问题。如果我们能优化我们的运输成本,我们可能会更有利可图。最难的部分是第一英里。我们如何能更有效地把自行车运到全国货运站的仓库呢?”Kitty 安排了一个Zoom会议,与提供包装和运输支持作为服务的ExFed的小企业主进行了交流。Cathy 是位于达拉斯的ExFed代表,John 是位于 Kitty 工厂所在地的 Alpine 的代表,他们仔细听取了 Bumble Bikes 的困境。
“良好的物流工作流程的关键,”Cathy 说,“是确保每次卡车离开你的工厂时,它都装满了自行车。当卡车回来时,它应该装满了为下一批自行车准备的原料。”女孩们还没有考虑到 Cathy 解释的第二部分。John 表示同意。在 Phoebe 解释了她的自动化工厂是如何工作之后,四个人在电话中商讨了一些细节。
“你已经向原材料供应商发出了一个信号,告诉他们何时消耗材料来制作自行车,”John 指出。Cathy 接过了他的思路并完成了它。“对——我们需要的只是得到一个信号,让我们知道何时有一车自行车供我们取货。我们的卡车可以把自行车运到全国货运站的仓库,就像其他任何货运一样。”
菲比说:“没问题!我们只需在我们的自行车对象中添加另一个装饰器类。”约翰和凯西茫然地看着他们各自的摄像头。“菲比!他们说的不是我们说的极客话!”凯蒂责备道。“而且,那不会起作用。每辆自行车的原材料使用情况都会报告。我们不想为每辆自行车发送取货信号。那会效率低下。”
“如果那样做,你会,”约翰说,“我们可能会带着半满的卡车离开。根据你的包装尺寸,我们需要每批至少有 10 辆自行车才能使我们的服务具有成本效益。”
“我们需要的是,”凯蒂开始说,“当至少有 10 辆自行车准备好被取走时,能够发送信号的某种东西。”
“好吧,”凯西说,“你需要有人在装配线的末端数自行车,当它们达到 10 辆时,在我们的网站上点击一个按钮。之后,大约需要 30 分钟才能把卡车开到你的码头。”
菲比不露痕迹地翻了个白眼。她的妹妹轻蔑地笑了笑。想想世界上所有那些不会说“极客话”且不理解软件自动化的“普通人”,他们过着多么艰难和未满足的生活!菲比和凯蒂知道他们一定会找到一种自动化他们的供应请求的方法。
“但是在这段时间里,”菲比继续说,“我们可能又制造了 5 到 10 辆自行车。这有问题吗?”
“不,”约翰说,“我们只需要至少 10 辆。”
约翰和凯西挂断了电话,列出了待办事项清单,并通过虚拟握手达成了一项处理 Bumble Bikes 首公里物流的协议。凯蒂和菲比继续在 Zoom 上进行头脑风暴。“凯西说我们需要有人在自行车从装配线下来时进行计数。我不想付钱给某人只是坐着观察。”
“就这样了!凯蒂,你真是个天才!”菲比大声说道。凯蒂带着疑惑的笑容。她不确定自己做了什么值得这种罕见的姐妹赞扬。
菲比意识到这种情况需要的是观察者模式。凯西设想了一个人在自行车计数达到至少 10 辆时观察过程并做出反应。为了自动化这个过程,女孩们将不得不编写能够观察生产过程并在达到所需的自行车库存时向物流公司发出信号的软件。观察者模式的通用图示如下:

图 5.6 – 观察者模式
观察者模式有两个基本部分——一个主题和一个或多个观察者。让我们回顾一下模式的各个部分,它们已经被适当地编号:
-
接口描述了观察者的方法要求。该接口定义了一个公共的
Update()方法。每当观察者“看到”它所观察的对象中发生有趣的事情时,就会调用Update()方法。 -
我们的观察者具体实现逻辑包含了当观察者“看到”主题状态中的一些有趣变化时发生的行为。
-
主题在进行一些有用的工作并保持其状态的同时,观察者在等待。请注意,观察者包含在主题内的一个集合中。另外,请注意
state属性是private的。我们需要一种方式让观察者知道发生了有趣的事情。为此,我们有一个名为Notify()的函数。当触发条件发生时,Notify()方法可以遍历每个附加的观察者并调用其Update()方法。 -
整个过程由我们称之为“客户端”的更大程序调用。
应用观察者模式
凯蒂打开 Zoom 的白板,他们合作实现了观察者模式,以解决以下图表所示的问题:

图 5.7 – 菲比和凯蒂实现的观察者模式,该模式可以发出信号让 ExFed 的卡车取走一批自行车。
这个很简单——我们需要一个主题和一个观察者。女孩们使用了一个接口,ILogisticsObserver,以防止LogisticsSubject类和名为ExFedObserver的具体观察者类之间的紧密耦合。
一旦他们画出了图表,他们各自打开了他们最喜欢的 IDE,该 IDE 包含协作编码功能。这意味着女孩们可以像坐在彼此旁边一样一起编码。
编码观察者模式
菲比轻松处理了ILogisticsObserver接口:
public interface ILogisticsObserver
{
public void SchedulePickup();
}
凯蒂随后添加了一个具体观察者,该观察者消费了菲比的接口:
public class ExFedObserver : ILogisticsObserver
{
public void SchedulePickup()
{
Console.WriteLine("ExFed has been notified that a
shipment is ready for pick up.");
}
}
我省略了实际的 API 调用,因为凯蒂和菲比担心我可能会不小心泄露他们的 API 密钥。众所周知,只有业余爱好者会将 API 密钥提交到 GitHub,没有它代码将无法工作,所以我将其放在一个Console.WriteLine语句中作为替代。
LogisticsSubject类是真正的行动所在。它稍微长一些,所以女孩们一起工作:
public class LogisticsSubject
{
凯蒂添加了一个List<ILogisticsObserver>字段来存储所有观察者。然后她跟着一个典型的构造函数,初始化该字段:
private readonly List<ILogisticsObserver>
_logisticsObservers;
public LogisticsSubject()
{
_logisticsObservers = new
List<ILogisticsObserver>();
}
菲比添加了一个Attach方法,允许我们添加一个或多个观察者,这些观察者是符合ILogisticObserver接口的对象:
public void Attach(ILogisticsObserver observer)
{
_logisticsObservers.Add(observer);
PrintObserversCount();
}
同样,她还添加了一个可以从列表中删除观察者的方法:
public void Detach(ILogisticsObserver observer)
{
_logisticsObservers.Remove(observer);
PrintObserversCount();
}
凯蒂意识到,如果他们有一种方法可以看到列表中的观察者,测试将会更容易。因此,她添加了一个快速方法来为初始运行提供一些输出。该方法简单地打印出存储在私有_logisticsObservers字段中的观察者数量:
private void PrintObserversCount()
{
switch (_logisticsObservers.Count)
{
case < 1:
Console.WriteLine("There are no
observers.");
break;
case 1:
Console.WriteLine("There is 1 observer");
break;
default:
Console.WriteLine("There are " +
_logisticsObservers.Count + "
observers.");
break;
}
}
最后,我们需要通知我们的观察者。通用的 UML 图指定了一个Notify()方法。我们称之为NotifyPickupAvailable()。它简单地遍历观察者,并在列表中的每个观察者上调用SchedulePickup()方法:
public void NotifyPickupAvailable()
{
foreach (var observer in _logisticsObservers)
{
observer.SchedulePickup();
}
}
}
沿着姐姐的榜样,菲比在Program.cs中编写了测试程序。首先,她创建了一个LogisticsSubject的实例:
var logisticsSubject = new LogisticsSubject();
然后,她创建了观察者并将其附加到主题上:
var exFed = new ExFedObserver();
logisticsObserver.Attach(exFed);
接下来,让我们模拟制造 100 辆自行车。每次我们有 10 辆,我们就会发送一个通知:
var pickupOrder = new List<Bicycle>();
for (var i = 0; i < 99; i++)
{
var bike = new MountainBike();
在这里,菲比只是在模拟时间的流逝,试图保持这是一个真实的模拟。她希望她的机器人能在 3 秒内制造出一辆自行车。在延迟之后,她使用ToString方法写出了自行车,并将其添加到pickupOrder列表中:
Thread.Sleep(3000);
Console.WriteLine(bike.ToString());
pickupOrder.Add(bike);
我们的观察者逻辑检查我们是否有足够的自行车。如果有,它就会触发NotifyPickupAvailable()方法,该方法遍历所有观察者,并调用它们各自的SchedulePickup()方法:
if (pickupOrder.Count > 9)
{
logisticsSubject.NotifyPickupAvailable();
在现实世界中,30 分钟后,一辆卡车才会到达 Bumble Bikes 取走他们的库存。然而,没有人想模拟这个过程,所以我们只是假装我们完成了,并清除了订单:
pickupOrder.Clear();
}
}
当我们完成一天的自行车制造后,我们可以断开连接。菲比理解工作与生活平衡对她工厂机器人的重要性:
logisticsSubject.Detach(exFed);
随着女孩们的软件操作使用中发现了越来越多的模式,需要解决的问题数量迅速减少。他们进入了每个软件项目都会经历的阶段,工作从开发新代码转向维护它。通常,在这个时候,高级开发者开始感到无聊,因为所有的大问题都已经被解决了。这些开发者必须做出选择:继续留在项目中,还是寻找另一个有新挑战的项目。这就是大多数软件公司发生的事情。当然,菲比作为一家自行车制造初创公司的杰出老板,绝不会屈服于单调乏味的诱惑?
策略模式
“我无聊死了!” 菲比对着房间另一边的姐姐大喊。凯蒂从阿尔卑斯山下来和 Lexi 一起审查一些电子表格,Lexi 是 Bumble Bikes 的会计主管。菲比和 Lexi 已经相识多年,所以当菲比有机会招募她时,她毫不犹豫地接受了。Lexi 习惯了菲比的古怪,对凯蒂笑了笑,合上笔记本电脑,说,“我明天会帮你完成这件事。”
当莱克西离开办公室时,菲比倒扣在沙发上,关掉电视声音,翻看着频道。她最终调到了第 52,381 频道,也就是自行车频道。Bumble Bikes 在这个频道上做了大量广告,此时,一个评论员正在评论自行车电脑。一个自行车电脑是一种报告你的速度和行驶距离的电子设备。高级型号可以追踪你的踏频,即你踩踏的节奏。一些甚至可以追踪你的心率以及骑手对踏板施加的力的电功率。
菲的脸开始变红。她倒扣的时间太长了,血液涌到了头部。她完全翻过来,露出了当她有一个价值百万美元的想法时的那个表情。
她的妹妹甚至不用看她就能看出她的想法。“什么?” 基蒂问。
菲比盯着天花板看了更长一段时间,她的眼睛来回移动。她在心里发明着什么。基蒂抬头看,能看到菲比头脑中的齿轮在转动。
“自行车电脑!” 菲比最后大喊道。“它们为什么这么无聊?我是说真的!它们所做的只是告诉那些自行车爱好者他们有多棒。谁需要电脑来做这个?” 这句话有点奇怪,但出自菲比之口,在 1 到 10 的评分中最多只有 4 分,1 分可能是像订购披萨这样的小事,10 分可能是她私下里已经放弃的工程问题的非连续性完成。
“如果一辆自行车电脑能做一些酷炫的事情,就像我们汽车电脑那样?当然,它可能会追踪速度、距离、功率,以及那些自行车爱好者愿意为自行车电脑支付的一切。但它还会提供导航路线、小径和路况。这是骑手们想要了解和拥有的信息,以便他们能够完成下一次史诗般的骑行,” 菲比脱口而出。这通常是菲比跑到她的实验室,订购一摞披萨和汽水的时候。然后,她会消失几天。姐妹俩最近的成功让她们有能力将实验室搬到各自的工厂。菲比的实验室里有一个浴室和一张从远墙到看起来像范德格拉夫发生器的床。“她为什么需要那个?” 基蒂默默地想。她早已放弃大声询问。那张床,铺满了之前工程冒险中的披萨盒子,看起来似乎从未有人睡过。
基蒂没有回应她姐姐关于自行车电脑用途的问题。她知道这是一个不应该被回答的问题。当基蒂第二天早上进来时,她注意到办公室里到处都是丢弃的披萨盒子和空汽水瓶。一个黑色的小盒子被安装在了菲比工作台上的把手组件上。菲比在她的实验室里睡着了。
好奇的小猫摆弄着盒子表面的按钮。菲比制作了一个带有一些传感器的迷你电脑。小猫能够找到吸引菲比想象力的导航功能。设计得很简陋,但功能齐全。“嘿,姐姐,”菲比含糊地咕哝着。“它还没工作。我卡在导航上了。”菲比的头又重重地倒在枕头上。小猫继续摆弄导航。她发现了问题:导航界面上允许你选择几种不同的地形。你可以搜索铺砌道路、砾石小径或山地小径的路线。然而,搜索结果始终只显示铺砌道路。原因很容易理解。
菲比正在利用知名的 GPS API 来计算她的路线。自然,这些 API 倾向于推荐铺砌的道路。菲比能够创建一个 API 的伪装和一个装饰器,稍微改变了默认行为,使得 API 不会推荐繁忙的高速公路,即使它们是最直接的路线。
在接下来的几个小时里,小猫研究了替代的地图 API,这些 API 更关注那些少有人走的道路,并优先考虑那些汽车无法到达的地方。她发现,每次她添加不同的 API 和路径查找算法,她的代码就开始变得复杂。她很容易看到在她不断增长的意大利面盘上,一个巨大的泥球正在形成。
经过一番重构和思考,她确定了一个应该可行的策略。剧透一下:策略就是她使用的模式的名字。这个模式很容易解释,因为模式的意思和英文中pattern这个词的意思是一样的。如果你试图实现一个复杂的目标,你就可以制定一个策略。在软件工程中,策略模式指的是灵活且可互换地处理一组算法。算法简单来说就是一组你可以遵循的步骤,用以在合理的时间内解决问题,并给出一致的结果。如果你要创建一个制作花生酱果酱三明治的算法,步骤将会很简单:
-
在盘子上放两片面包。
-
打开花生酱。
-
用一把钝刀,在一片面包的一侧涂上花生酱。
-
关闭花生酱罐。
-
打开果酱。
-
用另一把钝刀(你不喜欢别人用同一把刀,结果果酱弄到了你的果酱上吧?),在第二片面包的另一侧涂上一些果酱。
-
关闭果酱罐。
-
把第一片面包放在第二片面包上,让花生酱和果酱在面包片之间相遇。
那是一个算法。如果你遵循这个算法,我可以保证你最终会得到一个花生酱果酱三明治。这个算法可以在几分钟内完成,对我来说,这是一个合理的时间。我可以轻松地制作第二个算法来制作火鸡三明治,再制作一个来制作奶酪三明治。如果我用一个公共接口封装每个算法,我就可以根据我想要的哪种三明治来选择三明治制作策略。
Kitty 有一个需要算法从 A 点到 B 点的情况。她需要三种不同的策略,这些策略被编码为三个不同的算法。第一个算法将在铺砌的道路上找到一条路径。第二个将尝试使用砾石或未铺砌的道路。第三个将找到一条完全没有道路但可以用自行车通行的路径。她需要从 A 到 B 的这些路线遵循一个公共接口,并且她需要算法本身遵循一个公共接口,这样她就可以根据需要交换它们。
我们可以用 UML 来表示,如下面的图表所示:

图 5.8 – 策略模式。
让我们回顾一下模式的各个部分,它们已经被适当编号:
-
IStrategy接口定义了一个实现你的算法的方法。在我们的例子中,它将被命名为Run,我们可以传递算法可能需要的任何数据,例如起始和结束位置的地理坐标。 -
一个具体的策略对象,实现了
IStrategy接口,将实现你的算法。 -
一个上下文对象持有策略并可以使用某种方法执行它。我们称之为
DoBehavior()。
关键在于所有算法都遵循IStrategy接口。这意味着我可以传递任何包含Run方法的策略对象中的算法来调用算法。在这个时候,算法是可以互换的。
应用策略模式
Kitty 为她自己的实现提出了一个图表,如下所示:

图 5.9 – Kitty 对策略模式实现的绘图。
Kitty 看了看正在深度睡眠中的 Phoebe。也许通过策略模式,她可以在她醒来之前修复这个软件。
编码策略模式
Phoebe 已经在图表的底部有了数据结构。她输入了INavigationRoute:
public interface INavigationRoute
{
public string RouteDetails { get; set; }
}
她还有一个具体的NavigationRoute类。不幸的是,代表 Bumble Bikes 的律师 Karina 不允许我展示这部分代码。没关系。我们在这里是为了模式。接口和类都不是模式的一部分。它们只是实现中使用的结构。代替 Kitty 高度专有的数据结构,我将给你一个简单的字符串:
public class NavigationRoute : INavigationRoute
{
public string RouteDetails { get; set; }
}
让我们继续到策略的一部分代码。我们将从INavigationStrategy接口开始。此接口用于使你的算法符合一个通用结构,以便它们可以互换:
public interface InavigationStrategy
{
public InavigationRoute FindRoute(string parameters);
}
如前所述,Kitty 需要三个具体实现。第一个是用于在铺砌道路上寻找路线。Phoebe 已经实现了这个功能,所以 Kitty 只是重构了它以适应接口:
public class RoadNavigationStrategy : INavigationStrategy
{
public INavigationRoute FindRoute(string parameters)
{
// This is where your amazing algorithm goes. But
// since this is a book on patterns and not
// algorithms...
return new NavigationRoute
{
RouteDetails = "I'm a road route."
};
}
}
然后,Kitty 创建了一个算法来寻找砾石道路路线:
public class GravelNavigationStrategy : INavigationStrategy
{
public INavigationRoute FindRoute(string parameters)
{
// This is where your amazing algorithm goes. But
// since this is a book on patterns and not
// algorithms...
return new NavigationRoute
{
RouteDetails = "I'm a gravel route."
};
}
}
到目前为止,我预测你不会对第三个实现感到惊讶:
public class MountainNavigationStrategy :
INavigationStrategy
{
public INavigationRoute FindRoute(string parameters)
{
// This is where your amazing algorithm goes. But
// since this is a book on patterns and not
// algorithms...
return new NavigationRoute
{
RouteDetails = "I'm a mountain route."
};
}
}
剩下的就是NavigationContext类:
public class NavigationContext
{
Kitty 使用一个简单的属性来保存她的导航策略。自然地,她声明了接口,而不是一个具体对象:
public INavigationStrategy NavigationStrategy { get;
set; }
接下来是一个标准的构造函数。她将默认值设置为道路导航策略,因为这是 Phoebe 设备的默认设置:
public NavigationContext()
{
NavigationStrategy = new RoadNavigationStrategy();
}
最后,我们有一个方法来根据NavigationStrategy属性中的当前策略启动寻找我们正在寻找的路径的算法:
public void StartNavigation()
{
这里有很多酷的业务逻辑。最终,Kitty 使用策略生成路线:
var route = NavigationStrategy.FindRoute
("parameters go here");
Console.WriteLine(route.RouteDetails);
}
}
实话实说,这是我见过的最具创新性的算法集。遗憾的是律师介入了。然而,我们确实看到了这个模式,它最终证明与一些更复杂的模式相比非常简单。
当你需要从一组相关算法中选择,这些算法都旨在实现一个共同目标时,就会使用策略模式。无论是制作三明治还是设计一套可互换的地理空间路径查找算法,使用策略模式将有助于保持你的代码可维护和易于阅读。Kitty 可以轻松地添加更多算法,而不会破坏任何现有的策略。
摘要
行为模式以保持软件可管理的方式与算法协同工作。在本章中,我们探讨了四种非常有用且流行的模式,这些模式可以用于解决各种设计问题。
命令模式可以用来隔离指令与执行它们的对象。这是我们在第一章,“你的意大利面盘上有一个大泥球”中讨论的反模式之一。将逻辑与具体结构紧密耦合会导致软件脆弱且易于复杂化。命令模式将帮助你避免这个陷阱。
迭代器模式在需要以某种方式迭代集合,而这种方式不是由标准的.NET 迭代器处理时使用。这个模式与一个集合一起工作,从第一个项目开始,然后直线迭代到最后一个项目。这可能是在处理之前操纵集合,或者可能是一种新颖的移动方式以满足业务需求。这个模式的一些基本构建块已经内置到.NET 框架的System.Collections命名空间中。在构建迭代器之前,你应该检查是否已经存在一个。
观察者模式由一个主题和一个或多个观察者组成。主题通知观察者主题状态中的特定触发条件。观察者模式广泛应用于众多应用中。许多使用过事件监听器的软件开发者都见识过并理解了这种技术的力量。
我们最后发现的模式是策略模式。当我们有一组具有共同目的的算法时,我们使用策略模式。策略模式通过将算法符合一个共同的接口来实现,该接口被注入到上下文中。然后,根据业务需求,算法可以互换使用。
在下一章中,一系列命运多舛的事件将迫使 Kitty 和 Phoebe 寻求一个陌生人的帮助。Bumble Bikes 最初是一个激情项目,后来变成了一项商业冒险,但它很快将变成一项人道主义援助。太多的软件开发者、工程师和架构师没有意识到他们拥有可以改变世界的超能力。赌注很高,Kitty 和 Phoebe 的压力太大。他们需要一个理解 SOLID 原则和模式的人来领导一个非常重要的项目。这个低调的陌生人能够胜任这项任务吗?你会吗?
问题
回答以下问题以测试你对本章知识的掌握:
-
在一个上下文中,哪种模式用于使具有共同目的的算法可互换?
-
哪种模式用于封装并发送指令到接收者,同时避免执行指令所需的数据和执行指令的逻辑之间的紧密耦合?
-
哪种模式涉及一个主题和一个观察者?请注意,如果你错过这个问题,它将记录在你的永久记录中。
-
哪种模式用于以非 FIFO 的方式处理集合?
-
在实现迭代器模式时,.NET 框架中有哪两个接口是有用的?

第三部分:使用模式设计新项目
在学习了某些模式之后,让我们来看看设计过程。到目前为止,我们一直是在走一步看一步。如果我们首先退一步,用模式和 UML 来设计我们的项目,而不是直接进入代码,事情会变得多么容易,我们又能避免多少问题?本节将从这一角度探讨一个新的项目。我们首先将在第六章,在编码前使用模式进行设计!远离 IDE中,将新项目纯粹地设计成一系列图表。然后,我们将在第七章,除了打字外别无他物:实现轮椅项目中实现这个项目。最后一章总结了全书内容,旨在向您展示还有更多模式存在。实际上,它们无处不在!除了常见的四人帮集合之外,还有更多的发展模式,甚至还有更多超出了面向对象编程(OOP)的领域。你甚至还将学习创建和发布你自己的模式的文档过程!为了以防你是 C#、OOP 或 UML 的新手,我在最后附上了一个附录来回顾基本概念。
本部分涵盖了以下章节:
-
第六章, 在编码前使用模式进行设计!远离 IDE
-
第七章, 除了打字外别无他物:实现轮椅项目
-
第八章, 你现在知道了一些模式。接下来是什么?
-
附录 1, C#中面向对象原则的简要回顾
-
附录 2, 统一建模语言入门
第六章:离开 IDE!在编码之前使用模式进行设计
这本书的主要重点是学习最流行和最广泛使用的 Gang of Four (GoF) 模式。这本书的次要重点是考虑在实际应用中使用这些模式,考虑到现实和不断变化的企业需求。太多关于复杂主题(如模式)的书充满了抽象、虚构或微不足道的例子。你已经学习了一些流行的模式,但如果你不能应用它们,它们就不会从你的编码中获得好处。多年来,我开发了一套对我有效的方法,并希望它对你也有效。我的方法会逐步引导你考虑新的设计,并逐步引入模式。
这可能是一个令人不安的发现,但本章不会提供任何代码示例。我们将遵循在编写代码之前先绘制代码图的做法。
本章我们将涵盖以下主题:
-
我们将观察 Bumble Bikes 的新软件架构师如何使用两步法为新项目创建 UML 设计。
-
新架构师将通过绘制项目的类和基本结构进行第一次审查。
-
然后,建筑师将进行第二次审查,在此期间,他们将识别适当的模式并根据需要更新图。
本章将继续讲述两位姐妹的故事。Bumble Bikes 的业务需求将发生巨大变化。我们将看到如何使用模式适应和克服这些变化带来的固有障碍。
技术要求
由于本章包含的是图表而不是代码,我关于集成开发环境(IDEs)的常规说法不适用。这次,我们需要一套不同的工具。有许多用于绘制 UML 图的工具。在现实世界中,绘图练习通常在白板上进行。在本章的几个地方,我会停下来挑战你进行绘图练习。
如果你想接受挑战,你需要一种创建和使用 UML 图的方法。如果你之前从未绘制过 UML 类图,这本书的附录 2中有简短的教程。白板适合绘制临时草图,但在这本书中,我的图需要更加持久,所以这里是我使用的方法:
-
运行 Windows 操作系统的计算机。我使用 Windows 10。说实话,这并不重要,因为所有操作系统的绘图工具都很丰富,而且有很多可以在浏览器中工作。
-
一个绘图工具。我使用 Microsoft Visio。市场上有很多 UML 工具。以下是我多年来使用的一些工具的简要列表:
-
Microsoft Visio
-
StarUML
-
Altova UModel
-
Dia
-
Umbrello
-
UMLet
-
OmniGraffle(仅限Mac)
你可以在 GitHub 上找到本章的完整项目文件,链接为github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns/tree/main/chapter-6。
随着故事的发展,我们看到一个曲线球威胁要破坏姐妹们所完成的所有工作。在现实世界中,业务需求总是不断变化。有时这些变化很小,并不会真正影响项目。而有时,变化是激进的。姐妹们即将面临个人危机。她们应该如何应对?是选择捷径,随意修补一些问题,还是我们应该已经学到的,修补只会导致一大团混乱?然而,如果我们采取严谨和周到的态度,我们或许能够避免这种退化。
让我们看看会发生什么。
机构里的一天糟糕透顶
周五下午 3:58,汤姆在机构的电话响了。电话放在他的桌子上,他刚刚在和另一个隔间里的同事交谈。他们的开发团队一直在为一个客户的项目工作。前几轮发布都没有出现任何问题,客户非常满意。客户如此高兴,甚至送来了一大堆新的功能请求,并签署了合同延期。就在合同延期之后,事情开始变得糟糕。汤姆的开发团队远远落后于进度。实施最后一轮功能请求使得他们的客户产品变得不稳定,经常崩溃。汤姆目前正在参加代码审查会议。小组得出结论,项目的代码处于极度困境。他们的管理层为了迅速取得胜利并给新客户留下深刻印象,命令团队交付第一个原型。接下来的几轮发布来得很快。客户对机构的印象是他们完全由奇迹工作者组成。然而,在这层表象之下(这次不是模式),他们发现很难在不进行全面重写的情况下扩展和修复代码。他们发现他们已经创造了一大团混乱。
汤姆花了整整一分钟才将轮椅在隔间里转来转去,回到自己的办公桌前接听响起的电话。电话是他的上司的上司,斯蒂芬妮。“这真奇怪,她从不给我打电话,”汤姆想。“而且,她不是在安提瓜度假吗?”她本应下周才回来。
感到困惑,汤姆接起电话,斯蒂芬妮带着浓重的口音立刻开始说话。“汤姆!我们得谈谈。”这是一次简短的对话。它突然结束,汤姆在机构的雇佣关系也随之结束。他被解雇了。他感到震惊。一切原本都进行得很好,直到突然之间,一切都变了。斯蒂芬妮以喜怒无常著称,一旦她的主管团队中有人对她有任何不满的迹象,她就会惩罚她的员工。她正快速晋升到高层,她不会让一群书呆子破坏她的轨迹。
汤姆记得有一次,在一个不同的项目上,他想出了一个方法,通过自动化一个困难和易出错的流程来节省生产和时间。这成功了。项目通过了质量保证(QA)并一直进行到生产阶段。客户对这次发布没有投诉,汤姆的新方法将为未来的项目节省大量时间,从而使得这些项目更有利可图。直到在一次管理层聚餐中听到汤姆关于流程变更的消息,斯蒂芬妮才对汤姆的工作表现出兴趣。她非常愤怒。她如此讨厌这些变化,以至于她责备汤姆,并让他重新做整个项目。她撤回了生产中的发布,并在公司所有者面前将发布延迟归咎于汤姆。
汤姆希望当前项目的成功让他重新回到管理层的良好关系中。显然,并非如此。汤姆在那里愣了几分钟,然后他听到特拉维斯的电话响了。特拉维斯是一位热门的 C#开发者,他的隔间在汤姆旁边。特拉维斯非常讨厌斯蒂芬妮,以至于他在手机上为她设置了铃声。这是乐队 Electric Light Orchestra(ELO)的歌曲《Evil Woman》的副歌。当他听到这首歌标志性的吉他独奏时,他知道发生了什么。这不仅仅是汤姆被解雇,而是整个团队。一个接一个的电话响起,经过短暂而令人沮丧的单方面对话后,一种沉重的尴尬感像浓雾一样笼罩下来。
突然,团队的工作区被机构的安保团队淹没,他们要求开发者打包他们的个人物品。开发者被命令不要触摸电脑,甚至键盘。特拉维斯帮助汤姆打包,当他把箱子装进汤姆特别配置的丰田赛纳车后部时,汤姆正在帮忙。汤姆已经把货车改装成特殊设备,这让他能够自己驾驶,这是他的医生告诉他他永远无法独自做到的事情。转向机制是他自己的设计。汤姆想知道现在他失业了,他将如何继续支付货车的款项。
在接下来的几天里,汤姆在所有的招聘网站上四处寻找,希望能找到一份新工作。他的残疾使这变得很困难。他被困在轮椅上,手的使用有限。汤姆用脚打字编写代码,使用一个特殊的键盘。键盘安装在轮椅平台上,就在他的脚踝下方。汤姆还有语言障碍,这使得他很难被理解。特拉维斯和他的其他同事已经适应了他的声音,对他们来说,这并不是问题。他们理解他就像没有任何问题一样。汤姆意识到无论他去哪里,他都将不得不从头开始,而且这不会容易。当汤姆思考这个问题时,他意识到他错过了他的志愿者班次。每个月一次,汤姆会访问达拉斯医院最近受伤无法行走的患者。他帮助他们适应,并展示了非常实用的轮椅使用技巧。汤姆微笑着。也许当他寻找新工作时,他可以在慈善工作中投入更多的时间。
Bumble Bikes 工厂 - 德克萨斯州达拉斯
那是一个黑暗且暴风雨的午夜。菲比清醒着。虽然她白天睡觉了,但她的妹妹契蒂完成了菲比革命性的自行车电脑的代码。当菲比看到她妹妹成功实现策略模式的结果时,她感到既嫉妒又自豪。现在轮到契蒂崩溃了。她在 Bumble Bikes 的休息室沙发上伸展开来。菲比的手机响了。是她母亲打来的。她母亲深夜打电话并不罕见。菲比拿起电话,她的表情变得僵硬。“契蒂,醒醒!爸爸住院了!”她对她的妹妹喊道。
契蒂半梦半醒,让她的妹妹驾驶吉普车去医院。她没有意识到这辆车能达到如此速度。当他们完成前往医院的高速公路 75 号州际公路的短途旅行时,契蒂从紧张恐惧中惊醒,她的手都攥白了。
他们发现母亲卡瑞娜在医院的病房外。“医生现在和他在一起,”她解释道。“发生了什么事?”菲比问道。她们能听到母亲声音中的恐惧。她们的父亲很坚强。他很少生病,而且他从不会抱怨那些每个人时不时都会有的普通疼痛。三位女性都没有想过她们会需要把他送到医院。
“他最近几天都很累。这个周末,他骑了你的一辆新公路自行车去参加Cow Creek Classic,”卡瑞娜解释道。女孩们知道这是一个由附近小镇当地扶轮社支持的慈善自行车拉力赛。“他还抱怨头上有个疹子。我们以为是感染了蜱虫咬伤或其他什么。今天早上当他醒来时,他无法抬起头离开枕头。他无法坐起来,而且他非常痛苦。他开始哭泣,停不下来。我打电话叫了救护车,他们把我们带来了。”
几个小时过去了。各种医生和护士进入房间又离开。有些人带着设备,而有些人则带着装有血液的试管离开房间。最后,主治医师从房间里走了出来。他身材高大,苗条,说话带有浓重的东欧口音,他穿着一件黑色皮夹克,而不是人们通常期望的实验室白大褂。菲比忍不住想,他让她想起了在电影中见过的每一个邦德反派。当他与卡瑞娜交谈时,他的英语说得又快又含糊,但他的话在效果上却非常精确。“你的丈夫患有皮肌炎。这是一种罕见的自身免疫性疾病,通常由病毒引起。我们需要一位外科医生来对他的腿进行活检以确认我的诊断。”基蒂问道,“那是什么意思?”
医生继续解释说,这种疾病的症状始于病毒。“身体会产生一种针对病毒的免疫反应。然而,一旦病毒消失,免疫反应就会错误地将健康组织误认为是感染并摧毁它。在皮肌炎的情况下,疾病攻击的是毛细血管,这些血管是细小的毛发状血管。每次你呼吸时,你的肺部都会将氧气添加到你的血液中。这种血液通过动脉携带营养输送到你身体的各个部位,包括你的肌肉。你的肌肉使用氧气和营养,然后通过静脉将废物材料排出,这些废物材料通过肝脏和肾脏被带走。在那里,细胞中的废物被过滤出来,血液返回心脏,然后到肺部,这个过程重新开始。皮肌炎攻击的是微小的毛细血管,这切断了你的肌肉与整个循环系统的联系。基本上,你的肌肉开始死亡。当这种情况发生时,身体会启动一种炎症反应,这会导致极大的痛苦。这就是为什么你父亲无法停止哭泣。”医生继续说。“想想你最糟糕的肌肉疼痛。我们可以通过检测一种叫做肌酸激酶(CK)的化学物质来测量这种疼痛。在你艰苦训练后的第一天或第二天,你的 CK 水平达到 150 单位是正常的。如果你是达拉斯牛仔橄榄球队在训练营的球员,他们的 CK 水平可能会高达 300 单位。你父亲的 CK 水平是 10,000 单位。我们已经给他开了类固醇和止痛药,让他感到舒适。”
“治疗是什么?”菲比问道。医生严肃地回答:“硬皮病没有治愈方法**。我们可以尝试化疗来减少正在伤害他并破坏他肌肉的免疫反应。””凯蒂的脸色变得沉重,她几乎要哭出来了。这就是总是领先两步的思维方式的麻烦。“如果它影响到他的心脏或他用来呼吸的肌肉怎么办?”她问道。医生的表情坚定。“疾病不会影响他的心脏,因为它是用不同类型的肌肉组织制成的。但他的横膈膜* *是个问题。这需要相当长的时间才会开始发生。让我们做一些测试并尝试一些治疗方法来减缓肌肉退化。我们需要为疾病争取时间。疾病进入缓解是可能的,”医生回答说。
“你说没有治愈方法!”菲比生气地说。她并不是生医生的气。她生气的是她不习惯感到无助。“*没有,”医生说。“但有时疾病会停止并进入休眠状态。他将永远拥有它,但如果他的身体能够找到平衡,他的肌肉可能会恢复正常。我不想给你任何虚假的希望。这种疾病非常罕见,所有患者对治疗的反应都不同。你父亲的病例非常严重。昨天他还能走路。今天,他不能了。他可能永远无法恢复,或者他明年可能完全没事。没有办法预测。现在,我们必须控制那些 CK 水平,并停止对你父亲肌肉的损害。”医生把手放在卡丽娜的胳膊上,专业而安慰地走开了,消失在尽管是深夜但仍然挤满实验室外套和手术服的人群中。
下个月对整个家庭来说都很艰难。他们不断地往返于化疗、物理治疗和无数次的检查。随着疾病的进展,女孩们的父亲失去了说话的能力,有一天,他再也无法吞咽。医生在他的胃壁上植入了一根橡胶喂食管,奇怪的新生活就这样继续了。他们四个人总是坚持医生关于缓解的想法。这可能会在任何一天发生,也可能永远不会发生。他们都选择了坚持“任何一天”。
他们的母亲,卡丽娜,与医疗保险公司在几周内进行了斗争。他们拒绝了一切关于他们父亲的医疗索赔,因为硬皮病非常罕见,美国食品药品监督管理局没有为其指定治疗方法。这意味着医生尝试的所有救命治疗方法都被拒绝,因为这些治疗方法被归类为实验性的。
另一场保险之战是关于一辆轮椅。显然,他们的父亲,由于他的胳膊和腿几乎无法使用,需要一辆电动轮椅,但他们没有 1 万美元来购买他们物理治疗团队推荐的那一辆。经过几个月的医院和门诊治疗,这个家庭负债大约 100 万美元,已经耗尽了他们的所有储蓄、股票和退休账户。这使得他们无法通过贷款购买轮椅。他们的父亲是一个骄傲的人,拒绝让女孩们卖 Bumble Bikes 来支付他的疾病费用。事实上,Bumble Bikes 的利润正在维持这个家庭的生计。在他们的母亲与保险公司多次交涉后,菲比脸上露出了她在工厂电视前倒立时的那种表情。似乎即将发生什么。一件激进的事情。
菲比的实验室门锁了 3 天。连披萨盒或汽水都不进出。在菲比电脑下方门缝的诡异光芒中,几个有机发光二极管显示器投下了奇怪的影子。第三天,她像天使一样滚开一块大石头。她左臂下夹着一个大尺寸打印机的卷筒。看起来像某种蓝图。菲比召集工厂里的每个人都来参加一个大型会议。阿尔卑斯山的工厂通过 Zoom 加入。好奇的凯蒂坐在会议室桌子头部的位置。在接下来的一个小时内,菲比揭示了她的计划。Bumble Bikes 现在将开始制造轮椅!凯蒂很兴奋,但在会议结束后,她提出了一个很大的问题。他们俩已经因为经营自行车业务和照顾他们的父亲而筋疲力尽。工厂必须保持开放以支付抵押贷款。他们需要帮助。他们需要一个人,这个人懂得软件编码和架构,最好是有人有与残疾人一起工作的经验,他们是他们的目标客户。他们希望这个人能将他们的新使命变成个人使命。
一家物理康复诊所 - 德克萨斯州达拉斯
“你必须用床把自己弹到椅子上,”汤姆说。他的话含糊不清且声音洪亮,但他的新病人理解了。病人被诊断出患有汤姆以前从未听说过的某种疾病:“皮肤病什么什么。”他正在教病人如何从床上到轮椅上。新病人没有足够的肌肉力量站起来和坐下,因此他不能轻易地从床到轮椅移动。他的医生们正在考虑在他能够掌握从床到椅子的移动后让他回家;另一种选择是被送到养老院。
病人决心不去养老院,尽管他在精细运动控制方面有所欠缺,但他可以通过大幅度的动作来弥补。汤姆有了让病人将身体向上推的想法,当他下来时,床上的弹簧会将他弹得足够高,以便像蹦床一样跳进轮椅。病人用尽全身力气向上跳。他弹了起来。他没跳进去,由于没有能力抓住自己,重重地摔在了冰冷的地板上。汤姆没有帮他站起来。病人知道他必须学会自己这样做。这很难看,但却是必要的。
“现在就帮他站起来!”一个妇女从门口大声喊道。汤姆对这巨大的噪音和两个令人惊艳的美丽女人故意朝病人走来的不寻常景象感到震惊。显然,当菲比发出命令时,并没有考虑到后勤问题。毕竟,汤姆不可能轻易地抱起一个 200 磅重的男人,把他放回椅子上。“不,不要!”女孩们的父亲虚弱地说。他理解。即使需要一分钟或几个小时,他都必须自己努力站起来。“在外面等着,”他们的父亲说。所有人都明白了,包括汤姆,他们被解除了。
小组在医院房间外等待,并进行了自我介绍。女孩们来告诉她们的父亲她们的计划,即为他,以及任何需要的人,制作一辆高质量的轮椅,即使他们无法支付。Bumble Bikes 一夜之间变成了一个慈善机构。当汤姆听到这些计划时,他的脸色变了,身体也僵硬了。他不知道,但那天早上离开家时,这将是他新工作的第一天。他一直梦想着在一家热门初创公司担任软件架构师。他知道他使用从一开始就使用的模式来设计新软件项目的经验将会很有价值。他找到了他的梦想工作。
使用模式进行设计
回到达拉斯工厂,凯蒂和菲比花了几天时间向汤姆介绍 Bumble Bikes 的所有细节。他得到了工厂的亲密参观(物理意义上的,不是图案上的)并了解了其运作和功能。他还研究了菲比和凯蒂设计的先进机器人系统。他的工作将是设计一个类似的系统来制造任何人见过的最好的轮椅。
汤姆和菲比一起工作,设计轮椅。设计有三个不同的型号。第一个,旗舰型号,是菲比对终极动力轮椅的想法。它被称为德克萨斯坦克,将会舒适且功能强大。这把椅子将能够通过使用类似于坦克或推土机的轨道设计,将它的主人带到任何地方。楼梯、越野地形,甚至冰都不是问题。你可以在这里看到计算机辅助设计(CAD)设计:

图 6.1:德克萨斯坦克将是电动轮椅的终极设计
汤姆又提出了第二个想法。他计划为那些不能使用腿但其他方面非常强壮的人制作一把椅子。他计划制作一个轻便快速,可能用于竞技运动,如篮球的椅子。菲比带着这个目标,设计了一款带有弧形轮子和较短的腰部支撑的轮椅,以允许更大的运动范围。她称之为 Maverick,这是一个按自己规则行事的牛仔。您可以在下面看到这个设计:

图 6.2:Maverick
最后一个模型是菲比戏称的 平面轮椅。“你知道的,它只是一个普通的轮椅,”菲比说。到目前为止,Bumble Bikes 的所有产品都是以与德克萨斯州相关的事物命名的。德克萨斯州的普兰诺市为这个双关语提供了名字。它轻便、紧凑、实用,能把你带到你需要去的地方。“也许它只是临时使用,或者通过升级座椅,也许你会永远使用它,”菲比解释道。您可以在下面看到 平面轮椅 的设计:

图 6.3:平面轮椅,因为它只是一个普通的轮椅
菲比和汤姆向基蒂展示了他们的设计。她非常喜欢。菲比说:“当我们制作自行车工厂和软件时,我们是边做边建的。我们并不真正知道我们在做什么。我们需要什么模式就学习什么。我们希望利用我们的经验加上你的经验,比上次更快地完成这项工作。”
“而且希望错误更少,”基蒂插话道。菲比翻了个白眼。“一切不都正常吗?”菲比反驳道。
的确如此。它并不完美,但在 Bumble Bikes 工作的所有人都知道,“完美”是完成工作的敌人。你不能等待你的软件变得完美。没有一辆自行车是无法改进的。无论是软件、自行车还是其他任何东西,设计和编码都不会带来收入,这是大多数公司的首要目标。这些活动是必要的,但只有基于设计和代码的产品才能带来利润。如果你编码或设计得太久,你的公司就会破产。
“我在开始编码之前需要用 UML 把所有东西都画成图表,”汤姆说。每天和汤姆在一起的姐妹们都能更容易地理解他结巴的说话。菲比只是盯着他看了一分钟。只有凯蒂能理解她脸上的那种非言语的“那是谁做的?”表情。汤姆解释说,在打开 IDE 之前用 UML 设计将有助于项目更快地推进。你将能更快地看到你的设计错误,而且通常重构图表比重构代码要容易。你的图表也是你提前识别模式的地方,而不是在你试图重构已经投入生产的某个东西时反应性地做。
对于姐妹们来说,学会信任这个合格的局外人很重要。这是他的项目,女孩们知道不微观管理汤姆的价值。菲比为汤姆配备了最好的笔记本电脑,这是汤姆坚持作为他雇佣条件的一部分。凯蒂和菲比毫不犹豫地接受了这个条件。他们把他带到了自己的实验室,这是女孩们在达拉斯仓库的一个角落里匆忙但熟练地用钉子、绝缘材料和干墙搭建的。他们粉刷了房间,并从菲比自己实验室里储存的一些额外家具和设备中为汤姆买了一个盆景树,这是从附近街角的一个临时摊位上买的。办公室的家具是使用菲比在她自己的实验室里储存的一些额外家具和设备布置的。“在 IT 工作的第一条规则……,”她说,“永远不要把任何东西还回去!”这很有趣,因为她公司的一半股份,负责 IT。有趣,但却是真的。
第一次尝试
在任何面向对象的设计练习中,低垂的果实是决定对象的基本结构。汤姆知道这很可能是他会使用创建型模式的地方,尽管他并没有太在意他会使用哪一种。经验告诉他,当开发者开始使用模式时,分析瘫痪是一个真正的问题。你可以整天盯着一个空白的白板,试图决定是使用抽象工厂还是工厂方法。也许两者可以某种方式结合起来?也许我们还可以在那里放一个命令或单例呢?
汤姆开始画类,根本不考虑模式。模式通常是从混乱中产生的,所以不要害怕先创造混乱。既然我们只是在画图表,尖头发的老板不可能告诉你“周一清理并发货。”汤姆的新老板们永远不会给他这样的命令。菲比和凯蒂都没有尖头发,他们也不太可能把一个 Etch A Sketch 误认为是高质量的平板电脑;这是女孩们在两年前与 MegaBike Corp 的 IT 高管一起工作时分享的一个内部笑话的主题。
汤姆认为轮椅和自行车类似,但并不那么相似,以至于在基类中会有任何重用。话虽如此,项目可以以类似的方式组织。汤姆开始创建一组接口和类,这些接口和类将代表 Bumble Bikes 的新系列电动和手动轮椅。
我希望你能停下来,看看你是否能创建一个 UML 图来描述汤姆和菲比所描述的电动和手动椅子。当你完成你的图表后,看看你的工作与汤姆的有多接近。
汤姆首先绘制了Wheelchair类,如下所示:

图 6.4:汤姆的初始 Wheelchair 类
起初,Wheelchair类上什么都有。但随着他进一步考虑,他决定将椅子分为两个家族:电动和非电动。请看以下图表:

图 6.5:汤姆将设计分为两种主要的轮椅类型
他考虑了这些家族将有哪些共同属性,例如ModelName、Year和SerialNumber。他将这些定义提升到接口和抽象基类中。他可能只需要其中一个,但由于他的整个设计都将围绕这个图表来构建,他决定采用包容性的方法。
非电动椅子有常规的轮子和万向节。另一方面,德克萨斯坦克是一个全新的设计,它使用轨道而不是轮子。电动椅子还需要电源、电机和转向系统。这些可以单独绘制,但汤姆意识到,由于电动椅子的轨道设计非常奇特,他可能需要围绕这一事实进行设计。他创建了一个抽象的PoweredChair类,封装了市场上 99.9%的电动椅子。他有一天可能被要求在他的代码中表示这样的椅子,这是可以想象的。
整个图表最终看起来是这样的:

图 6.6:汤姆的初始设计
让我们按数字来分析,如下所示:
-
汤姆认为在这个结构的最顶部定义一个接口是有意义的,因为将来我们需要能够传递类似轮椅的对象。这是对未来进行防备的一种尝试。这种设计策略通常有效,但事实上,并没有真正的“防未来”。
-
当子类之间有一些共享方法实现时,需要一个接口的抽象实现。在
Bicycle类中,我们有一个类似的情况,即Build()方法,如下所示:

图 6.7:第三章中 Bicycle 工厂方法模式的实现。
-
与
Bicycle类一样,这个类有两个计算属性:Year和SerialNumber。这些属性就像方法一样工作。在这个阶段,我们可以以 DRY(不要重复自己)的原则来合理化使用额外的抽象类。关于不要重复自己(DRY)的解释,请参阅第二章,为 C#中模式在实际现实世界应用做准备。 -
UnpoweredChair类继承自Wheelchair。汤姆知道轮椅上的左右轮子可能看起来相同。在这第一张图(图 6.4)中,他将所有轮子都放在一个数组中。轮椅上的轮子并不相同,汤姆明白他不能如此具体地思考。他不是将轮子建模为椅子的一部分。他在建模轮子的插槽,这些插槽在某些层面上应该是可互换的。大多数轮椅有两个大轮子作为主要支撑,以及一套较小的轮子或万向轮,以平衡整体结构。 -
PoweredChair类也继承自Wheelchair。它被建模来代表市场上最常见的电动轮椅。由于汤姆在他 36 年的生命中一直骑着电动轮椅,他对设计非常了解。他的类在这里继承了基类的一些常见属性——一些是计算属性。一般而言,电动轮椅的独特元素也被表示出来。每个电动轮椅,包括德克萨斯坦克,都需要电源、转向和电机。这又是一次对未来进行保障的尝试。很容易预测,有一天,Bumble Bikes 将为电动轮椅设计出更常见的款式。虽然德克萨斯坦克很棒,但菲比设计它是因为她知道如果缓解永远不会成为现实,她的父亲会喜欢它。德克萨斯坦克不是为每个人设计的。它很重,价格昂贵,而且会发出重型机械的味道。注意,汤姆已经考虑了设计中需要方法,但在这次迭代中,他没有将任何内容提交到图中。他继续尽可能快地工作,以满足明显的需求,以保持动力。 -
两个类继承自
UnpoweredChair类。我认为这些不言自明。这两个类之间的唯一区别是SportChair上的倾斜轮子。在实际的椅子上,轮子向外倾斜以提高速度和敏捷性。如果你没有注意到这个细节,请参阅图 6.1,你可以在轮子上看到倾斜。
汤姆对他的早晨工作感到满意。基蒂和菲比从他们最喜欢的墨西哥餐厅订了午餐,三人放松。午餐后,汤姆感到精力充沛,开始着手下一轮的修订。
一旦你绘制了基本类图,下一步通常是要考虑组合。我们的对象中有属性和字段。这些属性和字段将包含哪些信息?当我们还是初学者程序员,事情比较简单时,它们是原语组合。我们不需要组合,但现在我们变得世故和明智,汤姆也不例外。他需要通过组合来梳理构成基本类的结构。例如,在Wheelchair基本类中,我们应该如何处理Seat属性?不同的椅子设计有不同的座位;因此,创建一个接口以实现最大灵活性是有意义的,然后在图表中记录这一需求。汤姆通过绘制和规划采取了不同的态度和方法。这与菲比和凯蒂在构建 Bumble Bikes 时所采取的态度不同。他们跳过了任何形式的图表或规划工作,直接编写代码。只需看看汤姆在猜测或做出判断的地方。Inflate()和Deflate()方法是一个很好的例子。他不确定是否需要它们。将现实世界对象抽象为类代码的练习主要是关于建模对应用程序重要的事物。你手机上的地址簿应用中的Person类与医疗记录应用中的Person类需要不同级别的细节。当你绘制图表时,你不需要担心 DRY(Don't Repeat Yourself)、YAGNI(You Aren't Gonna Need It),或违反 SOLID 原则(如果你不知道这些缩写代表什么,请参阅第二章)。在设计类结构这个阶段,它仅仅是一个图表。绘制图表的全部目的是将设计以可以讨论、争论、思考、社会化并修改的格式呈现出来。当然,你可能在思考 SOLID 或 YAGNI,但在构思阶段不完美是可以的。图表会自然演变,你将犯错误和疏忽,你可以纠正和改进。一旦图表变成代码,所有那些烦人的规则将开始适用。代码是真实的。有规则,而且不要误会,如果你在一个团队中工作,你将受到这些规则的评判。
汤姆绘制了他预见的所有组件,包括以下这些:
-
座位
-
车架
-
轮子和万向轮
-
动力轮椅的电机
-
动力轮椅的转向机构
-
动力轮椅的电池
-
Texas Tank的轨道驱动系统
让我们看看汤姆如何为每一组类绘制图表。每一组将包括一个抽象类或接口,以及从该抽象类继承的适当具体类。别忘了——在 UML 中,抽象类的标题用斜体书写。
座位
座椅可能是轮椅最重要的部分。汤姆深知这一点——每一款椅子都需要不同类型的座椅。Bumble Bikes 可以在那些不是全天候使用的轮椅上使用价格较低的通用座椅。Plano 轮椅是运输轮椅的完美例子。像Plano这样的基本轮椅用于从一处地点到另一处地点的短暂运输。例如,椅子的使用者会从轮椅转移到办公椅或床上。当需要从一个地方移动到另一个地方时,使用者会回到运输轮椅上,然后前往新地点。基本轮椅没有马达。这与其他轮椅不同。
Maverick轮椅也有一个特殊用途的椅子。这个椅子设计得既轻便又灵活。与Plano 轮椅类似,它不是设计成全天候使用的座椅。Maverick轮椅没有马达。
然而,动力轮椅针对的是不同类型的用户。最大的用户群体是老年公民。这个群体并不是残疾人。他们可以站立并走短距离,但需要椅子进行长途行走。轮椅用户的第二组类似于汤姆。他们是四肢瘫痪者,手臂和腿部的活动能力几乎为零。这些轮椅用户通常缺乏自己转移的能力。因此,他们不需要便宜的座椅,因为他们需要在椅子上坐很长时间。他们需要一个舒适的椅子。汤姆和菲比决定,这个群体将是德克萨斯坦克的设计灵感,这是终极动力轮椅。
汤姆为座椅类绘制了一个设计图。他的想法在这里得到了体现:

图 6.8:汤姆为 WheelchairSeat 类的设计
这种设计很简单。它由一个名为WheelchairSeat的抽象类组成。属性很简单。汤姆将具有无动力座椅的实体类放在图的左侧,而将动力座椅放在右侧。当我们把我们的设计组合成更大的图时,你可以期待使用这种惯例。
车架
我们的三款轮椅需要三种非常不同的车架设计。Plano 轮椅简单、相对轻便,是一种非常常见的款式。Maverick轮椅需要非常轻便,但足够坚固以承受碰撞和事故,这在激烈的竞争中是不可避免的。德克萨斯坦克需要像坦克一样坚固。它有重型轨道、大电池和超大的座椅。
汤姆对此进行了思考,并决定现在先保持简单。他本可以深入细节,弄清楚如何表示组成椅子框架的所有挤压铝管、螺栓、接头和铰链。然而,汤姆并不是那种工程师,所以他决定包含一个字段来保存框架的 CAD 文件的文件路径。基蒂和菲比都同意汤姆的看法。您可以在图 6.9中查看汤姆的设计。该图显示了一个简单的抽象类,代表轮椅框架。和之前一样,未供电组件位于左侧,而供电类则绘制在右侧:

图 6.9:代表轮椅框架的类的设计
车轮和万向轮
没有车轮,你真的能有一把轮椅吗?普兰诺轮椅和Maverick轮椅将使用传统的辐条轮,类似于自行车上使用的轮子。轮椅的标准设计涉及两个大固定轮和一组配置为万向轮的小轮。万向轮可以朝任何方向转动。
在图 6.10中,汤姆将他的抽象类命名为MechanicalWheel。这个类可以表示车轮和万向轮,但不能表示德克萨斯坦克上的坦克式履带。以下图显示了MechanicalWheel类的结构以及两个双轮子类。WheelchairWheel类代表椅子侧面的较大车轮,而Caster类代表轮椅上可以旋转的小轮,提供机动性:

图 6.10:MechanicalWheel 抽象类及其子类
汤姆已经为非电动轮椅建模了所有需要的部分。它们是简单的机械。真正的设计挑战,当然也是最有回报的,在于为电动轮椅创建类设计。汤姆小心翼翼地没有具体模拟德克萨斯坦克。坦克是一个非常雄心勃勃的项目——很容易因为建造成本高而被取消。此外,坦克的非传统设计可能不会吸引广泛的受众。只有时间和一些市场研究才能告诉我们。那么,汤姆的策略应该是创建不特定于坦克设计的组件类,而是更常见的电动轮椅。汤姆决定从电机开始。
电动椅子的电机
汤姆的电动轮椅仍在使用其原始电机。他十年前就买了这把椅子。这些电机很常见,汤姆对它们非常熟悉。电动轮椅的电机由电池提供的直流电供电。电机需要具有高扭矩和相对较低的速度,并且需要长时间运行。模拟这样的电机并不成问题。汤姆的设计在图 6.11中展示。

图 6.11:电动轮椅的电机类图
由于电动机是常见和通用的,汤姆决定将其制作为一个抽象类。他添加了一些属性和一个方法来模拟电机的开和关。然后,他添加了一个子类来处理电动轮椅使用的电动机的具体细节。
电动轮椅的转向机构
汤姆的轮椅使用一个安装在轮椅左臂上的操纵杆来控制它。操纵杆对任何给定方向上的推力大小都很敏感。您推得越用力,轮椅移动得越快。汤姆看不出有任何特别的原因要重新设计这个几乎无处不在的系统。他的类设计如下所示:

图 6.12:电动轮椅设计的转向机构
汤姆为转向机构选择的属性代表了一类广泛的工业式操纵杆控制系统,这些系统用于从无人机飞行控制到自动化,再到——您猜对了:电动轮椅。
电动轮椅的电池
轮椅的电机是电动的,我们需要一个电池来为系统供电。任何具有正确电压和电流的工业级电池都应适用。我们希望电池的寿命尽可能长。我们需要一个足够高的WheelchairBattery子类:

图 6.13:表示电动轮椅电池系统的类图
德克萨斯油箱的轨道驱动系统
在这个问题上,汤姆感到有些不知所措,因为他完全没有轨道驱动系统的经验。凯蒂和菲比在年轻时曾在家族的牛场工作过,对轨道驱动系统的工作原理有着不言而喻的理解。三人组队,找到了几家可能满足他们需求的供应商。由于汤姆时间紧迫,他只是简单地查看供应商网站上描述轨道驱动系统的属性。从这些来源中,汤姆得出了您将在以下图中看到的属性集:

图 6.14:轨道驱动系统
汤姆确信,在他所建模的所有组件中,轨道驱动系统将是会改变和发展的一个。这是菲比会不断构建原型并在此工作的一个。他将其子类命名为HighDriveTrackSystem,因为这正是设计的名称。在下面的图中,您将看到我们的德克萨斯油箱设计,它配备了一个高驱动系统。轨道是三角形的,有一个大轮子位于轨道设计的高处。将此与图 6.15 中推土机上的标准连续轨道设计进行比较,您将看到差异:

图 6.15:推土机上的连续轨道驱动系统与我们的动力轮椅上的高驱动设计对比
菲比和凯蒂使用高驱动系统设计了椅子,因为它提供了更吸震的悬挂。高驱动系统也较少磨损和故障,因为它们将驱动齿轮隔离开来,使其位于轨道驱动的一个柔性部分。“她会像梦一样驾驶!”菲比带着一个宽大的、露齿的笑容说道。工程原型很快就建成了。最难的部分是让汤姆离开“坦克”,正如我们在这里看到的:

图 6.16:汤姆垄断了德克萨斯坦克原型
最终,“坦克”的电池耗尽了,这时汤姆又回到了工作状态。
添加模式
汤姆对自己的第一次尝试感到相当满意。他有了足够的素材来引发关于如何构建项目的严肃讨论。这就是第一次尝试的目标。汤姆快速列出了凯蒂和菲比在自行车项目中使用的所有模式,如下所示:
-
创建型模式:
-
Factory pattern
-
Abstract Factory pattern
-
Factory method
-
Builder
-
Object pool
-
Singleton
-
-
结构型模式:
-
Decorator
-
Façade
-
Composite
-
Bridge
-
-
行为型模式:
-
Command
-
Iterator
-
Observer
-
Strategy
-
汤姆开始专注于图表,并开始思考每个模式的应用。他一个接一个地寻找机会。其中一些很明显。有些可能并不需要。汤姆的图表目前还没有包括对自行车工厂机器人技术的任何更改。大部分情况下,机器人课程不应该与制造自行车紧密耦合。经过几小时的个人辩论和深思熟虑,汤姆准备与凯蒂和菲比讨论模式。他能够在他们的日历上预订那个晚上的时间。
看看你是否能自己想出创建模式可能适用的情况和地方。记住——这是一个图表,这是头脑风暴。这不是一个有确定正确答案的考试。有时候犯错或不确定是正常的,也是可以接受的。在下一节中,汤姆将展示他打算在项目中使用的所有模式的图表。如果你想尝试找出哪些是最合适的,请在这里暂停并为自己列一个清单。小心“黄金锤”!没有必要,甚至不希望在一个项目中尝试使用你所有的模式。
第一次设计会议
汤姆配置了他的电脑显示屏,与会议室里的大 72 英寸 OLED 8K 屏幕共享。灯光调暗了。菲比一如既往地做了爆米花,并递了一圈汽水。当大家都坐得舒服后,汤姆开始他的演讲,讨论基础对象结构。一旦女孩们对基础知识有了了解,汤姆就将讨论转向创建模式。当姐妹们听汤姆讲话时,很明显她们对他非常尊敬。她们欣赏他的能力以及他适应命运所给予他的生活的态度。姐妹们知道大多数人都不太舒服与坐在轮椅上的人互动,尤其是如果他们还有语言障碍。她们知道汤姆希望人们看到真实的他,姐妹们对待汤姆就像对待其他同事一样。当汤姆开始他的演讲时,姐妹们从共同的幻想中惊醒。
“我想使用的第一个模式很简单。当你想要创建相关对象族时,使用抽象工厂模式。你用它来制造不同类型自行车的部件。你有公路自行车和山地自行车的车架类,它们在现实世界中根本不可互换,但它们的抽象结构足够相似,可以遵循公共接口。我们可以在两个地方做同样的事情。首先,我们可以有一个抽象工厂为轮椅生成部件,就像你为自行车做的那样。但我怀疑我们是否可以更进一步,在工厂自动化系统的最高级别使用抽象工厂模式来决定我们是在生成自行车还是轮椅。工厂模式的客户端不关心从工厂得到的对象的结构。因此,制造系统不应该关心它是制造自行车还是轮椅。”
“那是对的,汤姆,”凯蒂说,“但我发现建造者模式更灵活。虽然抽象工厂立即返回生产出的对象,但建造者模式允许你在构建过程中附加额外的步骤。”
“嘿!”汤姆插话道,“如果我们使用建造者,我们可以在构建过程中结合一些其他创建模式。你使用了组合模式来生成成本模型。你还使用了桥接模式来处理自行车的喷漆工作。我认为这对轮椅来说会是个大问题。我在医院和很多孩子一起工作,我知道他们宁愿在轮椅上涂上五彩斑斓的喷漆,也不愿像其他公司那样使用标准的黑色和铬色。建造者也可以生成桥接对象。”
“你难道不能为你的建造者类创建一个单例吗?”凯蒂问道。
菲比坐在桌子的另一端,吃着爆米花。她靠在椅背上,翻了个白眼。凯蒂和汤姆完全进入了极客模式。
“这需要我对设计进行另一次迭代,”汤姆说。他想起了那句古老的谚语,“没有战斗计划能在与敌人首次接触后幸存。”他几乎不把凯蒂和菲比视为他的敌人,但他并不期望他的初步设计图纸会被视为最终或完整的。这是第一次迭代。汤姆说,“我需要重新组织所有组件,以便它们能与组合模式相匹配。我认为这可能会改变很多事情。”时间已经很晚了,所以团队决定休息。
第二次迭代
第二天早上,汤姆非常兴奋。他早早地来到实验室,直接开始完善图表。总的来说,他觉得自己可以使用凯蒂和菲比在自行车项目中使用的五种模式,如下所示:
-
创建模式:
-
构建者:汤姆可以利用这个模式来处理轮椅类所需的复杂对象创建,包括处理组合和桥接实现
-
单例:汤姆认为构建者类可能被做成单例以节省资源。
-
-
结构模式:
-
组合:汤姆将创建一个类似于为自行车创建的成本模型,但这次它将直接集成到轮椅类的结构中,而不是单独创建。
-
桥接:与自行车一样,汤姆使用桥接来独立地改变油漆工作和轮椅类的复杂性。
-
-
行为模式:
- 命令:将使用命令模式将创建轮椅所需的所有细节捆绑成一个单一的对象,然后可以将其发送到接收器类
汤姆安排了与凯蒂和菲比的第二次设计会议,这次会议没有引起太多关注。三人围坐在会议室的显示屏周围,汤姆展示了他的图表。
构建者模式
汤姆没有浪费时间深入细节。“我们讨论了使用构建者模式的可能性。我认为它会非常有效,”汤姆说。
他巧妙地用大脚趾在屏幕上移动指针,添加了实现构建者模式所需的类。你可以在这里看到结果:

图 6.17:汤姆已经添加了构建者模式所需的结构
我们在第三章中详细介绍了模式的各个部分,用创建模式进行创新,但让我们快速回顾一下:
-
构建者由一个接口或抽象类定义。在这里,那将是
IWheelchairBuilder。 -
构建者直接由包含构建者接口私有实例的
WheelchairBuilderDirector类控制。所有工作都由导演完成。 -
为每个产品定义了构建者的具体实例。在这种情况下,每个轮椅型号都有自己的构建者,考虑到型号之间的固有差异,这为我们提供了很大的灵活性。
“这是一个好的开始,”基蒂说。“我们还讨论了将构建器做成单例的问题。”
“没错!”汤姆说。
单例模式
汤姆回答说,“这个没有太多可说的。”汤姆在图中的WheelchairBuilderDirector类中做了两个修改。你可以在这里看到这些修改:

图 6.18:用于轮椅的构建模式的结构已被转换为单例。你可以从图上只有一个数字来判断
“为了使这个构建器成为单例,我只是在内部添加了引用WheelchairBuilderSingleton(1)。如果对象已经被实例化,这个引用将持有WheelchairBuilderDirector的一个实例。然后,我添加了GetInstance()方法。这个方法将检查WheelchairBuilderSingleton中是否已经有了一个实例。如果它是空的,该方法将创建一个实例,将其存储在那个私有变量中,并返回该实例。如果那里已经有一个实例,它将返回那个实例。我不确定我们通过将其做成单例能得到什么。通常,单例与对象池结合使用效果最好,用于处理昂贵的实例化,比如数据库连接。我不确定我们这里是否有,但我们可以决定在编写时是否需要。移除它并不是什么大问题。”
女孩们点头表示理解。她们都焦急地等待看看汤姆接下来会如何使用组合模式,这是议程上的下一个项目。
组合模式
“我真的很喜欢你在自行车模型中使用组合模式的方式。你能够利用这个模式来计算组件成本和重量以便于运输。但你为了这个目的专门建模了一个特殊结构,”汤姆说。
基蒂回应说,“在建造它的时候,一个单独的结构满足了我们的需求。我们已经有了一些生产代码中的类,我们不想违反 SOLID 原则中的开闭原则。”我们在第二章中讨论了 SOLID 原则。
汤姆想要直接在轮椅的结构中构建组合模式。这有点挑战性,因为他并不一定想让组合的结构要求定义轮椅的结构。相反,他真正需要的是一个正常的对象结构,它充当一个组合。他说:“我们可以通过将组合结构直接嵌入到所有我们的对象中来改进你的设计。我们需要将轮椅组装视为一个层次结构。我认为它看起来像这样,”汤姆说着,推着轮椅走到附近的白板前。女孩们在特殊支架上设置了一个白板,这样汤姆就能够得着。他画出了一个层次图,展示了无动力轮椅的部件可能如何建模。一旦画完,凯蒂拿起一支记号笔。“这很好,但动力轮椅的外观会有点不同,”她说着,同时画出了她自己的动力轮椅层次模型的想法。白板看起来是这样的:

图 6.19:汤姆和凯蒂各自能够绘制一个有动力和无动力的轮椅作为对象层次结构,以使其与组合模式兼容
将小组的注意力转回到电脑显示器上,汤姆继续说:“考虑到这个结构,我们需要彻底审查指定框架构建的类。而不是每个部件作为一个整体构建和组装,机器人需要按顺序组装一系列部件。这样结构化允许会计精确追踪每辆轮椅的重量和成本,直到最小组件。有了追踪重量和成本的能力,销售部门将能够以有竞争力的价格定价椅子,并与保险公司和慈善机构合作,确保这些椅子能够进入需要它们的人的生活,而不会使公司破产。”凯蒂和菲比继续盯着屏幕,几乎不眨眼。
他继续说:“ExFed的外包物流团队可以使用重量计算来保证准确的运费报价。在自行车项目中,你构建了一个单独的对象来容纳你的组合结构。”
汤姆在放大到图纸上适当部分时说:“对于这个项目,我会将组合隐藏在正常结构中。”他拖动一个新的类到页面上,并将其命名为WheelchairComponent。然后他将其斜体化,并解释了他为什么添加了实现他的组合模式想法所需的成员。你可以在这里看到这个想法:

图 6.20:汤姆不得不添加和重构许多类,以便在不一定看起来像组合的情况下使其符合组合模式
“我在这里添加了一个WheelchairComponent抽象类。当我需要添加带有实现的方法时,我通常这样做,而不是添加一个接口。在这种情况下,我们将有DisplayWeight()和DisplayCost()方法,这些方法将递归地处理子组件,以得出重量和成本的总额。然后,我将所有轮椅组件建模为抽象组件,以防我们以后需要替换这些组件的具体版本。”汤姆创建了一个单独的图表来展示这个想法。经验告诉汤姆,他现在正在将这个最佳实践教授给凯蒂和菲比,最好的图表是小的、专注的、简洁的。汤姆没有在图表中展开类,而是在每个抽象类下附加子类,而是制作了一个仅显示座椅组件继承链的图表。你可以在这里看到这个图表:

图 6.21:具体的座位对象继承自抽象的 WheelChairSeat 类,该类继承自 WheelchairComponent
汤姆看向菲比。显然,她的脑海中正在转动齿轮。她终于开口说话,说:“我们在这里做的是让所有东西都继承自WheelchairComponent。WheelchairSeat抽象类存在是为了确保具体的座位对象可以使用 Liskov 替换在组合结构中表示。任何座位都将具有父类的结构。”
“没错,”汤姆说。凯蒂继续她姐姐的想法。“我们可以在任何框架上放置任何座位,同时仍然能够拥有重量和价格。这意味着只要我们按照正确的顺序组装层次结构,”——她边说边指着白板(图 6.18)——“我们就能享受到组合模式的优点,同时我们也将拥有一种灵活的方式来组合我们的轮椅。”汤姆点头表示同意。“这样想吧:它就像基本的组合,这可能就是为什么 GoF 将其称为组合模式,但它是以不同的方式进行的组合。它同样灵活,但它也是可迭代的,因为组合使用列表来持有部分。”
菲比皱了皱鼻子。“我们应该担心这种结构可能会使我们在轮椅上添加 200 个座位的事实吗?”
“不,”汤姆说。“你可以用封装逻辑来处理这个问题。每个类都可以对其包含的组件列表施加规则。Liskov 替换使结构灵活,这给了你构建任何东西的能力。但你需要通过添加控制组成内容的封装方法来平衡这一点。”这次是凯蒂的脸皱了起来,好像闻到了什么难闻的东西。“这对必须使用这个方法的开发者来说似乎很复杂。他们必须记得按照正确的顺序组装对象。每次他们想要构建轮椅时,都必须查看图表(图 6.18)。”
“不,”汤姆说。“还记得我们用来指导对象组装的构建者模式吗?”两个女孩的脸放松了。她们立刻明白了。“这些模式可以一起使用!构建者的工作,它的存在理由,是使用一组定义良好的步骤来处理复杂对象的构建。”“我们需要修改我们的构建者类图来适应这个变化,”凯蒂说。菲比看起来很惊讶。“我们不是违反了开闭原则吗?我们已经设计了那个类!”凯蒂翻了个白眼,汤姆则露出了笑容。“不,菲比。那只是一个图表。它不是生产代码!”就在那一刻,菲比完全理解了为什么汤姆在将代码提交之前选择先绘制工作图表。在大师的键盘上,编码变成了一种纪律。我们需要一种方法来放松规则,这样我们至少有机会第一次就设计出正确的设计。
“父类WheelchairComponent中包含Subcomponents列表,但我正在考虑将其隐藏起来。目前它是公开的,但我的想法是在构建时让构建器将适当的子组件添加到数组中。这意味着我可以在不强迫整个结构符合通用接口的情况下拥有一个可遍历的组件树,这可能是无法实现的,”汤姆说。
“嘿,这真不错!”凯蒂热情地说。“我们能快速处理对构建者模式的更改吗?”凯蒂问道。三人拿出他们在图 6.16中创建的图表并开始修改它。几分钟过后,它看起来是这样的:

图 6.22:添加了构建复合方法后的构建者模式
“就这样了吗?”菲比问道。“我们之前讨论的所有那些花哨的封装逻辑呢?在图表上在哪里?”菲比追问。“逻辑将是WheelchairBuilderDirector类中的Make方法。记住,负责组装和返回对象的逻辑是导演类。”
凯蒂和菲比对汤姆的工作表现出热情。他转向下一个模式。
桥接模式
“在我们上次会议中,我们讨论了从自行车项目中重用桥接模式的工作。我认为可涂漆的框架将会很受欢迎,因为大多数公司只是制作黑色或灰色的轮椅。这对医院的借用轮椅来说是可以的,但如果你像我一样是终身用户,过一段时间你可能会想要升级。”汤姆微笑着说。他的轮椅框架上有一点红色油漆,但远未达到升级的程度。
菲比说:“在我们的自行车中,我们的一位 Kickstarter 支持者设计了我们的第一个定制喷漆工作。也许你应该为轮椅想一个设计。”
汤姆脸红了。“我很荣幸,但说实话,我更愿意带一些医院的孩子来,看看他们是否喜欢一些设计。”凯蒂热情地微笑着。菲比对汤姆的尊重加深了。
改变话题后,汤姆再次专注于他的屏幕。“我没有从自行车项目的实现中改变任何东西,除了添加具体的画家类。”凯蒂和菲比点头,很高兴汤姆将能够重用他们的一些早期工作。“当然,我还向抽象的Wheelchair类添加了一个FramePainter属性,这样所有的子类都会有它,”汤姆总结道。你可以在这里看到应用于轮椅项目的桥接模式的 UML 图:

图 6.23:桥接模式除了具体的类外,一般没有变化
“建造类会直接将油漆添加到轮椅对象上吗?”凯蒂问。汤姆说:“很高兴你提醒我。我差点忘了在建造图中添加这一点。”汤姆对我们上次看到的图 6.22 中的建造图进行了快速修改。新版本可以在图 6.24 中看到:

图 6.24:添加了 BuildFramePainter()方法后的 Builder 模式图
接下来是命令模式。
命令模式
“还有最后一个重构我想做。你使用了命令模式来指导制造机器人建造你的自行车。当然,我想使用相同的制造系统。我认为差异可以很容易地处理," 汤姆说。
“好的," 菲比好奇地挑起一边眉毛说。“你在对我的宝贝们做什么?"*”
汤姆将一些类拖到图中,并排列它们以形成命令模式。一旦他填完了所有内容,他的图看起来就像这样:

图 6.25:汤姆对命令模式的改动相当小
汤姆说:“我们需要对 AssemblyLineReceiver 中的 DoBusinessLogic 方法进行更改。您将其设置为只接受一个接口:IPaintableBike 接口。我无法将我的轮椅强行改装成可涂漆的自行车。我们可以将我项目的中央接口从 IMobilityDevice 更改为 IManufacturable。”他在说话的同时进行了更改。汤姆继续说:“这样,接口具有自行车和轮椅等元素的共同点,也许还有你想要制作的任何其他东西。”当他添加一个新的成员变量到 IManufacturable 接口:ManufacturingStatus 时,他说完了。这本来是在 PaintableBicycle 类中,但到目前为止,汤姆还没有想到把它放在图中。您可以在这里查看修订内容:

图 6.26:最终更改将中央接口重命名为 IManufacturable。
在这次演示中,菲比第一次站起来。她茫然地 staring for a moment. “我不知道那件事,”她说。在会议的剩余时间里,她没有再说一句话。
基蒂接过了接力棒。“汤姆,这看起来很棒!我看到菲比对你的上次重构有些犹豫,但我们可以克服这一点。让我们立即开始为你的项目设置基础类。”基蒂和汤姆在下午剩余的时间里在 GitHub 上设置了一个仓库,并为新项目配置了一个 持续集成(CI)构建。在推送了一个微不足道的类并看到它构建后,汤姆为该类添加了一个简单的单元测试。它也通过了。大约在这个时候,三个人意识到已经很晚了,或者很早了,这取决于你的观点。尽管他们并不真的想,但他们不情愿地结束了晚上(或早上)的活动,并决定第二天中午开始工作。
摘要
本章介绍了一个设计场景,我们看到了我们新兴自行车公司在勇敢地决定拓展业务并制造轮椅以帮助有需要的人时,业务需求发生了剧烈变化。正如这些事情通常发生的那样,这个变化没有计划,而且发生在不合适的时候。在时间和灵感都紧张的情况下,基蒂和菲比雇佣了汤姆,一位轮椅软件工程师,他在前雇主公司高管崩溃后发现自己被无情地解雇了。
汤姆利用他在软件开发方面的专业知识、个人经验以及作为志愿者帮助最近受伤的患者学习如何在轮椅上生活的经验。他能够为问题带来新的视角,并开始设计一个既适合制造环境,又符合 Bumble Bikes 已经存在的非常实际的成本和资源限制的解决方案。
经过大约一周的分析和设计,使用 UML,汤姆能够规划他的开发工作,为新的轮椅项目的第一个版本做准备。
汤姆在自行车项目上做了一些与女孩们不同的事情。主要的是,他首先使用模式和 UML 进行设计,而不是在寻找模式的过程中尝试。希望这能减少我们需要对代码进行重大更改的情况,这些更改可能会违反我们神圣的 SOLID 原则。汤姆坚信这种做法将节省大量时间和挫折。
汤姆按照以下步骤开发他的设计图表:
-
在第一次迭代中,他提前设计了所有基本类。他只是画出了类和继承线。
-
一旦有了类,他就填写了继承和组合线。
-
他考虑了模式,但没有将它们绘制成图表。相反,他与他的开发团队举行了一次会议,该团队拥有与他在工作中使用的相同模式的经验和洞察力。实际上,他正在使用迭代开发,但用图表代替代码。在尝试完成整个设计项目之前,他从利益相关者那里获得了反馈。
-
汤姆、菲比和凯蒂作为一个团队进行了第二次迭代,并共同添加了这些模式。
汤姆对性能的最后一个考虑是他没有感到有必要使用他可用的每一个模式。他尽力在可能的地方重用工作。凯蒂和菲比对这一点印象深刻,这比汤姆建议完全重写要强得多,许多开发者在加入新公司时似乎都想要这样做。
在下一章中,我们将跟随汤姆、凯蒂和菲比,看看他们如何编写实现其图表的代码。
问题
-
与仅将所有内容建模为代码相比,绘制项目图表有哪些优点?
-
我们在什么情况下一定会使用抽象类而不是接口?
-
汤姆是如何在设计的第二次迭代中利用建造者模式与其他模式协同工作的?
-
你能想到对汤姆的设计有任何改进的地方吗?
进一步阅读
查看本书的配套网站csharppatterns.dev。
第七章:除了打字,别无他物 – 实施轮椅项目
在上一章中,我们学习了创建一套图表作为我们下一个编码项目的规划设计的优点。整个目的就是将设计以可以讨论、争论、思考、社交和修改的格式呈现出来。设计完成后,最后一步是将图表实现为代码。我们的三位软件工程师刚刚完成了一个雄心勃勃的新项目,旨在改变可能数千人生活的质量,这些人将从使用高质量、低成本的轮椅中受益。
我经常把 UML 与乐谱相比较。一个好的 UML 设计可以像音乐作曲家将乐谱交给一个有能力的乐团一样,交给开发者。在音乐中,乐团经常会根据表演者的技能对乐谱进行修改和即兴创作。有时他们这样做是为了使音乐更符合表演者的技能。有时,他们可能需要改变或调整音乐以适应表演本身。古典作曲家约翰·塞巴斯蒂安·巴赫作品丰富。他最受欢迎的作品中包括 布兰登堡协奏曲。巴赫在 1721 年汇编了这个系列。在 1968 年,另一位作曲家温迪·卡罗斯将 布兰登堡协奏曲 改编成完全由模拟合成器演奏的形式,收录在她的作品 Switched on Bach 中。音乐本身并没有太多变化,但实现细节却有所不同。将编程看作是像演奏音乐一样,并不夸张。它需要创造力和即兴发挥。一个创建了良好图表的建筑师可以合理地确信一个有能力的开发团队能够实现这个图表,并在必要时进行一些即兴创作。
在本章中,我们将把汤姆的 UML 设计图表交给开发者进行实现。设计类和实现它们之间有很大的区别。首先,图表故意留下了一些模糊的区域。这样做是为了让开发者对图表不重要的细节做出决定。例如,这本书中有许多图表省略了类细节。看看上一章的 图 7.1:

图 7.1:上一章的综合图表留下了许多细节由开发者决定。
这并不是因为架构师懒惰。这样做是因为那些细节并不真正影响设计的结构。想象一下按照蓝图建造一栋房子。蓝图可能会指定墙壁的位置。甚至可能指定要使用的材料,就像我们有时会指定实例变量或返回类型的特定类型一样。然而,一些较小的细节,比如墙壁上要使用什么颜色的油漆,或者屋顶上要使用什么品牌的瓦片,这些都是实现细节。建造者可以独立于蓝图做出这些决定。
在关注第六章Chapter 6的设计,Step Away from the IDE! Designing with Patterns Before You Code时,我们将涵盖以下内容:
-
创建一个新的命令行项目。到目前为止,我一直避免涉及 IDE 机制。这里也不会深入探讨。以这种方式开始章节感觉是正确的。如果你是 C#开发的初学者,并且没有注意到书末的附录 1,你应该查看一下,因为我展示了你可能不认为是第二本能的一些这些机制。
-
我们将像汤姆一样先添加我们的基础类,但由于我们在设计阶段已经完成了所有组织工作,所以我们实际上不需要两遍系统。
-
我们将实现构建者模式(Builder pattern)。
-
我们将把构建者模式(Builder pattern)的实现转换为单例(Singleton)。
-
我们将实现组合模式(Composite pattern)。
-
我们将实现桥接模式(Bridge pattern)。
-
我们将实现命令模式(Command pattern)。
在整本书中,我假设你知道如何在你的首选集成开发环境(IDE)中创建新的 C#项目。我通常不会在设置和运行项目的机制上花费任何时间。这一章是一个轻微的例外,因为整章实际上是一个大型项目的例子。如果你需要更多细节,或者你想使用除 Rider 之外的 IDE,并且不确定如何设置项目,请参阅本书的附录 1Appendix 1。如果你决定尝试这些,你需要以下内容:
-
运行 Windows 操作系统的计算机。我使用的是 Windows 10。由于项目是简单的命令行项目,我相当确信这些内容在 Mac 或 Linux 上也能正常工作,但我还没有在这些操作系统上测试过这些项目。
-
一个支持的 IDE,如 Visual Studio、JetBrains Rider 或带有 C#扩展的 Visual Studio Code。我使用的是 Rider 2021.3.3。
-
任何版本的.NET SDK。再次强调,项目足够简单,我们的代码不应该依赖于任何特定版本。我恰好使用的是.NET Core 6 SDK。
你可以在 GitHub 上找到本章的完整项目文件github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns/tree/main/chapter-7。
正午的裂缝
在前一天晚上一直忙于绘制项目图之后,汤姆、凯蒂和菲比来到了 Bumble Bikes。第二天中午时分,房间里的气氛紧张而充满活力。
凯蒂腋下夹着一个盒子走进来。她订购了一个新的键盘,那种有响亮蓝色点击开关的键盘。她喜欢这些键盘,因为它们让她想起了她父亲的 IBM Model M 键盘。她小时候经常玩它。凯蒂想记住这个项目的灵感来源。
不久前,她和她的妹妹菲比成立了一家成功的自行车制造公司。她们的展望是乐观的,直到她们的父亲被诊断出患有罕见的进行性肌肉萎缩症——皮肤肌炎,并被新近困在轮椅上。凯蒂和菲比的妈妈与他们的医疗保险提供商进行了漫长的法律斗争,因为保险公司拒绝支付他们父亲需要的昂贵轮椅,以应对他的疾病。菲比亲自处理此事,几乎单枪匹马地决定她可以为他们的父亲以及所有需要的人制造轮椅。自然,凯蒂跟随她年轻的妹妹,因为她知道菲比是对的。“今天是改变世界的一天,”凯蒂想,“我们今天要改变世界。”新的键盘将让她专注于她的父亲以及她仅通过打字就能帮助的所有人。
汤姆穿着他最好的书呆子 T 恤来到实验室;上面印有一幅著名的 XKCD 漫画,描绘两位开发者手持剑在比武,T 恤上的字迹写着汤姆并没有偷懒,他只是在等待代码编译。菲比带着一摞披萨和一瓶汽水到来。中午开始工作的好处是很容易弄到所需的用品。众所周知,程序员不过是一台生物机器,将披萨和咖啡因转化为代码。
这三人准备就绪,菲比关掉了刺眼的顶灯。经过短暂的敏捷会议,他们决定从应用程序的哪个部分开始着手。如果一切顺利,他们几天内就能完成这项工作,这可能是姐妹俩在汤姆的帮助下可能写出的最重要的代码。
项目设置
奇蒂、菲比和汤姆挤在奇蒂的新键盘前。他们三人决心使用结对编程。结对编程发生在两个或更多开发者一起工作并且共享一个键盘的情况下。一个人打字,其他人观看。开发者们时不时地交换位置。不打字的开发者负责观看并帮助进行研究。结对编程消除了代码审查的需要,并且显示出显著提高开发者生产力的效果。如果你不熟悉结对编程的实践,可以查看本章“进一步阅读”部分列出的书籍《实用远程结对编程》。汤姆对设计非常熟悉,但奇蒂和菲比打字更快。汤姆用脚打字的能力真正令人惊叹,但他很久以前就接受了打字速度并不是他能为团队带来的价值。奇蒂和菲比有较少的编码经验,但过去几年在各自的大学撰写研究论文,他们的打字速度已经变得相当快。奇蒂决定先拿键盘,而菲比则吃披萨和汽水补充能量。奇蒂打开她最喜欢的集成开发环境(IDE),创建了一个新的控制台应用程序项目,如图 7.2 所示。

图 7.2:创建一个新的命令行项目。
如果你使用的是不同的 IDE,并且不确定如何创建命令行项目,可以查看本书的附录 1。其中,我涵盖了 Visual Studio、Rider 和 Visual Studio Code 的项目创建机制。我使用 Rider 编写这本书,因为它具有出色的代码清理和格式化工具,这在写书时非常有用。
一旦奇蒂创建了项目,她将 Visio 软件放在另一个显示器上,以便查看项目的 UML 图。她打开的第一个图实际上是他们最后工作的那个图。您可以在图 7.3 中看到它。汤姆对一个称为IManufacturable的中心接口进行了修改。这是一个非常好的起点,因为所有其他内容都从这个接口流出来。

图 7.3:IManufacturable接口和实现它的类结构,这是一个完美的起点。
奇蒂添加了一个名为IManufacturable的新文件,并按照图示输入代码:
public interface IManufacturable
{
public string ModelName { get; set; }
public int Year { get; }
public string SerialNumber { get; }
public string BuildStatus { get; set; }
}
我们之前在bicycle项目中见过这些属性。Year和SerialNumber属性将由实现类中的构造函数自动设置,因此只需要一个get方法。
接下来,奇蒂添加了抽象的Wheelchair类:
public abstract class Wheelchair : IManufacturable
{
根据图示,该类被标记为abstract。不要忘记在 UML 中,抽象类以斜体显示类标题。在使用某些 UML 建模工具的字体中,这可能很难看到。Wheelchair类实现了 Kitty 刚才制作的IManufacturable接口。大多数 IDE 可以生成接口指定的缺失成员。Kitty 生成了以下代码。她在类型定义旁边添加了问号,使BuildStatus可空:
public string ModelName { get; set; }
public int Year { get; }
public string SerialNumber { get; }
public string? BuildStatus { get; set; }
构造函数几乎与bicycle项目中使用的构造函数相同。由于该类是抽象的,因此将构造函数公开为protected是有意义的。在构造函数内部,Kitty 初始化了她能初始化的一切:
protected Wheelchair()
{
ModelName = string.Empty;
SerialNumber = Guid.NewGuid().ToString();
Year = DateTime.Now.Year;
}
}
SerialNumber属性是一个 GUID。这是一个系统生成的唯一字符串。这使得它非常适合作为序列号。Year属性初始化为当前年份。
“我们先等等框架和座椅属性。它们不是原始类型,我们还没有为它们创建类,”Tom 说。
Tom 的设计将轮椅分为两种类型:手动轮椅和电动轮椅。Kitty 接下来添加了UnpoweredChair类:
public abstract class UnpoweredChair : Wheelchair
{
}
她没有走得太远。像大多数类图一样,RightWheel、LeftWheel和Casters属性所需的类型没有指定。“我们需要复合图来指定类型,”Tom 提醒她。那个特定的图可以在图 7.4中看到:

图 7.4:复合结构。
Tom 为复合模式设计的方案是将轮椅的每个部分都由一个名为WheelchairComponent的抽象类构成。在我们当前模型中,车轮和脚轮需要定义这个抽象类,我们才能正确地获取类型。Kitty 添加了一个名为WheelchairComponent的新类:
public abstract class WheelchairComponent
{
为了使用复合模式,我们需要一些属性。复合模式的目的在于使用树状结构来处理列表。Kitty 和 Phoebe 希望能够递归地遍历他们的轮椅模型实例,以报告每个组件的重量和成本。这些数字可以在任何级别上累加,以确定单个部件或椅子子集的重量和成本。例如,了解组装好的框架的重量和成本可能是有用的。当我们处理自行车时,重量和成本是典型的权衡。较便宜的组件更重。竞争性自行车手愿意支付更多以减少他们的总重量。这样做可以减少长途骑行的时间几秒钟,这可能会赢得比赛。
轮椅的轻量组件与自行车的关注点相同;然而,动机是由不同的目的驱动的。Kitty 和 Phoebe 的父亲患有进行性肌肉疾病。如果他打算使用无动力椅子,他特别需要轻量级的物品。一些轮椅用户有非常强壮的上半身;而另一些人则不那么强壮。轻量材料仍然需要在成本和重量之间取得平衡,而组合模式将帮助工程团队分析和改进轮椅的成本与重量比。
由于这个类是抽象的,其成员将被设置为protected,除非有充分的理由不将其设置为protected。Kitty 为Weight和Price属性添加了浮点数:
protected float Weight { get; set; }
protected float Price { get; set; }
接下来,Kitty 添加了类的关键部分。组合模式需要一个Subcomponents集合。记住,组合中的组件要么是叶子,要么是容器。容器是树中的节点,其中包含其他节点。在设计阶段,Kitty 和 Tom 在白板上绘制了组合的树状结构图。您可以在图 7.5中再次看到它,Tom 的设计写在左侧,Kitty 的设计写在右侧。

图 7.5:带动力和无动力的椅子树状结构。
如果您查看无动力椅子的轴,您会看到其中包含组件。轴是一个容器。另一方面,右边的轮子不包含任何其他组件,因此它是一个叶子。
无论它们是容器还是叶子,结构中的所有类都使用相同的基类,该基类包含一个用于存储其他组件的集合。实际上,一切都有可能成为容器,即使它仅仅是一个叶子。Kitty 添加了该集合:
protected List<WheelchairComponent> Subcomponents { get; set; }
注意:集合类型正是她正在编写的类。这使得组合模式可以迭代。我们将通过要求我们的类型使用这个基类而大量依赖 Liskov 替换原则,但在实际实现 Builder 模式时,我们将提供具体的类。
接下来,Kitty 添加了一个构造函数并初始化了所有属性:
protected WheelchairComponent()
{
Subcomponents = new List<WheelchairComponent>();
Weight = 0.0f;
Price = 0.0f;
}
图 7.4中的图表明了一些方法。Kitty 还没有深入到需要担心这些方法的细节。目前,她只是添加了一条抛出NotImplementedException的语句,如果任何尝试访问这些方法。这是一个非常常见的占位符:
protected void DisplayWeight()
{
throw new NotImplementedException();
}
protected void DisplayCost()
{
throw new NotImplementedException();
}
}
完成这些后,Kitty 可以回到定义UnpoweredChair类。她切换到 IDE 中的UnpoweredChair.cs文件。这一次,她前进了一小步。她添加了类图(图 7.3)中所需的三个属性:
public abstract class UnpoweredChair : Wheelchair
{
protected WheelchairComponent RightWheel { get; set; }
protected WheelchairComponent LeftWheel { get; set; }
protected WheelchairComponent Casters { get; set; }
}
Kitty 的 IDE 在每个属性下方显示了一个黄色波浪线,表明存在问题。她还没有初始化任何一个属性。这不是一个大问题,因为她知道她将使用 Builder 模式来填充这些细节,这些细节将基于正在构建的椅子型号。
就在这时,凯蒂被背后的一声大叫声吓了一跳。“呸!你那里的代码真臭!”菲比惊叫道。显然,她刚刚吃了几片披萨和几罐含咖啡因的汽水,准备接受挑战。
“什么?”凯蒂尴尬地问道。汤姆插话道,“我想她生气是因为你为你的类型使用了 WheelchairComponent 基类。”
“我以为我们正在做这个,”凯蒂回答。
“再看看,姐姐。复合图 在 WheelchairComponent 类下有更具体的类,”菲比说。凯蒂站起来,菲比接替了她的位置。
轮椅组件
菲比坐下,假装伸脖子并敲打她的指关节。如果这是一部动作电影中的打斗场景,这将是对手感到威胁的提示。相反,光标在 IDE 中无畏地闪烁。“这是一个很好的地方来填充,因为我对实际组件了解得更多,”菲比说。她是对的。菲比花了很多小时寻找零件,并在组装自行车时想出如何制作她买不到的东西。在图 7.4中,我们可以看到有一组抽象组件,它们都继承自WheelchairComponent。WheelchairComponent基类为我们提供了在组合模式中使用的Weight和Price字段,帮助我们迭代计算一组组件的重量和价格。图中列出了九个这样的组件:
-
WheelchairSeat -
Axle -
CasterAssembly -
MechanicalWheel -
ElectricMotor -
WheelchairFrame -
Battery -
TrackDriveSystem -
SteeringMechanism
菲比需要为列表上的每一项创建一个类。“这有很多组件,”菲比说。“我们能不能专注于最小可行产品?”
最小可行产品(MVP)是敏捷开发中的一个术语。敏捷开发是凯蒂在她的产品开发课程中学到的一种项目管理范式。敏捷方法在软件公司中很受欢迎,因为它们允许你通过关注公司能卖出的最小产品来快速将产品推向市场。然后它使用迭代开发来分几个小阶段构建该产品。在这种情况下,我们有两种轮椅设计。德克萨斯坦克非常酷,但制作成本也会非常高。三人意识到,通过制作简单的非动力轮椅:普兰诺轮椅,他们可以产生巨大的影响。这个椅子越快上市,他们就能帮助更多的人。团队决定只专注于制作这张椅子。这使她的列表缩减到只有这些组件:
-
WheelchairSeat -
Axle -
CasterAssembly -
MechanicalWheel -
WheelchairFrame
“为什么不为这些文件创建一个文件夹呢? 这样以后找起来会更容易,我们的代码也会稍微整洁一些,”汤姆建议。一个专注的表情出现在菲比的脸上。她为自己的项目添加了一个名为WheelchairComponents的文件夹。
接下来,菲比在文件夹内添加了一个名为WheelchairSeat的类。她还添加了列表中的剩余类,目前每个类都是空的。在这个时候,她的项目类似于图 7.6:

图 7.6:抽象轮椅组件的文件夹结构。
“可能有必要将 WheelchairComponent.cs 文件也移动到那个文件夹中,”汤姆建议。菲比将文件从项目的根文件夹拖动到包含她刚刚创建的其他九个类文件的子文件夹中。
“嘿,快点打开那个文件。让我们确保 IDE 已经为你更改了命名空间,”汤姆说。“有些 IDE 会这样做,而有些则不会,”他继续说道。菲比在 Rider 中双击了文件。实际上,IDE 并没有更改代码。在 C#中,命名空间就像 Java 中的包。它们的作用是帮助您组织代码,并在复杂的项目中防止名称冲突。汤姆怀疑这个项目不会复杂到需要这样的名称冲突。名称冲突发生在您想要用相同的名称命名两个类时。如果这些类在同一个命名空间中,这在 C#中是不合法的。项目的命名空间通常在您在 IDE 中创建项目时自动创建。
在WheelchairComponent.cs文件中更改命名空间对于程序的操作并不关键。由于汤姆和菲比选择将代码组织到文件夹中,通常的做法是将命名空间与文件夹结构相匹配。菲比修改了WheelchairComponent类的代码,以反映新的文件夹位置。这是在文件顶部完成的。操作之前的代码如下所示:
namespace WheelchairProject;
public abstract class WheelchairComponent
{
菲比将其更正为以下内容:
namespace WheelchairProject.WheelchairComponents;
public abstract class WheelchairComponent
{
她这样做之后,她的集成开发环境(IDE)立即用红色下划线标记了她的UnpoweredChair类,提醒她存在一个语法问题。它使用了WheelchairComponent组件,而我们只是更改了命名空间。修复这个错误很容易。我们只需要在文件顶部添加一个using语句。然而,现在这样做是没有意义的。菲比打算让这个类使用她刚刚创建的类文件,而不是过于通用的抽象WheelchairComponent类,后者不足以代表一个实际组件。
菲比将注意力转回到她刚才创建的MechanicalWheel组件。目前,这个类只包含 IDE 添加的样板代码:
namespace WheelchairProject.WheelchairComponents;
public class MechanicalWheel
{
}
菲比开始处理这个问题:
namespace WheelchairProject.WheelchairComponents;
public abstract class MechanicalWheel : WheelchairComponent
{
protected float Radius { get; set; }
protected int SpokeCount { get; set; }
protected bool IsPneumatic { get; set; }
}
菲比为抽象轮子添加了三个属性。“我们为什么要创建这个抽象类?”凯蒂问道。凯蒂坐在一边,但小组已经设置了一个显示器,这样无论谁坐在打字之外都能看到所有的动作。菲比回答道:“我们稍后会定义更具体的轮子,但拥有这个抽象的是对未来变化的明智防御。我们不希望我们的无动力椅子类与特定的轮子紧密耦合。相反,几乎任何你能想象到的机械轮都可以作为这个轮子的子类实现。除非我们想出一种全新的轮子类型,所有的轮子都将有一个半径。大多数轮椅轮子都有辐条,但即使你没有,你也可以将辐条数设置为零。最后,大多数轮椅都有某种轮胎。有些是实心的,有些使用空气。使用空气的被称为充气轮胎,类似于典型的自行车轮胎。”
“我明白了!”凯蒂大声说道。“*你打算使用这个类以及我们列表中的其他类来定义无动力椅子类的结构。然后你将定义实际使用的具体轮子的类。当构建类构建轮椅对象时,它可以指定具体的类。Liskov 替换原则允许我们用父类替换子类,因此我们的设计保持灵活。”
“你明白了!”汤姆说。“现在我们需要对其他所有组件做同样的事情。”
我需要在这里打破第四面墙一会儿:我可能被你们称作细节狂热者。我这类人喜欢指出电视剧和电影中的错误。鉴于你在读这本书,我敢肯定你很可能看过电视剧《星际迷航:下一代》。如果你没看过,那么在读完这本书后,你将有一个狂热观看的任务。以下是我在这部剧中注意到的几点。在第一季第 13 集中,机器人数据喝下了加了料的香槟,倒退着走,而在接下来的场景中,他脸朝下。在第一季第 25 集中,里克要求乔治提高星际飞船企业号的航速至 warp 6。乔治回答道:“是,先生,全速前进。”在《星际迷航》中,“warp”指的是相对于光速的对数尺度,而“impulse”指的是亚光速。正如我所说的,这类事情会让我感到烦恼。我意识到,这么说后,我可能会收到大量关于我在这本书中自己不一致性的邮件。关于这一点,我的规则是:只有当你告诉所有你的朋友买这本书,这样你才能在看到我的错误时开心地大笑时,你才能这样做。如果你的所有朋友都买了这本书,我们就能卖出足够的数量来再版,我就能修正所有的连续性错误。当然,在修订过程中,我可能会引入一些新的错误,这样循环就会继续。如果你也这样想,我就没问题。
在这里,我试图避免让你感到乏味,因为要使它如此真实,以至于有数百个类。别忘了,我们的重点是模式。这不是尝试构建一个能够通过机械工程师审查的真正轮椅模型。随着章节的进展,我会做更多类似的事情。将你的注意力集中在对象的结构上,而不是它们的内含物上。对模式重要的部分始终都会存在;其余的只是构成一个好故事。现在我们已经解决了这个问题,让我们快速前进一点。WheelchairComponents文件夹中的所有类都将作为子类继承自WheelchairComponent的抽象类。其他属性不会影响组合模式或我们程序的任何其他部分。
我们现在将你带回已经进行中的故事。
菲比继续填写组件列表中剩余的类别。我们将按字母顺序进行,只关注在非动力轮椅中需要的组件。第一个类别是轴类。记住,在组件模式中,每个元素要么是容器,要么是叶子。容器包含其他容器和叶子。叶子不能包含任何东西。根据图 7.5,轴是一个容器,它包含左右两个轮子。
using WheelchairProject.WheelchairComponents.Wheels;
namespace WheelchairProject.WheelchairComponents.Axles;
public abstract class Axle : WheelchairComponent
{
我将添加两个轮子作为私有字段。
private MechanicalWheel _leftWheel;
private MechanicalWheel _rightWheel;
接下来的两个属性定义了轴,它只是一个圆柱体。
protected float Radius { get; set; }
protected float Length { get; set; }
接下来,菲比为_leftWheel和_rightWheel字段添加了访问器方法。首先是左轮。注意FixComposite()方法。这个方法实现了汤姆最初的想法,即直接将组合模式嵌入到对象结构中,与凯蒂和菲比在自行车项目中的实现形成对比,后者使用了一个单独的对象图。我们将在稍后创建FixComposite()方法。
public MechanicalWheel LeftWheel
{
get => _leftWheel;
set
{
_leftWheel = value;
FixComposite();
}
}
然后是右轮。
public MechanicalWheel RightWheel
{
get => _rightWheel;
set
{
_rightWheel = value;
FixComposite();
}
}
接下来,在汤姆的指导下,菲比创建了一个名为FixComposite()的方法。汤姆说:“你需要把它放在抽象组件上。”菲比添加了以下代码:
private void FixComposite()
{
Subcomponents.Clear();
Subcomponents.Add(_leftWheel);
Subcomponents.Add(_rightWheel);
}
}
“我明白你在做什么!”凯蒂从坐在菲比后面的椅子上说。“每个组件管理它内部的子组件。你不需要创建一个单独的对象图,而是让访问器方法在左右轮子改变时重建组合。”
“我们为什么不在WheelchairComponent基类中放它?”菲比问道。“有两个原因。”汤姆说。“首先,这个方法特定于轴。它包含这两个特定的组件。”菲比打断说,“但我们也可以将其设为抽象的,并在子类中重写它。”
“让我说完,菲比,”汤姆说。他继续说,“第二个原因是 SOLID 原则中的接口隔离原则。只有容器需要这个方法。叶子没有任何子组件,因此不需要在WheelchairComponent基类上固定子组件列表。接口隔离原则认为,没有类应该被迫实现它不使用的接口,也不应该依赖于它不需要的方法。如果我们把这个做成基类中的抽象方法,叶子类将不得不实现它。我们添加的任何实现都将是有意为之的,并且会给这些类引入代码异味。”
“现在谁在写有异味的代码,菲比?”凯蒂一边说,一边享受着对妹妹的甜蜜报复。
菲比对妹妹的嘲讽置之不理,继续工作。根据图 7.5,她知道这些类需要一个FixComponent()方法,因为它们是容器:
-
Axle(已编写) -
CasterAssembly -
Wheelchair -
WheelchairFrame
Wheelchair是所有轮椅的基类。它需要从WheelchairComponent继承,因为我们需要一个顶级容器。菲比对Wheelchair类进行了大量修改。当她完成时,它看起来是这样的:
using WheelchairProject.WheelchairComponents.Frames;
using WheelchairProject.WheelchairComponents.Seats;
菲比已经添加了框架和座椅命名空间,这样我们就可以指定进入轮椅的两个组件。她继续在命名空间下方添加WheelchairComponents引用。
namespace WheelchairProject;
using WheelchairComponents;
接下来,她将WheelchairComponent作为基类添加:
public abstract class Wheelchair : WheelchairComponent, IManufacturable
{
接下来,她添加了座椅和框架的私有字段:
private WheelchairSeat _seat;
private WheelchairFrame _frame;
public string ModelName { get; set; }
public int Year { get; }
public string SerialNumber { get; }
然后她像之前一样添加了访问器方法,使用Axle类:
public WheelchairSeat Seat
{
get => _seat;
set
{
_seat = value;
FixComposite();
}
}
public WheelchairFrame Frame
{
get => _frame;
set
{
_frame = value;
FixComposite();
}
}
接下来,菲比添加了神奇的FixComposite()方法,它清除了子组件列表并添加了_seat和_frame字段。这个方法是从访问器方法中调用的,所以每次这些更改时,组合都会更新。说实话,由于我们将使用 Builder 模式来创建这些对象,一旦创建对象,可能不会有很多更改的需求,但知道你可以这样做是件好事。
private void FixComposite()
{
Subcomponents.Clear();
Subcomponents.Add(_frame);
Subcomponents.Add(_seat);
}
类的其余部分保持不变。与其覆盖菲比创建的每个类,我鼓励你查看书中示例代码中的chapter-7项目。你会在WheelchairComponents文件夹中找到大部分内容。
完成轮椅基类
“干得好,菲比。剩下要做的就是清理凯蒂的烂摊子了,”汤姆责备道。凯蒂伸出舌头。菲比咯咯地笑,打开了UnpoweredChair类。现在她有了所有需要的类型来建模抽象类。她像这样更新了类代码:
using WheelchairComponents;
这里,需要using语句,因为我们把component类移动到了WheelchairComponents命名空间。接下来的几行保持不变:
public abstract class UnpoweredChair : Wheelchair
{
菲比的下一个更改是设置RightWheel、LeftWheel和CasterAssembly属性的正确的类型:
protected MechanicalWheel RightWheel { get; set; }
protected MechanicalWheel LeftWheel { get; set; }
protected CasterAssembly Casters { get; set; }
完成组合
“我们很快应该休息一下,” 菲比说。“我们已经做了很多工作。我们几乎写完了大部分,如果不是全部的话, 我们的抽象基类。这些类是我们模式中最重要的部分。我们几乎完成了组合模式。我们只需要添加递归计算每个组件重量和成本的方法。”
“为了做到这一点,” 汤姆说,“我们只需要做两个小的调整。打开 WheelchairComponent 类。”
菲比编译了。她记得基蒂把DisplayCost和DisplayWeight方法留到了以后。它们现在的样子如下:
protected void DisplayWeight()
{
throw new NotImplementedException();
}
protected void DisplayCost()
{
throw new NotImplementedException();
}
菲比添加了实现:
protected void DisplayWeight()
{
与BicycleComponent实现一样,首先,我们检查是否有任何子组件。如果没有,我们简单地返回:
if (!Subcomponents.Any()) return;
如果有,我们打印出名称和重量:
foreach (var component in Subcomponents)
{
Console.WriteLine(component.GetType().Name + " weighs " + component.Weight);
然后,我们递归地调用DisplayWeight方法:
component.DisplayWeight();
}
}
我们对Price做同样的事情:
protected void DisplayCost()
{
if (!Subcomponents.Any()) return;
foreach (var component in Subcomponents)
{
Console.WriteLine(component.GetType().Name + " costs $" + component.Price + " USD");
component.DisplayCost();
}
}
菲比瘫坐在椅子上。“呼!这 工作量很大,” 菲比说。“我知道。我们 有组合模式的结构,但我们还需要一个 Builder 来填充它,” 汤姆说。“是的,” 基蒂说。“这个结构很复杂。我们不能像简单组合那样创建一个轮椅对象,然后添加椅子、框架和一些轮子。我们需要一种方法来组装我们在板上之前画出的层次结构(图 7.5)。 三个人休息了一下,补充了体力。他们知道下一个他们需要制作的模式是 Builder 模式。
实现 Builder 模式
基蒂给他们的母亲卡拉琳打了电话,让她过来休息一下,以便从连续不断的医院守候中解脱出来。菲比分享了一些披萨,四个人在任天堂 Switch 上玩了一会儿马里奥赛车,以此来分散他们的注意力。几场比赛后,汤姆、基蒂和菲比准备回去工作了。卡拉琳回到了医院
“这将是最困难的部分,” 汤姆说。“Builder 模式的实现将要做很多工作。它需要组装轮椅对象,组装组合,并最终使用桥接模式处理轮椅的喷漆工作。”
“我们应该审查我们的图表,”基蒂说。轮到她输入了。她调出了他们绘制的 Builder 模式图表。你可以在图 7.7中查看它:

图 7.7:包含所有内容的 Builder 模式设计,包括组合模式和桥接模式元素。
“我想我会创建一个文件夹来存放所有的 Builder 类和接口。看起来会有几个。” 基蒂说。“好主意。” 汤姆说。基蒂创建了一个名为Builders的文件夹。接下来,基蒂将IWheelchairBuilder接口添加到了Builders文件夹中:
namespace WheelchairProject.Builders;
基蒂实现了 UML 设计中指定的代码:
public interface IWheelchairBuilder
{
public void Reset();
public void BuildFrame();
public void BuildWheels();
public void BuildSeat();
public Wheelchair GetProduct();
}
除了接口之外,Builder 模式还需要一个director类。也就是说,它需要一个实现接口的抽象构建器,以及一个或多个具体的builder子类来构建具体的产品。你可以在图 7.4中看到这些。
Kitty 接下来添加了导演类。她决定将其命名为WheelchairBuilderDirector:
namespace WheelchairProject.Builders;
public class WheelchairBuilderDirector
{
导演持有任何实现IWheelchairBuilder的类的私有实例:
private IWheelchairBuilder _builder;
Kitty 添加了一个构造函数,指定了实例化时要使用的构建器:
public WheelchairBuilderDirector(IWheelchairBuilder builder)
{
_builder = builder;
}
图表中指定的Build方法旨在调用IWheelchairBuilder中指定的builder类的各种方法。记住,导演的职责是有序地调用这些方法。构建对象背后的复杂逻辑在这里由导演类控制:
public Wheelchair Build()
{
_builder.BuildSeat();
_builder.BuildFrame();
_builder.BuildAxleAssembly();
_builder.BuildCasterAssembly();
最后,导演使用GetProduct方法返回构建好的对象:
return _builder.GetProduct();
}
}
“到目前为止,一切顺利!”Tom 说。“让我们为 Plano 轮椅制作一个具体的构建器。”
Kitty 添加了一个名为PlanoWheelchairBuilder的类。它看起来像这样:
namespace WheelchairProject.Builders;
public class PlanoWheelchairBuilder : IWheelchairBuilder
{
这个模式要求我们有一个private字段来保存正在构建的Wheelchair对象:
private PlanoWheelchair _wheelchair;
该模式还要求一个Reset方法,它本质上重新初始化了_wheelchair字段。为了保持类的Reset方法,然后在构造函数中调用它,这也是你所需要的。Kitty 更喜欢确保构造函数总是类中的第一个方法,所以它排在第一位:
Public PlanoWheelchairBuilder()
{
Reset();
}
然后,她添加了Reset方法:
public void Reset()
{
_wheelchair = new PlanoWheelchair();
}
由于这个类实现了IWheelchairBuilder接口,我们需要实现剩余的所需方法。大多数 IDE 都会为你生成这些方法。Kitty 使用 Rider,所以她将光标点击到类名行,位于类顶部附近。它下面有一些看起来很生气的红色波浪线,因为她还没有实现接口所需的方法。她按下Ctrl + .(控制点)并看到生成缺失成员的占位符代码的选项。你可以在图 7.8中看到这个样子:

图 7.8:Rider,像大多数 IDE 一样,将为接口所需的任何缺失成员自动生成占位符代码。
生成的代码看起来像这样:
public void BuildFrame()
{
throw new NotImplementedException();
}
public void BuildWheels()
{
throw new NotImplementedException();
}
public void BuildAxleAssembly()
{
throw new NotImplementedException();
}
public void BuildCasterAssembly()
{
throw new NotImplementedException();
}
public void BuildSeat()
{
throw new NotImplementedException();
}
public void BuildComposite()
{
throw new NotImplementedException();
}
public void BuildFramePainter()
{
throw new NotImplementedException();
}
public Wheelchair GetProduct()
{
return _wheelchair;
}
}
这段代码很长且平淡无奇,但它节省了很多打字!Kitty 只需要填写每个方法的实现。她实际上还做不到这一点。她需要各种抽象轮椅组件类的具体实现,而这些还没有被创建。
另一次重构
“这真是大量的占位符代码,”Tom 说。“也许该为轮椅组件添加一些具体的类,以便我们构建一个真正的轮椅了?”
“好的,” 基蒂同意了。她开始思考如何构建具体的类。对于每个抽象组件类,至少将有一个具体的组件。假设轮椅项目非常成功,具体的实现列表可能会增长,使得WheelchairComponents文件夹非常拥挤。
“汤姆,我们为什么不多分一点WheelchairComponents文件夹呢?随着我们添加具体类,我认为这样做会更容易找到所有东西,”基蒂说。“好主意,”汤姆回答。
基蒂在WheelchairComponents文件夹下添加了一系列文件夹:
-
Axles -
Casters -
Frames -
Seats -
Wheels
接下来,她将每个组件的抽象类移动到相应的文件夹中。在每种情况下,她纠正了她移动的类的命名空间。
Axle类放在Axles文件夹中。命名空间代码应调整为以下内容:
namespace WheelchairProject.WheelchairComponents.Axles;
Caster Assembly类放在Casters文件夹中。命名空间代码应调整为以下内容:
namespace WheelchairProject.WheelchairComponents.Casters;
WheelchairFrame类放在Frames文件夹中。命名空间代码应调整为以下内容:
namespace WheelchairProject.WheelchairComponents.Frames;
WheelchairSeat类放在Seats文件夹中。命名空间代码应调整为以下内容:
namespace WheelchairProject.WheelchairComponents.Seats;
最后,将MechanicalWheel类移动到Wheels文件夹,并按如下调整其命名空间:
namespace WheelchairProject.WheelchairComponents.Wheels;
当这次重构完成时,代码的文件夹结构看起来像图 7.6。
添加具体组件类
基蒂的下一项工作是添加每个组件类型的组件类。我们已经为每个组件建立了一个基类和结构。接下来,我们只需添加扩展基类的具体类。
Axles
她从添加一个名为StandardAxle的类开始,放在Axles文件夹中。Plano 轮椅的部件在机械上简单且常见。菲比提供了一个材料清单(BOM),列出了组件,以及复合模式实现所需的数据。在制造业中,这是一个通常导出到 Excel 电子表格的部件列表。基蒂和汤姆可以简单地参考电子表格来获取在具体类中需要的值。
StandardAxle类看起来如下:
using WheelchairProject.WheelchairComponents.Wheels;
namespace WheelchairProject.WheelchairComponents.Axles;
public class StandardAxle : Axle
{
public StandardAxle(MechanicalWheel leftWheel,
MechanicalWheel rightWheel)
{
Price = 4.33f;
Weight = 0.335f;
Radius = 0.24f;
Length = 28.5f;
LeftWheel = leftWheel;
RightWheel = rightWheel;
}
}
轮子
接下来,基蒂为将在Plano 轮椅上使用的铸件添加了一个具体类。她将其命名为PlanoCasterAssembly。其内容如下:
using WheelchairProject.WheelchairComponents.Wheels;
namespace WheelchairProject.WheelchairComponents.Casters;
public class PlanoCasterAssembly : CasterAssembly
{
public PlanoCasterAssembly(MechanicalWheel wheel)
{
LoadCapacity = 300.0f;
MountingType = "STEM";
Weight = 0.443f;
Price = 4.32f;
Wheel = wheel;
}
}
Frames
是时候为Plano 轮椅的框架添加一个具体类了。它被称为PlanoWheelchairFrame:
namespace WheelchairProject.WheelchairComponents.Frames;
public class PlanoWheelchairFrame : WheelchairFrame
{
public PlanoWheelchairFrame()
{
Price = 75.92f;
Weight = 16.34f;
}
}
Seats
没有座位的地方,轮椅就不会很有用。基蒂添加了一个她称之为PlanoSeat的类:
namespace WheelchairProject.WheelchairComponents.Seats;
public class PlanoSeat : WheelchairSeat
{
public PlanoSeat()
{
Price = 27.48f;
Weight = 3.22f;
Width = 22;
BackHeight = 30;
SeatThickness = 2.4f;
}
}
Wheels
对于轮椅的构建来说,除了座位之外,轮子同样重要。这里有两组轮子。轮椅侧面的大型轮子由一个名为StandardWheel的类指定:
namespace WheelchairProject.WheelchairComponents.Wheels;
public class StandardWheel : MechanicalWheel
{
public StandardWheel()
{
Price = 11.34f;
Weight = 1.3f;
Radius = 16f;
IsPneumatic = true;
SpokeCount = 48;
}
}
我们需要的第二种轮子是较小的一组,它附着在椅子前面的旋转铸件上。基蒂将这些称为CasterWheel:
namespace WheelchairProject.WheelchairComponents.Wheels;
public class CasterWheel : MechanicalWheel
{
public CasterWheel()
{
Price = 5.21f;
Weight = 0.753f;
Radius = 6f;
IsPneumatic = true;
SpokeCount = 24;
}
}
现在,Kitty 的文件夹结构类似于图 7.9:

图 7.9:Kitty 添加所有具体类后的 WheelchairComponents 文件夹。
现在所有Plano Wheelchair的具体类都已经就位,我们可以设置 Builder 模式代码来构建一个完整的Wheelchair对象。
完成 Builder 模式
到目前为止,Kitty 需要完成PlanoWheelchairBuilder类。如果你还记得,这是她之前生成所有占位符代码的类。她需要用她新的具体类替换占位符代码。
Kitty 从框架开始,因为框架是轮椅所有其他部分的基石。她修改了 IDE 生成的BuildFrame方法中的代码:
public void BuildFrame()
{
wheelchair.Frame = new PlanoWheelchairFrame();
}
BuildFrame方法没有太多内容。它所做的只是实例化PlanoWheelchairFrame并设置_wheelchair的框架属性。
接下来,Kitty 替换了BuildAxleAssembly方法。根据组合模式,Axle对象,如StandardAxle类,是一个容器。团队在图 7.5中指定了这一点。Axle包含左右轮子,它们是StandardWheel类型。因此,代码看起来是这样的:
public void BuildAxleAssembly()
{
var leftWheel = new StandardWheel();
var rightWheel = new StandardWheel();
var axle = new StandardAxle(leftWheel, rightWheel);
_wheelchair.Frame.Axle = axle;
}
在这里,我们实例化了两个StandardWheel对象,并使用StandardAxle构造函数将它们设置到Axle中。最后,我们设置了_wheelchair的Axle,它是Frame属性的一个属性。这反映了现实生活,因为轴会被固定在轮椅的框架上,而轮子反过来会被固定在轴上。
在她的脑海中,Kitty 看到的是一个带有两个轮子的轮椅,尴尬地来回摇晃。在主轮安装后,Kitty 决定先做脚轮。她像这样修改了BuildCasterAssembly方法:
public void BuildCasterAssembly()
{
var planoCasterWheel = new CasterWheel();
var casterAssembly = new PlanoCasterAssembly(planoCasterWheel);
_wheelchair.Frame.LeftCaster = casterAssembly;
_wheelchair.Frame.RightCaster = casterAssembly;
}
PlanoCasterAssembly由CasterWheel组成,它被传递到PlanoCasterAssembly构造函数中。然后,这个组件被安装到框架的左右两侧。在我审查 Kitty 的代码时,我无法判断她是否在这里偷工减料。她在这两侧使用了相同的PlanoCasterAssembly实例。我确信这可能是正确的。如果不是,我确信 Phoebe 会让她知道。
我们有四个轮子和一个框架。我们还缺少一个座椅。Kitty 更新了BuildSeat方法:
public void BuildSeat()
{
_wheelchair.Seat = new PlanoSeat();
}
就像框架一样,这个也很直接。只需实例化PlanoSeat对象并将其附加到框架上。Kitty 还需要进行一个简单的更改。GetProduct不应该返回PlanoWheelchair的实例,而应该返回我们构建的那个:
public Wheelchair GetProduct()
{
return _wheelchair;
}
“哇,这真的很酷!”菲比说。菲比一直在实验室里进进出出,但突然间,她表现出了一种新的兴趣。“我明白你说的将组合模式复杂性隐藏在构建者内部的意思。任何与对象一起工作的人都可以使用正常的组合,但我们还获得了递归价格和重量函数的好处,”菲比说。
“我们还要将其变为单例吗?”汤姆问。
“我认为我们应该 尝试一下,”凯蒂说。“写一些测试来查看我们是否从将其变为单例中获得了任何好处,但现在,让我们继续将构建者变为单例。”
添加单例模式
“标签!我进来了!”菲比大声说,当她把凯蒂从打字椅上推开后,自己坐到了键盘前。菲比假装敲打她的手指关节,扭动她的脖子。他们已经到了最后阶段,菲比知道这一点。
“单例很简单,”汤姆说。“我记得,”菲比说。她继续说,“*看起来我只需要更改构建者模式中的导演类。”菲比在Builders文件夹中找到了导演类,并在她的 IDE 中打开了它:
public class WheelchairBuilderDirector
{
private IWheelchairBuilder _builder;
菲比添加了这一行来创建一个用于存储当前实例的字段。她将其标记为可空,因为如果字段是null,我们需要创建这个类的新实例并将其放置在_instance字段中。这个字段被标记为static以确保它在内存中是唯一的:
private static WheelchairBuilderDirector? _instance;
接下来,菲比将构造函数的访问器改为private。直接实例化导演是不可能的。要使用这个类,你必须使用标志性的GetInstance方法,她将在下面写:
private WheelchairBuilderDirector(IWheelchairBuilder builder)
{
_builder = builder;
}
将构建者导演类转换为最后的步骤是添加一个名为GetInstance的公共静态方法。我们仍然需要将IWheelchairBuilder参数传递进来,就像我们在原始构造函数中所做的那样。这个方法检查_instance字段是否为空。如果是,那么这个方法将调用私有构造函数并将_instance字段设置为结果。如果_instance字段不为空,GetInstance方法将简单地返回它已经拥有的实例:
public static WheelchairBuilderDirector? GetInstance(IWheelchairBuilder builder)
{
if (_instance == null)
{
_instance = new WheelchairBuilderDirector(builder);
}
return _instance;
}
类的其余部分保持不变。
“这并不难,”菲比说。“我可以想象,一旦工厂开始生产轮椅和自行车,这个物体在内存方面可能会很昂贵,”汤姆说。“我在犹豫,”菲比回答。“我不完全确定我们是否需要这个,但我想这不会有什么坏处。只剩下一件事了,那就是我最喜欢的!”凯蒂边说边从菲比的椅子后面看着。“你很自豪你的油漆系统,对吧,姐姐?”菲比问。凯蒂只是笑了笑,坐在她妹妹的后面。
使用桥接模式给椅子涂漆
我们需要完成的最后一个模式来完善我们的轮椅项目是桥接模式。记住,桥接模式用于当你有两个相关的复杂类系统时。桥接模式允许你通过组合来连接这些类。它让你能够改变复杂性并独立维护这些复杂的类。Kitty 和 Phoebe 使用这个系统为他们的自行车添加了定制喷漆的能力。但他们是在游戏后期才这样做,为了适应这些变化,他们不得不违反开闭原则。
这次,有了经验,他们可以在实现早期就集成桥接模式来喷漆轮椅。为轮椅指定颜色的能力将成为 Bumble Bikes 的一个大区别,因为大多数制造商只销售黑色和灰色椅子。这对于你当地医院的借用椅子来说是可以的,但对于每天使用轮椅来生活的的人来说,有一点点时尚感是很好的。
Kitty 为自行车创建了一个喷漆系统,使其工作方式与喷墨打印机类似。她设计了一个系统,可以使用青色、品红色、黄色和黑色喷漆混合出任何颜色。这被称为 CMYK 颜色模型,它是印刷行业的一个标准。
Tom 在一家当地儿童医院与残疾儿童一起工作,他对轮椅可能最受欢迎的颜色进行了非正式调查。然后他要求孩子们为初始产品发布选择一个颜色。经过一番热烈的讨论,孩子们决定选择一种绿色的色调。有一个和孩子们一起工作的女人,名叫 Judy。大家都喜欢她,她有一双闪亮的绿色眼睛。他们决定通过将颜色命名为 Green Eyed Judy 来向她致敬。
Phoebe 开始实现桥接模式。在现实生活中,可能可以重用自行车包中的桥接接口和类。唯一的缺点是,你会在两个产品之间创建一个依赖关系,这两个产品除了都是由同一家公司制造之外,可能没有任何关系。为了这本书的目的,我们只是将创建一套新的类,以便使轮椅项目自给自足。
Phoebe 首先创建了一个名为 Painters 的新目录。然后,她添加了一个名为 IFramePainter 的接口。这是桥接模式的关键。这个接口定义了一个复杂的类系统,用于指定颜色喷漆系统。这是桥接模式的一侧。
桥接模式的另一侧是轮椅类。我们可以自由地扩展和修改桥接的任一侧,而不会影响另一侧。
IFramePainter 接口看起来是这样的:
namespace WheelchairProject.Painters;
public interface IFramePainter
{
接口需要五个属性。PaintColorName 允许你为颜色组合命名。剩下的四个属性是 Cyan、Magenta、Yellow 和 Black 的值:
public string PaintColorName { get; set; }
public int Cyan { get; set; }
public int Magenta { get; set; }
public int Yellow { get; set; }
public int Black { get; set; }
接下来,菲比添加了两个方法的要求。这是为了机器人机械组装和涂漆轮椅的利益。系统需要首先混合油漆颜色,然后应用:
public void MixPaint();
public void PaintFrame();
}
下一步我们需要的是一个具体的类来实现这个接口。菲比依然专注于Plano 轮椅,因此创建了一个名为PlanoWheelchairPainter的类:
namespace WheelchairProject.Painters;
public class PlanoWheelchairPainter : IFramePainter
{
菲比将之前提到的五个属性作为自动属性:
public string PaintColorName { get; set; }
public int Cyan { get; set; }
public int Magenta { get; set; }
public int Yellow { get; set; }
public int Black { get; set; }
MixPaint方法很复杂,且高度专有。凯蒂和菲比的律师不允许我展示真正的代码。所以,我们只能用一些占位符代码来代替:
public void MixPaint()
{
Console.WriteLine("Mixing in Cyan: " + Cyan.ToString() );
Console.WriteLine("Mixing in Magenta: " + Magenta. ToString() );
Console.WriteLine("Mixing in Yellow: " + Yellow. ToString() );
Console.WriteLine("Mixing in Black: " + Black. ToString() );
Console.WriteLine("Mixing complete! The color is: " + PaintColorName);
}
同样,PaintFrame方法引用了一些专有的机器人 API,所以我们再次通过示例保持简单:
public void PaintFrame()
{
Console.WriteLine("Applying " + PaintColorName);
}
}
下一个我们需要做的更改是将接口通过组合添加到Wheelchair类中。菲比打开Wheelchair类并添加了一个属性:
public abstract class Wheelchair : WheelchairComponent, IManufacturable
{
public IFramePainter FramePainter { get; set; }
剩下的唯一一件事就是我们需要将桥接实现添加到构建器中,这样当构建器构建轮椅时,它会在相同的过程中进行涂漆。构建器真正地将一切联系在一起!
第一个更改将是IWheelchairBuilder接口。菲比只是添加了一个新的方法定义:
public interface IWheelchairBuilder
{
public void Reset();
public void BuildFrame();
public void BuildAxleAssembly();
public void BuildCasterAssembly();
public void BuildSeat();
她在这里添加了定义:
public void BuildFramePainter();
其余的代码没有变化:
public Wheelchair GetProduct();
}
接口更新后,菲比突然看到一些红色的波浪线,表示有问题。由于她更改了接口,PlanoWheelchairBuilder类是错误的,因为它没有新的方法。菲比打开PlanoWheelchairBuilder类并添加了缺失的方法:
public void BuildFramePainter()
{
菲比实例化了一个具体的PlanoWheelchairPainter类,并使用新的轮椅油漆颜色进行设置:
var painter = new PlanoWheelchairPainter
{
PaintColorName = "Green-Eyed Judy",
Cyan = 79,
Magenta = 22,
Yellow = 100,
Black = 8
};
接下来,她在构建器内部设置_wheelchair实例的属性。之后,她调用方法来混合油漆颜色并涂漆框架:
_wheelchair.FramePainter = painter;
_wheelchair.FramePainter.MixPaint();
_wheelchair.FramePainter.PaintFrame();
}
SLAP!
一阵响亮的声音在实验室中回荡,凯蒂和菲比互相击掌。汤姆发出了一阵传统的德克萨斯州“Yee haw!”
在接下来的一个星期里,团队将测试、调试和重构代码。如果一切按计划进行,Bumble Bikes 就能将高质量、低成本的轮椅运往世界各地的康复中心和儿科医院。
摘要
凯蒂、菲比和汤姆对他们的进展感到非常高兴。毫无疑问,他们将在接下来的几周内继续完善他们的软件,使其产品化。他们之所以能在短时间内完成很多工作,是因为他们先设计后实施,对软件进行了规划。
你可能已经注意到了项目提案和交付内容之间巨大的差距。我们只专注于Plano 轮椅,因为团队决定这张椅子代表了最小可行产品。 neither the Maverick nor the flagship product, the Texas Tank,在这一章中都没有被构建。我把这个留给你作为一个挑战。练习你所学的,并尝试自己实现Maverick和电动轮椅的图表。
我们还看到了很多模式之间的相互作用。构建者模式与桥接模式、单例模式和组合模式结合使用。这导致所有复杂性都集中在一个地方处理。当这些模式被引入时,它们是一次性一个一个被提出的。现在我们看到它们就像拼图一样相互契合。
问题
-
将构建者模式的导演类做成单例(Singleton)有什么意义?你同意这个设计决策吗?为什么,或者为什么不?
-
将组合结构嵌入轮椅的正常对象图中与单独创建对象图相比,有什么优势?就像我们在第四章中用自行车项目所做的那样?
-
你最喜欢的集成开发环境(IDE)中生成接口缺失成员的过程是怎样的?
进一步阅读
由 Adrian Bolboacă所著的《实用远程结对编程》: www.packtpub.com/product/practical-remote-pair-programming/9781800561366
一定要查看这本书的配套网站:csharppatterns.dev。
第八章:现在你已经了解了一些图案,接下来是什么?
在撰写这本书的过程中,我向几位朋友、同事以及至少一位我的许多死敌征求了意见。他们无一例外地会询问他们在学校学习或在一个项目中使用的某个图案,想知道为什么它没有被包括在这本精彩的著作中。对他们问题的简短回答是:本书的目标是专注于你可以快速添加到你的编码工具库中的图案。这些图案能快速回报你的时间和金钱的投资。
我选择省略的许多图案与我在这本书中包含的图案非常相似。被选中的图案完全是我个人的偏好。这些图案在我作为获奖的软件工程师使用 C#的 25 年经验中证明是最有用的。
到本章结束时,我们将涵盖以下主题:
-
我们没有涵盖的 GoF 图案将简要讨论。
-
面向对象编程(OOP)之外的图案。有一些图案不适用于 OOP——例如,设计用来描述数据库或网络结构的图案。
-
如何创建你自己的图案。GoF 书为创建你自己的图案提供了一个格式。
请注意,我仅使用图表。本章没有代码。同样,也没有任何技术要求。
我们没有讨论的图案
我没有涵盖 GoF 书中的所有 23 个图案。我只涵盖了一半左右。许多因素都影响了决定包含哪些图案以及在本章中隐含讨论哪些图案。有些图案比它们的价值更麻烦。备忘录模式解决了一个可以用几个.NET 特性轻松解决的问题。有些图案没有被包括,因为它们与我们覆盖的另一个图案非常相似。有些是你可能永远不会需要的图案。解释器模式只有在你在发明一种新的编程语言时才有用。由于领域特定语言(DSLs)的流行,这很少再做了。存在用于构建 DSL 的工具,这排除了需要解释器模式的需求。
这里是原始 GoF 书中我们没有在这本书中涵盖的图案:
-
原型
-
适配器
-
享元
-
责任链
-
代理
-
解释器
-
中介者
-
备忘录
-
状态
-
模板方法
-
访问者
我将在以下章节中从高层次讨论这些图案。
原型
对象的复制可能很棘手,但如果你的对象是平的,没有组成,只有几个字段,那就没问题了。然而,复制一个使用组合和继承构建的复杂对象的深度副本——它有每一层的几个层次——就更加困难了。深度复制指的是从最高层到最低层对对象进行精确复制。第一步是从相同的类中实例化一个新对象。然后,你需要将所有属性和字段中的值复制到新对象中。只有在你想要复制的对象中的每个字段或属性都是 public 的情况下,你才能这样做。这包括用于组成你正在复制对象的全部对象。即使你设法做到了这一点,你创建的副本也将是用于复制所使用的依赖类。
让我们从另一个角度来看。你开始了一个与 Transylvanian Historical Society 的项目。你的任务是逐块复制由一个名为 Vlad von Dracula 的人拥有的城堡。城堡的复制品将是一个位于另一个城镇的博物馆。有一个问题:城堡的吊桥是升起的,你不被允许进入。
复制城堡的外部部分并不难,但复制内部——尤其是地下室里那个令人毛骨悚然的墓穴——是不可能的。除非你有内部帮助。一个名叫 Renfield 的人提供了帮助。因为他从未离开过城堡的墙壁,所以他完全了解城堡的内部。如果你能让 Renfield 从内部帮助你,复制城堡就不会那么困难。
Prototype 是一种创建型模式,它使你能够逐块复制对象。它通过将复制任务委托给对象本身来实现。简而言之,这是一项内部工作。复制操作本身被称为 克隆。能够自我克隆的对象被称为原型。
看一下以下图示:

图 8.1:当你想要能够对对象进行深度复制时,使用 Prototype 模式。
让我们根据图上的数字来回顾一下 Prototype 模式图,如下所示:
-
Prototype接口定义了执行克隆操作的方法。 -
ConcretePrototype实现了 Prototype 接口,并提供了Clone方法的实现。如果你的对象结构复杂,这个方法定位为能够看到对象内部的所有内容。这个方法可能被重构,使其名称变为 Renfield。他是城堡内部可以帮助你克隆的人。Clone方法同样可以提供复制对象所需的所有细节,而不会损失任何数据。
如果你需要从由具体子类定义的一小套对象中复制大量类,原型模式可以非常有用。回想一下我们的自行车工厂。假设有几种非常受欢迎的自行车配置。进一步假设菲比的机器人需要一个新的bicycle对象来制造一辆实物自行车。在这种情况下,会实例化一个bicycle对象,制造自行车,然后销毁该对象。
在这种情况下,制作一组“主副本”的流行自行车型号和配置是有意义的。这些主副本可以被克隆,而不是软件需要运行昂贵的构建方法来生成符合常见配置的新自行车。
适配器
我在我的吉普车后备箱里放了两套扳手。一套是常见的 3/8 英寸机械棘轮扳手套装。有一次我从奥扎克山脉的度假地开车回家经过俄克拉荷马州。我们选择了一条风景优美的路线,通过州内的森林小道行驶。我们正开着车,突然在路中间压到了一块木板。这是不可避免的。我立即切换了汽车显示屏到轮胎,在接下来的几英里里,我看到一个轮胎的压力逐渐下降。我的一个轮胎里扎了钉子。我在第一个机会停车,找到了一块平坦的地面,这样我就可以安全地升起我的车并更换轮胎。
我的妻子和两个女儿和我在一起。我们必须把所有的行李从车里拿出来才能到达千斤顶。如果你曾经尝试过用工厂提供的工具换轮胎,那么你就明白我当时有多热、多沮丧,而且被困在路边。一辆皮卡停在了我们后面,司机戴着牛仔帽、牛仔裤和 T 恤,主动提出要帮助我们。我能看到他车后有一系列昂贵的电动工具。我接受了他的提议。我们用他的电动扳手松开了轮胎的螺母,几分钟内我们就回到了路上。我发誓回到家后第一件事就是买一套和他一样的工具箱。
我一回到家,就迫不及待地想用我的新电动扳手做更多的事情,而不仅仅是换轮胎。毕竟,爆胎并不是经常发生。我想用它来配合我的另一套棘轮扳手。不幸的是,我另一套工具中的 3/8 英寸套筒不能与½英寸的电动工具配合使用。如果你不熟悉这些工具,可以查看以下截图:

图 8.2:我的电动冲击扳手只有在使用适配器的情况下才能使用套筒扳手。
动力扳手就像一个普通的扳手,只不过动力工具上的驱动方头更大。驱动方头是工具上你安装套筒的部分,套筒上有方孔。3/8 英寸的套筒根本无法安装在½英寸的驱动方头上。也就是说,直到我找到了适配器,它才适配。适配器让我可以使用一个接口——比如 3/8 英寸的套筒——与另一个接口,比如½英寸动力套筒工具的驱动方头。
适配器模式对你的类也做了同样的事情。适配器实现允许具有不同接口的两个类一起使用。如果我的扳手问题用统一建模语言(UML)来表示,可能看起来像这样:

图 8.3:遵循适配器模式的类结构用于允许遵循一个接口的类与不同接口无缝工作。
让我们这样分解一下:
-
动力扳手是客户端,我们需要一种方法将较小的 3/8 英寸套筒连接到½英寸驱动方头上。为此,我们需要一个适配器。
-
适配器应该用接口来描述,以防止紧密耦合。这个接口要求一个
AttachSocket方法,该方法在具体适配器类中实现。 -
具体适配器类实现了接口,并包含一个接受客户端可以适配的东西的方法。
-
ThreeEighthsInchSocket类代表你想要连接到客户端的不兼容接口。
简而言之,适配器实现了客户端接口——在这个例子中是ISocketWrenchAdapter。它还包装了我们与之交互的类——在这种情况下,是ThreeEighthsInchSocket。适配器在适配器类中调用AttachSocket方法时,从动力工具接收驱动方头。这个方法内部的逻辑操作将输入转换为适配者可以使用的东西。在软件术语中,客户端会调用适配器类。被调用的方法会提供转换逻辑,以便将传递给方法的内容转换为与适配者兼容。
当你需要利用第三方或遗留系统与新工作结合时,这个模式可以非常有用。
Flyweight
你有没有想过某个想法让你整夜无法入睡?或者也许你有一个让你醒来的想法?你正睡得很香,然后突然醒来。你的半睡半醒的大脑刚刚想出了第 37 行的问题所在。
在一个晚上的启发式狂热中,Kitty 想知道如果有一天,Bumble Bikes 的需求爆炸了会怎样。如果这家小公司被数千个订单淹没怎么办?Phoebe 构建的机器人制造系统每次构建自行车时都会实例化一个自行车对象。每个自行车对象都会占用服务器随机存取存储器(RAM)的空间。Kitty 决定尝试使用开发服务器进行模拟。经过几次负载测试后,她确定她可以加载 1,000 个带有桥接画家系统的自行车对象实例。一旦对象计数超过 1,000 个并发对象,服务器开始变慢。一旦她在内存中达到 2,000 个对象,系统几乎完全停止运行,变得不可用。
解决这个问题的明显方法是为服务器订购更多的 RAM。当然,这使其成为硬件问题。我们是软件开发者。也许有一种方法可以通过严格使用软件模式来解决这个问题。也许一个小调整可以防止我们不得不向那个爱拍马屁的老板要求几千美元的升级。
享元模式用于将每个对象状态的一些共享元素移动到一个共享对象中。有时,你可以将大量数据从内存中移出并放入一个共享对象中。你在任何有高对象计数的地方都会看到这一点。例如,如果你使用 C#与 Unity 开发游戏软件,你的游戏可能会有数百甚至数千个敌人。也许你正在利用一个自制的粒子系统,有数千个闪亮的移动粒子,或者也许你正在进行一个工厂模拟,数千辆自行车由一小批机器人制造。
考虑以下图示:

图 8.4:享元模式涉及将重复的状态变量移入一个单独的对象中,以便可以共享以减少内存占用。
让我们根据图中的数字来分解它,如下所示:
-
这里,我们有一个包含所有部件的
Bicycle类。对于我们的模拟,我们只将制造公路自行车。定序系统允许客户使用特殊的喷漆定制自行车车架,或者从标准颜色中选择一种。座椅也可以更换为几种型号中的一种。然而,其余的属性没有提供定制选项。它们将是我们制造的每辆公路自行车都相同的。 -
现在,看看如果我们把那些不变化的状态部分移入一个单独的类会发生什么。在这里,我们的
Bicycle类只包含我们制造的每辆自行车之间可能不同的那些属性。 -
所有对象共有的状态元素被称为外显状态,并在
BicycleFlyweightCommonState类中表示。这个名字有点长,但我想要确保你能识别出哪部分是轻量级。我们可以创建这个类的一个实例,并与 Phoebe 模拟中的所有一百万个Bicycle类共享。
假设Bicycle类在实例化时占用 16 千字节(KB)的内存。如果 Phoebe 想要生成一百万个,那将消耗 16 千兆字节(GB)的内存。一旦我们将重复的状态转移到轻量级对象中,我们就可以为单个对象节省 14 KB。由于我们只实例化一次,内存占用只包括内在状态。我们以 2 KB 的实例化 1 百万个自行车对象,总消耗是 2 GB 加上轻量级类中单个外显状态的微不足道的 14 KB。这几乎减少了 8 倍!我不知道你怎么样,但如果我们能通过重新排列一些类来将软件的内存占用减少 8 倍,我会高兴一周!
责任链
Bumble Bikes 的成功并不完全归功于 Kitty 的设计或 Phoebe 的卓越机器人技术。Bumble Bikes 还注重质量,质量保证(QA)是完全自动化的。一套摄像头通过 OpenCV 使用人工智能(AI)进行一系列检查。检查从车架开始,然后移动到把手,传动部件,刹车,车轮,轮胎,最后是座椅。AI 执行检查所使用的逻辑不包含在单个方法中。那样会违反单一职责原则(SRP)。相反,每个检查的逻辑都封装在每个单独的检查方法中。检查是顺序发生的,从车架开始。如果任何检查失败,自行车将被标记为缺陷,并放置一旁,由人类自行车技师进行修复。
你可以在以下图中看到 QA 检查的概述:

图 8.5:自行车上的顺序质量保证检查是责任链的一个例子。
这个顺序排列的检查集使用责任链模式。如果任何检查失败,剩余的检查将不会执行。这就是丰田制造汽车的方式。如果在装配线上发现缺陷,一切都会停止,直到问题得到纠正。
模式本身采取的形式如下:

图 8.6:责任链模式在需要状态化的事件序列时被使用。
让我们按以下方式分解它:
-
一个接口,通常称为
IHandler,定义了两个方法。Handle方法定义了实现检查逻辑的方法签名。Next方法允许我们在当前步骤通过的情况下移动到下一个检查。 -
抽象处理类定义了下一个处理程序,这是具体类之间的一个共同属性。
-
具体类从
Handler继承,并使用Handle方法实现它们的检查逻辑,该方法覆盖了基类方法。同样,Next方法覆盖了基类,并用于将接力棒传递给下一个跑步者,或者在我们的案例中,将当前的检查传递给下一个。
流程图逻辑自图灵机以来就存在了。因此,有一个模式来封装这个普遍概念并不令人惊讶。
代理
一天,Kitty 和 Phoebe 接到 Eloise Swanson 博士的电话。Swanson 博士成立了一家名为“美国机器人及机械人”的新公司。她公司的新产品是一款高端的用于机器人控制的软件开发工具包(SDK)。Swanson 博士在麻省理工学院(MIT)的硕士研究生期间研究了 Phoebe 的设计,并认为 Bumble Bikes 是进行 beta 测试的良好合作伙伴。
该 SDK 非常有效且易于使用。然而,它并不能完全替代 Kitty 和 Phoebe 编写的软件。首先,任何 SDK 都无法完全替代任何业务的定制软件。第二个问题是膨胀,因为 SDK 被设计成可以与任何机器人系统协同工作。大量的代码被用来考虑每一种可能性。
Kitty 和 Phoebe 发现 SDK 非常适合控制他们的喷漆机器人。然而,只有在需要定制从未进行过的喷漆工作时才需要它。一旦完成喷漆工作,它就会被编目,颜色公式就可以重复使用。来自“美国机器人”的 SDK 使喷漆工作大大缩短,但代价是初始化缓慢和占用大量内存。如果女孩们要将她们的代码与 SDK 结合,当然她们不会这样做,那么所有的工作都会因为一个昂贵的初始化过程而变慢,而这个过程她们很少会用到。
解决方案是在需要时才懒加载 SDK 对象。代理模式允许你定义并使用一个对象占位符。然后,代理可以在需要时仅加载大而慢的 SDK 类。其余的生产过程将不受影响。
让我们以下面的图为例,检查代理模式的结构:

图 8.7:代理模式可以在需要之前用一个简单的对象替换一个更复杂的对象。
将其分解将有助于我们进一步理解,所以我们将这样做:
-
我们从第三方供应商那里有这个 SDK。我们知道与第三方供应商的 SDK 紧密耦合从一开始就是一个坏主意。在这种情况下,SDK 有一个我们想要使用的方法,但它的包含类有一个大而慢的构造函数,并且对象很少被使用。我们需要一种方法,在需要时才懒加载这个对象,同时防止紧密耦合。
-
我们创建一个接口以防止紧密耦合。我们的客户端软件可以要求这个接口,只要我们能够保持接口,未来的任何变化都是可以接受的。
-
然后,我们创建一个包装器来持有 SDK 实例。包装器持有
BigSlowButVeryUsefulPaintService的一个实例,这个实例可能需要几分钟才能实例化。这已经是很长时间了,考虑到我们只需要一个方法。我们的包装器实例化BigSlowButVeryUsefulPaintService,然后在真正需要时才调用昂贵的InitializeSystem方法。由于我们承担了实例化成本,我们可以将实例存储在私有属性中,并在需要时再次使用。这听起来可能像单例模式,但单例模式确保只创建一个实例。在这里,我们只是在重复使用我们已经制作好的东西,这并不完全相同。
记住,任何需要第三方、遗留或过于昂贵的对象占位符的时候,都要使用代理模式。这将防止紧密耦合,并推迟在实例化时可能发生的昂贵操作,直到它们真正需要时。
Interpreter
解释器模式是你可能永远不会需要的一种模式。当需要解释一种可以用抽象语法树(ASTs)表示的自定义语言时,会使用这种模式。随着领域特定语言(DSLs)的流行,整个工具包,如 JetBrains Meta Programming System 和 Visual Studio Enterprise 的建模 SDK,使得创建自定义语言解释器成为一个相对简单的项目。
由于 DSLs 超出了本书关于模式的范围,我将在本章末尾的“进一步阅读”部分列出我提到的 DSL 工具的参考。
Mediator
想象 Bumble Bikes 的未来并不困难,他们的自动化制造系统可能会扩展并变得更加复杂。目前,我们有建造模式控制着制造自行车和轮椅的机器人。有两个物理工厂,每个工厂只专注于几种产品。所有这些在未来都可能改变。
我曾在一家飞机制造商与另一家飞机制造商的合资企业工作。我们的公司制造动力装置(发动机)并组装最终的飞机。合作伙伴公司制造飞机机身。其他合作伙伴公司提供航空电子设备,这些是现代飞机中存在的电子飞行控制系统。还有一家公司制造我不得谈论的军事组件。
假设 Bumble Bikes 有如此多的制造操作。即使它们都集中在一个工厂里,所有不同机器人制造系统之间的信号通信水平可能会变得非常混乱。如果每个系统都要直接与其他系统通信,我们很快就会陷入麻烦。
如果你曾经使用 HTML 和 JavaScript 创建过大型网络应用程序,你可能会遇到类似的问题。你有数十种不同的代码片段在响应来自用户交互、计时器、表示状态传输(REST)应用程序编程接口(API)调用、第三方 SDK(如 jQuery)和第三方广告网站注入到你的网站以进行货币化的数百个信号时修改文档对象模型(DOM)。当这样一个系统在信号复杂度方面达到临界质量时,它就会变得缓慢,几乎无法调试和维护。
让我们再考虑一个例子:自然灾害,如飓风或龙卷风。如果所有第一响应者(FRs)都能直接相互联系呢?每位消防员都可以通过无线电与每位警察联系,警察可以与每位紧急医疗服务(EMS)单位联系。EMS 响应者可以直接与分级护士和红十字会志愿者交谈。所有这些都在一个大的开放通信渠道上发生。在这种环境下,你生存下来的机会有多大?开放直接通信有时可能是一件好事,但我认为我们所有人都意识到它无法扩展。现在,想象一下,在一个软件片段中有数百个对象,它们都可以直接访问堆栈上所有其他对象。你看到了问题,对吧?
在所有这些情况下,都需要某种形式的调度器:一个通信的中心枢纽。中介者模式体现了这个角色。中介者充当所有通信的中心枢纽,并以受控的方式将请求路由到需要它们的对象。让我们看看这里显示的图:

图 8.8:中介者模式涉及一个单一的中心对象,该对象在对象之间指导调用。
根据图中的数字,它的工作方式如下:
-
这是同事对象的基础类。
-
你有一群同事对象,需要一种集中的通信方式。这里我只有四个,这可能不足以导致我们转向中介者模式。但想象一下,400 个对象都在直接相互通信!就像我之前说的那样,直接通信无法扩展!
-
我们创建了一个名为
IMediator的接口,当你大声说出来时听起来很酷。这通常可以防止紧密耦合。 -
现在来说说好的部分。一个基于
IMediator接口的中心对象包含所有对象的实例,并在它们之间定义了通信通道,这些通道以我称为ReactWithX的方法的形式存在,其中 X 是与发送信号的对象相对应的数字。ReactWith1会在Colleague1上调用一个适当的方法,依此类推。
这个模式旨在简化通信过程,但它通常会导致一个非常大的对象,其中包含大量的内部组件实例和方法用于通信。您必须权衡Mediator类的复杂性与其提供的集中化好处。一方面,作为一个开发者,有一个类可以在您的 IDE 中设置断点是很不错的。就像一头狮子在观察当地的饮水点一样,最终,所有消息流量都会通过这个类,这使得查找错误变得简单。
然而,这个类本身可能会变得难以控制。
Memento
您是否曾经保存过游戏或在您最喜欢的编辑器中使用过撤销功能?如果是这样,那么您可能已经与 Memento 模式互动过。对于记忆点的最佳类比是一种酷但过时的技术。当我还在上小学的时候,最热门的相机被称为宝丽来。那时的相机大多使用胶卷。您会在相机上拍照,然后把胶卷拿到药店去冲洗。大约需要一周的时间才能拿到您的照片。然而,使用宝丽来,您可以在几分钟内拍照,照片从相机中弹出并自行冲洗。如果您摇动照片,冲洗过程似乎会更快一些,这引发了一首流行的歌词和相应的舞蹈动作,“像宝丽来照片一样摇动它”。
那时候,我们把这些图片称为快照。今天,这个术语与 Memento 模式一起使用。一个快照,就像宝丽来照片一样,是特定时间点的事件或地点的表示。同样,在软件中也是如此:快照表示对象在某个时间点的状态。就像照片一样,软件快照可以保存为记忆点——提醒您对象处于该状态的那一次。
文本编辑器,例如您的 IDE 或 Microsoft Word,会持续跟踪您文档的状态,并在您键入时保存记忆点。当您在键盘上按下Ctrl/Command + Z时,您可以按顺序回到越来越早的记忆点。
初看之下,这似乎很简单。您只需创建一个List<>对象来保存,比如说,用户最近做的 100 个状态更改。也许您每 30 秒左右存储一个记忆点。这很简单。只有当您的状态中的每个对象以及用于继承和组合的每个对象都有 100%的公共属性时,这才会变得简单。如果整个状态都是公共的,您就不需要这个模式了。
这里真正的障碍与我们在之前的原型模式中遇到的相同。我们试图复制德古拉伯爵的城堡,但我们不允许进入前门。复制城堡的外部是简单的,但要复制整个城堡需要内部演员。这在备忘录模式中同样适用。在备忘录的情况下,内部演员是一个嵌套类。嵌套类可以访问外部类的状态,并可以存储我们的建议List<>对象,包含我们的撤销历史。让我们看看这里显示的图表:

图 8.9:备忘录模式。
由于最熟悉的例子是文本编辑器,我使用了它来绘制图表。让我们回顾一下图表的编号部分,如下:
-
这个类代表的是
DocumentEditor类,即我们的客户端。备忘录模式称这个类为发起者,因为存储的状态来自这里。 -
当编辑器需要保存撤销状态——或者,更确切地说,保存文件时——它可以使用
Memento类进行存储。请注意,所有内部内容都是私有的。Memento类嵌套在发起者内部;它定义为DocumentEditor类内部的嵌套类。除了被锁定外,你还应该考虑Memento类是不可变的:一旦创建,就不应该再改变它们。私有属性通过构造函数设置,随后不再修改。这意味着在实现时,不应该为这些属性提供任何 setter 访问器方法。 -
在 UML 中绘制内部类没有标准的方法,所以我将
Caretaker类放在虚线框外面,作为一个视觉提示,表明这个类有所不同。记住,Memento位于DocumentEditor内部,而不是通过组合。Caretaker类反过来包含Originator字段中的DocumentEditor对象。因此,Caretaker类负责创建和恢复备忘录,以及撤销历史。
备忘录模式可能很难正确实现,你可能在职业生涯中走得很远都不需要它。即使你在一家人文编辑器公司找到工作,你也很可能使用许多优秀的第三方用户界面(UI)控件,例如来自 Telerik 的控件,这些控件已经为你实现了这一功能。
值得注意的是,你可以使用 C#的序列化库实现几乎相同的效果。通常,保存应用程序状态也涉及持久化。好的编辑器让你可以撤销最后 100 次更改。伟大的编辑器让你可以在编辑会话之间这样做。你可以关闭笔记本电脑,飞越世界,重新启动,你的撤销历史仍然可用,因为它们被序列化(保存)到你的硬盘上的某个文件中。
.NET 框架为我们提供了一系列序列化选项,包括System.Runtime.Serialization、System.Runtime.Serialization.Json和System.Text.Json.Serialization。每个都包含一组旨在使将对象序列化到文件变得相当简单的类。然而,你仍然会面临私有属性的问题。幸运的是,微软为我们提供了DataContractSerializer类,它可以帮助你绕过这个限制,而无需使用复杂的类结构或担心备忘录的不可变性。
状态
我得承认,回顾过去八章,我可能应该介绍这种模式。你会用到它,尤其是如果你在 Unity 3D 游戏开发中工作。如果你不熟悉这个工具包,它本质上是一个能够为游戏机、PC、Mac、移动设备和网络创建 AAA 游戏的引擎。我在当地学院教授 Unity 游戏开发多年,因此我对它有着特殊的感情,尽管我从未作为职业游戏开发者工作过。Unity 3D 游戏引擎使用有限状态机来控制游戏角色的动画。它使用可视化编辑器来定义状态以及当游戏角色状态改变时使用的动画。
状态模式涉及根据对象内部状态的变化来改变对象的行为。一个对象的状态在概念上只是它在某个时间点所有属性值的集合。如果你制作一个你在乡村四处奔跑与僵尸战斗的游戏,你可能会用不同的行为来定义你的僵尸。大多数时候,它们只是在随机地四处游荡,寻找新鲜的 BRAINS!
当有大脑的东西出现在视野中时,僵尸的行为会改变。它一边拖着脚步向携带大脑的生物走去,一边反复地嘶吼“BRAINS!”一旦僵尸进入手臂可触及的范围,它的行为再次改变。僵尸攻击!
我们有一个对象,具有三种行为,所有这些行为都由内部状态控制。当我们把行为和状态结合起来时,我们就形成了所谓的有限状态机。它是有限的,因为僵尸的行为数量是有限的。在这种情况下,数量是 3。有限状态机可以像下面这样绘制:

图 8.11:表示视频游戏中僵尸行为的有限状态机。
这个状态图显示了不同状态之间的转换。僵尸不能随机攻击一个距离太远的受害者。它必须先看到受害者,然后缩短距离。模式的图示如下:

图 8.12:状态模式。
让我们将其分解如下:
-
IState接口定义了可能的行为。我们的僵尸可以巡逻、接近可见的受害者,并攻击。 -
Context类持有实现IState接口的对象的引用,用于与具体状态对象通信。 -
具体状态对象包含特定状态的方法。
上下文对象可以通过交换在私有状态属性中持有的具体状态对象来改变状态。这意味着上下文可以强制执行任何状态转换的逻辑。
我之前没有包括状态模式,因为它与我们在第五章中介绍的策略模式非常相似。凯蒂和菲比使用策略模式为自行车创建了一个导航系统。系统的行为根据用户请求的地形类型而改变。
模板方法
模板方法模式与我们在第五章中介绍的策略模式非常相似。这是一种行为模式,它允许你定义算法的结构,但将实现推迟到子类,这些子类覆盖了实际的逻辑而不是结构。
在第五章中,菲比为自行车设计了一个导航计算机。它使用策略模式来计算导航路线,取决于骑行者是否想要通过铺砌的道路、未铺砌的砾石道路还是极端地形。我们也可以用模板方法模式做到同样的事情,这就是为什么我没有觉得有必要介绍两者。
结构可以在以下图中看到:

图 8.13:模板模式。
让我们看看编号的部分,如下所示:
-
抽象模板类定义了一个形成算法的类的结构。算法的步骤被定义为抽象的,可以在子类中的具体实现中覆盖。然而,这些步骤是在模板类中的方法中调用的。在我的例子中,我称之为
ExecuteTemplate(),这个方法没有被覆盖。ExecuteTemplate()按顺序调用步骤,这永远不会改变。模板定义了可覆盖的逻辑,但每次使用模板时结构都是一致的。 -
这些是覆盖算法步骤的具体类。注意
AlgoImplementationA类覆盖了父类中的所有步骤。AlgoImplementationB类只覆盖了几个步骤。
这个模式允许你使用普通的继承来创建非常灵活的算法实现。
访客
访问者模式是另一种行为模式,旨在帮助你“附加”新的行为到现有对象上。其动机集中在 SOLID 原则(如果你需要这个缩写的解释,请回到第二章)。开放封闭原则得到了尊重,因为你是在不修改现有类的情况下添加行为的。单一职责原则也得到了尊重,因为通常你添加的行为是新的,可能几乎与原始类的目的无关。
这个名字背后的想法来源于这样一个观点:一个封装了新行为的对象可以访问现有的、已建立的类,从而允许它执行新的行为。你实际上是在教一只老狗新把戏。
想象一个你的身体有一个能力插槽的世界。你可以像将记忆卡插入相机一样轻松地插入新的行为。需要学会开直升机来逃离邪恶的秘密特工吗?插入飞行训练的卡片,你就可以瞬间驾驶任何民用或军用飞机!需要像五星级厨师一样烹饪吗?插入烹饪技能的卡片,你就能在任何烹饪比赛中击败戈登·拉姆齐或博比·弗莱!需要在夜总会施展舞技吗?插入夜总会技能的卡片,你就可以尽情跳舞!只是要注意最后一个。你无法知道卡片上可能还有什么其他技能。
让我们抛开最后一个例子,看看下面的图示:

图 8.14:访问者模式。
让我们把它分解如下:
-
我们的
Visitor接口定义了一组方法,这些方法必须实现以向现有的对象图授予新的超级能力。我们很幸运,C#支持方法重载。方法重载指的是只要方法签名不同,语言就可以重用方法名称。如果你不确定这是什么意思,请查看本书末尾的附录 1。我们将在那里介绍它。并非所有的面向对象语言都支持这种功能,但 C#支持。我们的接口定义了具有相同名称但具有不同参数类型的方法。 -
另一个名为
Element的接口定义了如何接受访问者。如果你想在未来的世界中获得新的能力,你将不得不在你的头骨上添加一个插槽,以便我们有一个地方可以插入我们的能力卡。同样,你的类将需要根据这个接口添加一个额外的方法。你不需要更改类中的任何现有方法或逻辑。你只需要添加一个接受访问者的方法。 -
具体的访问者类实现了我们的接口并提供新的行为逻辑。请注意,我们没有使用
IElement接口作为参数类型。传入的具体对象类型决定了基于方法重载运行哪个方法。 -
这些是实现了
Element接口的现有类。
当我想起 C#中的访问者模式时,有几个想法浮现在脑海中。首先,它感觉非常像装饰者模式,但重点是对象图而不是单个类。这可能只是我个人的感觉?
另一个类似的模式是第五章中提到的组合模式,再次在第七章和第八章中提到。Kitty 和 Phoebe 使用组合模式对一个用于组成自行车传动系统的对象图进行了一系列的计算,以计算重量和成本。Tom 通过在设计时将组合模式编织到对象图中来改进了设计。区别在于,组合模式在图外的对象上操作,而访问者模式是在插入新的行为。
最后,我第一次读到访问者模式时,立刻想到了扩展方法。扩展方法是 C#独有的。它们允许你使用一个单独的类文件向现有类添加行为。它们不是访问者模式的实现,但如果你的需求很简单,你可能想从扩展方法开始。如果扩展方法不够用,那么尝试更复杂的访问者模式。
超越 OOP 领域的模式
面向对象编程领域只是起点。还有一些超越 OOP 领域的模式,你很可能听说过,但可能不知道它们是编码化的模式。
软件架构模式
在软件架构领域寻找更多模式的一个明显区域。这似乎像是我们一直在谈论软件架构。是的,我们一直在谈论。然而,软件架构并不局限于面向对象编程(OOP)。本书中的每个模式都依赖于使用 C#,因为它是一种面向对象编程语言。软件架构模式跨越了所有语言,并真正帮助我们定义系统,而不仅仅是增强我们代码的结构。让我们看看一些你可能之前已经听说过的例子。
客户端-服务器模式
我们实际上已经涵盖了这一点——我们只是没有将其称为模式。这个模式涉及对等网络(P2P)架构,包括一个服务器和通常许多客户端。客户端向服务器发出请求并对结果进行处理。这基本上就是互联网的工作方式。
微服务模式
微服务模式包括将大型、单体应用程序分解成小型、自包含但相互依赖的服务。这个想法是将单一责任原则(SRP)推向其最终实现。想象一下,一个带有少量端点的 REST API,这些端点共同只服务于一个目的,比如密码重置。而不是将密码重置构建到一个更大的 API 中,该 API 执行数十个其他操作,密码重置变成了一个微服务。
这个想法是,维护小型单用途 API 更容易。权衡来自于依赖系统调用之间的延迟以及网络拓扑结构的结果复杂性。
模型-视图-控制器(MVC)模式
你肯定听说过这个!你会在将应用程序代码分为三个层次时在 Web 应用程序中看到它,如下所示:
-
一个包含表示数据模型的代码的模型层,通常通过对象关系映射器(ORM)实现。
-
一个表示用户体验(UX)的视图层。
-
一个控制器层,它代表接受视图请求和处理从模型层返回的数据所需的逻辑。这种配置体现了关注点分离(SoC)的理想。
发布-订阅(pub-sub)模式
这又是另一个在许多形式中都很受欢迎的模式。有时你会看到对象级别的实现,比如我们在第五章中讨论的观察者模式。你也会在 Redis、RabbitMQ 和 Apache Kafka 等软件中看到更高层次的架构实现。
在所有情况下,想法是允许从中央源进行通信。消息被发送到一个发布者,然后发布者将消息发布给相关的订阅者。这对于分布式架构至关重要。
命令查询责任分离(CQRS)模式
该模式旨在解决数据库查询数据发生的频率远高于数据库更新的情况。如果你有一个报告系统,其中报告被生成但很少随后修改,这是一个值得学习的模式。其解决方案通常涉及将很少更改但经常查询的数据分离到一个称为数据仓库的单独数据库中。这可以提高读写性能,因为责任是分离的。权衡来自于实施成本,这通常涉及建立一个单独的数据库服务器来处理分离的负载。
数据访问模式
数据访问模式出现在你与关系型数据库或甚至非关系型数据库的传统代码交互的任何地方。由于关系型数据库自 1970 年以来一直存在,并且基本上没有变化,并且完全无处不在,因此它们有自己的模式集并不令人惊讶。我在 Clifton Nock 所著的《数据访问模式:面向对象应用程序中的数据库交互》一书中磨练了我的技能。我将在本章末尾的“进一步阅读”部分列出这本书的详细信息。
这里只是书中的一些模式,你可能会认识。
ORM
你听说过实体框架(EF)吗?作为一名 C#开发者,你必须生活在某种高度学术性的岩石之下,才没有听说过微软的旗舰 ORM。ORM 的工作是在 SQL 数据库(如 SQL Server)中的关系结构到 C#中的对象之间映射数据。如果一个模式是解决频繁发生的问题的解决方案,那么这可能是软件开发中最重要的模式之一。
这种模式的实现,通常封装在第三方库中,或者在我们的案例中,在.NET 框架中,允许开发者仅使用 C#对象进行工作。使用 ORM,你永远不需要在应用程序数据库中创建或更新表结构。你永远不需要在代码中解决复杂的连接或连接长 SQL 语句。你创建一组代表你的数据库结构的对象。然后查询这些模型类,通过数据操作来操作你的数据库。
优点是,如果你不知道——或者不喜欢——SQL,你永远不需要直接与之打交道。权衡之处在于应用程序的性能。EF 代码在扩展规模上比直接连接到数据库运行得慢得多。它还给你的应用程序增加了一层依赖。这只是可能出错的一个额外因素。如果你觉得我不是它的粉丝,那你就对了。我喜欢 ORM 用于小型项目,用户数量有限。例如,如果你正在制作一个供内部使用的应用程序,并且你有有限的时间来创建它,使用 ORM 可能会帮助你更快地完成任务。
相反,如果你正在为大量用户制作应用程序,放弃 ORM 的便利性,直接实现我们的下一个模式,这将给你巨大的性能控制权,允许你利用数据库软件的全部功能。
Active Domain Object
Active Domain Object(ADO)模式很有趣,因为当你看到 ADO 时,你可能想到的是微软的ActiveX 数据对象(ADO)或者 ADO 的更老前辈数据访问对象(DAO)。然而,这可能只适用于 35 岁以上的人。这些技术已经有一段时间没有处于前沿了。
ADO 模式封装了数据访问和相关对象实现。他们的目标是消除模式实现之外的任何与数据库的直接交互。听起来熟悉吗?这正是微软 ADO 和 DAO 被设计来做的。我怀疑这不是巧合。
大多数开发者不使用这种模式,他们通常已经转向使用 EF 等 ORM。然而,如果你有 SQL 技能,通过直接通过原生驱动程序访问数据库,你通常可以创建一个性能更好的应用程序。
需求缓存
我的妻子和孩子经常这样做。开玩笑的,但也不是。有很多缓存模式。需求缓存体现在一个按需懒加载的缓存中。这里起主要作用的是你不知道数据何时会被需要,但你希望只支付一次检索数据的代价。
我的应用程序包括一个需求缓存,作为 Web 应用程序的一部分。我的应用程序中的一些查询返回大型记录集或包括相当多的处理,可能需要几秒钟才能完成。这听起来可能并不糟糕,但对于 Web 应用程序来说,这是一个漫长的过程。我的需求缓存方法在请求数据时首先检查缓存(在我的情况下是 Redis)。如果它在缓存中,数据就从缓存中提供。如果不在,我就检索并缓存它。第一个请求数据的客户承担性能惩罚,但之后的每个客户都能非常快速地获取数据。
事务
这又是另一个无处不在的数据处理术语,实际上是一个模式。在数据库术语中,事务指的是必须作为一个单元完成的多个 SQL 语句。例如,如果你有一个自动取款机,并且你想从一个账户取款并转到另一个账户,你需要这个操作作为一个工作单元——事务。
你从账户 A 中取出 100 美元,并将 100 美元存入账户 B。如果任一 SQL 语句失败,你需要考虑整个事务失败,并完全回滚所有更改。
乐观锁和悲观锁
没有什么比在数据库中处理锁更让应用程序开发者抓狂的了。乐观锁的使用是为了防止错过数据库更新。你会在库存管理系统(IMSs)中找到这种模式的使用,在这些系统中,最新的库存数据对于销售库存至关重要。上周末,我在一家热门商店买了一台新的衣物烘干机。我妻子挑选了一台完美的烘干机,烘干机上面的标牌写着,“现在订购,您将在 3 天内收到”。销售员去输入订单时发现,这些产品不仅缺货,而且制造商不接受退货订单。
标牌假设仓库有库存,并基于过时的信息。现在,用数据库查询替换标牌。销售应用程序查询以查看是否有烘干机库存。在同一个时刻,在城里的另一家商店,有人刚刚购买了同一型号的烘干机。
第一家商店的店员如何知道是否真的有烘干机可用?乐观锁模式包括在数据库表的每一行上创建一个版本号。在这种情况下,如果行版本号自事务开始以来没有改变,并且不锁定数据库行以进行更新,数据库将乐观地假设烘干机可用。
相比之下,在相同的场景中,如果第二个销售员的交易正在进行中更新库存行数据,悲观锁将阻止销售员的销售交易进行。
创建自己的模式
你认为你有一个自己设计模式的想法吗?GoF 书籍提供了一个模板文档框架,用于发布你自己的模式。我不会在这里完全重复它,但我会为你概述它。它包括以下四个基本要素:
-
名称和分类
-
问题描述
-
解决方案描述
-
使用该模式的后果
让我们简要谈谈每个部分。
名称和分类
每个模式都需要一个描述该模式的名称。大多数现有的模式名称都让人联想到日常语言中的通用词汇。单词memento指的是一个能够唤起过去时光记忆的物理对象。单词singleton则唤起了只有一个事物的想法。想出一个简短、易于记忆的名称,能够唤起模式背后的理念。
你还需要一个分类。在这本书中,我们观察到了 GoF 书中的三种分类:创建型模式、结构型模式和行为型模式。在前几节中,你看到了其他使用模式的领域也有自己的分类列表。也许你的模式适合现有的分类。如果不适合,你需要发明一个新的分类。
你的模式可能还有一个别名;一个“也称为”(AKA)的名称。装饰者模式也被称为包装器。如果有任何别名,请记录下来。
问题描述
本节文档描述了你的模式旨在解决的问题。当 GoF 描述问题时,他们会将其分解为几个更小的部分,如下所述:
-
意图指的是你的模式的整体目标。
-
动机/驱动力说明了为什么有人会使用你的模式。这比“因为它很酷”或因为它解决了特定问题要深入。当我们讨论第一章中的反模式时,我们描述了一组导致反模式出现的驱动力。在本节中,我们正在寻找类似的原因,以了解你的新模式为何相关且有用。
-
适用性列出模式的上下文。列出模式有用的简短情况列表。
虽然没有必要填充每一个空缺,但你将认识到我们在本书中使用的元素。
解决方案描述
在描述解决方案时,你需要描述用于构建设计的元素。阐述元素之间的关系。描述元素如何协作,并确保解释每个元素的责任。你可能还需要将这一部分分成更小的部分,如下所示:
-
参与者列出参与模式解决方案的类、对象、接口、枚举等。
-
协作描述了参与者之间的协作方式。
-
结构将包含模式的通用形式的 UML 图,也许还有更贴近现实的具体用例图。这是我在这本书中涵盖的每个模式所使用的格式。我发现通用图单独使用时不太有用。如果后面跟着一个具体示例,开发者可以查看这两个图,并更容易地将它们与他们的工作联系起来。
-
实现部分包含如何实现该模式的描述。在这本书中,我使用了编号图来描述各个部分是如何组合在一起的。
-
示例代码或伪代码通常不言自明。我建议使用现实世界的例子,而不是类 A 从类 B 继承,类 B 又组合了类 C。这太抽象了。如果你想让人们使用你的模式,找到使其与他们的工作相关的方法。
使用该模式的结果
你应该始终讨论使用你的模式所带来的积极成果和权衡。大多数模式都有支持者和反对者。如果你在网上阅读有关模式的资料,你会看到关于某些模式可能成为反模式的健康辩论。我们在讨论第三章中的 Singleton 模式时,就提出了这样一个案例。模式越复杂,出现权衡的可能性就越大。模板方法模式极其简单。我怀疑提出这个模式的人是否曾因考虑可能的负面结果而失眠。相比之下,第四章中提出的外观模式(Façade pattern)在第三方框架的复杂性和不需要第三方框架暴露的所有内容的开发者的易用性之间提供了一个权衡。
并非每个人都喜欢模式
我们在第三章中对 Singleton 模式的讨论中展示了,并不是每个人都认为模式是对软件开发领域的积极贡献。通常的论点是设计模式只是对无能、低效或不完整的 OOP 语言的权宜之计。学术文献表明,在 GoF 书中,当使用诸如列表处理(List Processing)或 Dylan 之类的语言时,多达 17 个模式变得不再必要。什么?谁甚至使用这些?
另一组学术上的反对者主张将你的范式从面向对象编程(OOP)切换到面向方面编程(AOP)作为解决所有问题的方案。你可能已经知道,OOP 旨在通过在现实世界中建模事物来解决问题。AOP 旨在将行为建模为横切关注点。AOP 不应该与 OOP 竞争,但有些论点将其定位为竞争关系。
核心观点是,总会有辩论说:“如果你只是切换到语言 X、框架 Y 或范式 Z,你所有的模式问题都会得到解决!”我对这种说法的回答是提醒你要警惕“黄金锤”!
概述
模式无处不在。有一个名为仿生学的领域,旨在研究受自然界模式启发的技术。现在谈论软件开发而不提及人工智能(AI)是很困难的,其主要任务是使用称为机器学习(ML)的技术在大量数据中寻找模式。自 1843 年 Ada Lovelace 编写了大多数人认为的第一个计算机程序以来,软件行业一直在嗡嗡作响。在那段时间里,我们反复遇到了相同的挑战和挫折。最终,我们足够聪明,开始把它们写下来并谈论它们。
我们一直在学习模式,其核心实际上仅仅是一种方式,通过这种方式我们可以传达与组织优化代码相关的最佳想法。
在本章中,我们简要介绍了原始 GoF 书中的一些模式,这些模式在本书的前几章中没有涉及,作为本书故事的一部分。在每个案例中,我出于一两个原因省略了这些模式,如下所述:
-
如果一个模式与已经介绍过的另一个模式非常相似,我就没有介绍它。状态模式与策略模式非常相似。策略模式适合我的故事,所以我使用了它。
-
如果一个模式非常复杂且很少使用,我就没有介绍它。备忘录模式是一个很好的例子。有更简单的方法来处理表示对象快照的使用案例,例如.NET 的序列化功能,这否定了复杂备忘录模式实现的需要。
我们超越了 GoF 模式,甚至超越了面向对象模式,列出了软件和数据库架构其他领域的一些常见且高度熟悉的模式。我们以如何记录你自己的模式(如果你发现了新的模式)的概述作为结尾。我在本章中给你留下了最后的警告。你可能会想关上这本书,像疯子一样在办公室里到处大喊“模式!”不要笑。我见过这种情况发生。如果你这样做,预期会有一些翻白眼。并不是每个人都认为模式是个好主意,有时他们是对的。模式本身很容易陷入黄金锤反模式。记住——模式是为了简化并改进你的软件。如果它们使事情变得更慢或更复杂,你必须在这些情况下放弃它们。模式是工具,而不是教条。
只有一处 loose end(未了结的尾巴)需要解决。
萨恩迪斯广场 – 德克萨斯州沃斯堡
在德克萨斯州沃斯堡,那是一个温泉天。那是 MS-150 自行车拉力赛的最后一天。MS-150 是一项每年为治疗多发性硬化症(MS)的研究筹集数百万美元的活动。达拉斯地区的数千名自行车手参加了这场 150 英里、为期两天的活动。大多数当地自行车店都在沃斯堡市中心的购物和娱乐区 Sundance Square 设立了帐篷。作为白金赞助商,Bumble Bikes 在终点线处搭建了一个大型帐篷。
汤姆、莱克西和卡瑞娜正在帐篷里工作,向冲过终点的勇敢者们提供水和击掌。虽然超过 3,000 名骑手开始这场拉力赛,但不到 10%的人真正完成。大多数人因为膝盖疼痛或设备故障而中途放弃。汤姆和莱克西密切注视着一群骑手,他们完全预期他们会是最后一名完成比赛的。这是一个拉力赛,而不是比赛。骑手们不是在相互竞争——他们是在与自己竞争,看看自己是否有完成全程的能力。他们在第一天从普莱诺出发,骑行 75 英里到达德克萨斯州赛车场,那里举行当地的全国汽车赛车协会(NASCAR)比赛。第二天他们再次出发,但首先在赛车场跑了一圈,然后骑行 75 英里到达沃斯堡。
“他们就在那里!”莱克西喊道,她眯着眼睛朝远处的一个小山丘看去。“Bumbles 队”正在翻越这座山丘,所有人都在骑全新的 Hillcrest 自行车:凯蒂、菲比,以及大多数 Bumble Bikes 的员工,还有凯蒂和菲比的父亲。
自从他确诊以来已经过去了 10 年。化疗和类固醇治疗对治疗这种疾病无效。事实上,类固醇导致了严重的骨质疏松症。医生们已经放弃了,说已经没有更多的办法可以尝试。凯蒂和菲比无数次地祈祷,最终得到了当地教堂非正式联盟的支持。
凯蒂和菲比制作的轮椅非常有帮助。他们免费为世界各地的儿童医院制作并分发了超过 1,000 辆轮椅。Bumble Bikes 的自行车和轮椅销售稳步增长,直到成为世界上第三大自行车制造商,也是唯一一家在其产品中标注美国(US)的公司。MegaBikeCorp,凯蒂和菲比多年前实习的公司,排名第 19 位。
他们的父亲多年来一直在使用他的“Texas Tank”。这种设计证明生产成本太高,无法大规模生产,所以他们只制作了一辆。尽管他非常喜欢他的“Tank”,但他每天都会尝试站起来。大多数日子他会摔倒。直到有一天,他没有摔倒。
就像它突然开始一样,有一天它停止了。医生们无法解释这一点。他们多年前就已经用尽了所有的想法。凯蒂和菲比知道他们的祈祷得到了回应。
这需要多年的物理治疗。在疾病几乎摧毁了他喉咙中的肌肉之后,需要电击疗法来让他的声带恢复工作。他每天在胃部注射以治疗骨质疏松症。他每天进行短距离散步——最初是几块街区,但后来是几英里。月复一月,年复一年,他变得越来越强壮。他将他的德克萨斯坦克换成了 Bumble Bikes 的新款带电机的电动自行车。汤姆很高兴将坦克从他的手中拿走。这款新电动自行车提供了一种助力踏车的电机。电机并不是做所有的工作——它只是帮助。
最终,他能够再次骑上普通自行车了,今天,他和他的女儿们以及一支小型残疾人骑行队伍一起骑行了 75 英里到达终点线。Bumble Bikes 为无法使用双腿的骑行者生产了手摇自行车,并邀请任何没有这种自行车的骑行者加入Team Bumbles。
当他们看到队伍的最后一名,随后是警察护送通过终点线时,广场上所有人停止了手中的事情,为他们欢呼。这是一条非常漫长的道路,不仅仅是因为一天内骑行 75 英里是很长的距离。终点线意味着的不仅仅是拉力的结束。最重要的是,他们作为一个家庭一起跨过了终点线。
进一步阅读
-
诺克,克利夫顿。数据访问模式:面向对象应用程序中的数据库交互。波士顿:Addison-Wesley,2004.
-
本书配套网站:
csharppatterns.dev
附录 1:C#中 OOP 原则的简要回顾
编程语言的领域中有数百种选择。如果你正在阅读这本书,你可能在旅途中某个时刻遇到过 C#。对于一些人来说,C#是他们唯一学过的语言。对于其他人来说,它是第三或第四种语言。也许你每天都在用 C#开发,已经很多年了,或者你可能刚刚开始学习,现在它成了你的新好朋友。
我意识到读者们带着不同的经验水平、不同的背景和不同的职业目标来到这本书。我过去在 LinkedIn Learning 上发布了一个非常受欢迎的视频系列,旨在教授 C#入门。说实话,这始终是我最喜欢的受众。教新手软件开发就像在一个缺乏远见的世界上教授魔法。我每周几乎都能在我的学生中看到“啊哈!”的时刻,我在南方卫理公会大学的全栈代码训练营教授。
我对撰写这本书的前景感到兴奋的一件事是这本书能帮助你迈出下一步。要么你刚刚学会了如何编码,要么你可能已经成功使用了多年的魔法技巧,但你现在意识到还有更多东西要学。模式是一个很好的下一步。学习模式会使你在编程的几乎所有其他方面都变得更好。
这里的问题是,我不知道你在你的旅程中处于什么位置。如果你是我的 SMU 学生,你已经非常擅长 JavaScript,并想迈出下一步。C# 与 JavaScript 完全不同。仅知道 JavaScript 就跳入这本书会有难度,但并非不可能。如果你是自学成才的,你可能专注于我所说的“生存技能”。它们包括基本的面向对象编程以及如何与数据库和可能的技术工作。如果你在大学里学习如何编码,你的教科书可能不太有启发性。我知道。我在大学和学院里教了 25 年书。通常,我写自己的材料,因为市面上好的书籍很少。
无论你现在身处何地,无论你来自何方,这一章都是为了指引你。最初,我打算将这作为本书的第二章。我的编辑明智地建议我们尽快开始使用模式。她建议添加一个附录,我们在整本书中都提到了。你将获得良好的复习或 C# 的快速课程。
在这个附录中,你可以期待学习以下内容:
-
关于 C# 的快速背景介绍以及如何沿着一系列分类学线条定义这种语言。这听起来很复杂,但你会发现它相当基础。
-
C# 的语法机制,长度和细节足够让你通过这本书。由于这本书主要处理普通的 C# 对象(POCOs)和来自 .NET Framework 的几个常见类型,如列表,这一节是可能的。
-
如何使用 Windows 中的三个常见 IDE 设置本书中的项目。我们使用了两种项目类型:命令行项目和库。
-
如何克隆本书中涵盖的示例代码项目。
让我们开始吧!
技术要求
这个附录主要是基本主题的概述,而不是项目集合。话虽如此,在附录的末尾,你将指导使用目前市场上最流行的三个 C# IDE。你将有一个选择。无论你选择哪个 IDE,你都需要一台运行 Windows 操作系统的计算机。我使用的是 Windows 10。本书中使用的项目可能在 Mac 或 Linux 上也能正常工作,但我没有在那里测试过,所以你的体验可能会有所不同。
要遵循本章末尾的 IDE 教程,你需要以下内容:
-
这些 IDE 之一:
-
Visual Studio
-
Visual Studio Code (VS Code)
-
JetBrains Rider
-
-
.NET Core 6 SDK
你可以在 GitHub 上找到本章的完整项目文件,网址为 github.com/Kpackt/Real-World-Implementation-of-C-Design-Patterns/tree/main/appendix-1。
C# 的简要背景
让我直接大声说出来:C# 是 Java 的仿制品。如果你知道 Java 但不知道 C#,你将会非常容易上手。现在,假装我没有先提到这一点,让我穿上我的灯芯绒运动夹克。夹克上有肘部的补丁。我在夹克的正面口袋里放了一根烟斗,让它竖起来,这样你可以看到它。当然,在这个时代,没有人会想到把烟草或其他东西放进去。那是不对的。但为了尽可能看起来像一位大学教授,我可以稍微讲一些历史。
C# 语言是微软为企业和游戏编程的旗舰语言产品。它由安德斯·赫尔伯格于公元 2000 年设计。我的有些学生声称我非常古老,所以我通过指出它是公元 AD 而不是 BC 来澄清这一点。该语言通过 欧洲计算机制造商协会(ECMA)提交并获得批准,作为标准化语言。你可能知道这个机构也标准化了 JavaScript,它实际上被称为 ECMAScript。许多语言都是标准化的。这仅仅意味着语言有一个公开的规范可用,其他人可以根据规范创建基于该语言的竞争性实现。C# 是微软的实现。有一个开源的竞争对手叫做 Mono,它曾经在一些领域非常流行,包括跨平台移动和游戏开发。
当微软推出 C# 时,它还发布了 .NET Framework 和 Visual Studio。.NET Framework 是一套庞大的库,为 C# 以及其他微软语言提供了语言支持基础设施。每个支持的语言都可以将其代码编译成中间格式。这种中间形式称为 Microsoft Intermediate Language(MSIL),然后可以使用 .NET 运行时执行。这使得整体语言架构与 Java 非常相似,Java 编译成中间形式称为字节码,然后在 Java 虚拟机(JVM)上执行。
该语言的声明性设计目标包括以下内容:
-
一种简单、通用、面向对象的语言
-
支持强大的静态类型变量系统
-
数组类型上的自动边界检查和未初始化变量的检测
-
自动垃圾回收
-
源代码的可移植性;代码应在各种环境中执行,无需显著更改代码且无需重新编译
让我们进一步探讨这些想法。
C# 是一种通用编程语言
几乎每个人都听说过理查德·克莱本在 1842 年发明的东西。我在图 A.1 中向你展示了一个可调节扳手:

图 A1.1:一个可调节扳手。你可以用它来做几乎所有需要扳手的活儿。如果你遇到麻烦,你也可以用它来开瓶盖和钉钉子。它是一个多用途的工具,就像 C# 语言一样,可以用来制作几乎任何类型的软件
在美国,我们称之为 crescent 扳手,而许多其他国家称之为 spanners。这个扳手是一个通用扳手。它可以用于各种适合扳手的任务,从松开和拧紧 IKEA 家具上的金属紧固件到在你的 1957 年雪佛兰汽车中安装新引擎,再到为你的厨房水槽安装新的垃圾处理器。
现在考虑图 A1.2 中的扳手:

图 A1.2:这是一个 basin 扳手。它只适用于一件事:在洗碗池下拧紧水龙头硬件上的固定螺母。它是一个专用工具,就像 SQL 是一种专用语言一样。两者都只能用于一个目的
你可能以前从未遇到过这样的东西。这个看起来奇怪的装置被称为 basin 扳手。它只有一个任务。它用于在安装新的厨房或浴室水槽时拧紧水龙头连接。扳手头可以旋转 180 度,这样你就可以在所有管道周围操作。长把手帮助你处理向下突出的水槽碗,如果你用通用扳手尝试这样做,这会阻挡你的操作。
同样,存在一些专用语言,其中最流行的是结构化查询语言(SQL)。SQL 只用于查询关系型数据库。你不能用它来制作视频游戏或操作系统。
另一方面,C# 是一种通用语言。它可以用来制作从企业级软件到 AAA 级视频游戏几乎任何东西。是的,有人曾经尝试用 C# 制作一个名为 SharpOS 的操作系统,这表明用 C# 编写操作系统是可能的。
选择 C# 作为你的首选语言的最大原因是其灵活性。结合 .NET 框架,你拥有数千个构建块。你可以使用它们来构建你可能需要的任何软件。
C# 是纯粹且完全面向对象的
你会在编程语言中看到两种主要的范式:面向对象编程和函数式编程语言。也许当有可能在单一语言中混合这两种范式时,会出现第三种范式。让我们来看看它们之间的区别:
| 面向对象编程 | 函数式编程 |
|---|---|
| 代码被组织成类,作为程序的主要构建块。 | 代码被组织成不同的函数,这些函数使用它们的参数代替属性。 |
| 方法通常具有副作用。它们可以在方法内部改变对象的状态。 | 纯函数永远不会产生副作用。 |
| 在面向对象编程中,我们支持可变和不可变对象。 | 函数式编程从不支持可变变量。如果你想改变某物,你必须创建一个新的变量。 |
| 数据存储在对象的属性中,并且从设计角度来看,它们比通常只用于改变对象状态的函数具有更高的优先级。 | 设计目标集中在函数上。它们接受不可变输入并产生输出。 |
图 A1.3:面向对象编程和函数式编程之间的区别。
一些语言,如 C#和 Java,是严格面向对象的。像 Haskell 和 F#这样的语言是严格函数式的,而像 JavaScript、Python 和 PHP 这样的语言可以支持其中一种或两种范式。本书重点介绍的原始四人帮(GoF)软件设计模式,是围绕面向对象编程使用 SmallTalk 语言构建的。自然地,我们将严格关注面向对象编程。
C#使用静态、强类型系统
关于类型系统,存在三种不同的观点。首先,让我告诉你我所说的类型系统是什么意思。编程语言有一个共同的目的:它们都接受某种输入并将其转换为某种输出。程序的输入和输出被称为数据。就你的计算机而言,存在三种基本的数据类型。
字符串是字母数字数据——想想字母和数字。如果你能在键盘上输入它,它就是字母数字的。
第二种数据类型是数字。我想你知道这是什么意思。虽然数字可以是字母数字字符串,但你不能使用字符串进行数学运算。这种区别是强类型系统的核心,所以请记住这一点。
第三种基本的数据类型是布尔值。布尔值是二进制的,这意味着它们可以具有真或假的值。有时,这个值会表示为 0(假)和 1(真),或者 0(假)和非 0(真)。
这三种基本类型被称为原始数据类型。在强类型系统中,当你声明变量或创建函数时,你必须告诉你的程序你将使用什么类型的数据。
除了原始类型之外,C# 支持创建自己的类型作为对象。像原始类型一样,这些类型是强的,也是静态的。静态类型系统意味着当你说你将使用一个变量来存储字符串时,你永远不能将变量更改为任何其他类型。它是静态的,意味着它永远不会改变其类型。
虽然 C# 和许多其他语言使用强静态类型系统,但其他语言并不如此。JavaScript 采取了相反的方法。JavaScript 使用弱动态类型系统。在这种系统中,创建一个变量并赋予任何类型的值是完全正常和合理的。比如说,我们创建一个名为 foo 的变量并将其设置为“这是一个字符串。”
接下来的那一行可以将值 9 赋予名为 foo 的变量。你不需要用变量声明类型的事实表明这是一个 弱类型系统。你可以随时更改类型的事实表明这是一个动态类型系统。JavaScript 非常强大和灵活,但它对于学习过强静态系统的开发者来说感觉非常陌生。这种想法认为 JavaScript 是一种“玩具语言”,不适合严肃的工作。这当然是完全错误的。
你会遇到第三种类型系统,这被称为鸭子类型。最著名的使用鸭子类型的系统可能是 Python。在 Python 中,你看到了 C# 中的强静态类型和 JavaScript 中的弱动态系统之间的中间地带。
在 Python 中,你不需要用变量声明类型。然而,Python 解释器会观察变量是如何使用的,并推断出一个强类型。这种系统被称为鸭子类型,因为创建这种类型系统的设计者声称:“如果它像鸭子走路,像鸭子嘎嘎叫,那么它就是一只鸭子。”
C# 的后续版本也支持鸭子类型,所以我们将在稍后看到一些示例。
C# 具有自动边界检查和未初始化变量的检测功能。
C# 的创建是为了纠正其他语言的不足。你几乎可以听到在过去 20 年中创建的每一种新语言都是如此。某个团体讨厌 C 或 Java 中某些工作的方式,因此他们创建了一种新的语言。通常,它们是其他语言所谓好部分的混合体。如果你不相信我,可以了解一下苹果的 Swift 语言,谷歌创建的 Rust 语言或 Golang 语言。C# 并无不同。
研究表明,缺乏边界检查和未初始化变量都是软件错误的主要来源。实际上,这里的目的是创建一种让开发者更难犯错的编程语言。在接下来的几个特性中,我们将讨论实现这一目标的方法。
边界检查是指数组。数组是一种特殊类型,允许你在单个变量中存储多个值。由于 C#是强类型,数组元素必须都是同一类型。你可以有一个字符串数组、数字数组或对象数组。此外,在 C#中,当你定义一个数组时,你必须告诉你的程序你将放入数组中的元素数量。一旦设置,大小就固定了,你不能不跳过一些比喻性的障碍来改变它。
未初始化的变量不仅仅是代表工作马虎——在某些编程语言中,它们可能是危险的。C 编程语言在这里值得一提。C 在过去 10 年中经历了一些剧烈的变化,但在我你这个年纪的时候,C 的座右铭是“权力越大,责任越大。”我认为这也是蜘蛛侠的座右铭。也许彼得·帕克应该成为一名程序员而不是摄影师。
在 C 语言中,就像在大多数语言中一样,一个变量只是一个指向内存地址的指针。计算机内存就像一个公寓楼。实际上,C#和 Visual Basic 的早期版本将它们的内存模型称为“公寓线程”。公寓是你和你的东西的容器。有些公寓小而经济(除非你住在纽约市,在这种情况下,它们只是小)。其他公寓更大,也更贵。无论它们的大小、形状和装饰如何,它们的职能是容纳你和你的东西。
每个公寓楼中的公寓都有一个公寓地址号码。因此,你可以有效地表示“我想把我的东西放在 122 号公寓*”。如果你在工作中得到加薪,并决定升级,你可以把你的东西搬到更大的 300 号公寓。地址告诉我们你的东西在哪里,而你的公寓楼经理知道每个公寓的大小和价格。
计算机内存以同样的方式工作。有一些地址寄存器(如公寓楼)可以存储不同大小的物品。我们可以扩展我们的类比,说公寓管理员是运行你的程序的计算机。因为它保留了一个每个公寓地址和每个公寓占用空间的列表,所以它知道容器在哪里。
地址不是像 122 或 300 这样的简单数字。它们更复杂,不容易处理。因此,我们不是处理地址,而是可以给我们的公寓或容器一个简单易懂的名字,这个名字描述了你的变量在程序中的使用方式。
当你创建一个定义大小的容器,比如一个公寓(小而便宜与大而昂贵),它有一个地址。但你不使用那个地址,而是使用你认为容易使用和记忆的任何名字。
现在想象一个作为建筑一部分建造的公寓,但它充满了建造时留下的建筑垃圾和垃圾。没有人租过这个公寓,所以没有人清理过这个混乱。这是一个未初始化的变量。如果有人想租这个公寓并在同一天搬进去,也就是说访问地址,他们会发现里面都是垃圾。当一个具有强大静态类型系统的运行程序遇到它不期望的垃圾时,程序会崩溃。如果你曾经遇到过 Windows 中的蓝屏死机(BSOD),你就知道这看起来像什么。未初始化的变量将保留上次使用该内存地址时留下的任何垃圾。在 C 语言中,手动分配和释放内存是工作中的一个主要头痛问题。如果你做错了,事情会以最不优雅的方式崩溃。C 开发者真正需要的是一种处理垃圾回收的方法。
C#支持自动垃圾回收
我们刚才讨论的噩梦场景显示了语言改进的必要性。C#旨在简单且实用。在 C 语言中,当 C#被构想出来时,我们手动分配内存地址。这意味着执行一个使用十六进制值定义你的分配的语句。大多数人在以 10 为基数的情况下工作得很好。我们的旧环境让我们在心理上将十进制(基数 10)数字转换为基数 8(八进制)和基数 16(十六进制)来进行内存分配。然后,我们使用那个内存,当我们完成时,我们需要记得释放内存。在我们的公寓比喻中,这相当于搬出公寓并在过程中彻底打扫。当我们释放内存时,公寓应该准备好供人入住。
如果我们有一个自动处理分配和释放的语言会怎样?我们有。C#有一个垃圾回收系统,为你处理一切。你创建一个变量,细节由.NET 运行时处理。当你的变量超出作用域时,它会被标记为清理,最终运行时的垃圾回收过程会为你释放和清理内存。正如你可以想象的那样,在一个没有安全网的系统中进行手动分配和释放内存也是错误的主要来源。
C#代码具有高度的便携性
C#语言规范旨在拥有可移植的代码。在 C#和.NET 的最早版本中,我们并不非常可移植。我们只有 Windows。你无法在 Mac、Linux 或手机上运行 C#代码。游戏机也不行。
开源社区创建了 Mono,这是 C#的开源版本,旨在在 Linux 操作系统上运行,但它始终落后于微软在 C#中的实现几年。最终,Mono 成为 C#开发者利用他们的语言技能,使用 Mono 和名为 Xamarin 的框架创建移动应用程序,为 Android 和 iPhone 平台提供了一种非常流行的途径。
一个名为 Unity 3D 的游戏开发引擎也利用了 Mono,并将 AAA 级别的游戏引擎带到了大众市场。在此之前,像 Unreal 这样的游戏引擎都是严格内部使用的。唯一开发高质量游戏的方法是在能够负担得起非常昂贵的 SDK 的公司工作。Unity 3D 打开了游戏开发行业的门户,他们使用 C#/Mono 来实现这一点。
随着时间的推移,微软使每种语言的迭代都更加便携。当微软宣布其 Azure 云平台时,我们看到了一个巨大的转变。他们明智地认识到,大多数 IT 专业人士永远不会使用限制他们使用微软产品的系统。Linux 拥有大多数网络服务器的份额。当云计算在受欢迎程度上达到临界质量时,我们看到微软的生态系统中引入了大量的开源兼容性,包括 .NET Core,它允许我们编译和运行 C# 或任何 .NET 语言,在几乎任何类型的硬件环境中运行,使你的代码真正便携。这种便携性来自 .NET 运行时。让我解释一下这意味着什么。
任何编程语言执行代码的方式都有几种。首先,有可以编译成原生机器代码的代码。为此,你需要像 C、C++ 或 Rust 这样的语言。以这种方式编译的程序在“裸机”上运行,这使得它们在执行速度上非常快,但用于此的语言通常更难使用。C 和 C++ 要求你监控你的内存使用情况,这是这些工具中编写的主要错误来源。Rust 通过非常严格的编译时间限制来消除内存错误。使用这些语言可能会很沮丧,而且你的上市时间通常较慢,尤其是在首次采用这些语言时。在开发者的生产力和软件运行时的性能之间有一个权衡。
第二阵营是使用解释器的语言。通常,这些语言是脚本语言,例如 Python、PERL 和 Lua。这些语言围绕开发者生产力构建,通常牺牲执行速度和效率。
第三组处于中间位置。所有 .NET 语言,与 Java 类似,都编译成中间二进制格式。Java 称之为字节码。C# 和其他 .NET 语言称之为公共中间语言(CIL)。这些二进制格式比解释语言执行得更快、更高效,但它们确实需要一个运行时来执行中间形式。
记住任何给定语言的代码发布版本可能的表现是很重要的。总的来说,编译后的 C# 代码通常比“裸机”编译语言慢,但差距并不大。
C# 的语言机制
我们已经提到 C#是一种严格的面向对象编程(OOP)语言。OOP 语言支持一系列抽象特性,在我们继续之前,你需要理解这些特性。首先,OOP 语言与过程式语言组织代码的方式不同。在 OOP 中,我们使用代码来模拟现实世界中的元素。现实世界元素使用属性、方法和事件来描述。
例如,如果我要模拟一个圆,我可能会给它一组如图 A1.3 所示的属性:

图 A1.4:一个圆,就像其他任何事物一样,可以通过描述其属性集来表示
使用这些属性,我可以描述我在程序屏幕上想要绘制的任何圆。
实际上,属性是属于圆的变量。它们包含描述圆的数据。当描述属于对象的变量时,我们称之为属性、成员变量或成员。
此外,对象还描述了它们可能执行的动作。当我们从 OOP 的角度谈论函数时,我们称之为方法。我们可以说我们的圆对象有一个名为draw()的方法,它根据描述圆的属性将圆绘制到屏幕上。我们还可以添加一个调整圆大小的方法。方法与函数是同一件事,因此我们可以传递参数。我们的调整大小方法需要知道圆的新半径。它可能这样被调用:resize(100)。这将把圆的半径从 200 改为 100。
对象通常还有一组专门的方法,用于响应事件。事件可以描述为程序运行期间发生的事情。常见的例子可能包括用户点击圆圈、将光标悬停在圆圈上、用鼠标右键点击圆圈、在触摸屏上长按圆圈,或者时间的流逝。也许你希望圆圈在 10 秒后消失。
对象并不需要具备所有这些部分。有些对象仅使用属性来表示数据。有些则仅用于常量和方法,例如 C#中的Math类。然而,大多数情况下,它们是混合使用的。
除了对象通过属性和方法组织之外,还有一些其他术语需要学习:
-
封装
-
组合
-
继承
-
多态
-
开放递归
所有这些都听起来非常复杂,但别担心,这正是我在这里的原因。我们将在本章中涉及所有这些概念。
面向对象编程首次在 20 世纪 80 年代通过 Ada 编程语言出现。Ada 是以编程先驱 Augusta Ada King,Countess of Lovelace 的名字命名的。你可能听说过她,她是 Ada Lovelace。以她的名字命名的语言是在与美国国防部(DoD)签订的合同下创建的,旨在取代当时在 DoD 使用的 450 多种不同的编程语言。正如你可以想象的那样,它有一些很大的挑战。Ada 语言对编程的影响,就像 Tucker 汽车对美国汽车工业的影响一样。1948 年的 Tucker 汽车是第一个提供标准安全带、水冷铝制发动机、碟刹、燃油喷射和独立悬挂的汽车。这些都是现代汽车的标准功能,我们很难想象今天没有这些功能就购买汽车。然而,在 20 世纪 40 年代设计和制造的汽车上,这些功能并不存在,或者至少不是在单一汽车上。
原始的 Ada 语言支持面向对象、基于合同的编程、强类型、显式的并发语法、任务、消息传递、使用私有和受保护类进行封装,以及由编译器强制执行的代码安全性。听起来熟悉吗?许多现代语言,包括 C#,都包含这些特性,这主要归功于 Ada 所体现的优点。
面向对象编程(OOP)之所以流行,是因为它允许你以有意义且易于理解的方式组织你的软件。首先学习 OOP 的开发者很难切换到其他方式。在与这两种思维方式共事多年后,我认为 OOP 是创建大型软件项目的最佳方式,因为它迫使你以某种特定的方式思考。面向对象编程的主要原则,其中一些在 SOLID 原则中有所涉及,就像内置的方式,以最安全的方式使你的软件可维护、可测试和可扩展。我认为在最初学习模式时使用面向对象的语言很重要。模式的原作是基于 Java 的,这与 C#非常相似。这本书中提出的许多想法在非面向对象、多范式或动态语言中可能无法工作或必须强行适应。例如,JavaScript ES5 被认为是面向对象的,但对象是动态的。通过这种方式,我的意思是,在代码运行期间,你可以随时更改任何对象的结构,包括语言内部支持的任何对象。你甚至可以因为 JavaScript 使用原型继承而改变一个对象的所有实例。JavaScript 是弱类型,它支持的类型列表有限。类和封装在 ES5 中根本不存在。在像这样的语言中研究模式是困难的,因为模式需要一个基础的限制规则集,而在许多其他语言中并不完全存在。
通过选择使用 C#,你将自己与开发软件时使用已知、经过实战检验的模式的所有关键原则对齐。
C#中的变量
我认为变量的最简单定义如下:
变量是一个命名容器,它在计算机内存中存储数据。
当你学习一种编程语言时,你需要知道该语言是强类型还是弱类型。
在强类型语言中,也称为静态类型语言,你必须定义变量的数据类型。你必须告诉程序你正在给定的变量中存储什么类型的数据。
在弱类型语言中,你不需要声明类型,因为实际上只有一个类型:一切都是对象。
这里有一个很好的思考方式。数据可以有一个形状,容器也可以。容器也有一个确定的大小。你不能把一磅糖放进只能容纳四分之一磅的容器里。然而,你可以把同样的一磅糖放进一个有空间的 5 加仑桶里。
C# 是一种静态类型语言,它支持隐式类型,也称为鸭子类型。在像 C# 这样的强类型语言中,你会看到如下这样的代码:
int myNumber = 5;
这里,我们创建了一个名为 myNumber 的变量,类型为 int,并将其初始值设置为 5。
隐式类型允许我们进行一个小小的改动:
var myNumber = 5;
与类型声明出现在名称之前不同,你使用一个通用的关键字 var。编译器会将这些语句视为相同。编译器可以根据其初始赋值来确定变量的类型。在这里,我们看到初始赋值为 5,因此编译器会假设你想要这是一个整数(int)。如果你不是这个意思,你可以给编译器一个提示。比如说,我们真正想要 myNumber 是 decimal 类型。由于 5 和 5.0 之间没有数学上的区别,编译器会出错,最终你会在代码中得到红色的波浪线,表明你有问题。你如何指定类型并仍然使用 var 关键字语法?看这里:
var myNumber = 5d;
我们在数字后面添加一个 d 作为后缀。就这样,编译器现在知道你想要这是一个十进制数。但我跑题了。
由内存大小使用的有符号数值类型
C# 支持有符号和无符号数值类型。编程语言中的数字在内存中使用二进制补码的数学运算来处理,如图 A1.4 所示:

图 A1.5:计算机中整数的表示使用一个称为二进制补码的数学概念。有一年,我以一个整数的身份参加了办公室的万圣节派对。我把这个图印在了 T 恤上。最好的服装是那些需要解释的
如果你是一个数学爱好者,可以查看进一步阅读部分提供的维基百科页面。
对于我们其他人来说,这个概念可以简单地解释。任何打算存储数字的变量都将有一个数字的最小和最大大小。例如,常见的 32 位整数(int32)在最小端有-2,147,483,648 的范围,最大值为 2,147,483,647。我们必须从最大值中减去一个来考虑零。大小范围由使用的内存空间量决定。我刚才展示的范围代表有符号类型的容量,这意味着它支持小于零的数字。如果你不关心负值,C# 允许你使用无符号类型,这些类型将范围扩展到 0 到 4,294,967,295。
C# 允许你通过选择众多有符号和无符号的范围来控制内存使用。换句话说,有符号数值类型由内存大小使用来界定。我认为这是大多数开发者忘记或忽视的一个点,因为我们大多数人需要整数时,只是使用 int。我之前使用了一个类比:
你不可能把一磅糖放入只能装四分之一磅的容器中。然而,你却可以把同样的一磅糖放入一个 5 加仑的桶中,还有富余的空间。
在 5 加仑的桶中有很多浪费的空间。你可以通过选择一个与使用方式兼容的范围来控制浪费。想想看,你有多少次看到或做过这样的事情:
int myAge = 54;
这是一种巨大的空间浪费。首先,一个人的年龄不可能是负数。将其改为以下内容是有意义的:
uint myAge = 54;
在这里,我们使用 32 位整数的无符号版本。看看你能否从以下表格中选择一个更合适的类型:
| 类型 | 描述 | 最小值 | 最大值 | 位数 |
|---|---|---|---|---|
bool |
布尔值 | False (0) | True (1) | 1 |
byte |
无符号字节 | 0 | 255 | 8 |
sbyte |
有符号字节 | -128 | 127 | 8 |
short |
有符号短整数 | -32,768 | 32,767 | 16 |
ushort |
无符号短整数 | 0 | 65,535 | 16 |
int |
有符号整数 | -2,147,483,648 | 2,147,483,647 | 32 |
uint |
无符号整数 | 0 | 4,294,967,295 | 32 |
long |
有符号长整数 | -9e18 | 9e18 | 64 |
ulong |
无符号长整数 | 0 | 1.8e19 | 64 |
图 A1.6:C#整数类型列表及其范围和每种类型消耗的内存量。
如你所见,随着我们向下查看图表,我们使用的内存越来越多。位是计算机可以处理的最小单位。64 位整数只能用±9 kajillion或18 kajillion这样的术语来理解,这取决于它是有符号的还是无符号的。
我们为什么要用一个最大值为 80 亿的 32 位无符号整数来表示一个人的年龄?要么我们不了解数值类型的划分,要么我们只是懒惰,这是我们之前在第一章中提到的那种令人衰弱的力之一。我们刚刚打破了一扇窗户,把我们引入了一个大泥球。我们应该使用一个最小值为 0,最大值为 255 的无符号字节。除了在夏威夷的一个秘密岛屿上生活的猫王,以及显然永生的 Chuck Norris 之外,大多数正常人类都不会活过 100 岁。当你考虑到地球大约有 45 亿年的历史时,你开始看到这种常见做法的愚蠢。如果你这样做,你并不孤单。微软在其 C# 文档中也这样做。我差点开玩笑说写作者是糟糕的程序员,但如果我这样做,那我就自讨苦吃。只要你现在意识到你在浪费很多内存,我不会责怪你立即将所有生产代码从 int 改为 byte。
图 A1.7 展示了我们所有的有效整数类型。对于浮点数和文本类型,有类似的名字和范围。所有这些类型都被认为是 int 关键字映射到 Int32 类。
实际列表最终变成了类似于 图 A1.7 中的列表:
| 别名/原始关键字 | .NET 类型 |
|---|---|
bool |
System.Boolean |
byte |
System.Byte |
sbyte |
System.Sbyte |
short |
System.Int16 |
ushort |
System.Uint16 |
int |
System.Int32 |
uint |
System.Uint32 |
long |
System.Int64 |
ulong |
System.UInt64 |
图 A1.7:C# 整数范围的完整列表,第一列是别名,第二列是实际实现类。
C# 允许使用别名,并且通常别名指向用大写字母表示的相同类名。所有原始类型都在 .NET 的 System 对象中。你创建的几乎每个 Visual Studio 项目都在顶部有一个 using System 语句。这就是为什么你可以看到字符串以多种不同的方式创建。
使用全部小写字母定义的字符串之所以有效,是因为别名:
var string foo = "bar";
使用大写 String 关键字定义的字符串之所以有效,是因为你项目顶部可能存在的 using System 语句:
var String bar = "baz";
当然,你也可以这样写:
var System.String fooBarBaz = "foo bar baz";
实际上,我从未见过有人这样做。多年前,我在附近的一所大学教授 C# 初级编程课程时,String 和 string 之间的区别是一个广泛询问的问题,尤其是来自之前学习过 Java 的学生。Java 缺乏这些类型的别名。
使用不同引号分隔字符和字符串类
这个点很短,但如果你是从其他语言过来的,或者如果你经常切换语言,那么它值得提一下。C# 有用于字符串和字符的单独的类类型。字符串类型是 System.String。它是一个使用频率很高的别名,就是 string。字符类型,即单个字符字符串,是通过使用 System.Char 类或简单地使用 char 创建的。赋值时使用的引号也很重要。许多语言,如 JavaScript 和 Python,允许你单引号和双引号互换使用。在 C# 中,字符串用双引号表示,而字符使用单引号赋值。它们是不可互换的。
收藏
JavaScript 有 数组(也就是我希望在工作中能用来写书的东西)。Python 有 列表。Java 有 ArrayLists。C# 有一个广泛的 集合。C# 有类似于 JavaScript 的数组,但它们更有限。你必须提前设置你要放入数组中的项目数量,一旦设置,这个数量就不能轻易改变。C# 中更有用的数组形式是 .NET List 类。
列表类类似于数组,除了你可以在需要的时候随时添加和删除项目。它们要灵活得多。列表在 C# 中是强类型的,就像其他所有东西一样。这意味着列表中的所有项目都必须是同一类型。C# 有一个名为 <> 的系统来处理这个问题。在书中,你会看到对 List<> 的引用。这表示一个泛型列表,再次强调,这意味着只要你放入列表中的所有东西都是同一类型,你就可以把任何东西放入列表。如果你想创建一个字符串列表,它可能看起来像这样:
var myStrings = new List<string>();
myStrings.add("foo");
myStrings.add("bar");
要使用这些泛型集合,你必须在类文件顶部添加一个语句,如下所示:
using System.Collections.Generic;
泛型集合是 .NET 框架中最灵活和最广泛使用的类之一。你肯定会很早很频繁地遇到它们。
类
到目前为止,我关于你对面向对象编程(OOP)了解的假设是,你知道类是什么以及它的用途。万一这是一个无效的假设,我们现在就澄清一下。一个 类 可以被看作是房子的蓝图。蓝图描述了建造房子所需知道的一切。你可以使用这个蓝图建造你需要的任何数量的房子,在你建造它们的时候,你可以改变每座房子的个别属性。它们不必都是相同的大小或颜色,也不必都有一定数量的窗户。在你建造房子的时候,你可以改变这些属性中的任何一个。
在大多数面向对象编程语言中,类是定义对象构造的主要形式。甚至还有称为构造函数的特殊方法,当对象被实例化时运行,也就是说,当你使用 new 关键字创建对象实例时。
这里是之前我使用的 Person 类的例子。这次,我添加了所有部分,这样我就可以向你展示了:
using System;
namespace MyProject;
class Person
{
public string Name { get; set; }
public byte Age { get; set; }
public Person()
{
Name = string.Empty;
Age = 0;
}
public Person(string name){
{
Name = name;
Age = 0;
}
public Person(string name, int age)
{
Name = name;
Age = age;
}
public override string ToString()
{
return "Person: " + Name + " " + Age;
}
}
一个类有几个主要部分:
-
使用语句来定义依赖项
-
命名空间
-
类名
-
构造函数
-
属性
-
方法
让我们逐一介绍每个部分。
使用语句
在 C#中,using关键字有两个含义。当你在一个类的顶部看到它时,它指的是类的依赖项。这在大多数语言中都很常见。在 Java 和 Python 中,这个词是import。在 JavaScript 中,根据你使用的约定,它可以是require或import。在 C#中,关键字是using。这个语句向编译器发出信号,表明我们将要引用的某些类存在于我们项目的其他部分,甚至可能存在于项目外部,这就是当我们使用第三方库时的情况。
例如,为了使用Console.WriteLine方法,该方法将文本打印到控制台,首先,我们必须声明我们将使用System命名空间。我们通过以下语句来完成:
using System;
Console对象位于System命名空间中。using语句告诉编译器我们将使用该命名空间中的类。最后一个语句很自然地过渡到下一个主题。
命名空间
命名空间是组织 C#代码的一种方式。如果你来自 Java,你使用过包。在 Python 中,它们被称为模块。在 JavaScript 中,你导出你想要在模块化代码中公开的对象。在 C#中,命名空间出现在你的类文件顶部。通常,它对应于你的项目名称。如果你在项目中创建文件夹来分割你的代码,通常,命名空间会反映文件夹结构。
在前面的示例代码中,我们有一个名为MyProject的命名空间,这将是 C#项目的名称。如果我创建一个名为helpers的文件夹来存放一些辅助对象,这些文件夹中的命名空间将是MyProject.helpers。
命名空间不是绝对必需的。大多数集成开发环境(IDE)会自动将它们添加进去,所以你通常会看到它们。五年前,当我最后一次使用 Unity 3D 游戏引擎时,IDE 没有自动在类代码中添加命名空间。虽然没有命名空间也可以工作,但这种情况很少见。Visual Studio 和 Rider 在创建类时会自动添加命名空间。在 VS Code 中,一切都是手动的,所以你可能需要自己添加。
类名
命名类的行并不难找:
class Person
你只需使用关键字class后跟类的名称。按照惯例,类名使用 Pascal 大小写约定,这意味着类名中的每个单词都由一个大写字母表示。如果我们想要创建一个表示零售店楼层经理的类的类,我们会称它为FloorManager。
类,就像属性和方法一样,可以使用访问修饰符来定义它们可以被如何访问。我们稍后会更多地讨论这个问题。
构造函数和实例化
构造函数是一种特殊的方法,当您创建实例(即实例化)时运行。看吧?这是一个每个软件开发者都需要理解的术语。实例化发生在您在构造函数方法前加上new关键字时。这就是为什么您的new语句后面总是有一个括号的原因。构造函数返回根据构造函数中的任何代码设置的实例。如果您没有提供构造函数,.NET 编译器在编译时会添加一个空的构造函数。
构造函数有一些定义规则。首先,构造函数的方法名必须与类的名称匹配。其次,您不能定义返回类型,因为它固定为返回正在构建的类的实例。
C# 允许您拥有多个重载的构造函数。这意味着只要类型和参数数量不同,您就可以有多个构造函数。我们之前提供的代码示例包含三个构造函数:
public Person()
{
Name = string.Empty;
Age = 0;
}
public Person(string name){
{
Name = name;
Age = 0;
}
public Person(string name, int age)
{
Name = name;
Age = age;
}
注意以下事项:
-
所有构造函数都命名为
Person,因为这是类的名称。它们必须与类的名称匹配,否则它们不是有效的构造函数。 -
没有指定返回类型。返回类型应该在
public关键字和构造函数方法名称之间。 -
尽管这三个函数因为参数数量不同而具有相同的名称,但这在语法上是允许的。第一个构造函数没有参数,第二个有一个参数,第三个有两个参数。这被称为方法重载,它是一种多态形式。
构造函数是建立对象初始状态的一种非常有用的方式,即在对象创建的瞬间。我经常说,软件开发者的首要任务是确保程序中的任何对象都无法进入无效状态。
在前面的例子中,构造函数的语法有些过度。C# 会自动将 age 属性初始化为 0。我将字符串初始化为空字符串,因为我不喜欢处理 null 值的歧义。有时,你选择的语言,在这个例子中是 C#,会尝试为你处理一些事情,比如初始化。依赖自动的语言特性会导致精神上的懒惰,这是我在第一章中提到的具有破坏性的力量之一,它会导致混乱、难以维护的代码。如果你不相信我,去 YouTube 上找找关于 JavaScript 自动分号插入“功能”的视频。你需要记住,你的代码是一组将被地球上最愚蠢的东西之一执行的指令。计算机会字面地处理一切,如果你的指令中包含任何歧义,总会出现糟糕的喜剧。当然,有些人把这称为工作保障,每个人都需要好的战争故事来讲述。就像有一次,团队里的某个人将一个不完整的 SQL 更新语句粘贴到 SSMS 中,指向了一个生产数据库,并将所有客户记录更新到了一个账户。每天有百万用户使用的系统瘫痪了 7 个小时。为了清楚起见,不,那不是我。如果是我,我永远不会提起这件事。我是在指出一个观点。那次痛苦的经历发生是因为有人太懒惰,没有正确地完成他们的工作。就我个人而言,我宁愿做正确的事情,让我的周末保持空闲。当我编写代码时,我总是明确且有意。这让我远离麻烦。始终关注你的对象状态,并编写代码以防止无效状态。你的构造函数是第一道防线。第二道防线是封装,我们很快就会谈到。
属性
对象由状态变量和能够对这些状态变量执行工作的函数组成。这是一种说法,即对象就像两个常见编程概念的结合:变量和函数。类被设计用来表示现实世界中的对象。现实世界中的任何对象都是由其描述性属性定义的。它是什么颜色?它比面包箱大吗?物理对象进一步由它们能够做什么来定义。一辆车可以行驶,一只狗可以吠叫,等等。
现在,让我们专注于对象的描述符。很容易称它们为属性。一个篮球有周长、橙色、黑色线条和它所含空气量等属性。这些都是篮球的属性。在 C# 类中,属性看起来像这样:
public string Name { get; set; }
单词 public 是一个访问修饰符。我们之前在附录中描述过。
每个属性都有一个名称。这个属性的名称是 Name,它被定义为字符串。说 { get; set; } 的部分使其成为一个自动实现的属性,这是一种我稍后会解释的简单封装形式。
你使用点符号来访问实例化对象上的属性。例如,要设置Person对象的Name属性,你需要实例化然后像这样设置它:
var somebody = new Person();
somebody.Name = "Bruce";
访问修饰符定义了 C#中属性、方法和甚至类的可用性。
方法
方法是附加到对象上的函数。由于 C#是严格面向对象的,我们可能应该始终将它们称为方法而不是函数,但这两个词实际上是同义的。即使是 OOP(面向对象编程)的坚定支持者有时也会不小心说出函数这个词。把这个问题放一边,关于 C#中的方法,有几个要点需要记住。首先,它们遵循与变量相同的规则,即它们是强类型的。它们需要传入的任何参数都应该是已类型化的,以及返回值也应该是已类型化的。像变量一样,方法名需要在作用域内是唯一的。
C#使用类似于 C 语言的 C-like 方法签名,这是所有基于 C 的语言的共同特点。方法签名由定义方法独特性的部分组成:
-
函数的名称
-
参数的数量和类型
-
返回值的类型
在前面的例子中,我们有这个方法:
public override string ToString()
{
return "Person: " + Name + " " + Age;
}
在这个方法签名中,我们可以看到这个方法是公开的。它可以被程序中的任何类访问。我们可以看到它的名字是ToString,并且它不接受任何参数。此外,我们可以看到这个方法预期返回一个字符串。
这个方法在访问修饰符和返回类型之间有一个额外的关键字。它说override。ToString()方法在 C#中的每个对象上都有,因为该方法在 Object 基类中。它总是从基类继承到每个你创建的对象中。基类实现并不非常有用。非常常见的是用更有用的东西来覆盖函数。这正是前面例子中发生的事情。我们通过用我们自己的实现覆盖基类实现来更改基类实现。我们在本书涵盖的项目中做了很多这样的地方。
封装
这个概念涉及到 OOP(面向对象编程)最重要的方面之一:维护对象的状态。
在前面的图 A1.3中,我展示了一个具有一组描述你所看到的圆的属性值的圆。随着你的程序运行,这些属性可能会根据程序中的事件而改变。如果你在程序执行过程中的任何时刻对这个圆进行快照,你就可以谈论它的当前状态。它目前有一个 200 像素的半径,黑色线条颜色和深灰色填充颜色。让我们编写一些代码,以最简单的方式表示我们的圆,没有任何封装,这样我们就可以稍后看到它被添加:
public class Circle
{
ushort centerX;
ushort centerY;
ushort radius;
byte lineWidth;
string lineColor;
string fillColor;
}
在这个列表中,我们创建了一个 Circle 类,并在其中定义了基于我们之前讨论的字段。请注意,我没有说属性。在 C# 中,属性和字段是不同的。字段仅仅是成员变量。这些字段是开放的。实际上,任何其他类都可以在任何时候直接修改这些字段。为什么这是不好的呢?记住,这全部都是为了保护状态。有一个负半径真的没有意义,这就是为什么我使用了无符号短整型。同样,计算机上的图形坐标系统通常严格为正,0,0 位于屏幕的左上角。因此,我这里使用了无符号短整型,因为我们不需要负值,而且上限看起来是合理的。到目前为止,我做得一切都很正确,除了我没有在限制基于类型的无效值的基础上保护状态。
让我们想象一下,你正在编写一个像 Adobe Illustrator 或开源程序 Inkscape 这样的程序。这些程序处理矢量图形,并且它们肯定需要一个结构来处理圆形。从代码行数和类的数量来看,这两个程序都相当庞大。我意识到这两个程序都不是用 C# 编写的,但如果它们是的话,想象一下每个程序在任何给定时刻都会有成千上万的不同的对象在飞舞。如果其中任何一个对象有在任意时刻修改我们圆形对象属性的能力,那么在圆形中找到和调试奇怪的行为变化几乎是不可能的。通常,缺乏封装是由于懒惰的致命罪过、缺乏想象力,或者两者兼而有之。
我们的程序可以在任何时候更改这些属性。封装的目的是防止你将你的对象置于一个无效的状态。例如,如果我尝试指定一个负数的半径,这实际上是没有意义的。
为了保护我们的对象不进入无效状态,我们可以使用访问修饰符隐藏属性。访问修饰符是定义“谁或什么”可以更改对象状态的关键字。在一个理想程序中,对象应该始终只负责自己的状态。程序中的不同对象不应该直接操作半径。除了访问问题之外,除了我们的强类型变量系统之外,没有其他东西在监控传入数据的类型。为了改进事情,让我们从半径开始。早些时候,我提到我认为ushort是一个合适的类型,因为它是无符号的,不支持负值。此外,我认为下一个最低的无符号类型byte,最大值为 256,太小了。我很容易就能看到需要能够制作直径大于 256 像素的圆。但是 65K 可能太大。假设我想将最大半径限制为 1,000 像素。这可能很明智,因为在大型图形组合中,我们可能有成千上万的圆,我们应该考虑我们的内存消耗。我们可以通过一个特殊函数来隐藏半径字段,该函数检查你设置的半径是否有效,在这种情况下,是一个介于 0 和 1,000 之间的数字。
这里有两个步骤。首先,你必须使用访问修饰符隐藏半径。最常见的访问修饰符如下所示:
-
公共:任何内容都可以访问属性或方法。
-
私有:只有这个对象可以访问属性或方法。
-
受保护:只有这个对象及其后代可以访问属性或方法。在讨论继承之后,我们将进一步讨论这个问题。
你会在大多数面向对象的语言中找到这些。C#有一些额外的访问修饰符:
-
内部:同一程序集内的任何内容都可以访问属性或方法。
-
受保护的内部:属性或方法只能由声明它的程序集内的代码或从创建它的类的子类访问。
-
私有受保护:属性或方法可以被声明在同一程序集中的子类访问。
你可以使用以下表格来可视化这一点:

图 A1.8:调用者位置的相对访问级别
让我们回到改进Circle类。我们将做的第一个改进是给半径字段添加一个私有访问修饰符。我还会将radius移到顶部,这样在讨论中更容易看到:
public class Circle
{
private ushort radius;
ushort centerX;
ushort centerY;
byte lineWidth;
string lineColor;
string fillColor;
}
私有访问修饰符告诉我们,只有“这个特定对象”中的方法可以更改半径。这很完美,但现在半径字段完全隐藏了。没有从外部设置它的方法,我们当然需要在某个时候这样做。让我们将访问修饰符更改为更宽容的:
public class Circle
{
private ushort radius;
ushort centerX;
ushort centerY;
byte lineWidth;
string lineColor;
string fillColor;
}
好的。现在我们回到了起点。公共访问修饰符意味着任何东西都可以更改半径。这不好!该死!
我们需要私有回退,但我们需要它更好一些。我们需要访问器方法来允许我们从对象外部获取和设置字段值,但以一种我们控制的方式。当我这样做时,我使用一个常见的约定,将字段重命名,使其以下划线开头。这样做是为了我们可以轻松记住它是一个私有回退字段:
private ushort _radius;
接下来,让我们添加一个访问器方法,以便我们可以获取半径。get 方法通常被称为 getter。我们不需要对这个方法有任何限制:
public ushort getRadius()
{
return _radius;
}
接下来,让我们处理手头的真正问题,即控制半径的设置方式。另一个访问器方法可以用来:
public void setRadius(int newRadius)
{
if(newRadius >= 0 && newRadius <= 1000)
{
_radius = newRadius;
}
else
{
throw new Exception("The radius must be between 0
and 1000.");
}
}
}
现在我们有一个 set 方法,通常被称为 setter,可以设置值。请注意,它的访问修饰符是公共的,这是我们刚刚学到的意味着任何东西都可以访问它。这个设置器方法包含检查新半径是否符合所需范围的代码。如果符合,它就会将半径更改为新值。现在对象控制着自己的状态。你不能随意将值设置为无效的数字。你必须 请求 对象更改其半径值,而且它不会自动说 好的 – 它会检查确保你没有传递一些没有意义的东西。
此外,访问器方法还可以用来定义只读属性。通过简单地忽略创建公共设置器方法,你限制了将私有字段值设置给这个类中其他方法的权限。
C# 自动实现属性
如果你是一个 C# 老兵,你现在可能正在对这本书大喊大叫。不要这样做。首先,除非你戴着耳机,否则这会让你看起来很疯狂,然后至少你周围的人会认为你只是在对着岳母大喊大叫。其次,这本书非常敏感。你可能会伤害它的感情。我明白。早期的例子绝对不是我们通常在 C# 中创建访问器方法的方式 – 至少不是现在。在我和你一样大的时候,汽油是每加仑 35 美分,我们就是这样做的。我喜欢这样展示给你,因为我认为如果不解释它是如何工作的就把 int foo { get; set; } 丢给某人,那有点残忍,也就是说,这就是平均的 C# 书籍可能会涵盖它的方式。众所周知,残忍 是平均的。那是一个数学双关语吗?是的,我认为是。
现代 C#定义属性有不同的语法,基于这样一个观点:大多数属性实际上都是字段。但如果你把它们当作字段,你的面向对象教授会给你一个 B,因为你没有封装它们。我们不能这样。我可以回到我的肥皂箱上,谴责它们的存在,但我有一个页数限制需要遵守,所以我们来看看它们是如何工作的,你可以决定它们是否是一个好主意。
如果你想要说明你的类是完全封装的,但不需要任何逻辑来控制状态,你的代码最终会变成很多像这样的样板代码:
public class Student {
private string _firstName;
private string _lastName;
private int _age;
public string getFirstName(){
return _firstName;
}
public void setFirstName(string firstName){
_firstName = firstName;
}
public string getlastName(){
return _lastName;
}
public void setLastName(string lastName){
_lastName = lastName;
}
public int getAge(){
return _age;
}
public void setAge(int age){
if(age > -1 && age < 970){
_age = age;
} else {
throw new Exception("Age must be between 0 and
970 years");
}
}
}
这是一大堆不做什么的代码。它只存在于调用类成员 封装 的服务中。在这种情况下,可能没有必要控制姓名字段的第一和最后部分。我想不出你可能会对数据类型施加的任何限制,除了它必须是一个用户界面中的必填字段。假设样板代码是合法的,但我们想现代化并使用自动实现的属性。你的代码可以简化为这样:
public class Student {
public string FirstName {get; set;}
public string LastName {get; set;}
自动实现的属性允许你在不需要在获取器和设置器方法中放入逻辑的情况下使用这种简化的语法。
带有后备变量的访问器逻辑
封装的实际力量在于你使用访问器方法来控制对象的状态。作为开发者,你的最大任务是确保你的对象无法进入无效状态。例如,考虑 Age 属性。年龄属性中的负数没有意义。同样,一亿年也没有意义。据我所知,有史以来最长寿的人类是《旧约》中的玛土撒拉,他活到了大约 970 岁。由于没有访问器逻辑,我可以将年龄设置为 100 岁或 1,000,000,000 年。如果我想让控制逻辑限制输入值,我需要创建类似这样的东西:
private int _age;
在这里,我们创建了一个名为 _age 的私有后备变量。我们需要这个变量来保存值,因为属性现在处于访问器逻辑的控制之下。通常,以下划线开头的私有变量命名是常见的。
接下来,我们添加逻辑。在获取器上我们实际上并不需要任何特殊的东西:
public int Age {
get => _age;
设置器是魔法发生的地方:
set {
if(value > -1 && value < 970){
_age = value;
} else {
throw new Exception("Age must be between 0 and
970 years");
}
}
}
}
然后,我们使用自动实现的方法语法来创建属性,但这次内容更丰富。本质上,获取器的语法是一个返回 _age 后备变量的胖箭头函数。设置器除了值变量外都很直接。这个值变量从哪里来?这是魔法。它是语言的一部分,用于这种场景。它保存了传递给设置器的任何值。
继承
C# 是一种静态编译的语言,支持经典继承模型。我所说的静态编译是指你的对象结构不能改变,除非你停止运行程序,修改类的源代码,重新编译,然后再次运行。你可以将 C# 的静态特性与设计为动态的语言(如 JavaScript)进行对比。
JavaScript 与许多约定不同,其中最不寻常的是它使用原型进行继承而不是类。就这个而言,它不支持封装。它基于 Lambda 函数的概念,这在 JavaScript 被发明时也是新颖的。JavaScript 使用词法作用域而不是我们在 C#中找到的更传统的块作用域。简而言之,如果你只了解 C#,JavaScript 与 C#相比真的很奇怪。
既然我们已经确定了有不止一种继承方式,让我们回到 C#中看看它是如何工作的。
假设你正在编写软件来跟踪学校或大学的教师和学生群体。你需要建模一个学生类——可能像这样:
public class Student
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string EmailAddress { get; set; }
public float GradePointAverage { get; set; }
public List<Course> Courses { get; set; }
public void Study()
{
Console.WriteLine("I am studying");
}
public void TakeTest()
{
Console.WriteLine("I am taking a test!");
}
public void DoHomework()
{
Console.WriteLine("I am doing homework.");
}
public void AskParentsForMoney()
{
Console.WriteLine("Hey Dad, do you have a
minute?");
}
}
此外,你还需要建模一个professor类——可能像这样:
public class Professor
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string EmailAddress { get; set; }
public bool HasTenure {get; set;
public List<Course> Courses { get; set; }
public void GradeTest()
{
Console.WriteLine("I am grading a test.");
}
public void TeachClass()
{
Console.WriteLine("I am teaching a class.");
}
public void BegForResearchFunding()
{
Console.WriteLine("Hey National Science Foundation,
do you have a minute?");
}
}
这里我们遇到了一个问题。由于这些属性在两个类中都被使用,所以有很多属性是冗余的。面向对象分析(OOAD)会让我们通过将公共属性抽象成超类来重新设计这两个类。这留下了只有Student和Professor类中独特的元素。提升到超类中的明显目标属性如下:
-
FirstName -
LastName -
Email -
Courses
明显的目标提升包括学生类中的AskParentsForMoney()方法和教授类中的BegForResearchFunding()方法。它们实际上做的是同一件事。唯一的区别是询问谁放弃现金。
让我们创建一个超类:
public abstract class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string EmailAddress { get; set; }
public List<Course> Courses { get; set; }
public void BegForMoney(string who)
{
Console.WriteLine("Hey " + who + ", do you have a
minute?");
}
}
我把超类命名为Person。它包含学生和教授之间的公共元素。现在,让我们让Student类从Person类继承。为了表示继承,我将在第一行添加一个冒号后跟超类:
public class Student : Person
{
现在,我只保留在超类中未定义的属性。只有一个:
public float GradePointAverage { get; set; }
对于方法也是如此。我只需要那些不在超类中的方法:
public void Study()
{
Console.WriteLine("I am studying");
}
public void TakeTest()
{
Console.WriteLine("I am taking a test!");
}
public void DoHomework()
{
Console.WriteLine("I am doing homework.");
}
}
我可以用同样的方式对Professor类做同样的事情:
public class Professor : Person
{
public bool HasTenure { get; set; }
public void GradeTest()
{
Console.WriteLine("I am grading a test.");
}
public void TeachClass()
{
Console.WriteLine("I am teaching a class.");
}
}
现在,这个类只包含Professor类中独特的属性和方法。公共属性和方法在Person超类中。
理解继承的关键是口头表达Person、Student和Professor之间的关系。我们可以说一个Student是一个Person(在他们的大学一年级和二年级可能存在争议),我们也可以说一个Professor是一个Person(在他们获得终身教职后可能存在争议)。这种口头表达既定义了又说明了超类(有时称为父类)与其后代(称为子类或孩子)之间的关系。
这引出了一个问题:是否有可能实例化Person类?答案是:不会。Person类被设计成是一个父类。它不打算被直接使用。那些不打算被实例化的类被称为Person类,如下所示:
public abstract class Person
abstract关键字将防止直接实例化。
只需记住,继承是 OOP 工具箱中的一个重要部分。继承就像鸡蛋一样。它是均衡早餐的一部分,但一个真正好的早餐还应该有一些纤维。让我们通过学习接口来给我们的代码增加一些比喻性的纤维。接口并不能像一碗低糖燕麦粥那样减少你患心脏病的风险。使用它们可能会在你发现一旦将它们纳入其中,你的代码将多么灵活时降低你的压力水平,这同样很好。如果你吃低糖燕麦粥并且在代码中使用接口,也许你会在寿命上与猫王一较高下。
接口
接口通过定义类必须具有的公共方法签名和属性来定义类必须采取的结构的一部分或全部。这是一个强大的工具,它允许你为对象的行为创建一个规范。接口的力量通过这样一个事实而倍增,即你不仅限于实现单个接口,与使用子类化的情况相比,在子类化中你只允许有一个父类。
接口用于松散地定义行为或类型。向所有在场的Unity 3D开发者致敬:想象一下创建一个玩家与僵尸和 H.P. Lovecraft 想象中的古老生物战斗的视频游戏——哦,还有猫,因为猫很可怕。每个怪物都可以有自己的类。你可以创建一个接口来定义诸如战斗、奔跑、吃人等行为。这样的视频游戏可能看起来像图 A1.9:

图 A1.9:一个巧妙使用接口将行为映射到游戏角色中的角色设计糟糕的视频游戏。
使用这些接口,你可以在不必要地适合继承链的对象上定义行为,并最终得到非常容易扩展的游戏代码。
与继承相比,接口的关键优势在于灵活性。在第二章中,我们学习了 SOLID 原则,并了解了避免将类紧密耦合在一起的技术。如果你在一个具有Monster类型的类上定义一个属性,那么只有Monster或Monster的子类才能工作。你的类与Monster紧密耦合。如果你通过一个接口,例如IEatHumans,来定义它,那么只要所有你需要的是该接口定义的方法,你就可以传递任何实现该接口的对象。
定义接口
实际上,如果下周游戏导演决定在游戏中添加一个新的怪物,如果新怪物不符合我们创建的结构,我们不需要重新调整我们的对象层次结构。我们只需创建一个新的类,并使用接口来定义其结构。让我们用 C# 编写我们在 图 A1.9 中看到的内容,这样我们就可以看到代码中的样子,而不仅仅是图表。四个角色使用了三个接口:
-
IRun -
IFight -
IEatHumans
让我们从 IRun 接口开始。创建接口相当简单。你所需要做的只是指定方法签名的基本要素——具体来说,是返回类型、方法名称以及参数的名称和类型:
public interface IRun
{
void Jog();
void Sprint();
}
这很简单。我们在这里所做的是指定任何实现此接口的类都必须有两个方法,这两个方法都有 void 返回类型。其中一个必须命名为 Jog(),它不接受任何参数,另一个必须命名为 Sprint(),它也不接受任何参数。如果你使用的是一个好的 IDE,任何实现接口的类都会在你的代码中用红色波浪线标记,直到你满足接口的所有要求。让我们接下来做 IFight:
public interface IFight
{
void Attack();
}
IFight 表示任何实现此接口的类都必须有一个名为 Attack() 的方法,该方法返回 void 并且不接受任何参数:
public interface IEatHumans
{
void Chomp();
}
IEatHumans 要求实现类必须有一个名为 Chomp() 的方法,该方法不接受任何参数并返回 void。接口很简单。让我们看看它们是如何使用的。
实现接口
首先,让我们为 HelplessVictim 创建一个类。该类实现了 IRun 接口。语法与继承相同。我们使用冒号来表示接口实现,这与从超类继承时相同:
public class HelplessVictim : IRun
{
public void Jog()
{
throw new NotImplementedException();
}
public void Sprint()
{
throw new NotImplementedException();
}
}
为了满足接口,我们必须实现两个方法,Jog() 和 Sprint(),它们必须与接口中指定的完全一致。这很好。如果我们需要实现多个接口怎么办?一个类不能有两个父类。幸运的是,C# 不支持多重继承,这是 C++ 程序员临床疯狂的主要原因之一。然而,类可以实现任意数量的接口。让我们创建一个 AncientTerror 类,它实现了三个接口——IRun、IFight 和 IEatHumans:
public class AncientTerror : IRun, IFight, IEatHumans
{
public void Chomp()
{
throw new NotImplementedException();
}
public void Attack()
{
throw new NotImplementedException();
}
public void Jog()
{
throw new NotImplementedException();
}
public void Sprint()
{
throw new NotImplementedException();
}
}
IRun 要求实现相同的 Jog() 和 Sprint() 方法。请注意,对于不同的类,不需要有完全相同的实现。实例方法只需符合接口即可。IFight 要求添加一个名为 Attack() 的方法,该方法返回 void 并且不接受任何参数。IEatHumans 要求我们根据接口添加一个 Chomp() 方法。
我会把最可怕的怪物,僵尸和家猫,留到最后的问答部分作为一个练习。挑战自己,看看你是否能想出这两个剩余类的基本实现!
C# 开发的 IDE
每当我使用一种新的或不熟悉的语言时,我最想了解的是用于该语言工作的工具。好的工具可以使学习和使用该语言变得容易得多。微软意识到了这一点,在发布 C#语言和相应的.NET 运行时后,他们也发布了 Visual Studio——这是一个专门为 C#和另一种非常流行的编程语言 Visual Basic 编写的 IDE。
Visual Basic 是 Visual Studio 的前身。在 20 世纪 90 年代,Visual Basic 是微软最广泛使用的开发语言产品。该公司还销售了一个针对 C++开发的 IDE,称为 Visual C++,并短暂而无效地尝试了 Java,推出了 Visual J++。在这些工具包中,Visual Basic 无疑是其中最重要的。当时,Visual C++被“严肃”的开发者使用。由于微软 Windows 是用 C 和 C++编写的,因此 Visual C++的工具首先是为了支持这项工作而设计的。企业级软件真正成为可能并主流化,这要归功于 BASIC 语言。事实上,BASIC 语言构成了微软自身的基石。比尔·盖茨购买了 BASIC 编译器的权利,并与微软磁盘操作系统(MS-DOS)一起,形成了将成为世界上最大的软件公司之一的实体。
Visual Basic 是由艾伦·库珀设计的。库珀是一位有远见的人。Visual Basic 的用户界面是计算机软件的第一个所见即所得设计平台。在万维网出现之前,我们构建的软件仅能在桌面上运行,而 Visual Basic 是推动行业创新的力量。
但历史就讲到这里。今天,我想介绍三个重要的集成开发环境(IDE)。很可能你已经使用过其中之一。这些工具包括以下内容:
-
Visual Studio
-
VS Code
-
Rider
当然,还有其他 IDE,但这三个是最受欢迎、最完整,坦白说,最重要的。如果你使用 Mac,值得一提的是,Visual Studio for Mac 并不是 Visual Studio 的移植版。之前我提到了 C#的开源版本 Mono。开发 Mono 的团队创建了一个名为Monodevelop的 IDE。它看起来很像苹果的 X-Code IDE,并且被设计成允许 Linux 开发者编写 C#程序。"Monodevelop"是开源的,并最终分叉成为Xamarin Studio。"Xamarin Studio"是一个面向移动开发的 IDE。几年前,微软收购了 Xamarin。该技术的另一个分支成为了 Visual Studio for Mac。我提到它是因为它与我们将要讨论的 IDE 看起来完全不同,所以如果你使用 Mac,我的截图可能不会很有帮助。
要学习模式,你只需要使用这些 IDE 提供功能的一小部分。在本节中,我想带你了解本书中提到的两种项目类型:命令行项目和库项目。
命令行程序是您可以创建的最简单的程序,实际上可以运行。它们没有用户界面,并且从,您猜对了,命令行中运行。库是用于存放要在项目之间共享的对象的代码项目。在 第三章 中,我创建了一个共享库,它被许多其他章节使用,因为它们有很多相同的代码,重复输入相同的类会非常乏味。
让我们首先从 Visual Studio 开始看看这些 IDE。
Visual Studio
Visual Studio 无需进一步介绍。您可以在 www.visualstudio.com 获取一份。IDE 有三个版本:
-
Visual Studio Community
-
Visual Studio Professional
-
Visual Studio Enterprise
前两个版本在许可方式上实际上是一样的,只是许可方式不同。社区版在满足许可要求的情况下是免费的。这些事情会随着时间的推移而变化,所以我不想在这里引用那些要求。请查看 Visual Studio 网站,看看您是否符合免费许可证的资格。
Visual Studio Professional 是同一工具的付费版本。如果您为拥有一定数量开发人员或收入达到一定数额的公司工作,您需要购买订阅。
Visual Studio Enterprise 是付费版本中更昂贵的一种,它包含了许多专业版中不存在的额外功能。
任何这些版本都可以与本书一起使用。我将演示社区版,它再次与专业版没有区别。
创建命令行项目
下载、安装、注册并启动 IDE 后,您将看到一个类似于 图 A1.10 的屏幕。

图 A1.10:Visual Studio 2022 的启动屏幕。
点击窗口右下角的 创建新项目 按钮。这样做会显示 图 A1.11:

图 A1.11:使用此对话框在 Visual Studio 中创建一个新项目。
在 C# 中,您正在寻找的是 控制台应用程序 项目。如果您完整安装了 Visual Studio,请注意,该工具可能会同时显示 Visual Basic 和 C# 项目。请确保您选择 C# 版本的模板。请注意,模板上的图标表示它们使用的语言,如图 图 A1.12 所示:

图 A1.12:注意项目模板上的图标;很容易不小心在错误的语言中生成新项目。
如果您没有看到控制台应用程序项目模板,可以使用顶部的搜索对话框进行搜索,如图 A.13 所示:

图 A1.13:如果您没有看到控制台应用程序模板,可以搜索它
点击控制台应用程序模板,然后在对话框的右下角点击下一步。这将带您到一个标题为配置您的项目的对话框,如图 A1.14 所示:

图 A1.14:通过命名和指定位置来配置您的项目
此对话框允许您为项目命名并指定其在硬盘上的位置。您还可以选择将解决方案命名为与项目不同的名称。解决方案是一组项目绑定在一起的一组文件。这允许您以方便的方式处理多个相关项目。也许您有一个属于同一组的 Web 应用程序和相关的命令行程序。解决方案允许您将相关项目存储在一起。如果您将相关项目存储在一起,可能不想将解决方案命名为与项目相同的名称。对于本书中的练习,这并不重要。
我们将项目命名为 BicycleConsoleApp。我们将解决方案命名为 BicycleSolution。请使用您计算机上方便的位置。底部的复选框指定文件夹结构。您可以保持它未选中。您的项目配置应类似于图 A1.15。点击对话框右下角的下一步按钮:

图 A1.15:附加信息对话框允许您设置项目的框架
附加信息对话框允许您选择用于开发的 .NET Framework 类型。在撰写本文时,默认值为.NET 6.0(长期支持)。只需接受默认设置并点击创建按钮。这将完成项目创建,您将看到完整的 IDE,如图 A1.16 所示:

图 A1.16:新项目已在 Visual Studio 中创建并准备就绪
您的项目已准备就绪!您现在可以开始编写代码了。
添加新类
一旦创建项目,您将需要添加新类。要添加类,右键单击 BicycleConsoleApp 项目,您将找到一个上下文菜单。找到添加选项,将其悬停。第二个菜单展开。在菜单底部找到类...并点击它。您可以在图 A1.17 中看到菜单布局:

图 A1.17:从 Visual Studio 的解决方案资源管理器访问的添加类菜单项。
下一步是使用 图 A1.18 中的对话框指定你想要添加的内容。在这种情况下,我们正在添加一个类。注意,还有一个添加接口的选项。你可能需要两者,但现在,让我们专注于添加类:

图 A1.18:Visual Studio 中的“添加新项”对话框可以用来添加任何东西,包括类和接口。
点击 类,然后给文件起一个与你要创建的类名相同的名字。通常,我会遵循古老的 Java 习惯,每个文件一个类。点击 添加 按钮,类就会被创建并添加到你的项目中。
要添加接口,遵循相同的步骤,但选择 接口 而不是 类。按照惯例,C# 接口总是以字母 I 开头(如“接口”),因此将你的文件命名为你计划命名的接口名。
向解决方案添加库项目
当你创建了命令行应用程序项目时,Visual Studio 也创建了一个解决方案。解决方案是项目的容器。即使你只有一个项目,它也会在解决方案内部。你可以向解决方案中添加更多项目。这是一种方便地将相关项目放在一起的方法。
库项目用于存放可重用的代码,这些代码旨在在项目之间访问。在这本书的 第 3-5 章 中,我使用库来存放常见的类,例如那些模拟我们在这些章节中构建的自行车类的基本部分。在现实世界中,你通常将你的业务逻辑放在库中。当你这样做时,你可以在 Web 应用程序、移动应用程序和桌面应用程序中利用该逻辑,而无需在三个地方重复代码。
创建库项目很容易。在解决方案资源管理器中右键单击解决方案。找到 添加 选项,将其悬停,然后点击 新建项目。会出现一个新的项目对话框,如图 图 A1.19 所示:

图 A1.19:在 Visual Studio 中用于向解决方案添加新项目的上下文菜单。
在对话框顶部的搜索框中定位到 Library。确保类库模板使用的是 C# 语言,而不是其他语言,如 Visual Basic 或 F#。点击 BicycleLibrary。位置应默认为解决方案的位置,以及由此扩展的我们刚刚创建的控制台应用程序项目。点击 下一步 按钮。
下一个对话框允许你为项目选择.NET 运行时。通常,你希望这些匹配。我们为控制台应用程序选择了.NET 6.0,所以这里也应该选择相同的东西。点击创建按钮,Visual Studio 将为你生成项目的文件。
将库项目链接到控制台项目
在你能够使用库之前,你必须在该BicycleConsoleApp项目中创建对其的引用。要创建引用,右键单击控制台应用程序中的依赖项选项,然后点击添加项目引用。这将显示一个对话框,如图A1.20所示:

图 A1.20:在 Visual Studio 中向项目中添加项目依赖项所用的菜单。
项目选项位于对话框的顶部,可能已经选中。你应该能在列表中看到你的库项目名称。点击你刚刚创建的库项目旁边的复选框,然后点击确定。
你的库现在在你的控制台应用程序项目中可用。
引用库
当你创建库项目时,Visual Studio 创建了一个名为Class1.cs的文件。让我们向该类添加一个方法,然后看看如何在控制台应用程序中引用它。
在类库项目中打开Class1.cs文件。添加以下代码:
namespace BicycleLib;
public class Class1
{
public string SayHello()
{
return "Hello from the Bicycle Library!";
}
}
保存文件。
现在,打开控制台应用程序项目中的Program.cs文件。你应该看到一个“Hello World”程序。用以下代码替换起始代码:
using System;
using BicycleLibrary;
var test = new Class1();
Console.WriteLine(test.SayHello());
注意,当你输入代码(输入代码,不要复制粘贴——复制粘贴学不到任何东西!)时,你会发现 IntelliSense 会为你提供关于你在库中添加的方法的提示。实际上,你就像使用控制台应用程序项目的一部分一样使用库中的代码!
剩下的唯一一件事就是:构建项目。
构建和运行控制台项目
C#应用程序会被编译。如果你习惯于像 JavaScript 或 Python 这样的解释型语言,你可能不熟悉这一步。C#编译器,代号foo(foo可以是任何给定的对象),增强型文本编辑器可能会为你提供所有可能的选项。真正优秀的编辑器可能会使用概率或 AI 来缩小范围。Visual Studio 只会根据自省显示可行的选项。最新的 Visual Studio 版本开始包含 AI 功能。当 AI 与 IntelliSense 结合时,结果令人恐惧。你可以期待 Visual Studio 为你编写整个代码块,而不仅仅是样板代码;你的代码中的实际方法。
与编译型语言一起工作的好处之一是更好的代码提示和关于代码错误的早期警告。编译器不允许最常见的错误类型,因此你必须尽早找到并修复它们,因为你必须这样做。编译型语言的产品在执行速度方面通常也更快。
在 Visual Studio 中构建非常简单,有几种方法可以做到。最直接的方法是运行你的程序。要这样做,只需在工具栏中点击绿色箭头。图 A1.21 显示了运行按钮的位置。不幸的是,图 A1.21 没有彩色。书籍编辑的老板们咕哝着关于预算和钱不是从树上长出来的话。我还没有见过 Packt 的老板,但我怀疑他们以前经常和我父亲(愿他在天堂安息)很多次一起出去玩。好消息是,你真的在 IDE 中找不到它:

图 A1.21:在 IDE 中找到绿色三角形;这是运行按钮。
当你点击运行按钮时,你的程序将构建,然后与附加的调试器一起执行。你将看到你的运行程序,如图 A1.22 所示:

图 A1.22:程序在窗口中运行。
要停止程序,请在运行的控制台应用程序窗口中按你的电脑的任意键:

图 A1.23:按任意键退出 Visual Studio 的程序运行;如果你的电脑没有像我一样的任意键,你可以使用空格键。
任意键在较旧的键盘上很常见。它确实限制了使用字母 H 编写代码的能力。由于我住在德克萨斯州,我只用 J。为了能够快速退出运行中的程序,这是微不足道的代价。
如果你的电脑没有任意键,只需按空格键。请注意,暂停信息不是你程序的一部分,如果你发布应用程序,它将不会出现。这是 Visual Studio 暂停应用程序。控制台应用程序通常在一秒内运行并退出。如果出现问题,程序将完成,显示错误,然后在你有机会看到任何内容之前关闭窗口。因此,Visual Studio 为你做了件好事,在程序退出之前冻结窗口,以便你可以检查它。你的程序应该没有错误地运行。
这就结束了我们对 Visual Studio 的简要游览。如果你对在 Windows 中设置 Visual Studio 开发环境的视频教程感兴趣,你可以在 csharppatterns.dev 找到相关链接。
VS Code
VS Code 的名字与 Visual Studio 很相似,但它完全是另一种生物。它不是完整的 IDE,而是一个增强型文本编辑器,而不是真正的 IDE。我们刚刚介绍了微软的黄金标准编辑工具。我为什么要谈论 VS Code 呢?答案是:VS Code 目前拥有超过 50% 的市场份额,成为大多数开发者每天使用的最受欢迎的工具。这有一些很好的理由:
-
VS Code 占用的空间比 Visual Studio 小得多,Visual Studio 所需的总空间接近 45 GB。您可以减小 Visual Studio 的占用空间,但 VS Code 仍然会保持更小的占用空间。
-
VS Code 启动非常快。Visual Studio 则不然。您可以使用 VS Code 快速查看从 GitHub 拉取的存储库或同事共享的某个文件夹。启动 Visual Studio 是一种承诺。如果您使用的是性能较低的计算机,您可能可以在 IDE 的启动屏幕出现之前处理电子邮件、快速浏览社交媒体、喝咖啡,甚至给母亲打电话。顺便说一句,给母亲打电话!她想念您。
-
VS Code 与几乎所有编程语言都兼容。自然,它与 Microsoft 支持的语言以及其他流行的语言(如 Golang 或 Rust)配合得很好。此外,您会发现扩展程序允许您使用它来处理一些奇怪的语言。原始的 GoF 书籍使用了 SmallTalk 编程语言,由两家扩展供应商提供。您还可以找到 Ada、Haskell 以及更多。Visual Studio 主要用于 Microsoft 语言产品的主流工作。
-
VS Code 在所有操作系统上提供一致的用户体验。Visual Studio 是一个仅适用于 Windows 的程序。它无法在 Linux 上运行。Visual Studio Mac 是一个完全不同的程序,外观与 Windows 版本截然不同。这种一致性使得该工具成为学校、大学和像我所在南方卫理公会大学那样的代码训练营的流行选择。
VS Code 有很多优点。像 Visual Studio 一样,VS Code 也可以在 www.visualstudio.com 下载。
如果 VS Code 将成为您首选的编辑器,那么您需要安装 .NET Core SDK 以提供用于与 C# 一起工作的工具和编译器。
安装 .NET Core
如果您打算使用 VS Code,则需要安装 .NET Core SDK 以获得用于与 C# 一起工作的工具和编译器。您可以从 dotnet.microsoft.com/en-us/download 下载它。当然,这本书出版后,该网址可能会发生变化,在这种情况下,您将需要变得机智。安装它很简单。
在安装了 VS Code 和 .NET Core SDK 之后,您就可以开始使用了。如果您阅读了在 Visual Studio 中设置项目的步骤,VS Code 的步骤则非常不同。
创建新的解决方案
Visual Studio 一步即可创建项目和解决方案。这是 VS Code。每个步骤都是单独执行的,并且所有操作都通过命令行完成。从技术上讲,VS Code 并未真正参与其中。您使用的是在安装 .NET Core SDK 时安装的 dotnet 命令行工具。您可以在 Windows Terminal 中完成所有这些操作。
由于 Windows Terminal 随 Windows 11 一起提供,因此无需安装任何其他软件。如果您从未使用过它,请点击如图 A1.24 所示的 Terminal。启动应用程序。如果您仍在使用 Windows 10,请搜索 PowerShell 而不是 Terminal:

图 A1.24:使用 VS Code 进行的大多数项目设置工作都在终端窗口中完成。
在打开终端(或 PowerShell)的情况下,输入此命令:
dotnet new sln -o BicycleSolution
这里,-o 是用于 输出 的。这告诉命令为解决方案创建一个文件夹。除了创建文件夹外,该命令在 BicycleSolution 文件夹中生成一组文件。让我们看看。输入以下内容:
cd BicyleSolution
然后,输入以下内容:
dir
dir(目录)命令将列出当前工作目录中的所有文件。您应该会看到类似于 图 A1.25 的内容:

图 A1.25:我们新解决方案命令的结果。
我们有一个解决方案,但里面什么都没有。我们需要一些项目来使解决方案变得有用。
创建命令行项目
接下来,我们将向当前解决方案文件夹添加一个控制台应用程序。首先,使用 cd 命令进入 BicycleSolution 文件夹。从现在开始,我给出的命令基于这样一个假设,即您的当前工作目录是 BicycleSolution 文件夹。
要将控制台应用程序添加到解决方案中,请在终端窗口中输入以下命令:
dotnet new console -o BicycleConsoleApp
这将创建一个新的控制台应用程序。同样,-o 是用于输出的。这告诉命令将项目命名为什么。dotnet 命令为控制台应用程序项目生成样板文件,如图 A1.27 所示:

图 A1.27:我们的命令为您生成了命令行项目的文件结构。
一旦创建了应用程序,您需要使用此命令将其添加到您的解决方案中:
dotnet sln add BicycleConsoleApp/BicycleConsoleApp.csproj
命令的结果可以在 图 A1.28 中看到:

图 A1.28:控制台应用程序已成功添加到解决方案中。
文件夹结构不会有明显的变化。该命令修改了 BicycleSolution.sln 文件以包含 BicycleConsoleApp 项目。您的命令行项目已准备好使用。
创建库项目
库项目用于存放可重用的代码,这些代码旨在在项目之间访问。在本书的第 3-5 章*中,我使用库来存储常见的类,例如那些模拟我们在这些章节中构建的自行车类的基本部分的类。在现实世界中,您通常将业务逻辑放在库中。当您这样做时,您可以在 Web 应用程序、移动应用程序和桌面应用程序中利用该逻辑,而无需在三个地方重复代码。
要在解决方案文件夹中创建一个库项目,请在您的终端窗口中输入以下命令:
dotnet new classlib -o BicycleLibrary
如前所述,该命令在当前解决方案中创建一个新的项目。-o开关告诉命令输出什么名称,在这种情况下,是库项目的名称。创建了库项目后,你需要使用以下命令将项目添加到你的解决方案中:
dotnet sln add BicycleLibrary/BicycleLibrary.csproj
使用dir命令确认你的文件夹结构看起来像图 A1.29 中的我的结构:

图 A1.29:创建了解决方案、控制台应用程序和库项目后的项目结构。
现在你有了库项目,你需要设置控制台应用程序和库项目之间的引用。
将库链接到控制台项目
要将控制台项目链接到库项目,请输入以下命令:
dotnet add BicycleConsoleApp/BicycleConsoleApp.csproj reference BicycleLibrary/BicycleLibrary.csproj
库已链接到控制台应用程序项目。我们在终端窗口中花费了很多时间。现在是时候开始在 VS Code 中工作了。
启动 VS Code 并添加 C# 扩展
在 VS Code 中添加类或接口很容易。你可以通过在终端窗口中输入以下内容来启动 VS Code,我们一直在使用这个窗口:
code .
你读得对:输入code,然后输入一个空格和一个句号,然后按Enter。这个命令会启动 VS Code 并加载解决方案文件夹。你会看到你通常的安全警告,如图 A1.30 所示:

图 A1.30:你信任你刚刚创建的文件吗?
假设你信任自己的工作,点击是。如果你不信任,请简要关闭这本书,深入思考。考虑你是否宁愿生活在一个美丽而无聊的安全世界,那里永远不会对你造成伤害;一个充满美丽花朵的世界,它们的香气只会让你想起生活中最好的时刻。或者你更愿意生活在一个充满高风险和彻底毁灭可能性的世界?海盗船在港口里是安全的藏身之处。但它们不是用来在那里停留的!这是你的时刻!去做吧!点击是的,我信任作者按钮!我保证,如果你这样做,你将在生活中转一个弯。已经长出脊椎了,点击按钮吧!顺便说一句:我的律师希望我提醒你,作者不对恶意软件、计算机损坏、声誉损害或点击此按钮导致的海盗袭击负责。
解决了个人问题(如果你有的话)后,让我们继续。
我提到过,VS Code 并不被视为一个完整的 IDE。它是一个通用编码工具,不假设你将如何使用它。因此,VS Code 并没有内置 C# 支持。自然地,Microsoft 有一个插件可以帮助你处理 C#。项目打开时,你可能会被提示安装此插件。如果你没有,你可以按照图 A1.30 中显示的步骤进行安装:

图 A1.30:Microsoft 为 VS Code 提供了一个免费的扩展,使使用 C#更加愉快。
首先,单击界面左侧菜单上的扩展按钮(1)。然后,在搜索栏中搜索此扩展(2):
@id:ms-dotnettools.csharp
这将使列表缩小到单个扩展。单击它,然后单击安装按钮。现在 VS Code 完全了解 C#语言和你的项目结构。
添加类或接口
在资源管理器视图中右键单击BicycleConsoleApp文件夹,然后单击新建文件,如图 A1.31所示:

图 A1.31:在资源管理器区域右键单击,然后单击“新建文件”以创建新文件。
一旦你点击了.cs扩展。例如,我把我文件命名为Class1.cs。如果你正在创建一个接口,通常的做法是将文件名以大写 I 开头(例如,“Interface”)。
向 BicycleLibrary 项目添加代码
让我们在BicycleLibrary项目中添加一个方法,以便我们可以验证它是否已链接并正确工作。
在BicycleLibrary项目中找到Class1.cs文件。当你创建项目时,该文件为你生成。单击文件,它将在编辑器中打开。添加以下代码:
namespace BicycleLibrary;
public class Class1
{
public string SayHello()
{
return "Hello from the Bicycle Library!";
}
}
一定要保存文件!将你的项目与我的项目进行比较,如图 A1.32所示:

图 S1.32:库代码已添加到 BicycleLibrary 项目中的 Class1.cs 文件。
接下来,让我们切换到BicycleConsoleApp项目中的Program.cs文件。替换掉由dotnet生成的行,使用以下内容:
using System;
using BicycleLibrary;
var test = new Class1();
Console.WriteLine(test.SayHello());
当你输入时,IntelliSense 会显示我们添加到BicycleLibrary项目的库方法,就像它在BicycleConsoleApp项目中一样。将你的工作与图 A1.33进行核对:

图 A1.33:带有我们的测试代码的更新后的 Program.cs 文件。
我们的使命已经完成!只剩下一件事要做:构建项目。
构建和运行控制台项目
我们可以切换回我们的终端窗口,但 VS Code 有一个集成的终端窗口。使用它比不断切换窗口更方便。
要激活它,使用键盘快捷键Ctrl + `(控制+反引号)。此外,你可以从主菜单中选择视图然后终端,如图 A1.34所示:

图 A1.34:您可以使用此菜单项在 VS Code 中打开集成终端窗口,或者使用 Ctrl + `作为键盘快捷键。
运行项目将会构建项目,然后执行BicycleConsoleApp项目的可执行文件,该文件由Program.cs文件中的代码组成。要在终端窗口中运行程序,请输入以下命令:
dotnet run –-project .\BicycleConsoleApp\BicycleConsoleApp.csproj
您将看到程序正在构建,命令行项目将会运行。您应该在终端窗口中看到“来自自行车库的问候!”消息,就像您在图 A1.35中看到的那样:

图 A1.35:这是我们测试运行的结果。
这就结束了我们对 VS Code 的简要游览。如果您对在 Windows 中设置 VS Code 开发环境感兴趣,并希望有一个视频教程来指导您,您可以在csharppatterns.dev找到相关链接。
Rider
JetBrains Rider 是 C# IDE 世界中的最新成员。JetBrains 以其为 Java(IntelliJ Idea)、Python(Pycharm)、PHP(PHPStorm)和 JavaScript(WebStorm)等语言制作出色的 IDE 而闻名。他们还构建了谷歌的 Android 开发 IDE,Android Studio。除了 IDE 之外,该公司还拥有一个非常受欢迎的 Visual Studio 插件产品,名为Resharper。Resharper中的工具为您提供了许多在Visual Studio Enterprise中找到的类似功能,但成本却低得多。
我使用 Rider 创建这本书,主要是因为程序中提供的代码格式化和重构工具。此外,我经常使用他们的一些其他 IDE,并且我已经将键盘快捷键配置为在每种语言 IDE 中都是通用的。这纯粹是个人偏好。我知道我可以使用 Rider 更快地完成这本书。
Rider 不是一个免费产品。对于独立开发者有一个较低成本的版本,而在公司使用时,同样的产品会以更高的价格出售。如果您对使用这个工具感兴趣,请查看www.jetbrains.com/rider获取更多信息。
由于我使用 Rider 创建了这本书,如果我不至少带您参观一下,那就显得有些奇怪了。让我们再次使用 Rider 来做这个练习。
创建命令行项目
当您启动 Rider 时,您将看到一个欢迎屏幕对话框。在对话框右上角单击新项目按钮,如图图 A1.36所示:

图 A1.36:Rider 中的欢迎屏幕,新项目按钮被突出显示。
单击按钮会弹出一个新的对话框,如图图 A1.37所示:

图 A1.37:Rider 中的项目创建。
使用 Rider,所有内容都在一个屏幕上。找到 BicycleSolution 和 ProjectName 为 BicycleConsoleApp。点击创建按钮,您的项目将被生成。完整的 IDE 将会显示,您将在 IDE 窗口的左侧的探索器面板中找到您的项目层次结构。
添加类或接口
添加类与我们在前面提到的其他两个 IDE 中的操作相同。右键单击 BicycleSolution 项目。出现一个上下文菜单,如图 A1.38 所示:

图 A1.38:将类或接口添加到项目中的功能可以在您右键单击项目时在上下文菜单中找到。
将鼠标悬停在添加菜单项上,然后在子菜单中找到类/接口。点击它。您将看到一个小的对话框,要求您命名类或接口。列表中还有一些其他可能性,但本书只关注类和接口。选择类选项,并为您想要的类命名。一旦按下Enter,您将看到类文件已添加到您的项目中。
当您需要一个接口时,遵循相同的程序,但不要选择类,而是选择接口。不要忘记,在 C# 中,接口的命名约定规定以字母 I(如“Interface”)开头命名接口。
接下来,我们将查看如何将库项目添加到我们的解决方案中,以便在项目之间最大化代码重用。
创建库项目
库项目用于存放可重用的代码,这些代码旨在在项目之间可访问。在本书的第 3-5 章,我使用库来存放常见的类,例如那些模拟我们在这些章节中构建的自行车基本部分的类。在现实世界中,您通常将业务逻辑放在库中。当您这样做时,您可以在 Web 应用程序、移动应用程序和桌面应用程序中利用该逻辑,而无需在三个地方重复代码。
在探索器菜单中右键单击 BicycleSolution。将鼠标悬停在添加选项上,并选择新建项目...。您可以在图 A1.39 中看到这一点:

图 A1.39:在 Rider 中将项目添加到解决方案的操作是通过右键单击解决方案,然后悬停在添加上,然后点击新建项目。
您将得到我们最初开始的相同项目对话框。定位到 BicycleLibrary。您的项目应该与我的项目一致,如图 A1.40 所示:

图 A1.40:按照所示设置您的新的库项目。
点击创建按钮,你会在解决方案中找到一个新项目。你的资源管理器窗格应该看起来像图 A1.41:

图 A1.41:资源管理器窗格显示了解决方案中的两个项目。
在我们能够在控制台应用程序项目中使用库之前,我们需要通过引用将两个项目链接起来。
将库项目链接到控制台项目
在资源管理器窗格中,定位到BicycleConsoleApp项目,并在项目层次结构中右键单击Dependencies项。会出现一个上下文菜单,如图 A1.42 所示:

图 A1.42:在资源管理器窗格中右键单击依赖项时显示上下文菜单。
点击添加引用。会出现一个对话框,如图 A1.43 所示:

图 A1.43:Rider 中的添加引用对话框。
你应该看到库项目被列出。勾选库旁边的复选框,然后点击添加。你现在可以从控制台应用程序项目中引用库项目。让我们试试看。
测试链接库
在BicycleLibrary项目中打开Class1.cs文件。添加以下代码:
namespace BicycleLibrary;
public class Class1
{
public string SayHello()
{
return "Hello from the Bicycle Library!";
}
}
这是我第三次输入相同的代码。谢天谢地,英语语言不需要作者遵循 DRY 原则。
接下来,在BicycleConsoleApp项目中打开Program1.cs文件。将该文件中的代码更改为以下内容:
using System;
using BicycleLibrary;
var test = new Class1();
Console.WriteLine(test.SayHello());
当你输入代码时,你应该会看到代码提示显示你能够访问该库。一旦输入了代码,我们就准备好运行测试。
构建和运行控制台项目
与 Visual Studio 一样,你正在寻找一个表示工具栏中运行按钮的大绿箭头。你可以在图 A1.44中看到这个按钮被突出显示,遗憾的是,不是以颜色显示。
当你点击运行按钮时,你的项目会像在 Visual Studio 中一样构建并运行,并附加调试器。与 Visual Studio 不同,你的程序在 Rider 的集成终端中运行,就像在 VS Code 中一样。你可以在图 A1.44中看到我运行的结果:

图 A1.44:在 Rider 中完成测试程序的运行;已指出终端窗口。
这就结束了我们对Rider的简要游览。如果你对在 Windows 中设置Rider开发环境的视频教程感兴趣,你可以在csharppatterns.dev找到链接。
摘要
这个附录原本打算是一个关于使用 C#的简要总结。我几乎没把它包括在书中。你可以在很多其他地方获得关于该语言的培训和指导。然而,鉴于我几十年来一直在教授 C#,我觉得如果是从其他语言转过来的,或者对 C#的经验有限,或者有一段时间没有接触了,我可能能够更简洁、更全面地帮助你入门。
在这个过程中,我们学习了以下内容:
-
C#是一种标准化的通用、严格面向对象的语言。
-
C#使用强、静态类型系统。
-
C#的设计旨在通过一些特性来限制在 C 和 C++开发中最常见的错误,例如边界检查、轻松的内存分配和自动垃圾回收。
-
C#支持许多不同的数值类型,包括有符号和无符号变体。
-
如何创建自动实现的属性和方法,以及如何处理封装。
-
如何在 C#中使用面向对象的基石,包括继承和与接口一起工作。
在所有这些之后,我们得到了一个关于 C#最流行的三个 IDE 选择的基本教程:Visual Studio、VS Code 和 Rider。我称这个附录为简洁。我不会说它很短,但考虑到大多数只介绍 Visual Studio 的 C#书籍的大小,我认为这是一个物有所值的交易。如果你还想了解更多,请访问csharppatterns.dev,那里我提供了额外的资源链接。
进一步阅读
-
《C#面向对象编程入门:C#和.NET》,作者 Praveenkumar Bouna。
-
《C#开发者用 Visual Studio Code》,作者 Praveenkumar Bouna。
-
《动手实践 Visual Studio 2022》,作者 Hector Uriel Perez Rojas 和 Miguel Angel Teheran Garcia。
-
csharppatterns.dev提供了额外的资源链接。
附录 2:统一建模语言(UML)入门
你不需要仔细思考就能意识到,设计软件与设计其他事物有很多相似之处。在第一章《你的意大利面盘上有一个大泥球》中,我们讨论了来自建筑领域先驱的软件模式的底层,不是软件架构,而是涉及建筑和城市架构的传统、工程和设计实践。1977 年,Christopher Alexander 记录了一种旨在形成城镇最佳实践基础的模式语言。他的书描述了 253 个模式,这些模式被视为建筑设计的典范。这本书把所有东西都分解成了对象。
面向对象分析设计(OOAD),作为一种与 面向对象编程(OOP)相关的实践,涉及独立于编写代码的练习来设计对象结构。这通常是由软件架构师或高级开发者负责的工作。软件架构师类似于建筑的建筑师:他们设计应用程序的结构。这可以在实施团队选择项目编程语言之前完成,实施团队是负责构建建筑师设计的内容的团队。
OOAD 利用一组编码为 统一建模语言(UML)的文档规范。它听起来像是一种编程语言,但它不是。相反,它是一个创建图例的标准,用于解释软件系统中组件的结构和关系。
这样想。如果你是一个音乐作曲家,你可以在乐谱上写下你的音符。你可以在不接触任何乐器的情况下做到这一点。如果你在作曲方面很在行,你可以在纸上使用乐谱符号创作完整的管弦乐作品。然后你可以把乐谱交给一个乐团和指挥,假设乐团由合格的乐手组成,他们应该能够演奏你的音乐。建筑师就像作曲家一样。程序员就像音乐家,而团队领导或主要开发者就像指挥。
在整本书中,UML 类图被用来传达将要在我们现实世界项目中实现的设计模式代码的结构。如果你之前从未使用过 UML,你可能需要一个简短的入门指南。本附录旨在成为那个指南。它不能替代研究生级别的 OOAD 课程,甚至也不能替代阅读一本关于 UML 的整本书。我只会涵盖我们全书所使用的图例规范,以便你能够理解这些图例,以及它们是如何转换为代码的。
再次强调,UML 不是一个编程语言;它是一个用于绘制代码规范的规范。UML 2.5 规范中有 14 种类型的图。我们只需要一种;在所有不同的图例中,最常见的是 类图。
技术要求
有很多工具可以绘制 UML 图,尽管我已经展示了众多这样的图,但我对制作它们的工具却视而不见。在现实世界中,这种练习通常是在白板上进行的。白板适合那些后来会被擦除的临时绘图。对于这本书,我的图例需要更加持久,所以我使用的是:
-
运行 Windows 操作系统的计算机。我使用的是 Windows 10。说实话,这并不重要,因为所有操作系统中都有大量的绘图工具,而且有很多可以在你的浏览器中工作。
-
一个绘图工具。我使用的是 Microsoft Visio。
市场上有很多 UML 工具。以下是我多年来使用过的一些工具的简要列表:
-
Microsoft Visio
-
StarUML
-
Altova UModel
-
Dia
-
Umbrello
-
Umlet
-
Omnigraffle(仅限 Mac)
网上还有很多。我倾向于使用运行在我电脑上的应用程序,而不是基于浏览器的解决方案。
类图的构成
类图由几种类型的结构和一组表示结构之间关系的连接线组成。结构如下:
-
一个类(显然)
-
接口
-
枚举
-
包(展开和折叠)
结构之间的连接线显示了这些结构是如何相关的。我们可以表达的关系包括以下内容:
-
继承
-
接口实现
-
组合
-
关联
-
依赖
-
聚合
-
有向关联
UML 图上最后可能出现的元素是注释。注释就是你所想的那样。有时,架构师需要添加比标准 UML 允许的更多一点的信息。注释让你可以做到这一点。你不应该用它们来写一封信。在实现逻辑中,最常见的短注释。
要理解这本书中的模式,你需要理解类图。这本书中的每个模式至少用到一个 UML 图表达。最重要的模式每个都有两个图覆盖。
让我们看看 UML 图的各个部分。一旦你意识到所有图块的含义,它们就不难理解。让我们从结构开始。
类
类图是对象类的视觉表示。假设我们的程序需要我们像 图 A2.1 中的那样建模一个圆:

图 A2.1:这不是一个圆的 UML 图。
如果我们想在 UML 中建模这个,类图将看起来像 图 A2.2。

图 A2.2:这是一个 UML 图的例子。类名 Circle 在顶部,后面跟着属性列表、分隔线和方法列表。+ 表示它们都是公共的。
那不是太难,对吧?它是一个分成三个部分的框。
顶部部分包含类的名称。类只是我在 UML 图中放入的编码版本。理解这些细节对于 第三章 中关于“用创造模式发挥创意”的覆盖至关重要。这些模式围绕着对象创建。
图 A2.2 中间的框包含了这个类成员的属性列表。注意它们没有类型。有些人会在图中添加类型。我被告知不要这样做,因为这只是一个实现细节。建筑师只是设计;他们不会比必要的更多地去约束建造者。实现这个图画的程序员选择类型。如果你既是建筑师又是开发者,如果你觉得这样有帮助,可以自由地指定类型。
在属性列表下方,我们画一条线来区分属性部分和下一部分。Visio 在我的绘图中使用的是虚线。虚线没有意义。它可以是一条实线或任何你喜欢的线条样式。
图 A2.2 底部的框是一个方法列表。它不包括实现方法的细节或它们的功能——只是名称和任何参数。
你可能已经注意到图中有一些 + 符号。这些指的是你的访问修饰符。与类型一样,有些建筑师会添加它们,而有些则不会。其他人会在至关重要的地方添加它们——当类如果不尊重这个细节就无法工作时。我屈服于诱惑,制作了一个可爱的图来展示访问修饰符在图 A2.3 中的样子。

图 A2.3:访问修饰符。
加号(内部,但它们是 C#特有的,所以 UML 不会有它们的符号。如果你不理解这些访问修饰符,请参阅附录 1)。
就这些!UML 类图在本质上非常简单。我们还没有完成。UML 图可以显示不同级别的细节。这完全取决于你的偏好和你的图目的受众。当我研究生阶段的 OOAD(面向对象分析与设计)时,我的教授希望我在图中尽可能少地包含实现细节。这个习惯一直伴随着我。我看到其他人把所有细节都绘制到最小。我从未是那种微观管理的人,所以我打算尽可能地把事情留给开发者。
抽象类
抽象类是我们不允许直接实例化的类。你只能实例化抽象类的子类。绘制它们需要对图进行微小的修改。图 A2.4 中有一个例子。

图 A2.4:当绘制抽象类时,用斜体字写出类的名称。
在这里,我们可以看到Shape类是抽象的,而Circle类不是。要指定一个类为抽象类,你需要在顶部的框中用斜体字写出类的名称。注意这一点!有时,这可能会很难看到,特别是如果你的绘图工具使用了一种可爱的字体,使得斜体难以辨认。
绘制所需类型
有时候,你绝对必须定义属性类型、方法参数或返回类型,因为实现确实需要它。在这些情况下,你可以在图中指定类型,就像我在 图 A2.5 中所做的那样。

图 A2.5:你可以使用冒号后跟类型来指定特定类型。
Image 类在这里有一个属性,它绝对必须是 Shape 类型。它用冒号分隔属性名称和其类型来表示。同样,我可以为方法指定返回类型。Image 类有一个名为 GetCircle() 的方法,它必须返回 Circle。它也用冒号和位于方法签名之后类型来类似地指定。
构造函数
构造函数是一个在对象实例化时被调用的特殊方法。附录 1 详细介绍了这一点,如果你不确定我的意思的话。构造函数必须与定义它的类的名称相同。对于很多人来说,仅仅看到与类名相同的名称就足够了。它也可以更正式地绘制,如图 图 A2.6 所示。

图 A2.6:UML 图中的构造函数难以错过。
你不可能错过它。它是前面带有令人讨厌的 <<constructor>> 的方法。由于令人讨厌的东西非常有教育意义,这就是我将使用的约定。
不要绘制每个微小的细节
在网页设计的世界里,设计师在与客户打交道时使用了一个已经存在了几个世纪的技巧。在处理初步设计时,目标是确保布局正确,不要专注于文案。当你试图在网页设计中放入虚假文案时,问题就出现了。客户会关注文案的内容,他们会试图润色页面上的文字。目标是获得对设计的认可,但这是不可能的,因为所有利益相关者都陷入了占位文本的内容。
为了解决这个问题,设计师使用 lorem ipsum 文本。lorem ipsum 是归功于罗马政治家和诗人马尔库斯·图利乌斯·西塞罗的一本著名伦理学书籍中的单词。文本看起来像这样:
“Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum。”
这段文本被放置在设计草案中,作为实际副本的占位符。由于文本难以理解,任何查看带有此文本的设计的人都不会被诱惑去评论内容。他们专注于设计。
在 UML 中,当你想要这样做时,你可以使用空或几乎空的类来鼓励利益相关者,即开发者,专注于模式设计而不是你命名的属性。
我在这本书中做了很多这样的事情。项目不是真实的,代码在作为教学工具之外并不重要。我们在这里是为了学习模式。你可以期待看到很多像图 A2.7这样的图表。

图 A2.7:在设计模式时,看到没有填写所有细节的类是正常的。重点是设计和评论结构,而不是内容。
在Person和Teacher类中,我使用了占位符。在Student类中,我没有指定任何内容。这两种表示法都是有效的。当你确定了结构,你可以选择填写缺失的属性、方法和关系,或者将它们留给开发者作为实现细节。
如果你选择前者,要小心。当你刚开始习惯使用 UML 时,很容易将其用作指定细节的工具。抵制这种诱惑。如果不这样做,你的图表会变得如此复杂,以至于失去了意义。
接口
接口对于面向对象分析和模式绘图非常重要。绘制它们与绘制类没有太大区别。它们看起来一样,只是在图表标题框中表达了一个令人讨厌的<<Interface>>。

图 A2.7:UML 中的一个接口。
一些语言只允许你在接口中实现方法。C#中的接口可以指定除了通常的公共方法之外的公共属性,因此可以在接口上定义属性是合法的。再次强调:接口中方法的所有属性都必须是公共的。你只会看到+符号作为访问修饰符。
枚举
枚举指的是一组很少改变的有限值。例如,美国各州名单 70 年来没有变化。一周中的日子在数千年来也没有变化。这些都是枚举的好候选者。让我们看看一个快速示例:
public enum DaysOfWeek { Mon, Tue, Wed, Thur, Fri, Sat, Sun };
它们可以用作类型,其效果是防止虚假数据被传递到属性或变量中:
var dayOff = DaysOfWeek.Wednesday;
这比仅仅将其作为字符串要好:
var dayOff = "Wednesday";
我将变量设置为“Wednesday"”,但正如我的enum显示的那样,我的程序期望一个非常具体的值。字符串“Wednesday”是不正确的。只要是一个字符串,我就可以轻松地设置任何值。如果我的类需要一个星期中的某一天,并且我使用枚举作为类型,那么就不可能传入Bruceday作为星期中的某一天。Bruceday只出现在我的家里,那里每一天都是美丽、美丽的Bruceday。我已经尝试了多年将其标准化为国际上公认的休息日。我想这应该是肯定的,当国家超级巨星 Alan Jackson 录制了“It’s Bruceday Somewhere.”这首歌时。但在最后一刻,他将其标题改为“It’s Five O’Clock Somewhere.”。我认为这是一个错误。显然,你必须是一个北欧或希腊神,一个主序星,或者一个行星体,你才会被认真对待。
列表,就像接口一样,在标题框中添加了一个令人讨厌的符号,如图 A2.8所示。

图 A2.8:UML 中的枚举。
当你绘制一个枚举时,属性代表枚举中包含的值。枚举没有方法,所以图的下部不会有任何内容。也没有访问修饰符。只显示内容。
包
包是前面结构的集合。在 C#中,你会称它为命名空间。有两种方式来绘制包:展开和折叠。展开的包会用一个矩形框住属于该命名空间的类和结构。它们用来显示包的内容。
折叠的包不显示内容。它们只是命名空间的表示。你可以在图 A2.9中看到展开和折叠包的示例。

图 A2.9:UML 中的展开和折叠包。
我在书中只使用了一次展开的包来表示对第三方库的依赖。
连接器
在商业中,成功的一个重要因素是与你共事的人之间的关系。对于类、接口、枚举和包的系统来说,也是如此。除了类图中的结构外,图也许更重要的是表达结构之间的关系,使用一套标准化的线条和符号。这些线条将结构连接在一起。让我们看看 UML 类图中表达的关系。
继承
两个类之间的继承用一端带有开放三角箭头的实线表示。例如,如果我有一个名为Person的类,另一个名为Student的类继承自Person,则图表将看起来像图 A2.10。

图 A2.10:继承用带有空心三角形箭头指向基类的实线表示。
箭头应指向继承类到基类的方向。你表达的是学生 IS A 人的关系。作为一个最佳实践,基类总是绘制在子类之上,因此箭头应始终向上。
接口实现
如果您不确定这些是什么,我们已在 附录 1 中讨论了接口。接口是传递依赖关系最灵活的方式,而不会将两个对象紧密耦合在一起。这使得它们对我们研究模式非常重要。
当一个类实现一个接口时,你从实现接口的类到接口本身画一条虚线。线的末端有一个空心箭头指向接口,如 图 A2.11 所示。

图 A2.11:接口实现线是虚线,有一个空心箭头从实现类指向接口。
组合
组合指的是由其他对象创建一个对象。你通过说 HAS A 来表达一个关系。要创建一个 car 对象,你可能需要一个 engine 对象和一个车标对象。你会说一辆车 有一个 车标,你就是在表达组合。参见 图 A.12 了解组合的示例。

图 A2.12:UML 中使用实线表示组合,包含类的末端有一个菱形。
在 UML 中,组合用实线在类之间绘制,一端有一个实心菱形。菱形应指向放入其中的类。在我们的 Car 类中。
关联
两个类之间的关联表示它们相互交互。一个 driver 对象与一个 car 对象交互。司机往车里加油。汽车将司机带到另一个地方。要在 UML 中绘制关联,你使用没有箭头的实线,如 图 A2.13 所示。

图 A2.13:关联显示了相互交互的对象。
聚合
聚合是一种涉及多重性的关联类型。一个 warehouse 对象将与库存项目有“一个订单有许多库存项目”的关系。不要将其与组合混淆。它不仅看起来像同一件事,而且符号也几乎相同。聚合在实线上表现为一个空心的菱形,如 图 A2.14 所示。

图 A2.14:聚合在实线上用一个空心的菱形表示。
聚合和组合是不同的。组合指的是从其他对象构建一个对象。聚合指的是对象之间存在一对一、一对多或多对多的关系,但对象可以独立存在。如果我们有一个CollegeCourse对象,我们可以用student对象来表示聚合。一个班级中有很多学生。如果大学取消了课程,学生不会立即消失。你可以有一个没有班级的学生。
有向关联
一个有向关联显示了容器关系。一个spaceship对象以这种方式与passenger对象相关联。乘客被包含在飞船内。请注意,飞船不是由乘客组成的,所以它不同于组合。有向关联可以像在图 A2.15中看到的那样绘制。

图 A2.15:两个类之间的有向关联。
线是实线,箭头是两条线而不是三角形。
依赖
当一个类依赖于另一个类时,你会发现当需要修改时,你将不得不同时修改这两个类。如果你改变一个,你就必须改变另一个。想象一下一个电源插头和一个电源插座。它们确实是设计成相互物理依赖的。如果你不得不改变插头的形状,你可能也必须改变插座的设计,反之亦然。两个类之间的依赖关系是我们通常力求避免的,尽管它们是不可避免的。这可能听起来很禅宗,但我指的是两个类之间的关系。依赖关系用带有非三角形箭头的虚线表示,如图A.16所示。

图 A2.16:类之间的依赖关系用虚线和指向依赖关系的锐角箭头绘制。
这些关系可以用“A 依赖于 B 尽可能地少变化”来表述。我添加了最后一部分来提醒我讨论在第二章**,《为 C#中模式的实际应用做准备》*中的 SOLID 原则。SOLID 中的 O 代表开闭原则。类应该对扩展开放但对修改封闭。依赖是不可避免的,但当你看到图中的依赖关系时,要仔细审查它们并小心实现。
笔记
笔记就是你所想的那样。有时,架构师需要添加比标准 UML 允许的更多一点的信息。笔记让你可以做到这一点。你可以在图 A2.17中看到一个例子。

图 A2.17:这是 UML 中笔记的样子。
你不应该用它们来写一封长信。在实现逻辑中的简短笔记是你最常见到的。
最佳实践
UML 类图易于创建和理解,或者至少它们应该是这样的。不幸的是,许多图表都成为了我在第一章中提到的相同力量的受害者。它们可能成为金锤,而且它们可能不受控制地变得过于复杂,以至于不再有用。软件随着时间的推移逐渐屈服于这些力量。有时,需要数年才能使一个仓库充满意大利面。图表往往在几天内就会变得支离破碎。UML 是一个计划。如果你的计划是一团糟的灾难,那么你的软件怎么可能不是呢?
为了遏制这些破坏性力量的潮流,我将给你四个提示,以保持你的图表有用、易于阅读,并希望这能帮助你避免分析瘫痪。
少即是多——不要试图将所有内容都放入一个大的图表中
UML 图表原本是供开发团队使用的,但它们经常与其他项目利益相关者共享。如果你带着一个看起来像爱国者导弹系统引导系统图样的图表参加会议,你 shouldn’t expect to be well received. 开发者不会欣赏你,管理层也不会对你有很高的评价。在第六章**,远离 IDE!在编码前使用模式和第七章**,除了打字外别无他物:轮椅项目的实现中,如果你试图在一个图表中绘制整个项目,UML 图可能会变得过于庞大。查看图 A2.18*。即使我在 3 英尺宽、2 英尺高的绘图仪上打印它,图表仍然难以阅读。

图 A2.18:不要这样做!这个图表涵盖了第七章中展示的整个项目。它太大、太复杂,而且无法在一页纸上显示。
最好是把系统分解成小块,并绘制这些小块。你会在第七章中看到这一点。绘制小型系统的图表并保持它们简单。一般来说,我尽量保持最多一页或两页。如果图表无法适应两页标准纸张,那么它可能太大,你需要找到一种方法将其分解成更小的部分。
不要越界
在经典电影 鬼怪猎人 中,故事的主角发明了一种危险的、高科技的质子加速器,它能发射一束能量流,能够捕捉并保持鬼魂。在电影中,发明这项技术的首席科学家警告他的同志们永远不要让能量流交叉,这意味着他们永远不应该让两个质子包的能量脉冲相互交叉。试着想象你所知道的所有生命瞬间结束,因为你的身体中的所有原子粒子以光速爆炸。他们称之为 完全质子反转。这很糟糕。
现在,将同样的谨慎应用于你的 UML 图中的交叉线。交叉线是模糊的。你能做的最好的事情就是让它看起来像电路图,让一条线跳过另一条线。你可以在 图 A2.19 中看到一个例子。

图 A2.19:D 类和 E 类的连接器跨越了 C 类和 A 类之间的界限
你不确定它们的起源和终止位置。如果没有办法不交叉线条绘制类图,那么它可能太大了。
如果线是通往同一地方,则不适用。B 类和 C 类都继承自 A 类。技术上,它们的线是连接而不是交叉。当这种情况发生时,我会努力将线连接在一起,使它们看起来像一条线。
线条的最直接路径会导致混乱
这种最佳实践已经融入了你在网上找到的更好的 UML 绘图工具中。即使是 Visio 也默认这样做。在类之间绘制直线会导致图表混乱。你可以在 图 A2.20 中看到我的意思。

图 A2.20:带有直角的迂回线瞬间使图表看起来更好。
最好绘制一条更迂回但易于跟随的线,正如我们之前提到的,它不会与其他线交叉。
父元素位于子元素之上
当你在绘制任何类型的继承或接口实现图时,你应该始终将父元素绘制在子元素之上,或者至少与子元素一样高,如图 图 A2.21 所示。

图 A2.21:父元素应该始终位于子元素之上,这意味着你的箭头应该始终向上。
只要你保持一致,这会使你的图表更容易跟随。
保持你的图表整洁
花时间保持你的图表整洁。我还会给你同样的建议,用于你的代码。清理不再使用的部分。纠正你的拼写。细节很重要!我还会补充说,你应该选择易于阅读的字体。你的图表中的字体应该很好地显示斜体,这样你就可以始终看到抽象类。
摘要
本附录涵盖了 UML。虽然它听起来像是一种编程语言,但实际上它是一种绘制图表的标准方式,用于表示结构和代码模式。我们只需要 14 个认可的 UML 文档中的 1 个就能通过这本书,但我们使用的这个文档被广泛使用。
类图显示了系统的结构以及这些结构之间的关系。结构可能是类、接口、枚举和包。常见的关系包括继承、接口实现、组合、关联等。可以在图表中添加注释以提供更多细节,但应保持简洁。
我们学习了 UML 类图的最佳实践,包括尽可能使图表清晰易读。避免图表中出现因试图定义结构之间所有可能的关系而产生的杂乱。相反,应专注于对图表实现至关重要的那些关系。一个好的图表不一定必须完全符合 UML 的标准。一个好的图表是能够快速传达信息,只包含足够细节以便开发者能够成功实现图表意图,而不需要过度管理每个细节。
进一步阅读
Baumann, Henriette, Patrick Grassle, and Philippe Baumann. 《UML 2.0 实战:基于项目的教程》. Packt Publishing, 2005.


浙公网安备 33010602011771号