Julia-设计模式与最佳实践实用指南-全-
Julia 设计模式与最佳实践实用指南(全)
原文:
annas-archive.org/md5/cf7c7ac4f7ce92066baf2d230554da6f译者:飞龙
前言
Julia 是一种强大的编程语言,旨在通过考虑开发者的生产力来启用高性能应用。其动态特性允许你快速进行小规模实验,然后迁移到大型应用。其内省工具允许我们通过分析高级代码如何转换为低级指令和机器代码来优化性能。其元编程功能帮助更高级的程序员为特定领域使用构建自定义语法。其多分派和泛型函数特性使得通过扩展现有函数来构建新功能变得容易。由于这些以及其他许多原因,Julia 是开发跨多个行业应用的一个优秀工具。
本书满足了 Julia 开发者的一些需求。希望编写更好的代码。希望提高系统性能。希望设计易于维护的软件。从 Julia 语言诞生到 2018 年 8 月达到令人瞩目的 1.0 版本里程碑,许多设计模式已经从最聪明的大脑中涌现出来,从语言的核心开发者到语言的重度使用者。有时,这些模式在博客文章和会议上展示。有时,它们出现在 Julia Discourse 论坛的随机讨论线程中。有时,它们在社区成员在各种 Julia Slack 频道上的闲聊中出现。这本书是这些模式的集合,记录了设计高质量 Julia 应用程序的最佳方法。
本书的主要目标是将这些经过充分验证的模式组织成 Julia 开发者社区易于消费的格式。组织并命名这些模式有以下几个好处:
-
它使开发者之间更容易沟通。
-
它使开发者能够更好地理解使用这些模式的代码。
-
它使开发者能够更清晰地表达何时应该应用模式。
这本书的目标简单但强大——在阅读这本书之后,你应该对如何在 Julia 中设计和开发软件有更多的了解。此外,本书中介绍的材料可以作为任何关于 Julia 中设计模式的未来讨论的参考。正如我们从历史中得知的那样,随着 Julia 语言的持续发展,新的设计模式将继续出现。
希望你喜欢这本书。祝您阅读愉快!
本书面向的对象
这本书是为那些想要提高编写适用于大型应用的惯用Julia 代码的初学者到中级开发者而写的。这不是一本入门书,因此你应具备一些基本的编程知识。如果你熟悉面向对象编程范式,那么你可能会发现这本书很有帮助,因为它展示了在 Julia 中如何以不同的方式解决相同的问题,并且通常以更好的方式解决。
本书描述的许多模式适用于任何行业领域和用例。无论你是数据科学家、研究人员、系统程序员还是企业应用开发者,你都应该能够在你的项目中从使用这些模式中受益。
本书涵盖的内容
第一章,设计模式及相关原则,介绍了设计模式的历史以及它们在开发应用程序中的用途。它涵盖了适用于任何编程语言和范式的几个行业标准软件设计原则。
第二章,模块、包和数据类型概念,讨论了如何组织更大的程序以及如何管理依赖关系。然后,它解释了如何开发新的数据类型以及如何在自定义类型层次结构中表达它们之间的关系。
第三章,设计和接口设计,解释了函数是如何定义的以及多态是如何发挥作用的。它还讨论了参数方法和接口,不同的函数可以根据预先确定的合同相互正确地工作。
第四章,宏和元编程技术,介绍了宏编程功能以及如何将其用于将源代码转换为不同的形式。它描述了开发宏和调试宏的几种更有效的方法。
第五章,可重用性模式,涵盖了与代码重用相关的模式。这包括通过组合重用代码的委派模式,用于更正式接口合同的圣洁特质模式,以及用于从参数化数据结构创建新类型的参数类型模式。
第六章,性能模式,涵盖了与提高系统性能相关的模式。这包括用于提高类型稳定性的全局常量模式,用于缓存先前计算结果的记忆化模式,用于重新排列数据以实现更优布局的数组结构模式,用于通过并行计算优化内存使用的共享数组模式,以及通过函数专业化提高性能的屏障函数模式。
第七章,可维护性模式,涵盖了关于代码可维护性的设计模式。这包括用于更好地组织大型代码库的子模块模式,用于创建可以更轻松构建的数据类型的关键字定义模式,用于用更少的代码定义许多类似函数的代码生成模式,以及用于为特定领域创建新语法的领域特定语言模式。
第八章,鲁棒性模式,涵盖了帮助你编写更安全代码的设计模式。这包括提供对字段的标准访问的访问器模式,控制对字段访问的属性模式,限制变量作用域的 Let-Block 模式,以及处理错误的异常处理模式。
第九章,杂项模式,涵盖了不属于前面类别的几个设计模式。它包括用于动态分派的 Singleton Type 模式,用于构建隔离测试的 Mocking 模式,以及用于构建线性数据处理管道的 Functional Pipe 模式。
第十章,反模式,涵盖了应该避免的模式。主要反模式是盗版,它涉及为不属于你的数据类型定义或扩展函数。然后,它涵盖了窄参数和非具体类型字段模式,这些模式会阻碍系统性能。
第十一章,传统面向对象模式,涵盖了 Gang-of-Four 的设计模式书中描述的传统面向对象模式。它讨论了这些模式如何在 Julia 中简化或以不同的方式实现。
第十二章,继承与变异性,讨论了 Julia 如何支持继承以及为什么它被设计成这样,因为它的方法与主流面向对象编程语言截然不同。然后,它涵盖了类型变异性这一重要概念,这是在多分派使用的数据类型之间的子类型关系中的一个重要概念。
要充分利用本书
你应该从 Julia 语言网站(julialang.org/)下载最新版本的 Julia。
代码示例可在 GitHub 上找到,如每章技术要求部分所述。在编写本文时,代码已用 Julia 版本 1.3.0 进行测试。要下载代码示例,请按以下步骤从 GitHub 克隆项目:

鼓励你运行并实验本书附带的代码示例。代码示例通常存储在以下格式之一中:
-
Julia 源文件中的代码片段。这些片段可以被复制并粘贴到 REPL 中。
-
存储在包目录中的代码。该包可以按以下方式实例化:
例如,在第五章,重用模式中,内容如下所示:

要使用DelegationPattern的代码,只需在该文件夹中启动 Julia REPL 并使用--project=.命令行参数:

然后,进入包模式,通过输入] instantiate命令实例化包:

之后,您可以使用包像平常一样:

如果有测试目录,则可以读取和运行提供的测试脚本。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support,并注册以将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择支持选项卡。
-
点击代码下载。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的软件解压或提取文件夹:
-
Windows 系统上的 WinRAR/7-Zip
-
Mac 系统上的 Zipeg/iZip/UnRarX
-
Linux 系统上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-on-Design-Patterns-and-Best-Practices-with-Julia。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!
代码实战
访问以下链接查看代码实战视频:
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词,如变量名、函数名、数据类型等。例如,“format函数接受一个formatter和一个数值x,并返回一个格式化的字符串。”
代码块设置如下:
abstract type Formatter end
struct IntegerFormatter <: Formatter end
struct FloatFormatter <: Formatter end
任何实验或 REPL 的输出都作为截图展示:

粗体:表示一个重要的单词或概念。例如,“桥接模式用于将抽象与其实现解耦,以便它可以独立演变。”
斜体:强调将在文本中稍后解释的新概念。例如,“前几章中提出的案例包括我们可以通过编写惯用Julia 代码解决的问题。”
重要提示看起来像这样。
技巧和窍门看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书籍标题,并通过customercare@packtpub.com给我们发邮件。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packt.com。
第一部分:设计模式入门
本节的目标是向您介绍设计模式的一般应用以及 Julia 与面向对象编程范式之间的不同之处。
本节包含以下章节:
- 第一章,设计模式及相关原则
第一章:设计模式和相关的原则
现在,学习和应用设计模式是软件工程的一个重要方面。设计模式就像水一样——没有它们你无法生存。你不相信吗?只需问问招聘经理,你就会发现他们中的许多人不仅在职位发布中提到了设计模式,而且在面试中也提出了相关问题。人们普遍认为,设计模式是软件开发的重要成分,每个人都应该了解它们。
在本章中,我们将提供一些关于为什么设计模式是有用的以及它们在过去几十年里是如何为我们服务的背景信息。通过理解设计模式背后的动机,我们将能够提出一套指导原则,用于软件开发。本章将讨论以下主题:
-
设计模式的起源
-
软件设计原则
-
软件质量目标
让我们开始吧!
设计模式的起源
设计模式对计算机程序员来说不是一个新概念。自从 20 世纪 80 年代个人电脑变得价格更合理且更受欢迎以来,编程职业蓬勃发展,并为各种应用编写了大量代码。
我记得,当我 14 岁的时候,学习 BASIC 程序的 GOTO 语句是我觉得最酷的事情之一。它实际上允许我在任何时候将控制流程带到代码的另一个部分。也许并不令人惊讶,当我上大学时学习了结构化编程和 Pascal 语言,我开始意识到 GOTO 语句会产生混乱的意大利面代码。用 GOTO 进行分支是一个模式。它只是一个很糟糕的模式,因为它使得代码难以理解、跟踪和调试。在今天的通用语言中,我们称它们为反模式。当涉及到结构化编程技术时,将代码组织成小函数也是一个模式,这是编程课程中作为主流主题教授的内容。
当我从大学毕业时,我开始我的编程生涯,并花费了大量时间进行黑客攻击。我有机会进行各种研究,并了解系统是如何设计的。例如,我意识到 Unix 操作系统有一个美丽的设计。那是因为它由许多小程序组成,这些程序本身并没有很多功能,但你可以用任何数量的方式将它们组合起来,以解决更复杂的问题。我也非常喜欢 Scheme 编程语言,它起源于麻省理工学院的 AI 实验室。这种语言的简洁性和多功能性至今仍让我感到惊奇。Scheme 的遗产可以追溯到 Lisp,这对 Julia 语言的设计产生了一定的影响。
设计模式的出现
1994 年,当我深入到 C++和分布式计算中,为金融应用程序开发时,四位软件专业人士,也被称为四人帮或 GoF,聚集在一起并出版了一本关于设计模式的书,该书在面向对象编程社区中引起了轰动。该小组收集和分类了在开发大型系统时常用的 23 个设计模式。他们还选择使用统一建模语言(UML)和 C++、Smalltalk 来解释这些概念。
首次,一套设计模式被收集、组织、解释并广泛分发给软件开发者。也许该小组做出的最重大决定之一就是将这些模式组织成高度结构化和易于消费的格式。从那时起,程序员们可以轻松地相互交流他们如何设计软件。此外,他们可以使用通用符号直观地展示软件设计。当一个人谈论单例模式时,另一个人可以立即理解和甚至在自己的脑海中可视化该组件的工作方式。这不是很方便吗?
更令人惊讶的是,设计模式在构建优秀软件时突然成为了圣经。在某种程度上,使用它们甚至被视为编写优秀软件的唯一途径。GoF 模式在开发社区中被广泛传播,以至于许多人滥用它们,没有合理的理由就将它们应用到各个地方。问题是——当你只有一把锤子时,一切看起来都像钉子!并非所有问题都可以或应该用相同的模式来解决。当设计模式被过度使用或误用时,代码变得更加抽象、复杂,也更难以管理。
那么,我们从过去学到了什么?我们认识到,每个抽象都伴随着成本。每个设计模式都有其自身的优缺点。本书的主要目标之一不仅是讨论如何使用,还要讨论为什么使用或不使用,以及在什么情况下应该使用或不使用某个模式。作为软件专业人士,我们将装备所需的信息,以便在何时应用这些模式时做出良好的判断。
关于 GoF 模式的更多思考
GoF 设计模式分为三大类:
-
创建型模式:这些模式涵盖了以各种方式构建对象的方法。由于面向对象编程将数据和操作结合在一起,并且一个类可能继承祖先类的结构和行为,因此在构建大型应用程序时涉及一些复杂性。创建型模式有助于在各种情况下标准化对象创建方法。
-
结构型模式:这些模式涵盖了如何扩展或组合对象以形成更大的事物。这些模式的目的在于使软件组件更容易重用或替换。
-
行为模式:这涵盖了如何设计对象以执行单独的任务并相互通信。大型应用程序可以被分解成独立的组件,从而使代码更容易维护。面向对象编程范式要求对象之间有坚实的交互。这些模式的目的在于使软件组件更加灵活,并更方便彼此协作。
一种观点是,设计模式是为了解决各自编程语言中的局限性而创建的。在 GoF 书籍出版两年后,彼得·诺维格发表了一项研究,表明 23 个设计模式中的 16 个要么是不必要的,或者可以在像 Lisp 这样的动态编程语言中简化。
这是一个不容忽视的观察结果。在面向对象编程的背景下,从类层次结构中抽象出额外的抽象需要软件设计者思考对象是如何实例化和相互交互的。在一个强大、静态类型语言如 Java 中,对对象的行为和交互进行推理就更加必要了。在第十一章,传统面向对象模式中,我们将回到这个话题,并讨论 Julia 与面向对象编程相比是如何不同的。
目前,我们将从基础知识开始,回顾一些软件设计原则。这些原则就像北极星,在我们构建应用程序时指引我们。
我们如何在本书中描述模式?
如果你刚开始学习 Julia 编程,这本书将帮助你理解如何编写更符合 Julia 习惯的代码。我们还将专注于描述一些在现有的开源 Julia 生态系统中被广泛使用的最有用的模式。这包括 Julia 自己的 Base 和 stdlib 包,因为 Julia 运行时大部分是用 Julia 编写的。我们还将参考其他用于数值计算和网络编程的包。
为了便于参考,我们将按名称组织我们的模式。例如,神圣特质模式指的是实现特质的具体方法。领域特定语言模式讨论了如何构建新的语法来表示特定的领域概念。拥有名称的唯一目的是为了便于参考。
当我们在本书中讨论这些设计模式时,我们将试图理解其背后的动机。我们试图解决什么具体问题?在现实世界中,什么样的场景会用到这样的模式?然后,我们将深入探讨如何解决这些问题。有时,解决同一个问题可能有几种方法,在这种情况下,我们将研究每种可能的解决方案,并讨论其优缺点。
话虽如此,了解使用设计模式的最终目标对我们来说很重要。我们为什么要首先使用设计模式?为了回答这个问题,首先了解一些关键的软件设计原则会有所帮助。
软件设计原则
虽然这本书没有涵盖面向对象编程,但一些面向对象的设计原则是通用的,可以应用于任何编程语言和范式。在这里,我们将探讨一些最著名的设计原则。特别是,我们将涵盖以下内容:
-
SOLID: 单一职责、开放/封闭、李斯克夫替换、接口隔离、依赖倒置
-
DRY: 不要重复自己
-
KISS: 简单就是美!
-
POLA: 最小惊讶原则
-
YAGNI: 你不会需要它
-
POLP: 最小权限原则
让我们从 SOLID 开始。
SOLID
SOLID 原则由以下内容组成:
-
S: 单一职责原则
-
O: 开放/封闭原则
-
L: 李斯克夫替换原则
-
I: 接口隔离原则
-
D: 依赖倒置原则
让我们详细了解每个概念。
单一职责原则
单一职责原则指出,每个模块、类和函数都应该只负责一个功能目标。应该只有一个理由去修改任何东西。
这个原则的好处如下:
-
在开发过程中,程序员可以专注于单一上下文。
-
每个组件的大小更小。
-
代码更容易理解。
-
代码更容易测试。
开放/封闭原则
开放/封闭原则指出,每个模块应该对扩展开放,但对修改封闭。区分增强和扩展是必要的——增强指的是现有模块的核心改进,而扩展被认为是提供额外功能的附加组件。
以下是这个原则的好处:
-
现有的组件可以很容易地重用来派生新的功能。
-
组件松散耦合,因此更容易替换而不影响现有功能。
李斯克夫替换原则
李斯克夫替换原则指出,一个接受类型T的程序也可以接受类型S(它是T的子类型),而不改变行为或预期结果。
以下是这个原则的好处:
- 函数可以用于任何传入参数的子类型。
接口隔离原则
接口隔离原则指出,客户端不应该被迫实现它不需要使用的接口。
以下是这个原则的好处:
-
软件组件更模块化和可重用。
-
新的实现可以更容易地创建。
依赖倒置原则
依赖倒置原则指出,高级类不应该依赖于低级类;相反,高级类应该依赖于低级类实现的抽象。
以下是这个原则的好处:
-
组件更加解耦。
-
系统变得更加灵活,可以更容易地适应变化。低级组件可以被替换,而不会影响高级组件。
DRY
我们现在将介绍 DRY 原则:
-
D: 不要
-
R: 重复
-
Y: 自己
这个缩写是一个很好的方式来提醒程序员,重复代码是坏事。显然,重复代码可能难以维护——每当逻辑发生变化时,代码中的多个地方都会受到影响。
当我们发现重复代码时,我们该怎么办?消除它,并创建一个可以从多个源文件复用的通用函数。
此外,有时代码并不是 100%重复的,而是 90%相似。这种情况并不少见。在这种情况下,考虑重新设计相关组件,可能需要将代码重构到公共接口。
KISS
让我们谈谈 KISS 原则:
-
K: 保持
-
I: 它
-
S: 简单
-
S: 愚蠢!
经常,当我们设计软件时,我们喜欢提前思考并尝试处理各种未来的场景。构建这种防未来的软件的麻烦在于,它需要指数级更多的努力来设计和编码。从实际的角度来看,这是一个难题——因为技术会变化,业务会变化,人也会变化,所以没有 100%的防未来解决方案。此外,过度设计可能会导致过度抽象和间接,使系统更难测试和维护。
此外,当使用敏捷软件开发方法时,我们重视快速和高质的交付,而不是完美或过度工程。保持设计和代码简单是每个程序员都应该记住的美德。
POLA
让我们看看 POLA 原则:
-
P: 原则
-
O: 的
-
L: 最小化
-
A: 惊讶
POLA 原则指出,软件组件应该易于理解,其行为永远不应该让客户感到惊讶(或者更准确地说,震惊)。我们如何做到这一点?
以下是一些需要注意的事项:
-
确保模块、函数或函数参数的名称清晰且无歧义。
-
确保模块大小适中且维护良好。
-
确保接口小且易于理解。
-
确保函数具有很少的位置参数。
YAGNI
让我们继续讨论 YAGNI 原则:
-
Y: 你
-
A: 不
-
G: 将来
-
N: 需要
-
I: 它
YAGNI 原则指出,你应该只开发今天需要的软件。这个原则来自极限编程(XP)。看看极限编程的共同创始人 Ron Jeffries 在他的博客中写了什么:
“总是在你需要的时候实现事物,而不是仅仅预见你需要它们的时候。”
软件工程师有时会诱惑开发他们认为客户将来会需要的功能。一次又一次地证明,这并不是开发软件最有效的方法。考虑以下场景:
-
功能永远不会被客户需要,因此代码永远不会被使用。
-
商业环境发生变化,系统需要重新设计或替换。
-
技术发生变化,系统需要升级以使用新的库、新的框架或新的语言。
代价最低的软件是你没有写的那部分。你不会需要它!
POLP
现在,让我们来看看 POLP:
-
P:原则
-
O:的
-
L:最小
-
P:权限
POLP 指出,客户端只能访问他们需要的或功能。POLP 是构建安全应用最重要的支柱之一,并且被像亚马逊、微软和谷歌这样的云基础设施供应商广泛采用。
当应用 POLP 时,有很多好处:
-
敏感数据受到保护,不会暴露给非特权用户。
-
由于用例数量有限,系统可以更容易地进行测试。
-
由于只提供了有限的访问权限并且接口更简单,系统不太可能被误用。
我们迄今为止学到的软件设计原则是伟大的工具。尽管 SOLID、DRY、KISS、POLA、YAGNI 和 POLP 看起来只是一堆缩写,但在设计更好的软件时它们是有用的。虽然 SOLID 原则来自面向对象编程范式,但 SOLID 的概念仍然可以应用于其他语言和环境。随着我们在本书的其余章节中继续前进,我鼓励你记住它们。
在下一节中,我们将讨论设计软件时的几个软件质量目标。
软件质量目标
每个人都喜欢美好的设计。我也一样。但是,使用设计模式的目的不仅仅是让某物看起来好看。我们做的每一件事都应该有目的。
GoF 将面向对象设计模式分为创建型、结构型和行为型。对于 Julia,让我们从不同的角度出发,根据各自的软件质量目标对模式进行分类,如下所示:
-
可重用性
-
性能
-
维护
-
安全性
让我们在接下来的章节中了解每个概念。
可重用性
当设计软件时,人们经常谈论自顶向下和自底向上的方法。
自顶向下的方法从一个大问题开始,将其分解成一系列较小的问题。然后,如果问题不够小,正如我们在查看单一职责原则时所讨论的,我们将进一步将问题分解成更小的问题。这个过程会重复进行,最终问题足够小,可以设计和编码。
自底向上的方法是相反的方向。给定领域知识,你可以开始创建构建块,然后通过组合这些构建块创建更复杂的构建块。
无论采用何种方式,最终都会有一组相互协作的组件,从而形成应用程序的基础。
我喜欢这个比喻。即使是 5 岁的孩子也能仅用几种乐高积木块搭建出各种结构。想象力是无限的。你是否曾想过为什么它如此强大?好吧,如果你还记得,每个乐高积木块都有一个标准的连接器集合:一个、两个、四个、六个、八个或更多。使用这些连接器,每个积木块可以轻松地插入另一个积木块。当你创建一个新的结构时,你可以将其与其他结构结合,以创建更大、更复杂的结构。
在构建应用程序时,关键的设计原则是创建可插拔的接口,以便每个组件都可以轻松重用。
可重用组件的特征
以下是可以重用组件的重要特征:
-
每个组件只服务于单一目的(SOLID 中的 S)。
-
每个组件都定义良好,并准备好重用(SOLID 中的 O)。
-
为父-子关系设计了抽象类型层次结构(SOLID 中的 L)。
-
接口被定义为一个小集合的函数(SOLID 中的 I)。
-
使用接口在组件之间建立桥梁(SOLID 中的 D)。
-
模块和函数的设计考虑了简洁性(KISS 原则)。
可重用性很重要,因为它意味着我们可以避免代码重复和浪费精力。我们编写的代码越少,维护软件所需的工作就越少。这包括开发工作,也包括测试、打包和升级的时间。可重用性也是开源软件之所以成功的原因之一。特别是,Julia 生态系统包含许多开源包,它们往往相互借用功能。
接下来,我们将讨论另一个软件质量目标——性能。
性能
Julia 语言是为高性能计算设计的。然而,这并非免费。在性能方面,编写更符合编译器友好的代码需要实践,这使得程序更可能被转换为优化的机器代码。
在过去几十年里,计算机似乎每年都在变得越来越快。过去曾是性能瓶颈的问题,现在使用今天的硬件更容易解决。同时,我们也面临着更多挑战,因为数据的爆炸性增长。一个很好的例子是大数据和数据科学领域。随着数据量的增长,我们需要更多的计算能力来处理这些新的用例。
不幸的是,计算机的速度增长并没有像过去那样快。摩尔定律指出,芯片上晶体管的数量大约每 18 个月翻一番,自 1960 年以来,它与 CPU 速度的增长相关。然而,众所周知,由于物理限制,摩尔定律很快将不再适用:芯片上可以容纳的晶体管数量和制造过程的精度。
为了应对今天的计算需求,特别是在人工智能、机器学习和数据科学的世界中,从业者一直在转向一种利用多台服务器上多个 CPU 核心的 扩展 策略,并研究利用 GPU 和 TPU 的效率。
高性能代码的特性
以下是一些高性能代码的特性:
-
函数较小,易于优化(SOLID 中的 S)。
-
函数包含简单的逻辑而不是复杂的逻辑(KISS)。
-
数字数据被布局在连续的内存空间中,以便编译器可以充分利用 CPU 硬件。
-
应将内存分配保持在最低限度,以避免过多的垃圾回收。
性能是任何软件项目的重要方面。对于数据科学、机器学习和科学计算用例来说,尤其重要。一个小设计变更可能会带来很大的差异——根据情况,它可能将 24 小时的过程缩短到 30 分钟,也可能在使用 Web 应用时为用户提供实时体验,而不是请等待... 对话框。
接下来,我们将讨论软件的可维护性作为另一个软件质量目标。
可维护性
当软件设计得当,维护起来会更加容易。一般来说,如果你能够有效地使用之前列出的设计原则(SOLID、KISS、DRY、POLA、YAGNI 和 POLP),那么你的应用程序更有可能具有良好的架构和长期维护的设计。
可维护性是大型应用程序的重要成分。一个研究生项目可能不会持续很长时间。相反,一个企业应用程序可能持续数十年。最近,我从一个同事那里听说 COBOL 仍在使用,COBOL 程序员仍然能过上好日子。
我们经常听到技术债务。与现实生活中货币债务类似,技术债务是每次代码更改时你必须支付的东西。而且,技术债务存在的时间越长,你付出的努力就越多。
为了理解为什么,考虑一个充斥着重复代码或不必要的依赖的模块。每当添加新功能时,你必须更新源代码的多个部分,并且需要对系统更大的区域进行回归测试。因此,每次代码更改时,你都要为债务付出(从编程时间和努力的角度来看)直到债务完全偿还(即,直到代码完全重构)。
可维护代码的特性
以下是一些可维护代码的特性:
-
不使用未使用的代码(YAGNI)。
-
不重复代码(DRY)。
-
代码简洁简短(KISS)。
-
代码清晰易懂(KISS)。
-
每个函数都有一个单一的目的(SOLID 中的 S)。
-
每个模块都包含相互关联并协同工作的函数(SOLID 中的 S)。
可维护性是任何应用程序的重要方面。当设计得当,即使是大型应用程序也可以频繁且容易地更改,而不必担心。应用程序也可以长时间运行,从而降低软件的成本。
接下来,我们将讨论软件安全性作为另一个质量目标。
安全性
“安全性——指免受伤害、受伤或损失的状态。”
– 梅里厄姆-韦伯斯特词典
预期应用程序能够正确运行。当应用程序出现故障时,可能会产生不希望的结果,其中一些可能是致命的。考虑一下美国宇航局使用的至关重要的火箭发射子系统。一个缺陷可能导致发射延迟;或者,在最坏的情况下,它可能导致火箭在空中爆炸。
编程语言被设计为允许灵活性,同时提供安全特性,以便软件工程师犯更少的错误。例如,编译器的静态类型检查确保将正确的类型传递给期望这些类型的函数。此外,大多数计算机程序在数据上操作,正如我们所知,数据并不总是干净或可用的。因此,处理不良或缺失数据的能力是重要的软件质量。
安全应用程序的特征
安全应用程序的一些特征如下:
-
每个模块都公开一组最小的类型、函数和变量。
-
每个函数都使用参数调用,使得相应的类型实现函数的预期行为(SOLID 中的 L;POLA)。
-
函数的返回值清晰并已记录(POLA)。
-
正确处理缺失数据(POLA)。
-
变量限制在最小的范围内。
-
异常被捕获并相应处理。
安全性在这里是最重要的目标之一。一个错误的应用程序可能造成重大灾难。它甚至可能使公司损失数百万美元。2010 年,丰田因防抱死制动系统(ABS)的软件缺陷召回超过 40 万辆 Prius 混合动力汽车。1996 年,欧洲航天局发射的阿丽亚娜 5 火箭在发射后 40 秒爆炸。当然,这些只是几个更极端的例子。通过利用最佳实践,我们可以避免陷入这类尴尬且代价高昂的事件。
现在,我们理解了软件设计原则和软件质量目标的重要性。
摘要
在本章中,我们首先回顾了设计模式的历史,讨论了为什么设计模式对软件专业人士有用,以及根据我们过去学到的知识,我们如何希望组织本书中的设计模式。
我们回顾了几个可以在任何编程语言中普遍应用的软件设计原则,这在开发代码和应用 Julia 的设计模式时非常重要。我们涵盖了 SOLID、DRY、KISS、POLA、YAGNI 和 POLP。这些设计原则在面向对象编程社区中广为人知,并且受到好评。
最后,我们讨论了一些我们希望通过使用设计模式来实现的软件质量目标。在这本书中,我们决定专注于可重用性、可维护性、性能和安全目标。我们还欣赏了这些目标的好处,并回顾了一些实现这些目标的一般性指南。
下一章将会非常精彩!我们将深入探讨 Julia 程序的组织方式以及如何使用 Julia 的类型系统,同时还会介绍一些关于 Julia 的基础知识。
问题
回顾以下问题,以加强你对本章主题的理解。答案在书的后面提供:
-
使用设计模式有哪些好处?
-
列举一些关键的设计原则。
-
开放/封闭原则解决了什么问题?
-
为什么接口隔离对于软件的可重用性很重要?
-
保持应用程序可维护性的最简单方法是什么?
-
避免过度设计和臃肿软件的好习惯是什么?
-
内存使用如何影响系统性能?
第二部分:Julia 基础
本节的目标是快速让您了解 Julia 编程语言的基本概念和特性。对 Julia 基础有清晰的理解对于您能够充分欣赏我们在后续章节中将要探讨的设计模式之美至关重要。
本节包含以下章节:
-
第二章,模块、包和数据类型概念
-
第三章,设计函数和接口
-
第四章,宏和元编程技术
第二章:模块、包和数据类型概念
本章讨论了开发大型应用程序的几种组织技术。信不信由你,这通常是容易被忽视的事情。在开发应用程序时,我们通常专注于构建数据类型、函数、控制流等。然而,正确组织代码同样重要,以便它既干净又易于维护。
在本章的后期部分,我们将介绍 Julia 的类型系统。数据类型是任何应用程序最基本的建设模块。与其它编程语言相比,Julia 的类型系统是其最强大的特性之一。对类型系统的深入了解将使我们能够实现更好的设计。
本章将涵盖以下主题:
-
开发应用程序的成长之痛
-
与命名空间、模块和包一起工作
-
管理包依赖
-
设计抽象类型和具体类型
-
理解参数化类型
-
在数据类型之间进行转换
到本章结束时,你应该知道如何创建自己的包,将代码划分为独立的模块,并为你的应用程序开始创建新的数据类型。
让我们开始吧!
技术要求
本章的示例源代码位于 github.com/PacktPublishing/Hands-on-Design-Patterns-and-Best-Practices-with-Julia/tree/master/Chapter02。
代码在 Julia 1.3.0 环境中进行了测试。
开发应用程序的成长之痛
"从你所在之处开始。利用你所拥有的。做你能做的。"
- 亚瑟·阿什
每个人的旅程都是不同的。Julia 是一种多才多艺、动态的编程语言,可以用于许多有趣的用例。更具体地说,你可以用它轻松地编码和解决问题,而不必过多地考虑系统架构和设计。这对于小型研究项目通常是足够的;然而,当项目对业务变得更为关键,或者你必须将一个概念验证硬化为生产环境时,它需要更好的组织、架构和设计,以便项目或应用程序能够更长久地生存并更易于维护。
我们通常处理哪些类型的项目?让我们探索一些例子。
数据科学项目
典型的数据科学项目从从一组数据中学习并做出预测的想法开始。大量的前期工作都投入到数据收集、数据清洗、数据分析以及可视化中。然后,数据被进一步转化为特征,作为机器学习模型的输入。直到这一点的过程被称为数据工程。然后,数据科学家选择一个或多个机器学习模型,并不断优化和调整模型,以达到预测模型的良好准确度。这个过程被称为模型开发。当模型准备好投入生产时,它就会被部署,有时还会为最终用户创建一个前端。最后的过程被称为模型部署。
数据工程和模型开发过程在开始时可能是交互式的,但通常最终会自动化。这是因为过程需要可重复,结果必须一致。数据科学家在开发过程中可能会使用各种工具,从多个 Jupyter 笔记本到一系列相关的库和程序。
当一个预测模型准备好投入生产时,它可以作为一个网络服务部署,以便用于进行实时预测。在这个阶段,模型需要有一个生命周期并得到维护,就像任何其他生产软件一样。
企业应用程序
开发企业应用程序的人有不同的思维方式。与数据科学项目不同,软件工程师通常一开始就知道他们需要构建系统所需的内容。他们还知道是否必须接受某些假设和政策。例如,当项目开始时,技术栈可能已经确定。其他可能已经熟悉的因素包括将要使用的系统架构、将利用的云服务提供商、应用程序必须集成的数据库等。
企业应用程序通常需要一个丰富的业务领域对象模型。数据对象被创建、操作,并传输到应用程序的不同层。系统架构可能包括用户界面、中间层和数据库后端。
企业应用程序通常也需要与其他系统进行高度集成。例如,一家投资公司使用的交易系统通常连接到会计系统、交易结算系统、报告系统等。因此,这些应用程序通常被设计为处理静态数据(例如,存储在数据库中的数据)或动态数据(例如,被流式传输到另一个系统的数据)。此外,数据移动可能发生在实时或作为夜间批量处理。
适应增长
无论你开发什么类型的应用程序,都不应该难以识别成长的痛苦。
对于数据科学项目,以下迹象通常表明与增长相关的问题:
-
"我的笔记本变得越来越长。我经常不得不上下滚动以理解我之前做了什么以及我现在在做什么。中间创建了太多的变量,我正在失去追踪它们的意义以及它们是如何被使用的。”
-
"数据结构太复杂了。我正在处理一个数据框,已经以十种不同的方式进行了转换。我现在已经失去了追踪哪种转换版本代表什么,以及为什么最初需要它们。”
-
"我在磁盘上保存了许多机器学习模型,但我正在失去追踪每个模型是如何训练的以及为每个模型所做出的假设。”
-
"我的代码散布在许多笔记本中。一些代码被重复或稍作修改用于不同的目的。我无法实现一致的结果。”
对于企业应用程序,可能也会出现类似的症状:
-
"应用程序逻辑太复杂了,有一个组件执行了太多的功能。”
-
"在不破坏现有功能的情况下添加新功能变得越来越困难。”
-
"新来的人理解这个模块中的代码需要花费很多时间,而且似乎同一个人每隔一段时间就必须重新学习它。”
处理无组织代码和数据并不有趣。如果你发现自己正在说出前面的一些短语,那么可能是时候重新思考你的策略,并开始正确地组织你的程序了。
现在,让我们通过使用 Julia 更好地组织代码来开始我们的学习之旅。由于我们正在处理一个高层次的问题,我们将介绍命名空间的概念,并概述如何创建模块和包。
与命名空间、模块和包一起工作
Julia 生态系统生活在命名空间中;事实上,这是我们保持事物有序的唯一方式。为什么我会这么说呢?原因在于命名空间被用来逻辑上分离源代码的片段,这样它们就可以独立开发,而不会相互影响。如果我在一个命名空间中定义了一个函数,我仍然可以在不同的命名空间中定义另一个具有相同名称的函数。
在 Julia 中,命名空间是通过模块和子模块创建的。为了管理分布和依赖关系,模块通常被组织成包。Julia 包有一个标准的目录结构。尽管顶层目录结构定义得很好,但程序员在组织源文件方面仍有很大的自由度。
在本节中,我们将探讨以下主题:
-
理解和使用命名空间
-
如何创建模块和包
-
如何创建子模块
-
如何在模块中组织文件
在接下来的几节中,我们将详细学习每个主题。
理解命名空间
命名空间是什么?让我们通过一个现实生活中的例子来尝试理解。
每种语言都有其字典中定义的一组词汇。当来自不同文化的人互相交谈时,他们经常会陷入有趣的情况。考虑以下例子:
对话 1:
-
美国人:你的裤子看起来很脏。你应该换一下。
-
英国人:你是说我裤子吗?我的内衣...相当干净和整洁!
对话 2:
-
美国人:这些饼干很美味!
-
英国人:在哪里?饼干在哪里...?
对话 3:
-
美国人:我想恢复体型,已经尝试过很多教练,但没有一个好的。
-
英国人:你试过耐克的新跑步鞋了吗?我觉得它们对我日常慢跑足够舒适。
实际上,你甚至不需要来自不同的文化才能体验到这个问题。有时,同一个词在不同的语境中已经有了不同的含义。例如:
-
游泳池 - 游泳池还是一组东西?
-
南瓜 - 蔬菜还是运动?
-
电流 - 电流还是水流?
由于这些模糊性,我们无法在所有领域强制使用单一词汇表。幸运的是,计算机科学家很聪明,很久以前就解决了他们领域的问题:为了区分一个词的两个不同含义,我们只需在词前加上相应的上下文。使用前面列表中的例子,我们可以这样限定每个词:
-
Facility.游泳池和Grouping.游泳池 -
Vegetable.南瓜和Sport.壁球 -
Electricity.电流和Liquid.水流
前缀被称为命名空间。现在,这些词已经通过各自的命名空间进行了限定,它们不再模糊,具有明确的意义。
在 Julia 中,命名空间是通过模块创建的,我们将在下一节中学习。
创建模块和包
模块用于创建新的命名空间。在 Julia 中,创建模块就像将你的代码包裹在一个模块块中一样简单,如下所示:
module X
# your code
end
通常,模块是为了共享和重用而创建的,实现这一点的最佳方式是将代码组织在 Julia 包中。一个 Julia 包是一个用于维护模块定义、测试脚本、文档和相关数据的目录和文件结构。
Julia 包有一个标准的目录结构和约定;然而,每次手动配置相同结构的新程序都会很麻烦。幸运的是,有一些开源工具可以自动为新包创建结构。虽然不正式推荐任何特定的工具,但我选择了 PkgTemplates 包进行演示,如下所示。
如果你之前没有安装 PkgTemplates 包,可以按照以下方式安装:

安装完成后,我们可以用它来创建我们的示例模块。第一步是创建一个 Template 对象,如下所示:

基本上,template 对象包含一些默认值,这些值将用于创建新包。然后,创建新包就像调用 generate 函数一样简单。

默认情况下,包生成器在 ~/.julia/dev 文件夹中创建新目录,但可以使用 Template 对象的 dir 关键字参数进行自定义。
generate 命令用于创建一个名为 Calculator 的新包。它将自动创建一个具有以下包结构的目录:

现在,你可以开始编辑 Calculator.jl 文件,并用你自己的源代码替换文件内容。
如果你刚接触 Julia,请确保查看 Revise 包,它允许你编辑源代码并自动更新工作环境。使用 Julia 的生产力将提高 10 倍,这是有保证的。
让我们通过实现一些财务计算来工作在 Calculator 模块上。在这个过程中,我们将学习如何管理外部客户端对变量和函数的可访问性。我们的初始代码设置如下:
# Calculator.jl
module Calculator
export interest, rate
"""
interest(amount, rate)
Calculate interest from an `amount` and interest rate of `rate`.
"""
function interest(amount, rate)
return amount * (1 + rate)
end
"""
rate(amount, interest)
Calculate interest rate based on an `amount` and `interest`.
"""
function rate(amount, interest)
return interest / amount
end
end # module
这段代码应该保存到 Calculator.jl 文件中。
定义功能行为
我们的 Calculator 模块定义了两个函数:
-
interest函数用于计算存款金额amount的利息,该利息具有指定的利率rate,用于整个投资期。 -
rate函数用于计算可以投资存款金额amount并获得利息金额interest的利率。
记住,在 Calculator 的上下文之外,interest 和 rate 可能意味着完全不同的事情。
导出函数
在模块内部定义的函数不会暴露给外部世界。为了暴露它们,可以使用 export 语句导出 interest 和 rate 函数,这样模块的使用者就可以轻松地将它们引入自己的命名空间:
export interest, rate
一旦导出函数,它们将在使用 using 关键字加载模块的客户端作用域中可用。让我们在加载模块之前尝试从 Julia REPL 引用这些函数:

由于我们尚未加载 Calculator 包,因此 interest 和 rate 都未定义。现在让我们将它们引入:

当执行 using 语句时,模块导出的所有符号都会被引入当前命名空间。从 Julia REPL 来看,当前模块被称为 Main,如下面的图所示:

我们可以通过在 using 语句中指定特定名称来引入名称的子集。让我们重新启动 Julia REPL 并再次尝试:

在这种情况下,只有interest函数被引入了Main模块:

实际上,有几种方法可以将另一个模块中的名称导入当前命名空间。为了简化,我们可以总结如下:

如你所见,有四种方法(即前表中的 1、2、4 和 5)可以将interest函数引入当前命名空间。在using和import语句之间进行选择有一些细微差别。一个好的经验法则是当你使用功能时使用using语句,但在需要从模块扩展功能时选择import语句。从另一个包扩展函数是 Julia 的关键语言特性之一,你将在本书的各种示例中了解更多关于这一点。
解决冲突
然而,情况并不总是如此美好。让我们想象一下,主程序需要使用另一个名为Rater的模块,该模块为在线书籍提供评级服务。在这种情况下,主程序可能会尝试从两个模块中获取函数,如下所示:

但是,休斯顿,我们遇到麻烦了! 从Calculator模块引入的rate函数与来自Rater模块的另一个函数发生了冲突。Julia 在首次使用时会自动检测这种冲突,打印警告,并且从那时起要求程序员使用它们的完全限定名称来访问任一函数:

如果你对此不满意,特别是丑陋的警告,那么有一个替代方案。首先,你可以问问自己是否在主程序中真的需要两个rate函数。如果只需要一个rate函数,那么只需将其引入作用域,这样就不会再有冲突:
using Calculator: interest
using Rater: rate
# Here, the rate function refers to the one defined in Rater module.
根据我的经验,将特定名称引入当前命名空间确实是大多数用例的最佳选择。原因在于这将立即清楚地表明你依赖哪些函数。这种依赖关系在代码中也是自我文档化的。
有时,你可能需要同时使用两个rate函数。在这种情况下,你可以通过使用常规的import语句来解决问题:
import Calculator
import Rater
interest_rate = Calculator.rate(100.00, 3.5)
rating = Rater.rate("Hands-On Design Patterns with Julia")
这样,它只会加载包,而不会将任何名称引入当前命名空间。现在,你可以使用它们的完全限定名称来引用这两个rate函数——即Calculator.rate和Rater.rate。在创建这些模块之后,让我们继续看看如何创建子模块。
创建子模块
当一个模块变得太大时,将其拆分成更小的部分可能是有意义的,这样更容易开发和维护。解决这个问题的方法之一是创建子模块。
创建子模块很方便,因为它们只是在父模块的作用域内定义。假设我们使用两个子模块——Mortgage和Banking来组织Calculator模块。这些子模块可以在单独的文件中定义,并且可以直接包含到父模块中。考虑以下代码:
# Calculator.jl
module Calculator
include("Mortgage.jl")
include("Banking.jl")
end # module
子模块,就像常规模块一样,也是使用模块块定义的。Mortgage的源代码看起来就像一个常规模块定义:
# Mortgage.jl
module Mortgage
# mortgage related source code
end # module
由于Calculator模块块中包含了Mortgage的源代码,因此它形成了一个嵌套结构。子模块的使用与任何常规模块相同,只是你必须通过父模块来引用它们。在这种情况下,你会使用Calculator.Mortgage或Calculator.Banking。
使用子模块是分离大型代码库代码的有效方法。接下来,我们将介绍如何在模块中组织源代码。
在模块中组织文件
模块的源代码通常组织为多个源文件。尽管没有关于如何组织源文件的硬性规则,但以下是一些有用的指导原则:
-
耦合度:高度耦合的函数应放在同一个文件中。这样做可以在编辑源文件时减少上下文切换。例如,当你更改一个函数的签名时,该函数的所有调用者可能都需要更新。理想情况下,你希望最小化影响范围,并且不需要更改许多文件。
-
文件大小:一个文件中包含超过几百行代码可能是一个警告信号。如果文件内的代码都是紧密耦合的,那么可能最好重新设计系统以减少耦合度。
-
排序:Julia 按照你包含它们的顺序加载源文件。由于数据类型和实用函数通常是共享的,因此最好将它们分别保存在
types.jl和utils.jl文件中,并在模块的开始处包含它们。
类似地,在组织测试脚本时,也应考虑相同的因素。
到目前为止,我们已经学会了如何使用模块和子模块创建新的命名空间。更方便的是,模块被组织在一个包中,这样它就可以在应用程序中被重用。一旦我们创建了多个包,它们之间相互依赖是不可避免的。了解如何正确处理这些依赖关系非常重要;这将是下一节的主要内容。
管理包依赖
Julia 生态系统有一套丰富的开源包。当包设计有一个单一目标时,它们可以更容易地被重用;然而,处理大型代码库并不是一件容易的事情,因为它更有可能依赖于第三方包。开发者需要花费相当多的时间和精力来维护和管理这些依赖关系,以避免依赖地狱。
重要的是要理解,依赖关系不仅存在于包之间,还存在于特定版本的包之间。幸运的是,Julia 语言对语义版本控制有很强的支持,这可以帮助解决很多问题。
在本节中,我们将涵盖以下主题:
-
理解语义版本控制方案
-
指定 Julia 包的依赖项
-
避免循环依赖
现在,让我们快速了解一下语义版本控制方案。
理解语义版本控制方案
语义版本控制(semver.org/)是由 Tom Preston-Werner 开发的方案,他最著名的是 GitHub 的联合创始人和 CTO。语义版本控制服务于一个非常具体的目的,即为版本号变化提供意义——即语义。
当我们使用第三方包并且它被升级时,我们如何知道我们的应用程序是否需要更新?如果我们只是升级依赖包而不对我们的应用程序进行任何测试,我们将承担什么样的风险?
在语义版本控制之前,几乎总是猜测。然而,一个更加勤奋且风险规避的开发者至少会检查依赖包的发布说明,试图找出是否有任何破坏性更改,然后采取适当的行动。
在这里,我们将简要总结语义版本控制是如何工作的。首先,版本号是由以下组件构成的:
<major>.<minor>.<patch>
如果我们愿意,版本号可以在末尾后跟一个发布标签和一个构建号:
<major>.<minor>.<patch>-<pre-release>+<build>
版本号的每一部分都揭示了其含义:
-
当
主要版本号发生变化时,意味着在这个版本中引入了与上一个版本不兼容的重大更改。对于应用程序来说,引入新版本的风险非常高,因为现有的功能很可能会中断。 -
当
次要版本号发生变化时,意味着在这个版本中有非破坏性的增强。对于应用程序来说,引入新版本的风险是适度的,因为之前的函数至少在理论上应该继续按原样工作。 -
当
补丁版本号发生变化时,意味着在这个版本中有非破坏性的错误修复。对于应用程序来说,引入新版本的风险是低的。 -
当存在时,
预发布标签表示预发布候选,如 alpha、beta 或发布候选(RCs)。该发布被视为不稳定,应用程序永远不应该在生产环境中使用它。 -
构建标签被视为元信息,可以忽略。
注意,只有当所有包都正确使用语义版本控制时,它才有用。语义版本控制就像是一种通用语言,包开发者可以使用它来轻松地表明他们在发布新版本时所做的更改的影响。
Julia 包生态系统鼓励使用语义版本控制。接下来,我们将看看 Julia 包管理器Pkg如何使用语义版本控制处理依赖项。
虽然 Julia 鼓励使用语义版本控制,但许多开源包仍然带有 1.0 之前的版本号,尽管它们在生产使用中可能相当稳定。零主版本号是特殊的——它基本上意味着每个新版本都是破坏性的。
随着 Julia 语言的成熟,越来越多的包作者将他们的包标记为 1.0,随着时间的推移,包兼容性问题将会得到改善。
为 Julia 包指定依赖项
我们可以通过检查源文件中的 using 或 import 关键字来判断一个包是否依赖于另一个包;然而,Julia 运行时环境被设计为更明确,通过跟踪依赖项。此类信息存储在包目录中的 Project.toml 文件中。此外,同一目录下的 Manifest.toml 文件包含了关于完整依赖树的更多信息。这些文件使用 TOML 文件格式编写。尽管手动编辑这些文件足够简单,但可以使用 Pkg 包管理器的命令行界面(CLI)更轻松地管理依赖项。
要添加一个新的依赖包,你只需要执行以下步骤:
-
启动 Julia 交互式解释器。
-
通过按
]键进入Pkg模式。 -
使用
activate命令激活项目环境。 -
使用
add命令添加依赖包。
例如,让我们按照以下方式将 SaferIntegers 包添加到我们的 Calculator 包中:

让我们先检查一下 Project.toml 文件的内容,如下所示截图所示。这个看起来很奇怪的哈希码 88634af6-177f-5301-88b8-7819386cfa38 代表了 SaferIntegers 包的通用唯一标识符(UUID)。请注意,SaferIntegers 包没有指定版本号,尽管我们知道从前面的输出中安装了版本 2.5.0:

Manifest.toml 文件包含了该包的完整依赖树。首先,我们找到以下关于我们的 SaferIntegers 依赖项的部分:

注意,SaferIntegers 包现在在清单文件中有特定的版本。2.5.0。为什么?这是因为清单被设计用来捕获所有直接依赖和间接依赖包的确切版本信息。第二个观察结果是,官方捆绑的包,如 Serialization、Sockets 和 Test,没有版本号:

这些包没有版本号,因为它们总是与 Julia 二进制文件一起发布。它们的实际版本基本上由特定的 Julia 版本决定。
重要的是要认识到,尽管我们知道安装了SaferInteger的版本 2.5.0,但Project.toml和Manifest.toml文件中都不包含任何版本兼容性信息。为了指定兼容性约束,我们可以使用语义版本控制方案手动编辑Project.toml文件。例如,如果我们知道Calculator与SaferIntegers版本 1.1.1 及以后兼容,那么我们可以在Project.toml文件的[compat]部分添加此要求,如下所示:
[compat]
SaferIntegers = "1.1.1"
此兼容性设置提供了 Julia 包管理器所需的信息,以确保至少安装了SaferIntegers版本 1.1.1,以便使用Calculator包。由于包管理器对语义版本控制敏感,前面的设置意味着Calculator可以与从 1.1.1 到最新 1.x.y版本的所有SaferIntegers版本一起工作,直到 2.0。在数学表示法中,兼容版本的范围是[1.1.1, 2.0.0),其中 2.0.0 被排除。
现在,假设SaferIntegers得到了改进,并且包所有者决定发布 2.0.0 版本?嗯,因为主版本号从 1 提升到了 2,我们不得不期待会有破坏性变化。如果我们不采取任何行动,最新的版本 2.0.0 将永远不会安装到Calculator环境中,因为我们明确实现了 2.0.0 的排他性上限。
假设经过彻底的审查和测试,我们得出结论,Calculator不受SaferIntegers 2.0.0 的任何破坏性变化的影响。在这种情况下,我们只需对Project.toml文件进行微小修改,如下所示:
[compat]
SaferIntegers = "1.1.1, 2"
这一行指定了这两个兼容版本范围的并集:
-
1.1.1指定表示该包与SaferIntegers版本[1.1.1, 2.0.0]兼容 -
2.0指定表示该包与SaferIntegers版本[2.0.0, 3.0.0]兼容
这样的信息很重要。如果有人使用Calculator包,并且其环境锁定在SaferIntegers版本 1.1.1,那么我们知道Calculator在该环境中仍然兼容,并且可以加载它。
包管理器实际上非常灵活,并实现了几种更多的版本指定格式。您可以参考Pkg参考手册了解更多信息(julialang.github.io/Pkg.jl/v1/compatibility/#Version-specifier-format-1)。
指定包之间的兼容性很重要。通过使用Pkg接口和手动编辑Project.toml文件,我们可以正确管理依赖关系,并且包管理器将帮助我们保持工作环境处于良好状态。
然而,有时我们可能会遇到棘手的依赖性问题——例如,循环依赖。我们将在下一节中探讨如何处理此类情况。
避免循环依赖
循环依赖是有问题的。为了理解为什么,考虑以下示例。
假设我们拥有五个包(A、B、C、D 和 E),它们具有以下依赖关系:
-
A 依赖于 B 和 C
-
C 依赖于 D 和 E
-
E 依赖于 A
为了图形化地说明这些,我们可以创建一个图表,其中我们可以使用箭头符号来表示组件之间的依赖关系。箭头的方向表示依赖的方向。

问题是什么?
很明显,存在一个循环,因为 A 依赖于 C,C 依赖于 E,而 E 又依赖于 A。这种循环有什么问题呢?假设你需要在包 C 中进行一个应该向后兼容的更改。为了正确测试这个更改后的系统,我们必须确保 C 在其依赖项的条件下继续具有适当的功能。现在,如果我们沿着依赖链追踪,我们必须用 D 和 E 测试 C,因为 E 依赖于 A,所以我们必须包括 A。现在 A 被包括在内,我们必须包括 B 和 C。由于循环的存在,我们现在必须测试所有包!
我们如何解决这个问题?
无环依赖原则指出,包之间的依赖必须是有向无环图(DAG)——也就是说,依赖图必须没有环。如果我们确实在图中看到了环,那么这是一个设计问题的迹象。
当我们遇到这样的问题时,我们必须重构代码,以便将特定的依赖函数移动到单独的包中。在这个例子中,假设包 A 中有些代码被包内部使用,同时也被包 E 使用。这种依赖基本上是 E -> A。
然后,我们可以将此代码移动到新的包 F 中。在此更改之后,包 A 和 E 都将依赖于包 F,从而有效地消除循环依赖:

在此重构之后,当我们对 C 进行更改时,我们只需测试带有其依赖项的包,这将仅限于 D、E 和 F。包 A 和 B 都可以排除。
在本节中,我们学习了如何利用语义版本控制来清楚地传达包新版本的影响。我们可以使用Project.toml文件来指定当前包与其依赖包的兼容性。我们还回顾了解决循环依赖的技术。
现在我们知道了这个,我们将探讨如何在 Julia 中设计和开发数据类型。
设计抽象和具体类型
Julia 的类型系统是其许多语言特性的基础,例如多分派。在本节中,我们将了解抽象类型和具体类型,以及如何设计和使用它们,以及它们与其他主流面向对象编程语言的不同之处。
在本节中,我们将涵盖以下主题:
-
设计抽象类型
-
设计具体类型
-
理解
isa和<:运算符 -
理解抽象类型和具体类型之间的区别
让我们先看看抽象类型。
设计抽象类型
与许多其他面向对象编程语言类似,Julia 支持抽象类型的层次结构。抽象类型通常用于建模现实世界的数据概念;例如,Animal可以是一个猫或狗的抽象类型,而Vehicle可以是一个汽车、卡车或公共汽车的抽象类型。能够将类型分组并给这个组一个单一的名字,使得 Julia 程序员可以应用适用于这些类型的通用代码。
抽象类型通常方便地定义在特定领域的类型层次结构中。我们可以将抽象类型之间的关系描述为“父-子”,或者更技术性地,一个“是子类型于”的关系。父类型和子类型的术语分别是“超类型”和“子类型”。
Julia 设计的一个独特特性,与大多数其他语言不同,是抽象类型在没有任何字段的情况下定义。因此,抽象类型不指定数据实际上是如何存储在内存中的。乍一看可能有些限制,但随着我们对 Julia 了解得更多,当用于这种设计时,它将显得更加自然。因此,抽象类型仅用于为对象集合建模行为,而不是指定数据是如何存储的。
矩形和正方形对象模型是当允许抽象类型定义数据字段时事物可能崩溃的一个经典例子。假设我们能够定义一个具有width和height字段的矩形。正方形是一种矩形,所以直观上,我们应该能够将正方形建模为矩形的子类型。但很快我们就遇到了麻烦,因为正方形不需要两个字段来存储其边的长度;我们更应该使用一个单独的边长字段。因此,在这种情况下从超类型继承字段是没有意义的。我们将在第十二章“继承和变异性”中更详细地讨论这个案例。
在接下来的章节中,我们将通过一个构建抽象类型层次结构的示例来进行分析。
一个个人资产类型层次结构的示例
假设我们正在构建一个财务应用程序,该程序跟踪用户的财富,这可能包括各种类型的资产。以下图表显示了抽象类型及其父-子关系层次结构。在这个设计中,资产可能是一个房产、一项投资,或者仅仅是现金类型。房产可以是房子或公寓。投资可能是固定收益或股票。作为一个惯例,为了表明它们是抽象类型而不是具体类型,我们在框中斜体化了它们的名称:

要创建一个抽象类型层次结构,我们可以使用以下代码:
abstract type Asset end
abstract type Property <: Asset end
abstract type Investment <: Asset end
abstract type Cash <: Asset end
abstract type House <: Property end
abstract type Apartment <: Property end
abstract type FixedIncome <: Investment end
abstract type Equity <: Investment end
<:符号代表一个“是子类型于”的关系。所以,Property类型是Asset的子类型,Equity类型是Investment的子类型,等等。
虽然在现实中Asset抽象类型似乎位于层次结构的顶层,但它还有一个名为Any的超类型,当没有指定超类型且定义了抽象类型时是隐含的。Any是 Julia 中的顶级超类型。
导航类型层次结构
Julia 提供了一些方便的函数来导航类型层次结构。要找到现有类型的子类型,我们可以使用subtypes函数:

同样,要找到现有类型的超类型,我们可以使用supertype函数。

有时,以树形格式查看完整的层次结构很方便。Julia 没有提供标准函数供我们使用来实现这一点,但我们可以使用递归技术轻松地自己创建一个,如下所示:
# Display the entire type hierarchy starting from the specified `roottype`
function subtypetree(roottype, level = 1, indent = 4)
level == 1 && println(roottype)
for s in subtypes(roottype)
println(join(fill(" ", level * indent)) * string(s))
subtypetree(s, level + 1, indent)
end
end
这个函数对于新的 Julia 用户来说可能非常方便。实际上,我已经将代码保存在我的startup.jl文件中,以便它可以在 REPL 启动时自动加载。
startup.jl文件是一个用户自定义的脚本,位于$HOME/.julia/config目录中。它可以用来存储用户希望在 REPL 每次启动时运行的任何代码或函数。
我们现在可以轻松地显示个人资产类型层次结构,如下所示:

注意,此函数只能显示已加载到内存中的类型层次结构。现在我们已经定义了抽象类型,我们应该能够将函数与它们关联起来。让我们接下来这样做。
定义抽象类型的函数
到目前为止,我们所做的一切只是创建了一个相关概念层次结构。有了这些有限的知识,我们仍然可以定义一些函数来模拟行为。但是,当我们没有具体数据元素时,这有什么用呢?在处理抽象类型时,我们只需关注特定的行为以及它们之间可能存在的交互。让我们继续这个例子,看看我们可以添加哪些类型的函数。
描述函数
虽然这听起来可能不太有趣,但我们可以定义仅基于类型的函数:
# simple functions on abstract types
describe(a::Asset) = "Something valuable"
describe(e::Investment) = "Financial investment"
describe(e::Property) = "Physical property"
现在,如果我们用具有Property超类型的数据元素调用describe,那么将相应地调用Property的描述方法。由于我们没有为Cash类型定义任何描述函数,当用Cash数据元素调用describe时,它将返回更高层次类型Asset的描述。
由于我们尚未定义任何具体类型,我们无法证明这里关于Cash对象的describe函数将回退到describe(a::Asset)方法的说法。尽管这是一个简单的事情,我鼓励读者在阅读本章后将其作为练习来做。
函数行为
有层次结构的原因是为了创建关于类型常见行为的抽象。例如,Apartment和House类型有相同的超类型Property。这是故意的,因为它们都代表在某个位置上的某种物理住宅。因此,我们可以为任何Property定义一个函数,如下所示:
"""
location(p::Property)
Returns the location of the property as a tuple of (latitude, longitude).
"""
location(p::Property) = error("Location is not defined in the concrete type")
你可能会问,我们做了什么? 我们刚刚实现了一个什么也不做只是返回错误的函数!好吧,信不信由你,定义这个函数实际上有几个目的:
-
这清楚地表明,任何
Property的具体子类型都必须实现location函数。 -
在运行时,如果相应的具体类型没有定义
location函数,那么这个特定的函数将被调用,并抛出一个合理的错误,以便程序员可以纠正错误。 -
函数定义上方紧邻的文档字符串包含了一个有用的描述,说明
Property的具体子类型应该实现什么。
或者,我们可以定义一个空函数:
"""
location(p::Property)
Returns the location of the property as a tuple of (latitude, longitude).
"""
function location(p::Property) end
空函数和抛出错误的函数之间有什么区别?对于这个空函数,如果具体类型没有实现这个函数,则不会出现运行时错误。
对象之间的交互
定义抽象类型之间的交互也是有用的。既然我们知道每个Property都应该有一个位置,我们可以定义一个函数来计算任意两个属性之间的步行距离,如下所示:
function walking_disance(p1::Property, p2::Property)
loc1 = location(p1)
loc2 = location(p2)
return abs(loc1.x - loc2.x) + abs(loc1.y - loc2.y)
end
逻辑完全存在于抽象类型中!我们甚至还没有定义任何具体类型,但我们仍然能够开发出适用于Property任何具体子类型的通用代码。
Julia 语言的力量允许我们在这一抽象级别定义这些行为。让我们想象一下,如果我们不允许在这一级别定义函数,而只能使用特定的具体类型实现逻辑,我们会做什么。在这种情况下,我们必须为不同类型属性的每一种组合定义一个单独的walking_distance函数。这对程序员来说将是过于平凡和无聊的!
现在我们已经了解了抽象类型的工作原理,让我们继续我们的旅程,看看如何在 Julia 中创建具体类型。
设计具体类型
具体类型用于定义数据是如何组织的。在 Julia 中,有两种类型的具体类型:
-
原始类型
-
组合类型
基本类型携带纯比特。Julia 的Base包包含各种基本类型——8 位、16 位、32 位、64 位或 128 位的有符号/无符号整数。目前,Julia 只支持位数是 8 的倍数的基本类型。例如,如果我们有一个需要非常大的整数的用例,我们可以定义一个 256 位整数类型(32 字节)。如何做到这一点超出了本书的范围。如果你觉得这是一个有趣的项目,你可以查阅 GitHub 上的 Julia 源代码,看看现有的基本类型是如何实现的。事实上,Julia 语言的大部分代码是用 Julia 本身编写的!
复合类型由一组命名字段定义。将字段分组到单个类型中可以更容易地进行推理、共享和操作。复合类型可以指定一个特定的超类型或默认为Any。字段也可以根据需要注解其自己的类型,类型可以是抽象的或具体的。当字段的类型信息不存在时,它们默认为Any,这意味着该字段可以持有任何类型的对象。
我们将在本节中重点关注复合类型。
设计复合类型
复合类型使用struct关键字定义。让我们继续从上一个抽象类型部分中的例子,并继续构建我们的个人资产类型层次结构。现在,我们将创建一个名为Stock的具体类型,作为Equity的子类型。为了保持简单,我们只需将股票表示为交易符号和公司名称:
struct Stock <: Equity
symbol::String
name::String
end
我们可以使用标准构造函数实例化复合类型,它只需将所有字段作为参数:

现在,由于Stock是Equity的子类型,而Equity又是Investment的子类型,Investment又是Asset的子类型,因此我们应该遵守我们之前提出的契约,通过定义describe函数:
function describe(s::Stock)
return s.symbol * "(" * s.name * ")"
end
describe函数只是返回股票的字符串表示,包括交易符号和公司名称。
不可变性
复合类型默认是不可变的。这意味着在对象创建后,它们的字段不可更改。不可变性是好事,因为它消除了由于数据修改而意外改变系统行为时的惊喜。我们可以轻松证明我们在上一节中创建的具体Stock类型是不可变的:

那太好了!现在,不可变性的保证实际上只到字段级别。如果一个类型包含一个字段,并且该字段的类型是可变的,那么改变底层数据是被允许的。让我们通过创建一个新的复合类型BasketOfStocks来尝试一个不同的例子,这个类型用于存储股票的向量(即一维数组)以及我们持有它们的原因:
struct BasketOfStocks
stocks::Vector{Stock}
reason::String
end
让我们只创建一个对象进行测试:

如我们所知,BasketOfStocks是一个不可变类型,所以我们不能改变它中的任何字段;然而,让我们看看我们是否可以从stocks字段中移除一支股票:

在这里,我们只是直接在stocks对象上调用pop!函数,它会愉快地拿走我妻子一半的礼物!让我重复一遍——不可变性保证对底层字段没有任何影响。
这种行为是按设计进行的。程序员应该谨慎对待对不可变性的任何假设。
可变性
在某些情况下,我们可能实际上希望对象是可变的。可以通过在类型定义前添加mutable关键字轻松地移除不可变性约束。为了使Stock类型可变,我们做以下操作:
mutable struct Stock <: Equity
symbol::String
name::String
end
现在我们尝试在一个假设的情况下更新name字段,假设苹果公司更改了其公司名称:

name字段已经按我们的意愿更新。请注意,当一个类型被声明为可变时,它的所有字段都变为可变。因此,在这种情况下,我们也可以更改符号。根据情况,这种行为可能是或可能不是期望的。在第八章,鲁棒性模式中,我们将讨论一些我们可以用来构建更稳健解决方案的设计模式。
可变或不可变?
如您所见,可变对象似乎更灵活,并且提供了良好的性能。但如果是这样,那么为什么我们不想让所有东西默认都是可变的呢?有几个原因:
-
不可变对象更容易处理。因为对象中的数据是固定的,永远不会改变,所以对这些对象进行操作的函数将始终返回一致的结果。这是一个非常好的特性,因为没有惊喜。如果我们为这样的对象构建一个缓存计算结果的函数,缓存将始终良好并返回一致的结果。
-
可变对象在多线程应用程序中更难处理。假设一个函数正在从可变对象中读取,但该对象的内容被来自不同线程的另一个函数修改。那么当前函数可能会产生错误的结果。为了确保一致性,程序员必须使用锁定技术来同步对对象的读写操作。必须处理这种并发情况使得代码更加复杂且难以测试。
另一方面,可变性对于高性能用例可能很有用,因为内存分配是一个相对昂贵的操作。我们可以通过反复重用分配的内存来减少系统开销。
考虑到所有因素,不可变对象通常是更好的选择。
使用联合类型支持多种类型
有时,我们需要在字段中支持多种类型。这可以通过使用Union类型来实现,它被定义为可以接受任何指定类型的类型。要定义一个Union类型,我们可以在Union关键字之后用花括号包围类型。例如,Int64和BigInt的Union类型可以定义如下:
Union{Int64,BigInt}
当你需要整合来自不同数据类型层次的数据类型时,这些Union类型非常有用。让我们进一步扩展我们的个人资产示例。例如,假设我们需要将一些异国物品纳入我们的数据模型中,这可能包括艺术品、古董、绘画等等。这些新概念可能已经被不同的类型层次所建模,如下所示:
abstract type Art end
struct Painting <: Art
artist::String
title::String
end
事实上,我的妻子喜欢收集绘画,所以我可以将BasketOfStock类型泛化为BasketOfThings,如下所示:
struct BasketOfThings
things::Vector{Union{Painting,Stock}}
reason::String
end
向量中的物品可以是Stock或Painting。记住,Julia 是一种强类型语言,编译器知道哪些数据类型可以适合现有的字段是非常重要的。让我们看看它是如何工作的:

要创建一个包含Painting或Stock的向量,我们只需在方括号前指定数组的元素类型,如Union{Painting,Stock}[stock, monalisa]。
Union类型的语法可能非常冗长,尤其是当类型超过两个时,因此定义一个具有表示Union类型的有意义的名称的常量是很常见的:
const Thing = Union{Painting,Stock}
struct BasketOfThings
thing::Vector{Thing}
reason::String
end
如你所见,Thing比Union{Painting,Stock}更容易阅读。另一个好处是Union类型可以在源代码的许多部分被引用。当我们需要稍后添加更多类型时——例如,一个Antique类型——我们只需要在一个地方更改它,即Thing的定义。这意味着代码可以更容易地维护。
在本节中,尽管我们选择了使用如Stock和Painting这样的具体类型作为示例,但我们没有理由不能使用如Asset和Art这样的抽象类型来定义Union类型。
Union类型的另一个常见用法是将Nothing作为字段的合法值。这可以通过声明一个具有Union{T,Nothing}类型的字段来实现,其中T是我们想要使用的实际数据类型。在这种情况下,该字段可以分配一个真实值或只是Nothing。
接下来,我们将继续学习如何使用类型运算符。
使用类型运算符
Julia 的数据类型本身就是一等公民。这意味着你可以将它们分配给变量,传递给函数,并以各种方式操作它们。在接下来的几节中,我们将探讨两个常用的运算符。
isa运算符
isa运算符可以用来确定一个值是否是某个类型的子类型。例如,看看下面的代码:

让我解释一下这些结果:
-
数字
1是Int类型的实例,因此返回true。 -
因为
Float64是一个不同的具体类型,所以它返回false。 -
因为
Int是Signed的子类型,而Signed是Integer的子类型,Integer是Real的子类型,所以它返回true。
isa 操作符对于检查接受泛型类型参数的函数中的类型可能很有用。例如,如果函数只能处理 Real 数值,那么当意外传递一个 Complex 值时,它可能会抛出错误。
<: 操作符
is-a-subtype-of 操作符 <: 用于确定一个类型是否是另一个类型的子类型。从上一节中的第三个例子来看,我们可以检查 Int 是否确实是 Real 的子类型,如下所示:

有时开发者可能会对 isa 和 <: 操作符的用法感到困惑,因为它们非常相似。我们可以记住,isa 检查一个 值 是否与一个类型匹配,而 <: 检查一个 类型 是否与另一个类型匹配。这些操作符的文档字符串实际上非常有帮助。从 Julia REPL 中,输入一个 ? 字符并输入操作符以查找文档:

事实上,isa 和 <: 都是函数,但它们也可以用作中缀操作符。
这些操作符对于类型检查非常有用;例如,我们可以在构造函数中抛出异常,如果传递的参数没有正确的类型。它们还可以根据传递给函数的类型动态执行不同的逻辑。
抽象类型和具体类型是 Julia 中数据类型的根本构建块。快速了解它们之间的差异可能是有益的。接下来,我们将探讨具体细节。
抽象类型和具体类型的差异
讨论了抽象类型和具体类型之后,你可能想知道它们之间有什么不同。我们可以以下表总结它们的不同之处:
| 属性 | 抽象类型 | 具体类型 |
|---|---|---|
| 有超类型? | 是 | 是 |
| 允许子类型? | 是 | 否 |
| 包含数据字段? | 否 | 是 |
| 一等类型? | 是 | 是 |
可以是 Union 类型的一部分? |
是 | 是 |
对于抽象类型,我们可以构建一个类型层次结构。顶层类型只是 Any。抽象类型不能包含任何数据字段,因为它们是用来表示概念而不是数据存储的。抽象类型是一等类型,这意味着它们可以被存储和传递,并且有与它们一起工作的函数——例如,isa 和 <: 操作符。
具体类型与抽象类型相关联,作为超类型。如果没有指定超类型,则默认为 Any。具体类型不允许子类型。这意味着每个具体类型都必须是最终的,并且会在类型层次结构中成为叶节点。具体类型也是一等类型,就像抽象类型一样。
抽象类型和具体类型都可以在 Union 类型中引用。
我们刚才提到的内容可能会让来自面向对象编程背景的人感到惊讶。首先,你可能想知道为什么具体类型不允许子类型。其次,你可能想知道为什么抽象类型不能定义字段。这种设计实际上是故意的,并且核心 Julia 开发团队进行了激烈的辩论。辩论与行为继承与结构继承有关,这将在第十二章继承和变异性中讨论。
现在,让我们转换一下,来看看 Julia 语言的参数化类型特性。
与参数化类型一起工作
Julia 语言最强大的特性之一是能够参数化类型。实际上,很难找到不使用这个特性的任何 Julia 包。参数化类型允许软件设计者泛化类型,并让 Julia 运行时根据指定的参数自动编译到具体版本。
让我们看看复合类型和抽象类型是如何工作的。
与参数化复合类型一起工作
在设计复合类型时,我们应该为每个字段分配一个类型。通常,我们并不真的关心这些类型是什么,只要类型提供了我们想要的功能即可。
一个经典的例子是数值类型。数字的概念很简单:基本上就像我们在小学学的那样。在实践中,由于数据的不同物理存储和表示,许多数值类型在计算机系统中得到实现。
默认情况下,Julia 随带以下数值类型;具体类型较暗:

你还记得我们在本章前面设计了一个复合类型来表示投资组合中的股票吗?让我们在这里回顾一下这个例子:
struct Stock <: Equity
symbol::String
name::String
end
如果我必须在我的经纪账户中持有一些股票,那么我也应该跟踪我拥有的股票数量。为此,我可以定义一个新的类型,称为 StockHolding,如下所示:
struct StockHolding
stock::Stock
quantity::Int
end
默认情况下,Int 数据类型被别名到 Int64 或 Int32,这取决于你是否在使用 Julia 的 64 位或 32 位版本。这看起来只是为了开始而合理,但如果我们需要支持不同用例的分数份额怎么办?在这种情况下,我们只需将 quantity 的类型改为 Float64:
struct StockHolding
stock::Stock
quantity::Float64
end
我们基本上将quantity字段的类型扩展到支持整数和浮点值的类型。这可能是一个合理的方法,但如果我们需要同时支持Int和Float64类型,那么我们就必须维护两种略有不同的类型。遗憾的是,如果我们创建了两种不同的类型,那么维护工作就会变得非常困难。
为了使其更加灵活,我们可以通过一个参数重新设计StockHolding类型:
struct StockHolding{T}
stock::Stock
quantity::T
end
花括号内的符号T被称为类型参数。它作为一个占位符,可以在任何字段中用作类型。
现在,我们拥有了两者之优。StockHolding{Int}类型指的是包含Int类型quantity字段的类型。同样,StockHolding{Float64}指的是包含Float64类型quantity字段的类型。
在实践中,T类型参数只能是一个数值类型,因此我们可以进一步将T限定为Real的任何子类型:
struct StockHolding{T <: Real}
stock::Stock
quantity::T
end
这样我们就可以理解了——StockHolding类型包含一个股票和一个类型为T的子类型Real的数量。句子的后半部分很重要;这意味着我们可以创建一个新类型的StockHolding,其quantity类型可以是Float16、Float32、Float64、Int8、Int16、Int32等等。
让我们尝试用不同的类型参数实例化StockHolding对象,例如Int、Float64和Rational:

我们可以看到,根据传递给构造函数的参数,会自动创建不同的StockHolding{T}类型。
参数化类型的另一个用途是强制字段类型的统一性。假设我们想要设计另一种股票持有对象来跟踪持有物的价格和市场价值。让我们称它为StockHolding2以避免与前一个混淆。下面是它的样子:
struct StockHolding2{T <: Real, P <: AbstractFloat}
stock::Stock
quantity::T
price::P
marketvalue::P
end
知道quantity的类型可能不同于price和marketvalue的类型,我们添加了一个新的类型参数,P。现在,我们可以实例化一个包含整数数量的StockHolding2对象,而价格和市场价值字段为浮点值:

注意,类型是StockHolding2{Int64, Float64},如前一个截图所示。在这种情况下,类型参数T是Int64,参数P是Float64。
由于我们声明了price和marketvalue字段必须具有相同的类型P,Julia 会为我们强制执行这条规则吗?让我们试一试:

是的,确实如此! 我们正确地收到了一个错误,因为我们为 price 传递了一个 Float64 值,但为 marketvalue 传递了一个 Int64。让我们仔细看看错误信息,它揭示了系统期望的内容。对于 StockHolding2 的最接近的候选函数接受第三个和第四个参数的 P 类型,其中 P 是 AbstractFloat 的任何子类型。因为 Int64 不是 AbstractFloat 的子类型,所以没有匹配,因此抛出了错误。
参数化类型也可以是抽象的。我们将在下一节中讨论这一点。
与参数化抽象类型一起工作
抽象类型可以像复合类型一样被增强。让我们继续前面的例子。假设我们想要构建一个名为 Holding 的抽象类型,它跟踪其子类型使用的 P 类型。我们可以这样编写代码:
abstract type Holding{P} end
然后,Holding{P} 的每个子类型也必须接受一个 P 类型参数。例如,我们可以创建两个新的类型——StockHolding3{T,P} 和 CashHolding{P}:
struct StockHolding3{T, P} <: Holding{P}
stock::Stock
quantity::T
price::P
marketvalue::P
end
struct CashHolding{P} <: Holding{P}
currency::String
amount::P
marketvalue::P
end
我们可以如下检查这些类型之间的关系:

让我们创建一个新的 StockHolding3 对象:

如预期的那样,certificate_in_the_safe 对象是 Holding{Float64} 的子类型。
注意,当一个类型被参数化时,每个变体都被视为一个独立的类型,它们之间没有关联,除了它们有一个共同的超类型。例如,Holding{Int} 和 Holding{Float64} 是不同的类型,但它们都是 Holding 的子类型。让我们快速证明这一点:

总结来说,Julia 提供了一个非常丰富的类型系统,程序员可以使用它来推理每个类型与其他类型之间的关系。抽象类型允许我们在关系层次结构中定义行为,而具体类型用于定义数据的存储方式。参数化类型用于将现有类型扩展到字段类型的变体。所有这些语言结构都允许程序员有效地建模数据和行为。
接下来,我们将探讨数据类型转换及其如何应用于函数。
数据类型之间的转换
我们经常需要将数据从一种类型转换为另一种类型,以便利用现有的库函数。一个很好的工作示例是标准的数值数据类型。在大多数数学函数中,将一个整数日期转换为浮点数是一个常见的用例。
在本节中,我们将学习如何在 Julia 中执行数据类型转换。实际上,数据类型转换预计将被显式实现;然而,已经实现了一套规则,以便某些转换可以自动调用。
执行简单的数据类型转换
将一个值从一种数据类型转换为另一种数据类型有两种方法。显然的选择是从现有值构造一个新对象。例如,我们可以如下从有理数构造一个Float64对象:

另一种方法是使用convert函数:

无论哪种方式都行得通。当考虑性能优化时,使用convert函数有一个优点,我们将在本节后面解释。
小心有损转换
当涉及到转换时,考虑转换是有损还是无损是很重要的。一般来说,数据类型转换是无损的,这意味着当你从一个类型转换到另一个类型,然后再转换回来时,你会得到相同的值。
由于浮点数的数值表示,这种完美的转换并不总是可能的。例如,让我们尝试将1//3转换为Float64,然后再将其转换回Rational:

由于舍入误差,在将1//3转换为Float64类型后,无法重建它。这个问题不仅限于Rational类型。我们可以通过将值从Int64转换为Float64然后再转换回来,轻松地再次破坏它,如下所示:

我们可以看到这里存在精度损失。虽然我们可能对这些结果不太满意,但只要我们使用Float64类型,实际上我们真的无能为力。Float64类型是根据 IEEE 754 浮点规范实现的,并且预期会存在精度误差。如果您需要更高的精度,可以使用BigFloat代替,这可以解决这个特定问题:

在处理浮点数时,我们应该小心精度问题。
理解数值类型转换
Julia 由于安全原因不会自动在数据类型上执行转换。每个转换都必须由程序员显式定义。
为了让每个人更容易理解,Julia 默认已经包含了用于数值类型的转换函数。例如,您可以从Base包中找到这段有趣的代码:
convert(::Type{T}, x::T) where {T<:Number} = x
convert(::Type{T}, x::Number) where {T<:Number} = T(x)
这两个函数都接受Type{T}类型的第一个参数,其中T是Number的子类型。有效的值包括所有标准数值类型,如Int64、Int32、Float64、Float32等。
让我们进一步了解这两个函数:
-
第一个函数表示,当我们想要将
x从T类型转换为T类型(相同的类型)时,只要T是Number的子类型,我们就可以简单地返回参数x本身。这可以被认为是一种性能优化,因为当目标类型与输入类型相同时,实际上没有必要进行任何转换。 -
第二个函数稍微有趣一些。为了将
x(它是Number的子类型)转换为类型T(它也是Number的子类型),它只是调用T类型的构造函数并传递x。换句话说,这个函数可以处理任何Number类型到Number子类型的转换。
你可能会想知道为什么我们一开始不直接使用构造函数。这是因为 convert 函数被设计为在多种常见用例中自动调用。正如我们之前所看到的,这种额外的间接性也允许我们在转换不必要时绕过构造函数。
convert 在何时被调用?答案是 Julia 不会自动这样做,除了少数几种情况。我们将在下一节中探讨这些情况。
检查自动转换的规则
由于数据类型转换是一个相当标准的操作,Julia 被设计为在以下场景中自动调用 convert 函数:
-
将值赋给数组会将该值转换为数组的元素类型。
-
将值赋给对象的字段会将值转换为该字段的声明类型。
-
使用
new构造对象会将值转换为对象的声明字段类型。 -
将值赋给具有声明类型的变量会将该值转换为该类型
-
声明返回类型的函数会将它的返回值转换为该类型。
-
向
ccall传递值会将该值转换为相应的参数类型。
让我们确认这些功能确实按预期工作。
情况 1:将值赋给数组
在以下示例中,将 1 值赋给 Float64 数组会将前者转换为浮点值,1.0:

情况 2:将值赋给对象的字段
在以下示例中,Foo 结构体接受一个 Float64 字段。当字段被赋予 2 的值时,它会被转换为 2.0:

情况 3:使用 new 函数构造对象
在以下示例中,Foo 构造函数在创建 Foo 对象时会自动将 1 转换为 1.0:

情况 4:将值赋给具有声明类型的变量
在以下示例中,局部变量 x 被声明为 Float64 类型。当它被赋予 1 值时,它会被转换为 1.0:

情况 5:函数具有声明返回类型
在以下示例中,foo 函数被声明为返回一个 Float64 值。尽管 return 语句说 1,但在返回之前它会被转换为 1.0:

情况 6:向 ccall 传递值
在下面的示例中,使用了 C 库中的exp函数来计算一个数的指数。它期望一个Float64值作为参数,因此当将值2传递给ccall时,它会在传递给 C 函数之前转换为2.0:

所有这些都很好,但似乎缺少了什么。最常见的情况:向函数传递一个参数怎么办?Julia 在自动转换参数时不会调用它吗?答案可能有点令人惊讶。让我们在接下来的章节中更详细地探讨这个问题。
理解函数分派规则
Julia 是一种强类型语言,这意味着程序员必须非常清楚正在传递的类型。只有当函数参数的类型匹配正确时,才能调用(也称为分派)函数。适当的匹配可以定义为完全匹配(相同类型)或当传递的参数是函数签名中期望的类型的一个子类型时。
为了说明这一点,让我们创建一个函数,该函数将其AbstractFloat类型的参数值加倍。我们将使用我们的subtypetree实用函数来快速找出其子类型:

如果我们向函数传递一个整数会发生什么?嗯,它不太好用:

天真地,我们可能认为系统应该自动将参数转换为Float64然后加倍该值。但是,它并没有这样做。这不是一个转换问题。为了达到这个效果,我们可以显然地写另一个函数,它接受一个Int参数,然后将其转换为Float64,并调用原始函数。但是代码看起来完全一样,这是重复劳动。这个问题可以通过编写更通用的函数来解决:

如果我们认为参数必须是Number,那么我们可以再次将其限制为这样的类型:

我们在这里选择做什么取决于我们希望函数有多灵活。指定一个抽象类型,如Number的好处是我们确信该函数将很好地适用于实现Number定义的行为的任何类型。另一方面,如果我们将函数定义中的它留为未指定类型,那么只要定义了*运算符,就可以将其他对象传递给函数。
在本节中,我们学习了如何在 Julia 中执行数据类型转换。在某些情况下,Julia 还可以自动转换数值类型。
摘要
在本章中,我们开始讨论了为大型应用程序组织源代码的重要性。我们详细探讨了如何建立命名空间以及如何使用模块和子模块来实现它们。为了管理包依赖,我们介绍了语义版本化的概念,并学习了如何使用 Julia 的包管理器正确地使用它。
然后,我们讨论了如何设计抽象类型层次结构并定义抽象类型的函数。我们还讨论了具体类型以及不可变性和可变性的概念。我们演示了在处理来自不同抽象类型层次结构的数据类型时如何使用联合类型。我们考察了两种常见的数据类型运算符(isa和is-a-subtype-of)。为了进一步重用数据类型,我们介绍了参数化类型,并探讨了它们如何应用于具体类型和抽象类型。
最后,我们研究了 Julia 中的convert函数及其在特定情况下自动调用的方式。我们学习了 Julia 的函数分派是如何工作的,以及如何通过接受更广泛的抽象类型作为参数来使函数更加灵活。
到目前为止,你应该对如何组织代码和设计自己的数据类型有了很好的理解。
在下一章中,我们将探讨如何使用函数和 Julia 的多重分派功能来定义应用程序的行为。
问题
通过以下问题来测试你对本章主题的理解。答案在书的后面提供:
-
我们如何创建一个新的命名空间?
-
我们如何将模块的函数暴露给外部世界?
-
当同一个函数名从不同的包中导出时,我们如何引用正确的函数?
-
我们应该在何时将代码分成多个模块?
-
为什么语义版本化在管理包依赖时很重要?
-
为抽象类型定义功能性行为有什么用?
-
我们应该在何时使类型可变?
-
参数化类型有什么用?
第三章:设计函数和接口
本章将继续探讨 Julia 的基本概念。我们在这里选择的主题将为 Julia 编程的关键概念提供一个坚实的基础。特别是,我们将讨论与函数和接口相关的核心 Julia 编程技术。函数是软件的基本构建块。接口是软件不同组件之间的契约关系。有效地使用函数和接口对于构建健壮的应用程序是必不可少的。
本章将涵盖以下主题:
-
函数
-
多重分派
-
参数化方法
-
接口
作为学习过程的一部分,我们将回顾一个游戏设计的用例。更具体地说,我们将假装我们正在构建一个包含太空战游戏板、飞船和陨石部件的游戏。我们将构建移动游戏部件的函数,并为飞船配备武器以摧毁物体。
到本章结束时,你将具备设计和开发函数所需的知识。通过使用多重分派和参数化方法,你的应用程序将变得更加可扩展。一旦你学会了这些技术,你也应该能够设计一个包含可插拔组件的系统,基于接口。
我已经迫不及待了。让我们开始吧!
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
设计函数
函数是 Julia 定义应用程序行为的核心结构。实际上,与面向对象编程语言相比,Julia 更像是一种过程式/函数式编程语言。在面向对象编程中,你专注于构建类并为这些类定义函数。在 Julia 中,你专注于构建在数据类型或数据结构上操作的功能。
在本节中,我们将展示如何定义函数以及函数带来的强大功能。
我们的用例 - 太空战游戏
在本章中,我们将通过构建太空战游戏的部分来阐述编程概念。游戏的设计非常简单直接。它由散布在二维网格上的游戏部件(如飞船和陨石)组成。在我们的程序中,这些游戏部件被称为小部件。
让我们先定义以下数据类型:
# Space war game!
mutable struct Position
x::Int
y::Int
end
struct Size
width::Int
height::Int
end
struct Widget
name::String
position::Position
size::Size
end
由于数据类型是我们设计的关键,这需要更多的解释:
-
Position类型用于存储游戏部件的坐标。它由两个整数表示:x和y。 -
Size类型用于存储游戏部件的大小。它由两个整数表示:width和height。 -
Widget类型用于存储单个游戏部件。它由一个name、position和size表示。
注意,Position 类型是可变的,因为我们期望我们的游戏部件通过更新它们的坐标来移动。
接下来,我们将讨论与函数定义相关的一些主题。
定义函数
实际上,我们可以使用两种不同的语法来定义函数:
-
第一种方法是一个简单的单行代码,其中包含函数的签名和主体。
-
第二种方法使用
function关键字和签名,然后是代码块和end关键字。
如果函数足够简单——例如,如果它只有一条指令——那么通常更倾向于将其写在一行中。这种函数定义风格在科学计算项目中非常常见,因为许多函数只是模仿相应的数学公式。
对于我们的游戏,我们可以简单地编写四个函数来在棋盘上移动游戏部件,通过修改部件的坐标:
# single-line functions
move_up!(widget, v) = widget.position.y -= v
move_down!(widget, v) = widget.position.y += v
move_left!(widget, v) = widget.position.x -= v
move_right!(widget, v) = widget.position.x += v
在 Julia 中编写单行函数确实非常地道。来自不同背景的人可能会觉得以下更冗长的形式更直观。这并没有什么问题;这两种形式都运行得很好:
# long version
function move_up!(widget, v)
widget.position.y -= v
end
在编写这些函数时,有几个要点需要注意:
-
下划线使用:前面的函数名使用下划线来分隔单词。根据官方 Julia 手册,惯例是直接将单词组合在一起,除非变得过于混乱或难以阅读。我个人认为,对于多词函数名,应该始终使用下划线,因为它增强了可读性,并使代码更加一致。
-
感叹号使用:前面的函数名包含感叹号,以表示该函数会改变传入函数的对象的状态。这是一个好习惯,因为它提醒开发者调用函数时会有副作用。
-
鸭子类型:你可能想知道为什么函数参数没有标注任何类型信息。在
move_up!函数中,尽管我们没有类型注解,但我们期望当函数被使用时,widget参数具有Widget类型,而v具有整数Int类型。这是一个有趣的话题,我们将在下一节中进一步讨论。
如你所见,定义函数是一项相当直接的任务,Julia 处理函数参数的方式非常有趣。我们将在下一节中讨论这一点。
函数参数注解
在没有任何多态性的静态类型语言中,例如 C 或 Fortran,每个参数都必须使用确切的类型进行指定。然而,Julia 是动态类型,并支持鸭子类型——如果它像鸭子走路,像鸭子叫,那么它就是一只鸭子。源代码中根本不需要类型信息。相反,编译器会查看传递给函数的运行时类型,并为这些类型编译适当的方法。根据参数类型在整个方法体中进行类型推断的过程称为类型推断。
因此,根本不需要用类型信息注解函数参数。有时人们会有这样的印象,在他们所有的 Julia 代码中添加类型注解会提高性能。这通常不是情况。对于方法签名,类型对性能没有影响:它们仅用于控制调度。
那么,你会选择什么?给参数添加类型注解还是不添加?
无类型参数
当函数参数没有使用类型信息进行注解时,函数实际上更加灵活。为什么?那是因为它可以与传递给函数的任何类型一起工作。让我们假设,在未来,坐标系从Int变为Float64。当这种情况发生时,函数不需要进行任何更改:它只需正常工作!
相反,保持所有内容无类型可能也不是最好的主意,因为函数实际上不能与世界上定义的任何可能的数据类型一起工作。此外,它经常会导致晦涩的异常信息,并使程序更难调试。例如,如果我们不小心将Int值作为Widget参数传递给move_up!函数,那么它将抱怨类型没有position字段:

错误信息相当晦涩。我们是否可以做一些事情来使调试稍微容易一些?答案是,我们可以提供函数参数的类型。让我们看看这是如何实现的。
带类型参数
我们知道我们的move函数实现包含一些隐式的设计假设:
-
v的值应该是一个数值,正如+或-运算符所暗示的。 -
widget必须是一个Widget对象,或者至少包含一个Position对象,正如访问position字段所暗示的。
因此,通常更安全的是定义具有一些类型信息的函数。话虽如此,move_up!函数可以被重新定义为以下内容:
move_up!(widget::Widget, v::Int) = widget.position.y -= v
如果我们只是以相同的方式定义所有的move函数,那么调试就会变得更容易。假设我们像前面代码中那样犯了一个错误,即把一个整数作为第一个参数传递:我们现在将收到一个更合理的错误信息:

因此,而不是试图运行函数并因未知效果而失败,Julia 编译器现在会告诉我们,对于我们传递给函数的参数类型,不存在该方法。
在我们继续下一个主题之前,至少让我们玩一小会儿这个游戏。为了在 Julia REPL 中更优雅地显示这些对象,我们可以定义一些 show 函数,如下所示:
# Define pretty print functions
Base.show(io::IO, p::Position) = print(io, "(", p.x, ",", p.y, ")")
Base.show(io::IO, s::Size) = print(io, s.width, " x ", s.height)
Base.show(io::IO, w::Widget) = print(io, w.name, " at ", w.position, " size ", w.size)
这些 Base.show 函数提供了当 Position、Size 或 Widget 对象需要在特定的 I/O 设备(如 REPL)上显示时的实现。通过定义这些函数,我们得到了一个更优雅的输出。
注意,Widget 类型的 show 函数会打印出小部件的名称、位置和大小。Position 和 Size 类型的相应 show 函数将由 print 函数调用。
show 函数还有一个形式,即 show(io, mime, x),这样 x 值就可以以不同的格式显示不同的 MIME 类型。
MIME 代表多用途互联网邮件扩展。它也被称为媒体类型。这是一个用于指定数据流类型的标准。例如,text/plain 代表纯文本流,而 text/html 代表包含 HTML 内容的文本流。
show 函数的默认 MIME 类型是 text/plain,这本质上是我们用于 Julia REPL 环境的类型。如果我们使用 Julia 在笔记本环境中,例如 Jupyter,那么我们可以提供一个 show 函数,该函数使用 text/html MIME 类型提供额外的 HTML 格式化。
最后,让我们来试驾一下。我们可以通过调用各种 move 函数来移动小行星游戏部件,如下所示:
# let's test these functions
w = Widget("asteroid", Position(0, 0), Size(10, 20))
move_up!(w, 10)
move_down!(w, 10)
move_left!(w, 20)
move_right!(w, 20)
# should be back to position (0,0)
print(w)
结果如下。请注意,小行星小部件的输出格式与我们编码的完全一致:

使用类型参数定义函数通常被认为是一种良好的实践,因为函数只能与参数的具体数据类型一起工作。此外,从客户端使用角度来看,你只需查看函数定义就可以清楚地看到函数需要什么。
有时定义一个具有无类型参数的函数更有益。例如,标准的 print 函数有一个看起来像 print(io::IO, x) 的函数签名。其意图是 print 函数保证能够与所有可能的数据类型一起工作。
一般而言,这应该是一个例外而不是常态。在大多数情况下,使用类型参数更有意义。
接下来,我们将讨论如何为参数提供默认值。
处理可选参数
有时,我们不想在函数中硬编码任何值。一般的解决方案是提取硬编码的值并将它们整合到函数参数中。在 Julia 中,我们还可以为参数提供默认值。当我们有默认值时,参数就变成了可选的。
为了说明这个概念,让我们编写一个创建一系列小行星的函数:
# Make a bunch of asteroids
function make_asteroids(N::Int, pos_range = 0:200, size_range = 10:30)
pos_rand() = rand(pos_range)
sz_rand() = rand(size_range)
return [Widget("Asteroid #$i",
Position(pos_rand(), pos_rand()),
Size(sz_rand(), sz_rand()))
for i in 1:N]
end
该函数接受一个名为N的参数,表示小行星的数量。它还接受位置范围pos_range和大小范围size_range,用于创建随机大小的小行星,这些小行星随机放置在我们的游戏地图上。您可能会注意到,我们还在make_asteroid函数体内直接定义了两个单行函数pos_rand和sz_rand。这些函数仅存在于函数的作用域内。
让我们尝试不指定pos_range或size_range的任何值:

但它们是可选的,这也允许我们提供自定义值。例如,我们可以通过指定一个更窄的范围来使小行星彼此更近:

魔法从何而来?如果您在 REPL 中输入make_asteroid函数时按下Tab键,您可能会注意到单个函数定义最终有三个方法。
函数和方法是什么?
函数在 Julia 中是通用的。这意味着我们可以通过定义具有相同名称但接受不同类型参数的各种方法来扩展函数的目的。
因此,Julia 中的每个函数都可以关联到一个或多个相关方法。
内部,Julia 自动为每个签名创建这三个方法:

另一种找到函数方法的方式是使用来自 Julia Base包的methods函数:

当然,我们可以完全指定所有参数,如下所示:

如您所见,为位置参数提供默认值非常方便。如果默认值通常被接受,调用函数会变得更简单,因为它不需要指定所有参数。
虽然这里感觉有点奇怪——代码变得越来越难以阅读:make_asteroids(5, 100:5:200, 200:10:500)。5、100:5:200和200:10:500代表什么?这些参数看起来相当晦涩,程序员可能需要查看源代码或手册才能记住它们的含义。必须有一种更好的方法!接下来,我们将检查如何使用关键字参数解决这个问题。
使用关键字参数
可选参数的缺点是它们必须按照定义的顺序排列。当有更多参数时,从调用点绑定到哪个参数的值并不容易阅读。在这种情况下,我们可以使用关键字参数来提高可读性。
让我们按照以下方式重新定义make_asteroid函数:
function make_asteroids2(N::Int; pos_range = 0:200, size_range = 10:30)
pos_rand() = rand(pos_range)
sz_rand() = rand(size_range)
return [Widget("Asteroid #$i",
Position(pos_rand(), pos_rand()),
Size(sz_rand(), sz_rand()))
for i in 1:N]
end
这个函数与上一节中的函数之间的唯一区别只是一个字符。位置参数(在这种情况下,N)和关键字参数(pos_range和size_range)只需要用分号(;)字符分隔。
从调用者的角度来看,关键字参数必须按照参数的名称传递:

使用关键字参数使代码的阅读性大大提高!实际上,关键字参数甚至不需要按照它们在函数中定义的顺序传递:

另一个酷炫的功能是关键字参数不需要携带任何默认值。例如,我们可以定义一个相同的函数,其中第一个参数N成为一个强制关键字参数:
function make_asteroids3(; N::Int, pos_range = 0:200, size_range = 10:30)
pos_rand() = rand(pos_range)
sz_rand() = rand(size_range)
return [Widget("Asteroid #$i",
Position(pos_rand(), pos_rand()),
Size(sz_rand(), sz_rand()))
for i in 1:N]
end
在这一点上,我们只需指定N来调用函数:

使用关键字参数是编写自文档代码的好方法。一些开源包,如 Plots,广泛使用关键字参数。当函数需要许多参数时,它工作得非常好。
虽然在这个例子中我们为关键字参数指定了默认值,但它们实际上并不是必需的。在没有默认值的情况下,当函数被调用时,关键字参数变为强制性的。
另一个酷炫的功能是我们可以向函数传递任意数量的参数。我们将在下一节中探讨这一点。
接受可变数量的参数
有时,如果函数可以接受任意数量的参数会更方便。在这种情况下,我们可以在函数参数中添加三个点...,Julia 会自动将所有传递的参数滚成一个单一的变量。这个功能被称为吸入。
这里有一个例子:
# Shoot any number of targets
function shoot(from::Widget, targets::Widget...)
println("Type of targets: ", typeof(targets))
for target in targets
println(from.name, " --> ", target.name)
end
end
在shoot函数中,我们首先打印targets变量的类型,然后打印每发射击。让我们首先设置游戏元素:
spaceship = Widget("Spaceship", Position(0, 0), Size(30,30))
target1 = asteroids[1]
target2 = asteroids[2]
target3 = asteroids[3]
现在我们可以开始射击了!首先通过传递一个目标来调用shoot函数,然后再次通过传递三个目标来执行:

结果表明,参数被简单地组合成一个元组,并绑定到一个单一的targets变量。在这种情况下,我们只是迭代这个元组并对它们中的每一个执行操作。
吸入是一种将函数参数组合在一起并统一处理它们的绝佳方式。这使得可以以任意数量的参数调用函数。
接下来,我们将了解一个类似的功能,称为展开(splatting),它本质上执行的是吸入(slurping)的相反功能。
展开参数
Slurping 本身非常有用,但三点的表示法实际上还有第二个用途。在调用点,当一个变量后面跟着三个点时,该变量将被自动分配为多个函数参数。这个特性被称为展开。实际上,这个机制与 slurping 非常相似,只是它执行的是相反的操作。我们将通过一个例子来看看。
假设我们已经编写了一个函数来安排几艘太空船在特定的队形中:
# Special arrangement before attacks
function triangular_formation!(s1::Widget, s2::Widget, s3::Widget)
x_offset = 30
y_offset = 50
s2.position.x = s1.position.x - x_offset
s3.position.x = s1.position.x + x_offset
s2.position.y = s3.position.y = s1.position.y - y_offset
(s1, s2, s3)
end
我们还在太空战之前构建了几艘太空船:

我们现在可以使用展开技术调用 triangular_formation! 函数,这涉及到在函数参数后附加三个点:

在这种情况下,spaceships 向量中的三个元素被分配到三个参数中,正如 triangular_formation! 函数所期望的那样。
Splatting 可以在技术上与任何集合类型一起工作——向量(vector)和元组(tuple)。只要被展开的变量支持通用迭代接口,它就应该可以工作。
此外,你可能想知道当变量中的元素数量与函数中定义的参数数量不相等时会发生什么。
鼓励你将这种行为作为练习进行检查。
展开是构建函数参数并直接将它们传递给函数的好方法,而无需将它们拆分成单独的参数。因此,它非常方便。
接下来,我们将讨论如何传递函数以提供高级编程功能。
理解一等函数
当函数可以被分配给变量或结构体字段、传递给函数、从函数返回等时,我们称其为一等函数。它们被当作一等公民对待,就像常规数据类型一样。我们现在将看看函数如何像常规数据值一样被传递。
让我们设计一个新的函数,该函数可以使太空船向随机方向跳跃随机距离。你可能还记得,在本章的开头我们已经定义了四个 move 函数——move_up!、move_down!、move_left! 和 move_right!。以下是我们的策略:
-
创建一个
random_move函数,该函数返回可能的move函数之一。这为选择方向提供了基础。 -
创建一个
random_leap!函数,该函数使用指定的move函数和跳跃距离来移动太空船。
代码如下:
function random_move()
return rand([move_up!, move_down!, move_left!, move_right!])
end
function random_leap!(w::Widget, move_func::Function, distance::Int)
move_func(w, distance)
return w
end
如你所见,random_move 函数返回一个函数,该函数是从 move 函数数组中随机选择的。random_leap! 函数接受一个 move 函数,move_func,作为参数,然后它只是用小部件和距离进行调用。现在让我们测试 random_leap! 函数:

我们已经成功调用了随机选择的move函数。所有这些都可以轻松完成,因为我们可以将函数存储得就像它们是常规变量一样。一等性质使其非常方便。
接下来,我们将学习匿名函数。匿名函数在 Julia 程序中很常见,因为它们是快速创建函数并将其传递给其他函数的好方法。
开发匿名函数
有时候,我们只想创建一个简单的函数,并在不为其指定名称的情况下传递它。这种编程风格在函数式编程语言中实际上相当常见。我们可以用一个例子来说明它的用法。
假设我们想要引爆所有的小行星。一种方法是为它定义一个explode函数,并将其作为参数传递给foreach函数,如下所示:
function explode(x)
println(x, " exploded!")
end
function clean_up_galaxy(asteroids)
foreach(explode, asteroids)
end
结果看起来不错:

如果我们只是将匿名函数传递给foreach,我们也可以达到相同的效果:
function clean_up_galaxy(asteroids)
foreach(x -> println(x, " exploded!"), asteroids)
end
匿名函数的语法包括参数变量,后面跟着细箭头->和函数体。在这种情况下,我们只有一个参数。如果我们有更多的参数,我们可以将它们写成括号内的元组。匿名函数也可以被分配给变量并传递。假设我们还想引爆宇宙飞船:
function clean_up_galaxy(asteroids, spaceships)
ep = x -> println(x, " exploded!")
foreach(ep, asteroids)
foreach(ep, spaceships)
end
我们可以看到,使用匿名函数有一些优点:
-
没有必要想出一个函数名并污染模块的命名空间。
-
匿名函数的逻辑在调用位置可用,这使得代码更容易阅读。
-
代码稍微紧凑一些。
到目前为止,我们已经讨论了如何定义和使用函数的大部分相关细节。下一个主题,do-语法,与匿名函数密切相关。这是一种提高代码可读性的好方法。
使用 do-语法
当与匿名函数一起工作时,我们可能会得到一个位于函数调用中间的代码块,这使得代码更难阅读。do-语法是解决这个问题的好方法,可以产生清晰、易于阅读的代码。
为了说明这个概念,让我们为我们的战斗舰队构建一个新的用例。特别是,我们将增强我们的宇宙飞船以发射导弹的能力。我们还希望支持这样的要求:发射武器需要宇宙飞船处于健康状态。
我们可以定义一个fire函数,它接受一个launch函数和一个宇宙飞船。只有当宇宙飞船处于健康状态时,launch函数才会被执行。我们为什么想要将函数作为参数传递?因为我们想要使其灵活,以便以后可以使用相同的fire函数来发射激光束和其他可能的武器:
# Random healthiness function for testing
healthy(spaceship) = rand(Bool)
# make sure that the spaceship is healthy before any operation
function fire(f::Function, spaceship::Widget)
if healthy(spaceship)
f(spaceship)
else
println("Operation aborted as spaceship is not healthy")
end
return nothing
end
让我们尝试使用匿名函数来发射导弹:

到目前为止一切顺利。但如果我们需要更复杂的程序来发射导弹怎么办?例如,假设我们希望在发射前将宇宙飞船向上移动,发射后再将其降回原位:
fire(s -> begin
move_up!(s, 100)
println(s, " launched missile!")
move_down!(s, 100)
end, spaceship)
现在的语法看起来相当丑陋。幸运的是,我们可以使用 do 语法重写代码,使其更易读:
fire(spaceship) do s
move_up!(s, 100)
println(s, " launched missile!")
move_down!(s, 100)
end
它是如何工作的?好吧,语法被转换,使得 do 块变成了一个匿名函数,然后它被作为函数的第一个参数插入。
Julia 的 open 函数中可以找到一个有趣的 do 语法的用法。因为读取文件涉及到打开和关闭文件句柄,所以 open 函数被设计成接受一个接受 IOStream 并对其执行某些操作的匿名函数,而打开/关闭的维护工作则由 open 函数本身处理。
这个想法很简单,所以让我们用我们自己的 process_file 函数来复制它:
function process_file(func::Function, filename::AbstractString)
ios = nothing
try
ios = open(filename)
func(ios)
finally
close(ios)
end
end
使用 do 语法,我们可以专注于开发文件处理的逻辑,而无需担心诸如打开和关闭文件等维护工作。考虑以下代码:

正如你所见,do 语法可以在两种方式下发挥作用:
-
它通过将匿名函数参数重新排列成块格式,使代码更易读。
-
它允许匿名函数被包裹在一个上下文中,可以在函数执行前后执行额外的逻辑。
接下来,我们将探讨多重分派,这是在面向对象语言中不常见的一种独特特性。
理解多重分派
多重分派是 Julia 编程语言中最独特的特性之一。它们在 Julia Base 库、stdlib 以及许多开源包中被广泛使用。在本节中,我们将探讨多重分派是如何工作的,以及如何有效地利用它们。
什么是分派?
分派是选择执行函数的过程。你可能想知道为什么在执行哪个函数上会有争议。当我们开发一个函数时,我们给它一个名字,一些参数,以及它应该执行的代码块。如果我们为系统中的所有函数想出独特的名字,那么就不会有歧义。然而,很多时候我们想要重用相同的函数名,并将其应用于不同数据类型的类似操作。
Julia 的 Base 库中有许多例子。例如,isascii 函数有三个方法,每个方法都接受不同的参数类型:
isascii(c::Char)
isascii(s::AbstractString)
isascii(c::AbstractChar)
根据参数的类型,将分派并执行适当的方法。当我们用Char对象调用isascii函数时,将分派第一个方法。同样,当我们用String对象调用它时,该对象是AbstractString的子类型,那么将分派第二个方法。有时,传递给方法的参数类型直到运行时才知道,在这种情况下,将根据传递的具体值立即分派适当的方法。这种行为称为动态分发。
分发是一个会反复出现的关键概念。了解与被分发的函数相关的规则非常重要。我们将在下面进行讲解。
匹配最窄类型
如同在第二章“模块、包和数据类型概念”中讨论的那样,我们可以定义接受抽象类型作为参数的函数。当涉及到分发时,Julia 将找到与参数中最窄类型匹配的方法。
为了说明这个概念,让我们回到本章中关于飞船和陨石的最喜欢的例子!实际上,我们将改进我们的数据类型如下:
# A thing is anything that exist in the universe.
# Concrete type of Thing should always have the following fields:
# 1\. position
# 2\. size
abstract type Thing end
# Functions that are applied for all Thing's
position(t::Thing) = t.position
size(t::Thing) = t.size
shape(t::Thing) = :unknown
在这里,我们定义了一个抽象类型Thing,它可以代表宇宙中存在的任何事物。当我们设计这个类型时,我们期望它的具体子类型将具有标准的position和size字段。因此,我们愉快地为Thing定义了position和size函数。默认情况下,我们不希望假设任何事物的形状,所以Thing的shape函数只返回一个:unknown符号。
为了使事情更有趣,我们将为我们的飞船配备两种类型的武器——激光和导弹。在 Julia 中,我们可以方便地将它们定义为枚举:
# Type of weapons
@enum Weapon Laser Missile
在这里,@enum宏定义了一个名为Weapon的新类型。Weapon类型的唯一值是Laser和Missile。枚举是定义类型常量的好方法。内部,它们为每个常量定义了数值,因此应该相当高效。
现在,我们可以定义Spaceship和Asteroid具体类型如下:
# Spaceship
struct Spaceship <: Thing
position::Position
size::Size
weapon::Weapon
end
shape(s::Spaceship) = :saucer
# Asteroid
struct Asteroid <: Thing
position::Position
size::Size
end
注意,Spaceship和Asteroid都包含position和size字段作为我们的设计契约的一部分。此外,我们为Spaceship类型添加了一个weapon字段。因为我们设计了我们最先进的飞船,如飞碟,所以我们为Spaceship类型定义了shape函数。让我们来测试一下:

我们现在已经创建了两个飞船和两个陨石。让我们暂时关注一下前面shape函数调用的结果。当它用飞船对象s1调用时,它被分派到shape(s::Spaceship)并返回:saucer。当它用陨石对象调用时,它被分派到shape(t::Thing),因为没有其他匹配Asteroid对象的匹配项。
总结一下,Julia 的分发机制始终寻找参数中最窄类型的函数。在shape(s::Spaceship)和shape(t:Thing)之间进行判断时,它将选择为Spaceship参数执行shape(s::Spaceship)。
你熟悉多重分发吗?如果不熟悉,不要担心。在下一节中,我们将深入探讨多重分发在 Julia 中的工作原理。
带有多个参数的分发
到目前为止,我们只看到了接受单个参数的方法的分发示例。我们可以将相同的概念扩展到多个参数,这简单地称为多重分发。
当涉及多个参数时,它是如何工作的呢?让我们假设我们继续开发我们的太空战游戏,使其能够检测不同对象之间的碰撞。为了详细查看这一点,我们将通过一个示例实现来探讨。
首先,定义可以检查两个矩形是否重叠的函数:
struct Rectangle
top::Int
left::Int
bottom::Int
right::Int
# return two upper-left and lower-right points of the rectangle
Rectangle(p::Position, s::Size) =
new(p.y+s.height, p.x, p.y, p.x+s.width)
end
# check if the two rectangles (A & B) overlap
function overlap(A::Rectangle, B::Rectangle)
return A.left < B.right && A.right > B.left &&
A.top > B.bottom && A.bottom < B.top
end
然后,我们可以定义一个函数,当两个Thing对象发生碰撞时返回true。这个函数可以为任何组合的Spaceship和Asteroid对象调用:
function collide(A::Thing, B::Thing)
println("Checking collision of thing vs. thing")
rectA = Rectangle(position(A), size(A))
rectB = Rectangle(position(B), size(B))
return overlap(rectA, rectB)
end
当然,这是一个非常天真的想法,因为我们知道飞船和陨石有不同的形状,可能是非矩形的。尽管如此,这并不是一个坏的选择性实现。
在我们继续前进之前,让我们先进行一个快速测试。请注意,我故意抑制了返回值的输出,因为它们对我们这里的讨论并不重要:

知道碰撞检测逻辑可能根据对象类型的不同而不同,我们可以进一步定义这些方法:
function collide(A::Spaceship, B::Spaceship)
println("Checking collision of spaceship vs. spaceship")
return true # just a test
end
使用这种方法,基于最窄类型的选择过程,我们可以安全地处理飞船-飞船的碰撞检测。让我们用与前面代码相同的测试来证明我的说法:

看起来不错。如果我们继续定义其余的函数,那么一切都将被涵盖,并且完美无缺!
多重分发确实是一个简单的概念。本质上,当 Julia 试图确定需要分发哪个函数时,会考虑所有函数参数。同样的规则适用——最窄类型总是获胜!
不幸的是,有时不清楚需要分发哪个函数。接下来,我们将探讨这种情况是如何发生的以及如何解决这个问题。
分发过程中的可能歧义
当然,我们可以始终定义所有可能的方法,使用具体的类型参数;然而,在设计软件时,这可能不是最理想的选择。为什么?因为它在参数类型中的组合数量可能非常庞大,而且通常没有必要列举所有这些类型。在我们的游戏示例中,我们只需要检测两种类型——spaceship和asteroid之间的碰撞。所以我们只需要定义 2 x 2 = 4 种方法;然而,想象一下当我们有 10 种对象类型时我们会做什么。我们那时将不得不定义 100 种方法!
抽象类型的概念可以帮我们。让我们假设我们确实需要支持 10 个具体数据类型。如果其他八个数据类型具有相似的结构,那么我们可以通过接受一个抽象类型作为参数之一来大大减少方法的数量。如何?让我们看看:
function collide(A::Asteroid, B::Thing)
println("Checking collision of asteroid vs. thing")
return true
end
function collide(A::Thing, B::Asteroid)
println("Checking collision of thing vs. asteroid")
return false
end
这两个函数提供了检测 Asteroid 与任何 Thing 之间碰撞的默认实现。第一个方法可以处理第一个参数是 Asteroid 而第二个参数是 Thing 的任何子类型的情况。如果我们总共有 10 个具体类型,这个单一的方法就可以处理 10 种情况。同样,第二个方法可以处理其他 10 种情况。让我们快速检查一下:

太好了!这两个调用都运行得很好。让我们完成我们的测试:

但是等等,当我们尝试检查两颗小行星之间的碰撞时发生了什么?嗯,Julia 运行时在这里检测到了歧义。当我们传递两个 Asteroid 参数时,不清楚我们是要执行 collide(A::Thing, B::Asteroid) 还是 collide(A::Asteroid, B::Thing)。这两种方法似乎都能完成任务,但它们的签名没有一个比另一个更窄,所以它就放弃了并抛出了错误。
幸运的是,它实际上在错误信息中建议了一个修复方案。一个可能的修复方法是定义一个新的方法,collide(::Asteroid, ::Asteroid),如下所示:
function collide(A::Asteroid, B::Asteroid)
println("Checking collision of asteroid vs. asteroid")
return true # just a test
end
因为它有最窄的签名,所以当将两颗小行星传递给 collide 函数时,Julia 可以正确地调度到这个新方法。一旦这个方法被定义,就不会再有歧义。让我们再试一次。结果是以下这样:

如您所见,当您遇到多态的歧义时,可以通过创建具有更具体类型的参数的函数来轻松解决。Julia 运行时不会尝试猜测您想要做什么。作为开发者,我们需要向计算机提供清晰的指示。
然而,仅从代码中看可能不会很明显地发现歧义。为了减少在运行时遇到问题的风险,我们可以主动检测代码的哪个部分可能引入这种歧义。幸运的是,Julia 已经提供了一个方便的工具来识别歧义。我们将在下一节中查看它。
检测歧义
找到歧义的方法通常很难,直到你在运行时偶然遇到一个特定的用例。这并不好。我不知道你,但像我这样的软件工程师不喜欢在生产环境中遇到惊喜!
幸运的是,Julia 在 Test 包中提供了一个用于检测歧义的功能。我们可以使用类似的测试来尝试这个功能。考虑以下代码:

我们在 REPL 中创建了一个小模块,它定义了三个foo方法。这是一个典型的模糊方法示例——如果我们传递两个整数参数,那么不清楚是应该执行第二个还是第三个foo方法。现在,让我们使用detect_ambiguities函数来看看它是否能检测到这个问题:

结果告诉我们,foo(x::Integer, y)和foo(x, y::Integer)函数是模糊的。正如我们已经学到了如何解决这个问题,我们可以这样做并再次测试:

实际上,当你有扩展其他模块中函数的函数时,detect_ambiguities函数更加有用。在这种情况下,你可以将你想要检查的所有模块一起传递给detect_ambiguities函数。以下是传递两个模块时的工作方式:

在这个假设的例子中,Foo4模块导入了Foo2.foo函数并扩展了它,添加了一个新方法。Foo2模块本身是模糊的,但结合这两个模块可以解决模糊性问题。
因此,我们应该何时使用这个伟大的侦探函数呢?一种好的做法是在模块的自动化测试套件中添加detect_ambiguities测试,以便在每次构建的持续集成管道中执行。
现在我们知道了如何使用这个模糊性检测工具,我们可以放心地使用多重分派!在下一节中,我们将讨论分派的另一个方面,称为动态分派。
理解动态分派
Julia 的分派机制之所以独特,不仅因为它具有多重分派功能,还因为它在决定分派位置时动态处理函数参数的方式。
假设我们想要随机选择两个对象并检查它们是否发生碰撞。我们可以定义如下函数:
# randomly pick two things and check
function check_randomly(things)
for i in 1:5
two = rand(things, 2)
collide(two...)
end
end
让我们运行它看看会发生什么:

我们可以看到,根据two变量中传递的参数类型,会调用不同的collide方法。
这种动态行为在面向对象编程语言中可以找到,作为多态。主要区别在于 Julia 支持多重分派,在运行时利用所有参数进行分派。相比之下,在 Java 中,只有被调用的对象用于动态分派。一旦确定了分派的正确类,当有多个重载方法具有相同名称时,方法参数将用于静态分派。
多重分派是一个强大的特性。当与自定义数据类型结合使用时,它允许开发者控制在不同场景下调用哪些方法。如果你对多重分派更感兴趣,可以在 YouTube 上观看一个标题为 多重分派的不可思议有效性 的视频。这是 Stefan Karpinski 在 2019 年 JuliaCon 会议上的演讲。
接下来,我们将探讨如何对函数参数进行参数化,以增加额外的灵活性和表现力。
利用参数化方法
Julia 的类型系统和多重分派特性为编写可扩展的代码提供了强大的基础。实际上,我们还可以在函数参数中使用参数化类型。我们可以调用这些参数化方法。参数化方法提供了一种有趣的方式来表达在分派过程中可能匹配的数据类型。
在接下来的几节中,我们将介绍如何在我们的游戏中利用参数化方法。
使用类型参数
在定义函数时,我们有选项为每个参数注解类型信息。参数的类型可以是常规的抽象类型、具体类型或参数化类型。让我们考虑这个用于爆炸游戏组件数组的示例函数:
# explode an array of objects
function explode(things::AbstractVector{Any})
for t in things
println("Exploding ", t)
end
end
things 参数被注解为 AbstractVector{Any},这意味着它可以包含任何 AbstractVector 类型,该类型包含任何 Any 的子类型(实际上就是所有东西)。为了使方法参数化,我们可以简单地用 T 类型参数重写它,如下所示:
# explode an array of objects (parametric version)
function explode(things::AbstractVector{T}) where {T}
for t in things
println("Exploding ", t)
end
end
在这里,explode 函数可以接受任何带有参数 T 的 AbstractVector,其中 T 可以是 Any 的任何子类型。因此,如果我们只传递一个 Asteroid 对象的向量——即 Vector{Asteroid}——它应该可以正常工作。如果我们传递一个符号的向量——即 Vector{Symbol}——它也可以正常工作。让我们试一试:

注意,Vector{Asteroid} 实际上是 AbstractVector{Asteroid} 的子类型。一般来说,我们可以这样表述:当 SomeType{T} 是 SomeOtherType{T} 的子类型时,SomeType{T} 就是 SomeOtherType{T} 的子类型。但是,如果我们不确定,很容易进行检查:

也许我们并不真的想让 explode 函数接受任何类型的向量。由于这个函数是为我们的太空战游戏编写的,我们可以将函数限制为只接受任何类型的 Thing 子类型的向量。这可以很容易地实现,如下所示:
# Same function with a more narrow type
function explode(things::AbstractVector{T}) where {T <: Thing}
for t in things
println("Exploding thing => ", t)
end
end
where 语法用于进一步用超类信息限定参数。每当在函数签名中使用类型参数时,我们必须为相同的参数(s)提供 where 子句。
函数参数中的类型参数允许我们指定一个符合 where 子句中指示的约束的数据类型类。前面的 explode 函数可以接受包含 Thing 的任何子类型的向量。这意味着该函数是通用的,因为它可以用无限数量的类型进行分发,只要它满足约束。
接下来,我们将探讨使用抽象类型作为指定函数参数的替代方法。乍一看,它似乎与使用参数类型相当相似;然而,存在一个细微的差别,我们将在下一节中解释。
将抽象类型替换为类型参数
通常,我们可以在函数签名中将任何抽象类型替换为类型参数。当我们这样做时,我们将得到一个与原始方法具有相同语义的参数化方法。
这是一个重要的观察结果。让我们看看我们能否通过一个例子来演示这种行为。
假设我们正在构建一个 tow 函数,以便宇宙中的太空船可以拖走一些东西,如下所示:
# specifying abstract/concrete types in method signature
function tow(A::Spaceship, B::Thing)
"tow 1"
end
tow 函数当前使用具体的 Spaceship 类型和一个抽象的 Thing 类型参数。如果我们想查看为此函数定义的方法,我们可以使用 methods 函数来显示存储在 Julia 的方法表中的内容:

如预期的那样,返回了相同的方法签名。
现在,让我们定义一个参数化方法,其中我们使用类型参数作为参数 B:
# equivalent of parametric type
function tow(A::Spaceship, B::T) where {T <: Thing}
"tow 2"
end
我们现在已经定义了一个具有不同签名语法的新的方法。但这真的是一个不同的方法吗?让我们检查一下:

我们可以看到,方法列表仍然只有一个条目,这意味着新的方法定义已经替换了原始的方法。这并不令人惊讶。新方法签名虽然看起来与之前的不同,但确实具有与原始方法相同的含义。最终,第二个参数 B 仍然接受任何是 Thing 子类型的类型。
那么,我们为什么要费这么大的劲来做这件事呢?嗯,在这种情况下,没有必要将此方法转换为参数化方法。但请看下一节,你将看到为什么这样做可能是有用的。
在使用参数强制类型一致性
使用类型参数最有用的特性之一是它们可以用来强制类型一致性。
假设我们想要创建一个新的函数,将两个 Thing 对象组合在一起。由于我们并不真正关心传递的具体类型是什么,我们可以只写一个执行这项工作的单个函数:
function group_anything(A::Thing, B::Thing)
println("Grouped ", A, " and ", B)
end
我们还可以快速运行一些简单的测试,以确保太空船和陨石的所有四种组合都正常工作:

你可能会想知道我们是如何得到关于特定武器的如此好的输出的。正如我们之前所学的,我们可以通过我们的类型扩展 Base 包中的 show 函数。你可以在本书的 GitHub 仓库中找到我们对 show 函数的实现。
现在,一切看起来都很顺利,但随后我们意识到要求与我们最初的想法略有不同。而不是将任何类型的对象分组,该函数应该只能将相同类型的对象分组——也就是说,将飞船与飞船、小行星与小行星分组是可以的,但不能将飞船与小行星分组。那么我们在这里能做什么呢?一个简单的解决方案就是在方法签名中添加一个类型参数:
function group_same_things(A::T, B::T) where {T <: Thing}
println("Grouped ", A, " and ", B)
end
在这个函数中,我们已将两个参数都注解为类型 T,并指定 T 必须是 Thing 的子类型。因为两个参数使用了相同的类型,我们现在指示系统只有在两个参数类型相同时才调用此方法。现在我们可以尝试与之前相同的四个测试用例,如下面的代码所示:

事实上,我们现在可以确保只有在参数类型相同时才会调用该方法。这是使用类型参数作为函数参数的一个很好的理由。
接下来,我们将讨论使用类型参数的另一个原因——从方法签名中提取类型信息。
从方法签名中提取类型信息
有时,我们想在方法体中找出参数类型。这实际上是非常容易做到的。事实上,所有参数都被绑定为一个变量,我们可以在方法体本身中访问它。标准 eltype 函数的实现提供了这样一个用例的好例子:
eltype(things::AbstractVector{T}) where {T <: Thing} = T
我们可以看到类型参数 T 在方法体中被引用。让我们看看它是如何工作的:

在第一次调用中,因为数组中所有对象都具有 Spaceship 类型,所以返回 Spaceship 类型,同样,在第二次调用中返回 Asteroid。第三次调用返回 Thing,因为我们有一个混合的 Spaceship 和 Asteroid 对象。这些类型可以进一步检查如下:

总结来说,我们可以通过在函数定义中使用类型参数来构建更灵活的函数。从表达性的角度来看,每个类型参数可以覆盖整个数据类型类别。我们还可以在多个参数中使用相同的类型参数来强制类型一致性。最后,我们可以轻松地从方法签名中直接提取类型信息。
现在,让我们继续讨论本章的最后一个主题——接口。
与接口一起工作
在本节中,我们将探讨如何在 Julia 中设计和使用接口。与其他主流编程语言不同,Julia 没有正式定义接口的方法。这种非正式性可能会让一些人感到些许不安。尽管如此,接口确实存在,并且在许多 Julia 程序中被广泛使用。
设计和开发接口
接口是行为合同。一种行为由一组操作一个或多个特定对象的函数定义。在 Julia 中,合同完全是约定俗成的,并没有正式规定。为了说明这个概念,让我们创建一个包含将物体从银河系任何地方带走的逻辑的模块。
定义车辆接口
我们首先创建一个名为 Vehicle 的模块。这个模块的目的是实现我们的太空旅行逻辑。由于我们希望保持这个模块的通用性,我们将设计一个任何对象都可以实现的接口,以便参与我们的太空旅行程序。
模块的结构由以下嵌入的注释所示,分为四个部分:
module Vehicle
# 1\. Export/Imports
# 2\. Interface documentation
# 3\. Generic definitions for the interface
# 4\. Game logic
end # module
让我们看看模块中实际的代码是如何写的:
- 第一部分导出了一个名为
go!的单个函数:
# 1\. Export/Imports
export go!
- 第二段代码仅是文档说明:
# 2\. Interface documentation
# A vehicle (v) must implement the following functions:
#
# power_on!(v) - turn on the vehicle's engine
# power_off!(v) - turn off the vehicle's engine
# turn!(v, direction) - steer the vehicle to the specified direction
# move!(v, distance) - move the vehicle by the specified distance
# position(v) - returns the (x,y) position of the vehicle
- 第三段代码包含函数的通用定义:
# 3\. Generic definitions for the interface
function power_on! end
function power_off! end
function turn! end
function move! end
function position end
- 最后,最后一段代码包含太空旅行的逻辑:
# 4\. Game logic
# Returns a travel plan from current position to destination
function travel_path(position, destination)
return round(π/6, digits=2), 1000 # just a test
end
# Space travel logic
function go!(vehicle, destination)
power_on!(vehicle)
direction, distance = travel_path(position(vehicle), destination)
turn!(vehicle, direction)
move!(vehicle, distance)
power_off!(vehicle)
nothing
end
travel_path 函数计算从当前位置到最终目的地行驶的方向和距离。它预期返回一个元组。为了测试目的,我们只是返回硬编码的值。
go! 函数期望第一个参数传入的车辆对象是一种太空船。此外,逻辑还期望车辆表现出某些行为,例如能够启动引擎、朝正确方向转向、移动一定距离等。
如果客户端程序想要调用 go! 函数,它必须传递一个实现了该逻辑期望的接口的类型。但如何知道要实现哪些函数呢?嗯,它被定义为文档的一部分,如 接口文档 代码段中的注释所述:
# A vehicle must implement the following functions:
# power_on!(v) - turn on the vehicle's engine
# power_off!(v) - turn off the vehicle's engine
# turn!(v, direction) - steer the vehicle to the specified direction
# move!(v, distance) - move the vehicle by the specified distance
# position(v) - returns the (x,y) position of the vehicle
另一个线索是,所需的函数在之前的代码中被定义为空的通用函数——即没有签名或主体的函数:
function power_on! end
function power_off! end
function turn! end
function move! end
function position end
到目前为止,我们已经在代码中用注释的形式写下了接口的合同要求。通常,最好用 Julia 的文档字符串来做这件事,这样要求就可以生成并发布到在线网站或打印成硬拷贝。我们可以为接口中指定的每个函数做类似的事情:
"""
Power on the vehicle so it is ready to go.
"""
function power_on! end
Vehicle 模块现在已经完成,作为源代码的一部分,我们已经设定了一些期望。如果任何对象想要参与我们的太空旅行程序,它必须实现五个函数——power_on!、power_off!、turn!、move! 和 position。
接下来,我们将为太空旅行项目设计一条新的战斗机生产线!
实现FighterJet
现在我们已经了解了Vehicle接口的预期,我们可以开发一些真正实现该接口的东西。我们将创建一个新的FighterJets模块,并定义FighterJet数据类型如下:
"FighterJet is a very fast vehicle with powerful weapons."
mutable struct FighterJet
"power status: true = on, false = off"
power::Bool
"current direction in radians"
direction::Float64
"current position coordinate (x,y)"
position::Tuple{Float64, Float64}
end
为了符合之前定义的Vehicle接口,我们首先需要从Vehicle模块导入泛型函数,然后实现FighterJet车辆的逻辑。以下是power_on和power_off函数的代码:
# Import generic functions
import Vehicle: power_on!, power_off!, turn!, move!, position
# Implementation of Vehicle interface
function power_on!(fj::FighterJet)
fj.power = true
println("Powered on: ", fj)
nothing
end
function power_off!(fj::FighterJet)
fj.power = false
println("Powered off: ", fj)
nothing
end
当然,一个真正的战斗机可能比仅仅设置一个布尔字段为true或false要复杂得多。为了测试目的,我们还会在控制台打印一些信息,以便我们知道发生了什么。让我们也定义一个控制方向的函数:
function turn!(fj::FighterJet, direction)
fj.direction = direction
println("Changed direction to ", direction, ": ", fj)
nothing
end
再次强调,turn!函数的逻辑这里非常简单,就是改变方向字段并在控制台打印一些文本。而move!函数则稍微有趣一些:
function move!(fj::FighterJet, distance)
x, y = fj.position
dx = round(distance * cos(fj.direction), digits = 2)
dy = round(distance * sin(fj.direction), digits = 2)
fj.position = (x + dx, y + dy)
println("Moved (", dx, ",", dy, "): ", fj)
nothing
end
在这里,我们使用了三角函数sin和cos来计算战斗机将要飞往的新位置。最后,我们必须实现position函数,该函数返回战斗机的当前位置:
function position(fj::FighterJet)
fj.position
end
现在,FighterJet类型完全实现了接口,我们可以利用预期的游戏逻辑。让我们通过创建一个新的FighterJet对象并调用go!函数来试一试:

简而言之,实现一个接口是一个相当简单的任务。关键是理解实现接口所需的函数,并确保自定义数据类型可以支持这些函数。作为一名专业开发者,我们应该清楚地记录接口函数,以确保没有关于需要实现什么的混淆。
到目前为止,我们可以将我们刚刚设计的接口视为硬合同。它们之所以被称为硬合同,是因为我们接口中指定的所有函数都必须由任何参与我们太空旅行项目的对象实现。在下一节中,我们将讨论软合同,它们对应于可能可选的接口函数。
处理软合同
有时,当接口可以假定默认行为时,某些接口合同并不是绝对必要的。那些不是强制性的函数可能被称为软合同。
假设我们想要添加一个新的函数来降落车辆。大多数车辆都有轮子,但有些没有,尤其是高科技的!因此,作为降落程序的一部分,我们只有在必要时才需要启动轮子。
我们如何设计接口的软合约?在这种情况下,我们可以假设大多数未来的车辆都没有轮子,因此默认行为不需要驱动轮子。在这里,在Vehicle模块中,我们可以添加engage_wheel!函数以进行文档记录并提供默认实现,如下所示:
# 2\. Interface documentation
# A vehicle (v) must implement the following functions:
#
# power_on!(v) - turn on the vehicle's engine
# power_off!(v) - turn off the vehicle's engine
# turn!(v, direction) - steer the vehicle to the specified direction
# move!(v, distance) - move the vehicle by the specified distance
# position(v) - returns the (x,y) position of the vehicle
# engage_wheels!(v) - engage wheels for landing. Optional.
# 3\. Generic definitions for the interface
# hard contracts
# ...
# soft contracts
engage_wheels!(args...) = nothing
文档明确指出engage_wheels!函数是可选的。因此,我们不是提供一个空的通用函数,而是实现了一个实际的engage_wheel!函数,它什么也不做,只是返回一个nothing值。着陆逻辑随后编写如下:
# Landing
function land!(vehicle)
engage_wheels!(vehicle)
println("Landing vehicle: ", vehicle)
end
现在,如果调用者提供了一个实现了engage_wheels!函数的车辆类型,那么它将被使用;否则,调用engage_wheels!将调用通用函数并且不会做任何事情。
我将把这个练习留给读者来完成,方法是创建另一个实现engage_wheel!函数的车辆类型。(抱歉:你开发的车辆可能不是非常高科技,因为它有轮子。)
软合约是一种为可选接口函数提供默认实现的方法。接下来,我们将探讨一种稍微正式的方法来声明数据类型是否支持某些接口元素。我们将它们称为特性。
使用接口特性
有时,你可能会遇到需要确定数据类型是否实现接口的情况。关于数据类型是否表现出某些行为的信息也称为特性。
我们如何实现接口的特性?在Vehicle模块中,我们可以添加一个新函数,如下所示:
# trait
has_wheels(vehicle) = error("Not implemented.")
这个默认实现简单地引发一个错误,这是故意的。这个特性函数预期将由任何车辆数据类型实现。在接口代码中,着陆函数可以利用特性函数来实现更精细的逻辑:
# Landing (using trait)
function land2!(vehicle)
has_wheels(vehicle) && engage_wheels!(vehicle)
println("Landing vehicle: ", vehicle)
end
通常,特性函数只需要返回一个二元答案,true或false;然而,开发者如何设计特性完全取决于开发者。例如,定义特性函数使其返回起落架的类型——:wheels、:slider或:none是完全可以接受的。
定义特性尽可能简单是一个好主意。如您所回忆的,我们在上一节中为我们的战斗机实现的接口需要五个函数——power_on!、power_off!、move!、turn!和position。从设计角度来看,我们可以创建不同的特性:
-
has_power(): 如果车辆需要打开/关闭电源则返回true -
can_move(): 如果车辆能够移动则返回true -
can_turn(): 如果车辆能够向任何方向转向则返回true -
location_aware(): 如果车辆能够跟踪其位置则返回true
一旦我们有了这些小的构建块,我们就可以定义更复杂的特性,这些特性由这些简单的特性组成。例如,我们可以定义一个名为 smart_vehicle 的特性,它支持我们列出的所有四个特性。此外,我们还可以定义一个 solar_vehicle 特性,它用于依赖太阳能的车辆,并且始终处于开启状态。
使用特性是建模对象行为的一种非常强大的技术。有一些模式是围绕如何在实践中实现特性构建的。我们将在第五章 可重用性模式中更深入地讨论这些内容。
到目前为止,你应该对在 Julia 中设计接口感到更加舒适。它们相对简单易懂且易于开发。虽然 Julia 不提供任何正式的接口规范语法,但我们可以轻松地制定自己的约定。借助特性的帮助,我们甚至可以为我们的对象实现更动态的行为。
我们现在已经完成了本章的所有主题。
摘要
在本章中,我们通过讨论如何定义函数以及使用各种类型的函数参数(如位置参数、关键字参数和可变参数)开始了我们的旅程。我们讨论了如何使用 splatting 自动将数组或元组的元素分配给函数参数。我们通过将它们分配给变量并在函数调用中传递它们来探索了一等函数。我们学习了如何创建匿名函数,并使用 do 语法使代码更易读。
然后,我们讨论了 Julia 的分派机制,并介绍了多重分派的概念。我们意识到可能存在歧义,因此我们回顾了检测歧义的标准工具。我们已经了解到分派在本质上是动态的。我们研究了参数化方法以及它们在几个用例中的有用性,例如强制类型一致性和从类型参数中提取类型信息。
我们学习了如何设计接口。我们意识到 Julia 中没有正式的语言语法来定义接口,但我们认识到定义接口是直接且容易实现的。我们了解到有时可以接受软合同,这样开发者就不必实现所有接口函数。最后,我们通过特性及其在查询数据类型是否实现特定接口方面的有用性来结束讨论。
在下一章中,我们将讨论 Julia 语言中的两个主要特性——宏和元编程。宏在创建新的语法,使代码整洁且易于维护方面非常有用。深呼吸,继续前进吧!
问题
-
位置参数与关键字参数有何不同?
-
splatting 和 slurping 之间有什么区别?
-
使用 do 语法的目的何在?
-
有哪些工具可以用于检测与多重分派相关的方法歧义?
-
你如何确保在参数方法中传递给函数的是相同的具体类型?
-
如何在不使用任何正式语言语法的情况下实现接口?
-
你如何实现特性,特性有哪些用途?
第四章:宏和元编程技术
本章将讨论 Julia 编程语言中最强大的两个功能:宏和元编程。
简而言之,元编程是一种编写生成代码的代码的技术——这就是为什么它有前缀meta。这可能听起来很神秘,但它是今天许多编程语言中相当常见的实践。例如,C 编译器使用预处理器来读取源代码并生成新的源代码,然后新的源代码被编译成二进制可执行文件。例如,你可以定义一个MAX宏,如下所示#define MAX(a,b) ((a) > (b) ? (a) : (b)),这意味着每次我们使用MAX(a,b)时,它都会被替换为((a) > (b) ? (a) : (b))。请注意,MAX(a,b)比更长的形式更容易阅读。
元编程的历史相当悠久。早在 20 世纪 70 年代,它就已经在 LISP 编程语言社区中流行起来。有趣的是,LISP 语言的设计方式使得源代码的结构类似于数据——例如,LISP 中的函数调用看起来像(sumprod x y z),其中第一个元素是函数的名称,其余的是参数。由于它实际上只是一个包含四个符号的列表——sumprod、x、y和z——我们可以以任何方式操作这段代码——例如,我们可以扩展它以计算数字的和与积,因此生成的代码变为(list (+ x y z) (* x y z))。
你可能会想知道我们是否可以只为这个目的编写一个函数。答案是,是的:在我们刚刚查看的两个例子中,没有必要使用元编程技术。这些例子只是为了说明元编程是如何工作的。一般来说,我们可以这样说,99%的时间不需要元编程;然而,仍然有那剩下的 1%的情况,元编程会非常有用。第一部分将探讨我们想要使用元编程的场景。
在本章中,我们将学习 Julia 语言中的几个元编程功能。特别是以下内容将被涵盖:
-
理解元编程的需求
-
与表达式一起工作
-
开发宏
-
使用生成的函数
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
理解元编程的需求
在本章的开头,我们大胆地宣称 99%的时间不需要元编程。这确实不是一个虚构的数字。在 2019 年的 JuliaCon 会议中,麻省理工学院的史蒂文·约翰逊教授就元编程发表了主题演讲。他对 Julia 语言的源代码进行了一些研究。从他的研究中,Julia 版本 1.1.0 包含了 37,000 个方法,138 个宏(0.4%),以及 14 个生成函数(0.04%)。因此,元编程代码仅占 Julia 自身实现的不到 1%。虽然这只是元编程在一种语言中作用的例子,但它足以说明即使是最高明的软件工程师也不会经常使用元编程。
所以下一个问题是:何时需要使用元编程技术?一般来说,使用这些技术有几个原因:
-
它们可能允许以更简洁和更易于理解的方式表达解决方案。如果不使用元编程编写代码,代码看起来会很丑陋,难以理解。
-
它可能会减少开发时间,因为源代码可以生成而不是手动编写;尤其是样板代码可以被消除。
-
它可能会提高性能,因为代码是直接编写的而不是通过其他高级编程结构(如循环)执行。
我们现在将看看一些在现实世界中如何使用元编程的例子。
使用 @time 宏来衡量性能
Julia 内置了一个有用的宏 @time,它可以测量执行代码所需的时间。例如,为了测量计算一千万个随机数之和所需的时间,我们可以这样做:

宏通过在要测量的代码周围插入代码来工作。生成的代码可能看起来像以下这样:
begin
t1 = now()
result = sum(rand(10_000_000))
t2 = now()
elapsed = t2 - t1
println("It took ", elapsed)
result
end
新代码使用 now() 函数来获取当前时间。然后,它执行用户提供的代码并捕获结果。它再次获取当前时间,计算经过的时间,将计时信息打印到控制台,然后返回结果。
这是否可以不使用元编程来完成?也许我们可以尝试一下。让我们定义一个名为 timeit 的函数,如下所示:
function timeit(func)
t1 = now()
result = func()
t2 = now()
elapsed = t2 - t1
println("It took ", elapsed)
result
end
要使用这个计时功能,我们需要将表达式包裹在一个函数中。

这个函数工作得相当好,但问题是我们在测量其性能之前必须将代码包裹在一个单独的函数中,这是一件非常不方便的事情。正因为如此,我们可以得出结论,拥有一个 @time 宏更为合适。
展开循环
宏的另一个用途是将循环展开成重复的代码片段。循环展开是一种性能优化技术。其背后的前提是执行循环代码总是需要一些开销。原因是,每次迭代完成后,循环必须检查条件并决定是否应该退出或继续下一次迭代。现在,如果我们确切知道循环需要运行多少次代码,那么我们可以通过以重复的方式编写代码来展开它。
考虑一个简单的循环如下:
for i in 1:3
println("hello: ", i)
end
我们可以将循环展开成三行代码,它们执行完全相同的工作:
println("hello: ", 1)
println("hello: ", 2)
println("hello: ", 3)
但手动展开循环将是一项相当无聊和枯燥的任务。此外,工作量会随着循环中所需的迭代次数线性增长。借助Unroll.jl,我们可以使用@unroll宏定义一个函数,如下所示:
using Unrolled
@unroll function hello(xs)
@unroll for i in xs
println("hello: ", i)
end
end
代码看起来像应该的那样干净,@unroll宏被插入到函数以及for循环之前。首先,我们应该检查代码是否正常工作:

现在,我们应该质疑@unroll宏是否真的做了什么。检查循环是否展开的一个好方法是使用@code_lowered宏:

降低后的代码明显包含三个println语句,而不是一个单独的for循环。
什么是降低后的代码?Julia 编译器在将源代码编译成二进制文件之前必须经过一系列的过程。第一步是将代码解析成抽象语法树(AST)格式,我们将在下一节中学习。之后,它通过降低过程来展开宏并将代码转换为具体的执行步骤。
现在我们已经看到了一些示例并了解了元编程的力量,我们将继续学习如何自己创建这些宏。
处理表达式
Julia 将任何可运行的程序的源代码表示为树结构。这被称为抽象语法树(AST)。它被称为抽象的,因为树只捕获代码的结构而不是真正的语法。
例如,表达式x + y可以用一个树来表示,其中父节点标识自己为函数调用,子节点包括运算符函数+和x、y参数。以下是其实现:

略微复杂一些的表达式x + 2y + 1看起来如下所示。虽然它使用了两个加法运算符,但表达式被解析为对+函数的单个函数调用,它接受三个参数——x、2y和1。因为2y本身也是一个表达式,它可以看作是主抽象语法树的子树:

Julia 编译器必须首先将源代码解析成抽象语法树,然后才能执行额外的转换和分析,例如宏展开、类型检查、类型推断,最终将代码转换成机器码。
尝试解析器
因为抽象语法树只是一个数据结构,我们可以在 Julia 的 REPL 环境中直接检查它。让我们从一个简单的表达式开始:x + y:

在 Julia 中,每个表达式都表示为一个Expr对象。我们可以通过使用Meta.parse函数解析一个字符串来创建一个Expr对象。
这里,表达式对象以类似于原始源代码的语法显示,以便更容易阅读。我们可以确认该对象具有Expr类型如下:

为了查看抽象语法树,我们可以使用dump函数来打印结构:

在 Julia 中,每个表达式都由一个头节点和参数数组表示。
在这种情况下,头节点只包含一个call符号。args数组包含+运算符和两个变量,x和y。请注意,这里的一切都是一个符号——这是可以的,因为我们正在检查源代码本身,它本质上只是一个符号的树。
由于我们在这里玩得很开心,让我们尝试几个其他的表达式。
单变量表达式
其中一个最简单的表达式只是一个变量的引用。你可以尝试解析一个数字或字符串字面量,看看它返回什么:

带关键字参数的函数调用
让我们尝试一个稍微复杂一些的例子。我们将检查一个函数调用,它接受一个位置参数和两个关键字参数。在这里,我们使用三引号包围代码,以便正确处理其中的双引号:

注意,函数调用以call符号作为表达式的头节点。此外,关键字参数表示为子表达式,每个子表达式都有一个头节点kw和一个包含参数名称和值的两个元素数组。
嵌套函数
我们可能会好奇当函数嵌套时,Julia 是如何解析代码的。这里我们可以选择一个简单的例子,它计算x+1的正弦值,然后计算结果的余弦值。抽象语法树如下所示:

这里,我们可以清楚地看到树结构。最外层的函数cos包含一个参数,它是一个调用sin函数的表达式节点。这个表达式反过来包含一个参数,它是一个调用带有两个参数的+运算符函数的表达式节点——x变量和值为1。现在,让我们继续我们的表达式工作。
手动构建表达式对象
由于表达式只是一个数据结构,我们可以很容易地通过编程方式构建它们。理解如何做到这一点对于元编程至关重要,元编程涉及在运行时创建新的代码结构。
Expr构造函数有以下签名:
Expr(head::Symbol, args...)
头部节点总是携带一个符号。参数只包含头部节点期望的内容——例如,简单的表达式x + y可以创建如下:

当然,如果我们想的话,我们总是可以创建一个嵌套表达式:

到这个时候,你可能想知道是否有更简单的方法来创建表达式,而无需手动构建Expr对象。当然,可以像下面这样做到:

基本上,我们可以用左边的:(和右边的)将任何表达式包裹起来。代码块内的代码将不会被评估,而是被解析为一个表达式对象;然而,这种引用方式只适用于单个表达式——如果你尝试用多个表达式这样做,将会显示错误,如下面的代码所示:

这是不行的,因为多个表达式应该用begin和end关键字包裹。所以如果我们输入以下代码块会更好:

结果有点有趣。正如你所见,代码现在被包裹在一个quote/end块中,而不是begin/end块中。这实际上是有道理的,因为显示的是引用的表达式而不是原始源代码。记住,这是抽象语法树而不是原始代码。
结果表明quote/end可以直接用来创建表达式:

我们现在已经学会了如何将源代码解析为表达式对象。接下来,我们将探讨更复杂的表达式,以便我们更熟悉 Julia 程序的基本代码结构。
玩转更复杂的表达式
正如我们之前所说的,任何有效的 Julia 程序都可以表示为一个抽象语法树。现在我们已经有了创建表达式对象的构建块,让我们考察一些更多的结构,看看更复杂程序的表达式对象是什么样的。
赋值
我们首先看看赋值是如何工作的。考虑以下代码:

从前面的代码中,我们可以看到变量赋值有一个=的头部节点和两个参数——要赋值的变量(在这个例子中是x)和另一个表达式对象。
代码块
代码块由begin和end关键字包围。让我们看看抽象语法树是什么样子的。

头节点只包含一个 block 符号。当块中有多行时,抽象语法树也包括行号节点。在这个例子中,有一个 LineNumberNode 在 println 的第一次调用之前,行号为 2。同样,还有一个 LineNumberNode 在 println 的第二次调用之前,行号为 3。LineNumberNode 节点不做任何事情,但它们对于堆栈跟踪和调试很有用。
条件
接下来,我们将探索条件结构,如 if-else-end。参考以下代码:

头节点包含 if 符号。有三个参数——一个表示条件的表达式,一个当条件满足时的块表达式,以及一个当条件不满足时的另一个块表达式。
循环
我们现在将转向循环结构。考虑一个简单的 for 循环,如下所示:

头节点包含 for 符号。有两个参数:第一个包含关于循环的表达式,第二个包含一个块表达式。
函数定义
接下来,我们将看到函数定义的结构。考虑以下代码:

头节点包含 function 符号。然后,第一个参数包含一个带有参数的 call 表达式。第二个参数只是一个块表达式。
调用表达式可能看起来有点奇怪,因为我们之前在函数被调用时见过类似的表达式对象。这是正常的,因为我们目前处于语法层面。函数定义的语法确实与函数调用本身非常相似。
到现在为止,我们已经看到了足够的例子。显然,还有许多我们没有探索的代码结构。我们鼓励您使用相同的技巧来检查其他代码结构。理解抽象语法树的结构对于编写良好的元编程代码至关重要。接下来,我们将看到如何评估这些表达式。
评估表达式
我们已经详细地探讨了创建表达式对象的过程。但它们有什么用呢?记住,表达式对象只是 Julia 程序的抽象语法树表示。在这个阶段,我们可以要求编译器继续将表达式转换为可执行代码,然后运行程序。
表达式对象可以通过调用 eval 函数来评估。本质上,Julia 编译器将完成剩余的编译过程并运行程序。现在,让我们启动一个新的、全新的 REPL 并运行以下代码:

显然,这只是一个简单的赋值操作。我们可以看到,x 变量现在在当前环境中被定义了:

注意,表达式的评估实际上是在全局范围内进行的。我们可以通过在函数内部运行 eval 来证明这一点:

这不是一项无关紧要的观察!乍一看,我们可能预计 y 变量将在 foo 函数内部被分配;然而,变量分配实际上是在全局范围内发生的,因此 y 变量作为副作用在当前环境中被定义。
更确切地说,表达式是在当前模块中评估的。由于我们在 REPL 中进行测试,评估是在名为 Main 的当前模块中完成的。表达式被设计成这样,因为 eval 通常用于代码生成,这在定义模块内的变量或函数时可能很有用。
接下来,我们将学习如何更轻松地创建表达式对象。
表达式中的变量插值
从引号块中构造表达式非常简单。但如果我们想动态创建表达式怎么办?这可以通过 插值 实现,它允许我们使用简单的语法将变量值插入到表达式对象中。表达式中的插值与变量可以在字符串中插值的方式非常相似。下面的截图显示了示例:

如预期的那样,2 的值在表达式中被正确替换。请注意,splatting 也得到了支持,如下所示:

我们必须确保包含散列操作符的变量在这种情况下被插值。如果我们忘记在 v... 周围放置括号,那么我们会得到一个非常不同的结果:

在这里,散列操作实际上并没有在表达式插值过程中发生。相反,散列操作符现在成为表达式的一部分,因此散列操作将不会发生,直到表达式被评估。
在如 $v... 这样的表达式中,优先级顺序有些不清楚。v 变量是在散列操作之前还是之后绑定到插值操作的?在这种情况下,最好在我们想要插值的内容周围使用括号。因为我们希望插值完全发生,语法应该是 $(v...)。在需要运行时进行散列操作的情况下,我们可以写成 $(v)...。
插值是编写宏的重要概念。我们将在本章后面看到更多关于它的用法。接下来,我们将看到如何处理具有符号值的表达式。
使用 QuoteNode 为符号构造表达式
符号在表达式中出现时非常特殊。它们可能出现在表达式对象的头部节点中——例如,变量赋值表达式中的 = 符号。它们也可能出现在表达式对象的参数中,在这种情况下,它们将代表一个变量:

由于符号已经用来表示变量,我们如何将一个实际的符号赋给一个变量呢?为了弄清楚这是如何工作的,我们可以使用我们迄今为止学到的一个技巧——使用 dump 函数来检查表达式对象中的此类语句:

正如我们所见,一个实际的符号必须被包含在 QuoteNode 对象中。现在我们知道了需要什么,我们应该尝试将一个实际的符号插值到表达式对象中。实现这一目标的方法是手动创建一个 QuoteNode 对象,并像往常一样使用插值技术:

一个常见的错误是忘记创建 QuoteNode。在这种情况下,表达式对象将错误地解释符号,并将其视为变量引用。显然,结果非常不同,并且它将无法正常工作:

不使用 QuoteNode 会生成将一个变量的值赋给另一个变量的代码。在这种情况下,变量 x 将被赋予来自变量 hello 的一个值。
理解 QuoteNode 的工作原理对于动态创建表达式至关重要。程序员将符号插值到现有表达式中是很常见的。因此,接下来我们将探讨如何处理嵌套表达式。
在嵌套表达式中进行插值
有可能存在一个包含另一个引用表达式的引用表达式。除非程序员需要编写元元程序,否则这不是一个常见的做法。尽管如此,我们仍然应该了解如何在这样的情况下进行插值。
首先,让我们回顾一下单层表达式的样子:

我们可以通过将引用表达式包裹在另一个引用块中来查看嵌套表达式的结构:

现在,让我们尝试在这样的表达式中进行插值:

正如我们所见,2 值并没有进入表达式。表达式的结构也完全不同于我们预期的。解决方案是只需通过使用两个 $ 符号将变量插值两次:

通常,插值超过一层深度可能不是很有趣,因为逻辑变得难以处理。然而,如果你需要为宏生成代码,这可能是有用的。我绝对不建议你超过两层深度并编写元元元程序!
到现在为止,你应该对表达式更加熟悉,并且能够舒适地与之工作。从 Julia 的 REPL 中,很容易看到表达式是如何作为Expr对象来表示的结构的。你应该能够构造新的表达式并在其中插值值;这些是进行元编程所必需的基本技能。
在下一节中,我们将探讨 Julia 中一个强大的元编程特性——宏。
开发宏
现在我们已经理解了源代码是如何表示为抽象语法树的,我们可以通过编写宏来开始做一些更有趣的事情。在本节中,我们将学习宏是什么以及如何与之工作。
宏是什么?
宏是接受表达式、操作它们并返回新表达式的函数。这最好通过一个图表来理解:

如我们所知,表达式只是源代码的抽象语法树表示。因此,Julia 中的宏功能允许你取任何源代码并生成新的源代码。然后,生成的表达式就像源代码直接在原地编写一样被执行。
在这一点上,你可能想知道为什么我们不能使用常规函数来实现相同的事情。为什么我们不能编写一个接受表达式、生成新表达式然后执行结果的函数?
有两个主要原因:
-
宏扩展发生在编译期间。这意味着宏只从它被使用的地方执行一次——例如,当宏从一个函数中调用时,宏是在函数定义时执行的,以便函数可以被编译。
-
宏生成的表达式可以在当前作用域内执行。在运行时,由于函数本身已经编译,没有其他方法可以在函数内部执行任何动态代码。所以,评估任何表达式的唯一方法是在全局作用域内进行。
到本章结束时,你应该对宏的工作原理以及它们与函数的不同之处有更好的理解。
既然我们现在已经理解了宏是什么,我们将继续我们的旅程,编写我们的第一个宏。
编写我们的第一个宏
宏的定义方式与函数的定义方式类似,只是使用macro关键字而不是function关键字。
我们还应该记住,一个宏必须返回表达式。让我们创建我们的第一个宏。这个宏返回一个包含for循环的表达式对象,如下所示:
macro hello()
return :(
for i in 1:3
println("hello world")
end
)
end
调用宏就像用@前缀调用它一样简单。请参考以下代码:

与函数不同,宏可以在不使用括号的情况下调用。所以我们可以这样做:

太棒了! 我们现在已经编写了我们的第一个宏。虽然它看起来并不非常令人兴奋,因为生成的代码只是一段静态的代码,但我们已经学会了如何定义宏并运行它们。
接下来,我们将学习如何向宏传递参数。
传递字面量参数
就像函数一样,宏也可以接受参数。实际上,接受参数是宏最常见的用法。最简单的参数类型是字面量,例如数字、符号和字符串。
为了在返回的表达式中利用这些参数,我们可以使用我们在上一节中学到的插值技术。考虑以下代码:
macro hello(n)
return :(
for i in 1:$n
println("hello world")
end
)
end
hello宏接受一个参数,n,当宏运行时,这个参数会被插入到表达式中。像之前一样,我们可以这样调用宏:

如我们之前所学的,括号不是必需的,因此我们也可以这样调用宏:

你可以用字符串或符号参数尝试类似的练习。传递字面量很容易理解,因为它与函数的工作方式相同。但宏和函数之间确实存在细微的差别,我们将在下一节中详细讨论。
传递表达式参数
重要的是要强调,宏参数是以表达式而不是值的形式传递的。对于初学者来说,这可能会看起来有些混乱,因为宏的调用方式与函数相似,但行为完全不同。
让我们确保我们完全理解这意味着什么。当用一个变量调用一个函数时,变量的值会被传递到函数中。考虑以下showme函数的示例代码:

现在,让我们创建一个@showme宏,它除了在控制台显示参数外,不做任何事情。然后我们可以将结果与前面的代码进行比较:

如我们所见,运行宏的结果与调用函数得到的结果完全不同。函数参数x实际上只看到了宏被调用处的表达式。从本节开头的图中,我们可以看到宏应该接受表达式并返回一个单一的表达式作为结果。它们在语法层面上工作,不知道参数的值。
如我们在下一节中将要看到的,当宏运行时,表达式甚至可以被操作。让我们开始吧!
理解宏展开过程
按照惯例,每个宏都必须返回一个表达式。从一个或多个表达式取值并返回一个新的表达式的过程被称为宏展开。有时,看到返回的表达式而不实际运行代码是有帮助的。我们可以使用@macroexpand宏来达到这个目的。让我们尝试使用它来处理我们在这节中之前定义的@hello宏:

从这个输出中,有几个需要注意的事项:
-
i变量被非常奇怪地重命名为#67#i。这是 Julia 编译器为了确保卫生性而做的,我们将在本章后面讨论。宏的卫生性是一个需要记住的重要特性,以确保生成的代码不会与其他代码冲突。 -
在包含源文件和行号信息的循环中插入了一条注释。当使用调试器时,这是表达式的一个有用部分。
-
对
println的函数调用绑定到当前环境中的Main。这很有意义,因为println是Core包的一部分,并且对于每个 Julia 程序都会自动引入作用域。
因此,宏展开何时发生?让我们接下来讨论这个问题。
宏展开的时机
在 REPL 中,任何宏在我们调用它时都会立即展开。有趣的是,当定义包含宏的函数时,宏作为函数定义过程的一部分被展开。
我们可以通过开发一个简单的返回传递给它的任何表达式的@identity宏来看到这一点。在表达式返回之前,我们只是将对象dump到屏幕上。@identity宏的代码如下:
macro identity(ex)
dump(ex)
return ex
end
由于这个宏返回了传递给它的相同的表达式,它最终应该执行宏后面的原始源代码。
现在,让我们定义一个使用@identity宏的函数:

显然,编译器已经发现宏被用于foo函数的定义中,为了编译foo函数,它必须理解@identity宏的作用。因此,它展开了宏,并将其嵌入到函数定义中。在宏展开过程中,表达式被显示出来。
如果我们对foo函数使用@code_lowered宏,我们可以看到展开的代码现在位于foo函数的主体中:

在开发过程中,程序员可能会频繁地更改函数、宏等的定义。因为宏在定义函数时会被展开,所以如果使用的任何宏被更改,重新定义函数就很重要;否则,函数可能会继续使用先前宏定义生成的代码。
@macroexpand实用工具是开发宏不可或缺的工具,特别是对于调试目的非常有用。
接下来,我们将尝试通过在宏中操作表达式来更加有创意。
操作表达式
宏之所以强大,是因为它们允许在宏展开过程中操作表达式。这是一种非常有用的技术,尤其是在代码生成和设计领域特定语言时。让我们通过一些示例来了解可能实现的内容。
示例 1 – 创建新的表达式
让我们从简单的开始。假设我们想要创建一个名为 @squared 的宏,它接受一个表达式并将其平方。换句话说,如果我们运行 @squared(x),那么它应该被翻译成 x * x:
macro squared(ex)
return :($(ex) * $(ex))
end
初看起来,当我们从 REPL 运行它时,它似乎工作得很好:

但这个宏在执行上下文方面存在问题。最好的说明问题的方式是定义一个使用该宏的函数。所以让我们定义一个 foo 函数,如下所示:
function foo()
x = 2
return @squared x
end
现在,当我们调用该函数时,我们得到以下错误:

为什么会这样?这是因为,在宏展开期间,x 符号指的是模块中的变量,而不是 foo 函数中的局部变量。我们可以通过使用 @code_lowered 宏来确认这一点:

显然,我们的意图是平方局部 x 变量,而不是 Main.x。解决这个问题的一个简单方法是,在插值时使用 esc 函数,以便将表达式直接放入语法树中,而不让编译器解析它。以下是如何做到这一点的方法:
macro squared(ex)
return :($(esc(ex)) * $(esc(ex)))
end
由于宏是在 foo 定义之前展开的,我们需要再次定义 foo 函数,如下所示,以便这个更新的宏生效。或者,您也可以启动一个新的 REPL,并再次定义 @squared 宏和 foo 函数。下面是操作步骤:

现在 foo 函数工作正常了。
从这个例子中,我们学习了如何使用插值技术创建新的表达式。我们还了解到,插值变量需要使用 esc 函数进行转义,以避免编译器将其解析为全局作用域。
示例 2 - 调整抽象语法树
假设我们想要设计一个名为 @compose_twice 的宏,它接受一个简单的函数调用表达式,并再次以结果调用相同的函数——例如,如果我们运行 @compose_twice sin(x),那么它应该被翻译成 sin(sin(x))。
在我们编写宏之前,让我们首先熟悉一下表达式的抽象语法树:

sin(sin(x)) 的样子如何?请参考以下内容:

没有惊喜。顶层调用的第二个参数只是另一个看起来与我们之前看到的样子一样的表达式。
我们可以按照以下方式编写宏:
macro compose_twice(ex)
@assert ex.head == :call
@assert length(ex.args) == 2
me = copy(ex)
ex.args[2] = me
return ex
end
前两个 @assert 语句用于确保表达式代表一个接受单个参数的函数调用。由于我们想用类似的表达式替换参数,我们只需复制当前表达式对象并将其分配给 ex.args[2]。然后宏返回用于评估的结果表达式。
我们可以验证宏是否工作正常:

如你所见,我们可以通过直接操作抽象语法树来转换源代码,而不是将变量插值到看起来很棒的表达式中。
到现在为止,你可能已经体会到了元编程的强大之处。与使用插值相比,直接操作表达式并不容易理解,因为生成的表达式并没有在代码中表示;然而,操作表达式的能力为转换源代码提供了最大的灵活性。
接下来,我们将介绍元编程的一个重要特性——宏的卫生性。
理解宏的卫生性
宏的卫生性指的是保持宏生成代码清洁的能力。它被称为卫生性,因为生成的代码不会受到其他代码部分的影响。
注意到许多其他编程语言并不提供这样的保证。以下是一个包含名为SWAP的宏的 C 程序,该宏用于交换两个变量的值:
#include <stdio.h>
#define SWAP(a,b) temp=a; a=b; b=temp;
int main(int argc, char *argv[])
{
int a = 1;
int temp = 2;
SWAP(a,temp);
printf("a=%d, temp=%d\n", a, temp);
}
然而,运行这个 C 程序会产生一个错误的结果:

它没有正确地交换a和temp变量,因为temp变量也在宏的主体中用作临时变量。
让我们回到 Julia。考虑以下宏,它只是运行一个ex表达式,并重复n次:
macro ntimes(n, ex)
quote
times = $(esc(n))
for i in 1:times
$(esc(ex))
end
end
end
由于times变量用于返回的表达式中,如果调用点已经使用了相同的变量名,会发生什么?让我们尝试以下示例代码,它在宏调用之前定义了一个times变量,并在宏调用之后打印相同变量的值:
function foo()
times = 0
@ntimes 3 println("hello world")
println("times = ", times)
end
如果宏展开器将其字面地处理,那么在宏调用之后times变量将被修改为3;然而,我们可以在以下代码中看到它正常工作:

它之所以能工作,是因为宏系统能够通过将times变量重命名为不同的名称来保持卫生性,从而避免冲突。魔法在哪里?让我们通过使用@macroexpand查看展开的代码:

在这里,我们可以看到times变量已经变成了#44#times。循环变量i也变成了#45#i。这些变量名是由编译器动态生成的,以确保宏生成的代码不会与其他用户编写的代码冲突。
宏的卫生性是宏正确运行的一个基本特性。程序员不需要做任何事情:Julia 自动提供保证。
接下来,我们将探讨一种不同类型的宏,它为非标准字符串字面量提供动力。
开发非标准字符串字面量
有一种特殊的宏用于定义非标准字符串字面量,它们看起来像字面量字符串,但在引用时实际上会调用一个宏。
一个好例子是 Julia 的正则表达式字面量——例如,r"^hello"。由于双引号前的 r 前缀,它不是一个标准的字符串字面量。让我们首先检查这种字面量的数据类型。我们可以看到,从字符串创建了一个 Regex 对象:

我们还可以创建自己的非标准字符串字面量。让我们在这里一起尝试一个有趣的例子。
假设,为了开发目的,我们想要方便地创建具有不同类型列的样本数据帧。这样做的方法有点繁琐:

想象一下,我们偶尔需要创建具有不同数据类型的数十列。创建此类数据帧的代码会非常长,作为一个程序员,我会在输入所有这些时感到极其无聊。因此,我们可以设计一个字符串字面量,使其包含构建此类数据帧的规范——让我们称它为 ndf(数值数据帧)字面量。
ndf 的规范只需编码所需的行数和列类型。例如,字面量 ndf"100000:f64,i16" 可以用来表示前面的样本数据帧,其中需要 100,000 行,有两列分别标记为 Float64 和 Int16 列。
要实现这个功能,我们只需定义一个名为 @ndf_str 的宏。该宏接受一个字符串字面量并相应地创建所需的数据帧。以下是一种实现宏的方法:
macro ndf_str(s)
nstr, spec = split(s, ":")
n = parse(Int, nstr) # number of rows
types = split(spec, ",") # column type specifications
num_columns = length(types)
mappings = Dict(
"f64"=>Float64, "f32"=>Float32,
"i64"=>Int64, "i32"=>Int32, "i16"=>Int16, "i8"=>Int8)
column_types = [mappings[t] for t in types]
column_names = [Symbol("x$i") for i in 1:num_columns]
DataFrame([column_names[i] => rand(column_types[i], n)
for i in 1:num_columns]...)
end
前几行解析字符串并确定行数(n),以及列的类型(types)。然后,创建一个名为 mappings 的字典来将缩写映射到相应的数值类型。列名和类型从类型和映射数据生成。最后,它调用 DataFrame 构造函数并返回结果。
现在我们已经定义了宏,我们可以轻松地创建新的数据帧,如下所示:

非标准字符串字面量在特定情况下非常有用。我们可以将字符串规范视为编码在字符串中的迷你领域特定语言。只要字符串规范定义良好,它可以使代码更短、更简洁。
你可能已经注意到,ndf_str 宏返回一个常规的 DataFrame 对象,而不是通常宏会返回的表达式对象。这是完全可以的,因为最终的 DataFrame 对象将按原样返回。你可能认为常量的评估只是常量本身。我们在这里可以只返回一个值而不是表达式,因为返回的值不涉及调用点或模块中的任何变量。
一个好奇的头脑可能会问——为什么我们不能只为这个例子创建一个常规函数呢?我们当然可以为此虚拟示例做这件事。然而,使用字符串字面量在某些情况下可能会提高性能。
例如,当我们在一个函数中使用正则表达式字符串字面量时,Regex对象是在编译时创建的,因此它只执行一次。如果我们使用Regex构造函数,那么对象会在每次函数调用时创建。
我们现在已经结束了关于宏的主题。我们学习了如何通过取表达式并生成一个新的表达式来创建宏。我们使用了@macroexpand宏来调试宏展开过程。我们还学习了如何处理宏的卫生问题。最后,我们查看了一下非标准字符串字面量,并使用宏创建了我们的自定义字符串字面量。
接下来,我们将探讨另一个元编程功能,称为生成函数,它可以用来解决常规宏无法处理的不同类型的问题。
使用生成函数
到目前为止,我们已经解释了如何创建返回表达式对象的宏。由于宏在语法级别工作,它们只能通过检查代码的外观来操作代码。然而,Julia 是一个在运行时确定数据类型的动态系统。因此,Julia 提供了创建生成函数的能力,这允许你检查函数调用的数据类型并返回一个表达式,就像宏一样。当表达式返回时,它将在调用位置被评估。
要理解为什么需要生成函数,让我们回顾一下宏是如何工作的。假设我们创建了一个宏,它将它的参数值加倍。它看起来会像以下这样:
macro doubled(ex)
return :( 2 * $(esc(ex)))
end
无论我们传递给这个宏的什么表达式,它都会盲目地重写代码,使其加倍原始表达式。假设有一天,开发了一个超级无敌的软件,可以让我们快速计算浮点数的两倍。在这种情况下,我们可能希望系统只为浮点数切换到该函数,而不是使用标准的乘法运算符。
因此,我们的第一次尝试可能是尝试以下内容:
# This code does not work. Don't try it.
macro doubled(ex)
if typeof(ex) isa AbstractFloat
return :( double_super_duper($(esc(ex))) )
else
return :( 2 * $(esc(ex)))
end
end
但不幸的是,宏无法做到这一点。为什么?再次,宏只能访问抽象语法树。这是编译管道的早期部分,没有类型信息可用。前面代码中的ex变量仅仅是一个表达式对象。这个问题可以通过生成函数来解决。继续阅读!
定义生成函数
生成函数是在函数定义前带有@generated前缀的函数。这些函数可以返回表达式对象,就像宏一样。例如,我们可以定义doubled函数如下:
@generated function doubled(x)
return :( 2 * x )
end
让我们快速运行一个测试,确保它工作:

代码运行得非常完美,正如预期的那样。
因此,定义生成函数与定义宏非常相似。在两种情况下,我们都可以创建一个表达式对象并返回它,并且我们可以期望表达式被正确评估。
然而,我们还没有充分发挥生成函数的全部威力。接下来,我们将探讨如何使数据类型信息可用,以及如何在生成函数中使用它。
检查生成函数参数
需要记住的一个重要观点是,生成函数的参数包含数据类型,而不是实际值。以下是如何生成函数工作的视觉表示:

这与接受作为值的参数的函数形成鲜明对比。它也与接受作为表达式的参数的宏不同。在这里,生成函数接受参数作为数据类型。这可能会显得有些奇怪,但让我们做一个简单的实验来确认这确实如此。
对于这个实验,我们将再次通过在返回表达式之前在屏幕上显示参数来定义doubled函数。
@generated function doubled(x)
@show x
return :( 2 * x )
end
让我们再次测试这个函数。

如所示,在生成函数执行过程中,参数x的值是Int64而不是2。此外,当函数再次被调用时,它不再显示x的值。这是因为函数现在在第一次调用后被编译。
现在,让我们看看如果我们用不同的类型再次运行会发生什么:

编译器再次启动,并基于Float64的类型编译了一个新版本。从技术上讲,我们现在为每种参数类型都有两个版本的doubled函数。
您可能已经意识到,生成函数在专业化方面与常规函数的行为相似。区别在于我们有在编译发生之前操作抽象语法树的机会。
使用这个新的生成函数,我们现在可以利用假设的超级软件,通过切换到更快的double_super_duper函数来利用它,只要参数的数据类型是AbstractFloat的子类型,如下面的代码所示:
@generated function doubled(x)
if x <: AbstractFloat
return :( double_super_duper(x) )
else
return :( 2 * x )
end
end
使用生成函数,我们可以根据参数的类型来专门化函数。当类型是AbstractFloat时,函数将回退到double_super_duper(x)而不是2 *x表达式。
如官方 Julia 语言参考手册中所述,在开发生成函数时必须谨慎。确切的限制超出了本书的范围。如果您需要为您的软件编写生成函数,强烈建议您查阅手册。
生成函数是处理宏无法处理的案例的有用工具。具体来说,在宏展开过程中,没有关于参数类型的信息。生成函数使我们能够更接近编译过程的核心。有了关于参数类型的额外知识,我们在处理不同情况时更加灵活。
作为元编程工具,宏的使用比生成函数广泛得多。然而,了解这两种工具都可用是很好的。
摘要
在本章中,我们学习了 Julia 如何将表达式解析为抽象语法树结构。我们了解到表达式可以通过编程方式创建和评估。我们还学习了如何将变量插入到引号表达式。
然后,我们转向了宏的主题,宏用于动态创建新代码。我们了解到宏参数是表达式而不是值,并学习了如何从宏中创建新表达式。我们享受着创建宏来操作抽象语法树以处理一些有趣的用例。
最后,我们探讨了生成函数,这些函数可以根据函数参数的类型生成代码。我们学习了生成函数在假设用例中的有用之处。
现在我们已经完成了关于 Julia 编程语言入门部分的书籍。在下一章,我们将开始探讨与代码重用相关的设计模式。
问题
-
我们可以使用哪两种方式来引用表达式,以便稍后可以操作代码?
-
在什么环境下
eval函数执行代码? -
你如何将物理符号插入到引号表达式中,以便它们不会被误解释为源代码?
-
定义非标准字符串字面量的宏的命名约定是什么?
-
你在什么时候使用
esc函数? -
生成函数与宏有什么不同?
-
你如何调试宏?
第三部分:实现设计模式
本节的目标是向您提供一个现代 Julia 特定设计模式以及更传统的面向对象模式的清单。您将学习如何将这些模式应用于各种问题。
本节包含以下章节:
-
第五章,可复用模式
-
第六章,性能模式
-
第七章,可维护性模式
-
第八章,健壮性模式
-
第九章,杂项模式
-
第十章,反模式
-
第十一章,传统面向对象模式
第五章:可复用性模式
在本章中,我们将学习与软件可复用性相关的几种模式。如您从第一章,“设计模式及相关原则”中回忆起,可复用性是构建大型应用程序所需的四个软件质量目标之一。没有人愿意重新发明轮子。能够复用现有的软件组件可以节省时间和精力——这是一项整体的人类进步!本章中的模式是经过验证的技术,可以帮助我们改进应用程序设计,复用现有代码,并减少整体代码量。
在本章中,我们将涵盖以下主题:
-
委派模式
-
神圣特质模式
-
参数化类型模式
技术要求
本章中的代码已在 Julia 1.3.0 环境中进行了测试。
委派模式
委派是一种在软件工程中常用的模式。其主要目标是利用现有组件的能力,通过“包含”关系对其进行包装。
委派模式在面向对象编程社区中得到广泛应用。在面向对象编程的早期,人们认为可以通过继承来实现代码复用。然而,人们逐渐意识到,由于继承存在各种问题,这一承诺无法完全实现。从那时起,许多软件工程师更倾向于使用组合而不是继承。组合的概念是将一个对象包装在另一个对象中。为了复用现有函数,我们必须将函数调用委托给包装对象。本节将解释如何在 Julia 中实现委派。
组合的概念是将一个对象包装在另一个对象中。为了复用现有函数,我们必须将函数调用委托给包装对象。
一种方法是通过添加新功能来增强现有组件。这听起来不错,但在实践中可能会具有挑战性。 考虑以下情况:
-
现有的组件来自供应商产品,源代码不可用。即使代码可用,供应商的许可证可能不允许我们进行自定义更改。
-
现有的组件是由另一个团队为关键任务系统开发和使用的,对该系统来说,更改既不受欢迎也不适用。
-
现有的组件包含大量遗留代码,新的更改可能会影响组件的稳定性,并需要大量的测试工作。
如果修改现有组件的源代码不是一个选择,那么我们至少应该能够通过其发布的编程接口使用该组件。这就是委托模式的价值所在。
将委托模式应用于银行用例
委托模式的想法是通过包装一个称为 父对象 的现有对象来创建一个新的对象。为了重用对象的功能,为新对象定义的函数可以被委托(也称为转发)到父对象。
假设我们有一个提供一些基本账户管理功能的银行库。为了理解它是如何工作的,让我们看看源代码。
银行账户已经设计了一个以下可变数据结构:
mutable struct Account
account_number::String
balance::Float64
date_opened::Date
end
作为编程接口的一部分,库还提供了字段访问器(见第八章,鲁棒性模式)以及进行存款、取款和转账的函数,如下所示:
# Accessors
account_number(a::Account) = a.account_number
balance(a::Account) = a.balance
date_opened(a::Account) = a.date_opened
# Functions
function deposit!(a::Account, amount::Real)
a.balance += amount
return a.balance
end
function withdraw!(a::Account, amount::Real)
a.balance -= amount
return a.balance
end
function transfer!(from::Account, to::Account, amount::Real)
withdraw!(from, amount)
deposit!(to, amount)
return amount
end
当然,在实际应用中,这样的银行库要比这里看到的复杂得多。我怀疑当钱进出银行账户时,会有许多下游影响,例如记录审计跟踪、在网站上提供新的余额、向客户发送电子邮件等等。
让我们继续学习如何利用委托模式。
组合一个包含现有类型的新类型
作为一项新举措的一部分,银行希望我们支持一种新的储蓄账户产品,该产品为顾客提供每日利息。由于现有的账户管理功能对银行业务至关重要,并且由不同的团队维护,我们决定在不修改任何现有源代码的情况下重用其功能。
首先,让我们创建自己的 SavingsAccount 数据类型,如下所示:
struct SavingsAccount
acct::Account
interest_rate::Float64
SavingsAccount(account_number, balance, date_opened, interest_rate) = new(
Account(account_number, balance, date_opened),
interest_rate
)
end
第一个字段 acct 用于存储一个 Account 对象,而第二个字段 interest_rate 包含账户的年利率。还定义了一个构造函数来实例化对象。
为了使用底层的 Account 对象,我们可以使用一种称为 委托 或 方法转发 的技术。这就是我们在 SavingsAccount 中实现相同的 API,并在想要重用底层对象中现有函数时,将调用转发到底层 Account 对象。在这种情况下,我们可以简单地转发 Account 对象的所有字段访问器函数和修改函数,如下所示:
# Forward assessors
account_number(sa::SavingsAccount) = account_number(sa.acct)
balance(sa::SavingsAccount) = balance(sa.acct)
date_opened(sa::SavingsAccount) = date_opened(sa.acct)
# Forward methods
deposit!(sa::SavingsAccount, amount::Real) = deposit!(sa.acct, amount)
withdraw!(sa::SavingsAccount, amount::Real) = withdraw!(sa.acct, amount)
transfer!(sa1::SavingsAccount, sa2::SavingsAccount, amount::Real) = transfer!(
sa1.acct, sa2.acct, amount)
到目前为止,我们已经成功地重用了 Account 数据类型,但不要忘记我们最初实际上是想构建新的功能。储蓄账户应该每天基于每日利息进行过夜计息。因此,对于 SavingsAccount 对象,我们可以实现一个新的 interest_rate 字段访问器和一个名为 accrue_daily_interest! 的新修改函数:
# new accessor
interest_rate(sa::SavingsAccount) = sa.interest_rate
# new behavior
function accrue_daily_interest!(sa::SavingsAccount)
interest = balance(sa.acct) * interest_rate(sa) / 365
deposit!(sa.acct, interest)
end
在这个时候,我们已经创建了一个新的SavingsAccount对象,它的工作方式与原始的Account对象完全一样,除了它还具有累积利息的额外功能!
然而,这些转发方法的数量之多让我们感到有些不满意。如果我们可以不用手动编写所有这些代码,那就太好了。也许有更好的方法...
减少转发方法中的样板代码
你可能会想知道为什么写这么多代码仅仅是为了将方法调用转发到父对象。确实,转发方法除了将完全相同的参数传递给父对象外,没有其他作用。如果程序员按代码行数付费,那么这将会是一个非常昂贵的提议,不是吗?
幸运的是,这种样板代码可以通过宏大大减少。有几个开源解决方案可以帮助这种情况。为了演示目的,我们可以利用来自Lazy.jl包的@forward宏。让我们按照以下方式替换所有的转发方法:
using Lazy: @forward
# Forward assessors and functions
@forward SavingsAccount.acct account_number, balance, date_opened
@forward SavingsAccount.acct deposit!, withdraw!
transfer!(from::SavingsAccount, to::SavingsAccount, amount::Real) = transfer!(
from.acct, to.acct, amount)
@forward的使用相当直接。它接受两个表达式作为参数。第一个参数是你想要转发的SavingsAccount.acct对象,而第二个参数是你希望转发的函数名称的元组,例如account_number、balance和date_opened。
注意,我们能够转发像deposit!和withdraw!这样的可变函数,但对于transfer!则不能这样做。这是因为transfer!需要我们转发它的第一个和第二个参数。在这种情况下,我们只是保留了手动转发的方法。然而,我们仅用两行代码就成功转发了六个函数中的五个。这仍然是一个相当不错的交易!
实际上,我们可以创建更多接受两个或三个参数的转发宏。事实上,还有其他开源包支持这种场景,例如TypedDelegation.jl包。
那么,@forward宏是如何工作的呢?我们可以使用@macroexpand宏来检查代码是如何被展开的。以下是从行号节点中移除的结果。基本上,对于每个被转发的函数(balance和deposit!),它都会创建一个带有所有参数使用args...表示法展开的相应函数定义。它还加入了一个@inline节点,为编译器提供性能优化的提示:

内联是编译器优化,其中函数调用被内联,就像代码被插入到当前代码中一样。它可能通过减少函数重复调用时分配调用栈的开销来提高性能。
@forward宏仅用几行代码就实现了。如果你对元编程感兴趣,鼓励你查看源代码。
你可能想知道为什么有几个有趣的变量名,如#41#x或#42#args。我们可以将它们视为普通变量。它们是由编译器自动生成的,并且选择了特殊的命名约定,以避免与当前作用域中的其他变量冲突。
最后,重要的是要理解我们可能并不总是希望将所有的函数调用转发到对象。如果我们不想使用底层功能的 100%呢?信不信由你,确实存在这样的情况。例如,让我们想象我们必须支持另一种类型的账户,比如定期存款单,也称为 CD。CD 是一种短期投资产品,其利率高于储蓄账户,但在投资期间资金不能提取。一般来说,CD 的期限可以是 3 个月、6 个月或更长。回到我们的代码,如果我们创建一个新的CertificateOfDepositAccount对象并再次重用Account对象,我们就不想转发withdraw!和transfer!方法,因为它们不是 CD 的功能。
你可能想知道委托在面向对象编程语言中与类继承有何不同。例如,在 Java 语言中,父类中的所有公共和受保护方法都会自动继承。这相当于自动转发父类中的所有方法。
无法选择要继承的内容实际上是为什么委托比继承更受欢迎的原因之一。对于更深入的讨论,请参阅第十二章,继承和变异性。
检查一些真实世界的例子
委托模式在开源包中得到了广泛的应用。例如,JuliaArrays GitHub 组织中的许多包实现了AbstractArray接口。特殊的数组类型通常包含一个常规的AbstractArray对象。
示例 1 – OffsetArrays.jl 包
OffsetArrays.jl包允许我们定义具有任意索引的数组,而不是标准的线性或笛卡尔风格索引。一个有趣的例子是使用基于零的数组,就像你可能在其他编程语言中找到的那样:

要理解这是如何工作的,我们需要深入研究源代码。让我们保持简洁,只审查代码的一部分:
struct OffsetArray{T,N,AA<:AbstractArray} <: AbstractArray{T,N}
parent::AA
offsets::NTuple{N,Int}
end
Base.parent(A::OffsetArray) = A.parent
Base.size(A::OffsetArray) = size(parent(A))
Base.size(A::OffsetArray, d) = size(parent(A), d)
Base.eachindex(::IndexCartesian, A::OffsetArray) = CartesianIndices(axes(A))
Base.eachindex(::IndexLinear, A::OffsetVector) = axes(A, 1)
OffsetArray数据类型由parent和offsets字段组成。为了满足AbstractArray接口,它实现了某些基本功能,例如Base.size、Base.eachindex等。由于这些函数足够简单,代码只是手动将调用转发到父对象。
示例 2 – ScikitLearn.jl 包
让我们也看看ScikitLearn.jl包,它定义了一个一致的 API 来拟合机器学习模型和进行预测。
以下是如何定义FitBit类型的:
""" `FitBit(model)` will behave just like `model`, but also supports
`isfit(fb)`, which returns true IFF `fit!(model, ...)` has been called """
mutable struct FitBit
model
isfit::Bool
FitBit(model) = new(model, false)
end
function fit!(fb::FitBit, args...; kwargs...)
fit!(fb.model, args...; kwargs...)
fb.isfit = true
fb
end
isfit(fb::FitBit) = fb.isfit
在这里,我们可以看到FitBit对象包含一个model对象,并且它添加了一个新功能,用于跟踪模型是否已被拟合:
@forward FitBit.model transform, predict, predict_proba, predict_dist, get_classes
它使用@forward宏来代理所有主要功能,即transform、predict等。
考虑事项
你应该记住,代理模式引入了新的间接层次,这可能会增加代码复杂性,并使代码更难以理解。在决定是否使用代理模式时,我们应该考虑一些因素。
首先,你能从现有组件中重用多少代码?是 20%,50%,还是 80%?在你考虑重用现有组件之前,这应该是你最需要问的第一个问题。我们可以把重用量的比例称为利用率。显然,利用率越高,从重用角度来看就越好。
第二,通过重用现有组件可以节省多少开发工作量?如果开发相同功能性的成本很低,那么重用组件并增加额外间接的复杂性可能不值得。
从相反的角度来看,我们也应该审查现有组件中是否存在任何关键的业务逻辑。如果我们决定不重用组件,那么我们可能会再次实现相同的逻辑,违反了不要重复自己(DRY)原则。这意味着不重用组件可能会成为一个维护噩梦。
考虑到这些因素,我们应该对是否使用代理模式做出良好的判断。
接下来,我们将学习如何在 Julia 中实现特性。
神圣的特性模式
神圣的特性模式有一个有趣的名字。有些人也称它为Tim Holy 特性技巧(THTT)。正如你可能猜到的,这个模式是以 Tim Holy 的名字命名的,他是一位长期为 Julia 语言和生态系统做出贡献的贡献者。
特性是什么?简而言之,特性对应于对象的行为。例如,鸟儿和蝴蝶可以飞翔,因此它们都具有CanFly特性。海豚和乌龟可以游泳,因此它们都具有CanSwim特性。鸭子可以飞翔和游泳,因此它具有CanFly和CanSwim特性。特性通常是二元的——要么具有该特性,要么不具有——尽管这不是强制性的要求。
我们为什么想要特性?特性可以用作关于数据类型如何使用的正式合同。例如,如果一个对象具有CanFly特性,那么我们可以相当自信地认为该对象定义了某种fly方法。同样,如果一个对象具有CanSwim特性,那么我们可能可以调用某种swim函数。
让我们回到编程。Julia 语言没有内置对特性的支持。然而,该语言足够灵活,允许开发者通过多分派系统使用特性。在本节中,我们将探讨如何使用被称为神圣特性的特殊技术来实现这一点。
重新审视个人资产管理用例
当设计可重用软件时,我们经常创建抽象的数据类型并将行为与之关联。建模行为的一种方式是利用类型层次结构。遵循 Liskov 替换原则,当调用函数时,我们应该能够用子类型替换类型。
让我们回顾一下从第二章,模块、包和类型概念中管理个人资产的高级类型层次结构:

我们可以定义一个名为value的函数,用于确定任何资产的价值。如果我们假设所有资产类型都附带有某种货币价值,那么这个函数可以应用于Asset层次结构中的所有类型。沿着这条思路,我们可以说几乎每种资产都表现出HasValue特质。
有时,行为只能应用于层次结构中的某些类型。例如,如果我们想定义一个只与流动投资一起工作的trade函数怎么办?在这种情况下,我们会为Investment和Cash定义trade函数,但不会为House和Apartments定义。
流动投资是指可以在公开市场上轻松交易的证券工具。投资者可以快速将流动工具转换为现金,反之亦然。一般来说,大多数投资者在紧急情况下都希望他们的投资中有一部分是流动的。
非流动的投资被称为非流动资产。
编程上,我们如何知道哪些资产类型是流动的?一种方法是将对象类型与表示流动投资的类型列表进行比较。假设我们有一个资产数组,需要找出哪一个可以快速兑换成现金。在这种情况下,代码可能看起来像这样:
function show_tradable_assets(assets::Vector{Asset})
for asset in assets
if asset isa Investment || asset isa Cash
println("Yes, I can trade ", asset)
else
println("Sorry, ", asset, " is not tradable")
end
end
end
前面代码中的if条件有点丑陋,即使是这个玩具示例也是如此。如果我们有更多类型的条件,那么它会更糟。当然,我们可以创建一个联合类型来让它变得稍微好一些:
const LiquidInvestments = Union{Investment, Cash}
function show_tradable_assets(assets::Vector{Asset})
for asset in assets
if asset isa LiquidInvestments
println("Yes, I can trade ", asset)
else
println("Sorry, ", asset, " is not tradable")
end
end
end
这种方法有几个问题:
-
每当我们添加一个新的流动资产类型时,联合类型必须更新。从设计角度来看,这种维护是糟糕的,因为程序员必须记住在向系统中添加新类型时更新这个联合类型。
-
这个联合类型不可扩展。如果其他开发者想重用我们的交易库,他们可能想添加新的资产类型。然而,他们不能更改我们的联合类型定义,因为他们没有源代码。
-
如果-否则逻辑可能在我们的源代码的许多地方重复出现,每当我们需要对流动资产和非流动资产执行不同的操作时。
这些问题可以使用神圣特质模式来解决。
实现神圣特质模式
为了说明这种模式的概念,我们将实现一些函数,用于我们在第二章,模块、包和数据类型概念中开发的个人资产数据类型。如您所回忆的,资产类型层次结构的抽象类型定义如下:
abstract type Asset end
abstract type Property <: Asset end
abstract type Investment <: Asset end
abstract type Cash <: Asset end
abstract type House <: Property end
abstract type Apartment <: Property end
abstract type FixedIncome <: Investment end
abstract type Equity <: Investment end
Asset类型位于层次结构的顶部,具有Property、Investment和Cash子类型。在下一级,House和Apartment是Property的子类型,而FixedIncome和Equity是Investment的子类型。
现在,让我们定义一些具体的类型:
struct Residence <: House
location
end
struct Stock <: Equity
symbol
name
end
struct TreasuryBill <: FixedIncome
cusip
end
struct Money <: Cash
currency
amount
end
我们这里有什么?让我们更详细地看看这些概念:
-
一个
Residence是某人居住的房屋,并且有一个位置。 -
一个
Stock是股权投资,它通过交易符号和公司名称来识别。 -
TreasuryBill是美国政府发行的一种短期证券,它通过一个称为 CUSIP 的标准标识符来定义。 -
Money只是现金,但我们想在这里存储货币和相应的金额。
注意,我们没有注释字段的类型,因为它们在这里说明特性概念并不重要。
定义特性类型
当涉及到投资时,我们可以区分那些在公开市场上可以轻易换成现金的投资和那些需要相当多的努力和时间才能换成现金的投资。那些可以在几天内轻易换成现金的东西被称为是流动的,而难以出售的被称为是非流动的。例如,股票是流动的,而住宅则不是。
我们首先想定义特性本身:
abstract type LiquidityStyle end
struct IsLiquid <: LiquidityStyle end
struct IsIlliquid <: LiquidityStyle end
特性在 Julia 中不过是数据类型!LiquidityStyle特性的整体概念是它是一个抽象类型。这里的具体特性,IsLiquid和IsIlliquid,已经被设置为没有字段的实体类型。
特性的命名没有标准约定,但我的研究似乎表明,包作者倾向于使用Style或Trait作为特性类型的后缀。
识别特性
下一步是为这些特性分配数据类型。方便的是,Julia 允许我们使用函数签名中的<:运算符批量分配特性到整个子类型树。
# Default behavior is illiquid
LiquidityStyle(::Type) = IsIlliquid()
# Cash is always liquid
LiquidityStyle(::Type{<:Cash}) = IsLiquid()
# Any subtype of Investment is liquid
LiquidityStyle(::Type{<:Investment}) = IsLiquid()
让我们看看我们如何解释这三行代码:
-
我们选择默认将所有类型设置为非流动的。请注意,我们也可以反过来,默认将所有东西设置为流动的。这个决定是任意的,取决于特定的用例。
-
我们选择将所有
Cash的子类型都做成流动的,这包括具体的Money类型。::Type{<:Cash}的表示法表明了Cash的所有子类型。 -
我们选择将所有
Investment的子类型都做成流动的。这包括所有FixedIncome和Equity的子类型,在这个例子中涵盖了Stock。
你可能想知道为什么我们不将::Type{<: Asset}作为默认特性函数的参数。这样做使得它更加限制性,因为默认值只适用于在Asset类型层次结构下定义的类型。这可能是或可能不是所希望的,这取决于特性是如何使用的。无论如何都应该是可以的。
实现特性行为
现在我们能够判断哪些类型是流动的,哪些不是,我们可以定义接受具有这些特性的对象的方法。首先,让我们做一些非常简单的事情:
# The thing is tradable if it is liquid
tradable(x::T) where {T} = tradable(LiquidityStyle(T), x)
tradable(::IsLiquid, x) = true
tradable(::IsIlliquid, x) = false
在 Julia 中,类型是一等公民。tradable(x::T) where {T}签名捕获了参数的类型作为T。由于我们已定义了LiquidityStyle函数,我们可以推导出传递的参数是否表现出IsLiquid或IsIlliquid特性。因此,第一个tradable方法只是接受LiquidityStyle(T)的返回值,并将其作为其他两个tradable方法的第一个参数传递。这个简单的例子展示了派发效应。
现在,让我们看看一个更有趣的函数,它利用了相同的特性。由于流动性资产在市场上很容易交易,我们应该能够快速发现它们的市场价格。对于股票,我们可以从证券交易所调用定价服务。对于现金,市场价格只是货币金额。让我们看看这是如何编码的:
# The thing has a market price if it is liquid
marketprice(x::T) where {T} = marketprice(LiquidityStyle(T), x)
marketprice(::IsLiquid, x) = error("Please implement pricing function for ", typeof(x))
marketprice(::IsIlliquid, x) = error("Price for illiquid asset $x is not available.")
代码的结构与tradable函数相同。一个方法用于确定特性,而另外两个方法为流动性和非流动性工具实现不同的行为。在这里,两个marketprice函数只是通过调用错误函数来抛出异常。当然,这并不是我们真正想要的。我们真正想要的应该是一个针对Stock和Money类型的特定定价函数。好的;让我们就这样做:
# Sample pricing functions for Money and Stock
marketprice(x::Money) = x.amount
marketprice(x::Stock) = rand(200:250)
在这里,Money类型的marketprice方法只是返回金额。这在实践中是一种相当简化的做法,因为我们可能需要从货币和金额中计算出当地货币(例如,美元)的金额。至于Stock,我们只是为了测试目的返回一个随机数。在现实中,我们会将这个函数附加到一个股票定价服务上。
为了说明目的,我们开发了以下测试函数:
function trait_test_cash()
cash = Money("USD", 100.00)
@show tradable(cash)
@show marketprice(cash)
end
function trait_test_stock()
aapl = Stock("AAPL", "Apple, Inc.")
@show tradable(aapl)
@show marketprice(aapl)
end
function trait_test_residence()
try
home = Residence("Los Angeles")
@show tradable(home) # returns false
@show marketprice(home) # exception is raised
catch ex
println(ex)
end
return true
end
function trait_test_bond()
try
bill = TreasuryBill("123456789")
@show tradable(bill)
@show marketprice(bill) # exception is raised
catch ex
println(ex)
end
return true
end
这是 Julia REPL 的结果:

完美! tradable函数正确地识别出现金、股票和债券是流动的,而住宅是非流动的。对于现金和股票,marketprice函数能够返回预期的值。因为住宅不是流动的,所以抛出了一个错误。最后,虽然国库券是流动的,但由于marketprice函数尚未定义该工具,所以抛出了一个错误。
使用具有不同类型层次结构的特性
神圣特性模式的最佳部分在于我们可以用它来处理任何对象,即使它的类型属于不同的抽象类型层次结构。让我们看看文学案例,我们可能定义它自己的类型层次结构如下:
abstract type Literature end
struct Book <: Literature
name
end
现在,我们可以让它遵守 LiquidityStyle 特性,如下所示:
# assign trait
LiquidityStyle(::Type{Book}) = IsLiquid()
# sample pricing function
marketprice(b::Book) = 10.0
现在,我们可以像其他可交易资产一样交易书籍。
审查一些常见用法
神圣特性模式在开源包中很常见。让我们看看一些例子。
示例 1 – Base.IteratorSize
Julia Base 库广泛使用特性。这样的特性之一是 Base.IteratorSize。它的定义可以通过 generator.jl 查找:
abstract type IteratorSize end
struct SizeUnknown <: IteratorSize end
struct HasLength <: IteratorSize end
struct HasShape{N} <: IteratorSize end
struct IsInfinite <: IteratorSize end
这个特性与我们之前学到的略有不同,因为它不是二元的。IteratorSize 特性可以是 SizeUnknown、HasLength、HasShape{N} 或 IsInfinite。IteratorSize 函数定义如下:
"""
IteratorSize(itertype::Type) -> IteratorSize
"""
IteratorSize(x) = IteratorSize(typeof(x))
IteratorSize(::Type) = HasLength() # HasLength is the default
IteratorSize(::Type{<:AbstractArray{<:Any,N}}) where {N} = HasShape{N}()
IteratorSize(::Type{Generator{I,F}}) where {I,F} = IteratorSize(I)
IteratorSize(::Type{Any}) = SizeUnknown()
让我们专注于看起来相当有趣的 IsInfinite 特性。Base.Iterators 中定义了一些函数来生成无限序列。例如,Iterators.repeated 函数可以用来无限生成相同的值,我们可以使用 Iterators.take 函数从序列中选取值。让我们看看这是如何工作的:

如果你查看源代码,你会看到 Repeated 是迭代器的类型,并且它被分配了 IteratorSize 特性 IsInfinite:
IteratorSize(::Type{<:Repeated}) = IsInfinite()
我们可以快速测试它,如下所示:

Voila! 它是无限的,正如我们所预期的!但是这个特性是如何被利用的呢?为了找出答案,我们可以查看 Base 库中的 BitArray,这是一个空间高效的布尔数组实现。它的构造函数可以接受任何可迭代对象,例如一个数组:

也许不难理解构造函数实际上无法处理本质上无限的事物!因此,BitArray 构造函数的实现必须考虑到这一点。因为我们可以根据 IteratorSize 特性进行分派,所以当传递这样的迭代器时,BitArray 的构造函数会愉快地抛出一个异常:
BitArray(itr) = gen_bitarray(IteratorSize(itr), itr)
gen_bitarray(::IsInfinite, itr) = throw(ArgumentError("infinite-size iterable used in BitArray constructor"))
要看到它的实际应用,我们可以用 Repeated 迭代器调用 BitArray 构造函数,如下所示:

示例 2 – AbstractPlotting.jl ConversionTrait
AbstractPlotting.jl 是一个抽象绘图库,它是 Makie 绘图系统的一部分。这个库的源代码可以在 github.com/JuliaPlots/AbstractPlotting.jl 找到。
让我们看看一个与数据转换相关的特性:
abstract type ConversionTrait end
struct NoConversion <: ConversionTrait end
struct PointBased <: ConversionTrait end
struct SurfaceLike <: ConversionTrait end
# By default, there is no conversion trait for any object
conversion_trait(::Type) = NoConversion()
conversion_trait(::Type{<: XYBased}) = PointBased()
conversion_trait(::Type{<: Union{Surface, Heatmap, Image}}) = SurfaceLike()
它定义了一个 ConversionTrait,可以用于 convert_arguments 函数。目前,转换逻辑可以应用于三种不同的场景:
-
无转换。这由默认特质类型
NoConversion处理。 -
PointBased转换。 -
SurfaceLike转换。
默认情况下,convert_arguments函数在不需要转换时仅返回未更改的参数:
# Do not convert anything if there is no conversion trait
convert_arguments(::NoConversion, args...) = args
然后,定义了各种convert_arguments函数。以下是用于 2D 绘图的函数:
*"""*
*convert_arguments(P, x, y)::(Vector)*
*Takes vectors `x` and `y` and turns it into a vector of 2D points of the values*
*from `x` and `y`.*
*`P` is the plot Type (it is optional).*
*"""*
convert_arguments(::PointBased, x::RealVector, y::RealVector) = (Point2f0.(x, y),)
使用 SimpleTraits.jl 包
SimpleTraits.jl包(github.com/mauro3/SimpleTraits.jl)可能被用来使编程特质变得更容易。
让我们尝试使用 SimpleTraits 重新做LiquidityStyle的例子。首先,定义一个名为IsLiquid的特质,如下所示:
@traitdef IsLiquid{T}
语法可能看起来有点不自然,因为T似乎没有做什么,但实际上它是必需的,因为特质适用于特定的类型T。接下来,我们需要为此特质分配类型:
@traitimpl IsLiquid{Cash}
@traitimpl IsLiquid{Investment}
然后,可以使用带有四个冒号的特殊语法来定义具有特质的对象的功能:
@traitfn marketprice(x::::IsLiquid) = error("Please implement pricing function for ", typeof(x))
@traitfn marketprice(x::::(!IsLiquid)) = error("Price for illiquid asset $x is not available.")
正例中,参数被注解为x::::IsLiquid,而负例中,参数被注解为x::::(!IsLiquid)。请注意,括号是必需的,这样代码才能被正确解析。现在,我们可以按照以下方式测试函数:

如预期的那样,两种默认实现都会抛出错误。现在,我们可以实现Stock的定价函数,并快速再次测试:

看起来很棒! 如我们所见,SimpleTrait.jl包简化了创建特质的流程。
使用特质可以使你的代码更具可扩展性。然而,我们必须记住,设计适当的特质需要一些努力。文档也同样重要,以便任何想要扩展代码的人都能理解如何利用预定义的特质。
接下来,我们将讨论参数化类型,这是一种常用于轻松扩展数据类型的常用技术。
参数化类型模式
参数化类型是核心语言特性,用于使用参数实现数据类型。这是一个非常强大的技术,因为相同的对象结构可以用于其字段中的不同数据类型。在本节中,我们将展示如何有效地应用参数化类型。
在设计应用时,我们经常创建复合类型以方便地持有多个字段元素。在其最简单形式中,复合类型仅作为字段的容器。随着我们创建越来越多的复合类型,可能会变得明显,其中一些类型看起来几乎相同。此外,操作这些类型的函数可能也非常相似。我们可能会产生大量的样板代码。如果有一个模板允许我们为特定用途自定义通用复合类型会怎么样?
考虑一个支持买卖股票的交易应用。在最初的版本中,我们可能有以下设计:

请注意,前面图表中的符号可能看起来非常像统一建模语言(UML)。然而,由于 Julia 不是面向对象的语言,我们在用这些图表说明设计概念时可能会做出某些例外。
相应的代码如下:
# Abstract type hierarchy for personal assets
abstract type Asset end
abstract type Investment <: Asset end
abstract type Equity <: Investment end
# Equity Instruments Types
struct Stock <: Equity
symbol::String
name::String
end
# Trading Types
abstract type Trade end
# Types (direction) of the trade
@enum LongShort Long Short
struct StockTrade <: Trade
type::LongShort
stock::Stock
quantity::Int
price::Float64
end
我们在前面代码中定义的数据类型相当直接。LongShort枚举类型用于指示交易方向——购买股票将是多头,而卖空股票将是空头。@enum宏方便地用于定义Long和Short常量。
现在,假设我们被要求在软件的下一个版本中支持股票期权。天真地,我们可以定义更多的数据类型,如下所示:

代码已更新,添加了额外的数据类型,如下所示:
# Types of stock options
@enum CallPut Call Put
struct StockOption <: Equity
symbol::String
type::CallPut
strike::Float64
expiration::Date
end
struct StockOptionTrade <: Trade
type::LongShort
option::StockOption
quantity::Int
price::Float64
end
你可能已经注意到StockTrade和StockOptionTrade类型非常相似。这种重复多少有些令人不满意。当我们为这些数据类型定义函数时,看起来更糟糕,如下所示:
# Regardless of the instrument being traded, the direction of
# trade (long/buy or short/sell) determines the sign of the
# payment amount.
sign(t::StockTrade) = t.type == Long ? 1 : -1
sign(t::StockOptionTrade) = t.type == Long ? 1 : -1
# market value of a trade is simply quantity times price
payment(t::StockTrade) = sign(t) * t.quantity * t.price
payment(t::StockOptionTrade) = sign(t) * t.quantity * t.price
对于StockTrade和StockOptionTrade类型,sign和payment方法非常相似。也许不难想象,当我们向应用中添加更多可交易类型时,这并不能很好地扩展。我们必须有更好的方法来做这件事。这正是参数类型发挥作用的地方!
利用去除文本参数类型为股票交易应用
在我们之前描述的交易应用中,我们可以利用参数类型简化代码,并在添加未来的交易工具时使其更具可重用性。
很明显,SingleStockTrade和SingleStockOptionTrade几乎相同。实际上,甚至sign和payment函数的定义也是相同的。在这个非常简单的例子中,我们只为每种类型有两个函数。在实践中,我们可能有更多的函数,这会变得相当混乱。
设计参数类型
为了简化这个设计,我们可以参数化所交易事物的类型。那是什么东西?我们可以在这里利用抽象类型。Stock的超类型是Equity,而Equity的超类型是Investment。由于我们希望保持代码通用,并且买卖投资产品是相似的,我们可以选择接受任何是Investment子类型的类型:
struct SingleTrade{T <: Investment} <: Trade
type::LongShort
instrument::T
quantity::Int
price::Float64
end
现在,我们定义了一个新的类型,称为SingleTrade,其中基础工具的类型为T,T可以是Investment的任何子类型。在这个时候,我们可以创建不同种类的交易:

这些对象实际上有不同的类型——SingleTrade{Stock}和SingleTrade{StockOption}。它们之间是如何关联的呢?它们也是SingleTrade的子类型,如下面的截图所示:

由于这两种类型都是SingleTrade的子类型,这允许我们定义适用于这两种类型的函数,正如我们将在下一节中看到的。
设计参数化方法
为了充分利用编译器的特化功能,我们应该定义同时使用参数化类型的参数化方法,如下所示:
# Return + or - sign for the direction of trade
function sign(t::SingleTrade{T}) where {T}
return t.type == Long ? 1 : -1
end
# Calculate payment amount for the trade
function payment(t::SingleTrade{T}) where {T}
return sign(t) * t.quantity * t.price
end
让我们来测试一下:

但是,嘿,我们刚刚发现了一个小错误。3.50 美元的期权看起来太好了,不像是真的!在查看买卖期权时,每个期权合约实际上代表 100 股基础股票。因此,股票期权交易的支付金额需要乘以 100。为了修复这个问题,我们可以简单地实现一个更具体的支付方法:
# Calculate payment amount for option trades (100 shares per contract)
function payment(t::SingleTrade{StockOption})
return sign(t) * t.quantity * 100 * t.price
end
现在,我们可以再次测试。因此,新方法仅针对期权交易进行分发:

哇!这不是很美吗?我们将在下一节中看到一个更复杂的例子。
使用多个参数化类型参数
到目前为止,我们对重构相当满意。然而,我们的老板刚刚打电话来说,我们必须在下一个版本中支持对冲交易。这个新的请求又给我们的设计增添了另一个转折!
对冲交易可以用来实施特定的交易策略,例如市场中性交易或如保护性看涨期权等期权策略。
市场中性交易涉及同时买入一只股票和卖空另一只股票。其理念是抵消市场的影响,以便投资者可以专注于挑选相对于同行表现优异或表现不佳的股票。
保护性看涨期权策略涉及买入股票,但卖出执行价格更高的看涨期权。这允许投资者通过牺牲基础股票有限的上涨潜力来赚取额外的溢价。
这可以通过参数化类型轻松处理。让我们创建一个新的类型,称为PairTrade:
struct PairTrade{T <: Investment, S <: Investment} <: Trade
leg1::SingleTrade{T}
leg2::SingleTrade{S}
end
注意,交易的两侧可以具有不同的类型,T和S,并且它们可以是Investment的任何子类型。因为我们期望每个Trade类型都支持payment函数,所以我们可以轻松实现,如下所示:
payment(t::PairTrade) = payment(t.leg1) + payment(t.leg2)
我们可以重用前一个会话中的stock和option对象,创建一个对冲交易交易,其中我们买入 100 股股票并卖出 1 份期权合约。预期的支付金额是$18,800 - $350 = $18,450:

为了欣赏参数化类型如何简化我们的设计,想象一下,如果您必须创建单独的具体类型,您需要编写多少个函数。在这个例子中,由于对冲交易交易中存在两种可能的交易,并且每种交易可以是股票交易或期权交易,我们必须支持 2 x 2 = 4 种不同的场景:
-
payment(PairTradeWithStockAndStock) -
payment(PairTradeWithStockAndStockOption) -
payment(PairTradeWithStockOptionAndStock) -
payment(PairTradeWithStockOptionAndStockOption)
使用参数化类型,我们只需要一个支付函数就可以涵盖所有场景。
现实生活中的例子
你几乎可以在任何开源包中找到参数化类型的使用。让我们来看一些例子。
示例 1 – ColorTypes.jl 包
ColorTypes.jl 是一个定义了表示颜色的各种数据类型的包。在实践中,定义颜色的方式有很多:红-绿-蓝(RGB)、色调-饱和度-亮度(HSV)等等。大多数情况下,可以使用三个实数来定义颜色。在灰度的情况下,只需要一个数字来表示暗度。为了支持透明颜色,可以使用额外的值来存储不透明度值。首先,让我们看看类型定义:
*"""
`Colorant{T,N}` is the abstract super-type of all types in ColorTypes,
and refers to both (opaque) colors and colors-with-transparency (alpha
channel) information. `T` is the element type (extractable with
`eltype`) and `N` is the number of *meaningful* entries (extractable
with `length`), that is, the number of arguments you would supply to the
constructor.
"""*
abstract type Colorant{T,N} end
*# Colors (without transparency)
"""
`Color{T,N}` is the abstract supertype for a color (or
grayscale) with no transparency.
"""*
abstract type Color{T, N} <: Colorant{T,N} end
*"""
`AbstractRGB{T}` is an abstract supertype for red/green/blue color types that
can be constructed as `C(r, g, b)` and for which the elements can be
extracted as `red(c)`, `green(c)`, `blue(c)`. You should *not* make
assumptions about internal storage order, the number of fields, or the
representation. One `AbstractRGB` color-type, `RGB24`, is not
parametric and does not have fields named `r`, `g`, `b`.
"""*
abstract type AbstractRGB{T} <: Color{T,3} end
Color{T,N} 类型可以表示所有种类的颜色,包括透明和不透明的。T 参数代表颜色定义中每个单独值的类型;例如,Int, Float64 等。N 参数代表颜色定义中的值数量,通常为三个。
Color{T,N} 是 Colorant{T,N} 的子类型,代表非透明颜色。最后,AbstractRGB{T} 是 Color{T,N} 的子类型。请注意,在 AbstractRGB{T} 中不再需要 N 参数,因为它已经定义为 N=3。现在,具体的参数化类型 RGB{T} 定义如下:
const Fractional = Union{AbstractFloat, FixedPoint}
*"""
`RGB` is the standard Red-Green-Blue (sRGB) colorspace. Values of the
individual color channels range from 0 (black) to 1 (saturated). If
you want "Integer" storage types (for example, 255 for full color), use `N0f8(1)`
instead (see FixedPointNumbers).
"""*
struct RGB{T<:Fractional} <: AbstractRGB{T}
r::T # Red [0,1]
g::T # Green [0,1]
b::T # Blue [0,1]
RGB{T}(r::T, g::T, b::T) where {T} = new{T}(r, g, b)
end
RGB{T <: Fractional} 的定义相当直接。它包含三个类型为 T 的值,T 可以是 Fractional 的子类型。由于 Fractional 类型定义为 AbstractFloat 和 FixedPoint 的并集,因此 r、g 和 b 字段可以用任何 AbstractFloat 的子类型,如 Float64 和 Float32,或者任何 FixedPoint 数值类型。
FixedPoint 是在 FixedPointNumbers.jl 包中定义的类型。定点数是不同于浮点格式的表示实数的方式。更多信息可以在 github.com/JuliaMath/FixedPointNumbers.jl 找到。
如果你进一步检查源代码,你会发现许多类型是以类似的方式定义的。
示例 2 – NamedDims.jl 包
NamedDims.jl 包为多维数组的每个维度添加了名称。源代码可以在 github.com/invenia/NamedDims.jl 找到。
让我们看看 NamedDimsArray 的定义:
"""
The `NamedDimsArray` constructor takes a list of names as `Symbol`s,
one per dimension, and an array to wrap.
"""
struct NamedDimsArray{L, T, N, A<:AbstractArray{T, N}} <: AbstractArray{T, N}
# `L` is for labels, it should be an `NTuple{N, Symbol}`
data::A
end
不要被签名吓倒。实际上它相当直接。
NamedDimsArray是抽象数组类型AbstractArray{T, N}的子类型。它只包含一个字段,data,用于跟踪底层数据。因为T和N已经在A中作为参数,所以它们也需要在NamedDimsArray的签名中指定。L参数用于跟踪维度的名称。请注意,L在任何一个字段中都没有使用,但它方便地存储在类型签名本身中。
主要构造函数定义如下:
function NamedDimsArray{L}(orig::AbstractArray{T, N}) where {L, T, N}
if !(L isa NTuple{N, Symbol})
throw(ArgumentError(
"A $N dimensional array, needs a $N-tuple of dimension names. Got: $L"
))
end
return NamedDimsArray{L, T, N, typeof(orig)}(orig)
end
该函数只需要一个AbstractArray{T,N},它是一个具有元素类型T的 N 维数组。首先,它检查L是否包含一个包含N个符号的元组。因为类型参数是一等公民,所以可以在函数体中检查它们。假设L包含正确的符号数量,它只需使用已知的参数L、T、N以及数组参数的类型来实例化一个NamedDimsArray。
可能更容易看到它是如何使用的,让我们看一下:

在输出中,我们可以看到类型签名是NamedDimsArray{(:x, :y),Int64,2,Array{Int64,2}}。将其与NamedDimsArray类型的签名匹配,我们可以看到L是两个符号的元组(:x, :y),T是Int64,N是 2,底层数据是Array{Int64, 2}类型。
让我们看看dimnames函数,其定义如下:
dimnames(::Type{<:NamedDimsArray{L}}) where L = L
此函数返回维度元组:

现在,事情变得有点更有趣了。NamedDimsArray{L}是什么?我们在这个类型中不是需要四个参数吗?值得注意的是,像NamedDimsArray{L, T, N, A}这样的类型实际上是NamedDimsArray{L}的子类型。我们可以如下证明这一点:

如果我们真的想了解NamedDimsArray{L}是什么,我们可以尝试以下方法:

看起来正在发生的事情是NamedDimsArray{(:x, :y)}只是NamedDimsArray{(:x, :y),T,N,A}的简写,其中A<:AbstractArray{T,N},N和T是未知的参数。因为这是一个具有三个未知参数的更一般类型,所以我们可以看到为什么NamedDimsArray{(:x, :y),Int64,2,Array{Int64,2}}是NamedDimsArray{(:x, :y)}的子类型。
如果我们希望重用功能,使用参数化类型是非常好的。我们可以几乎将每个类型参数视为一个"维度"。当一个参数化类型有两个类型参数时,我们会根据每个类型参数的各种组合有许多可能的子类型。
摘要
在本章中,我们探讨了与重用性相关的几个模式。这些模式非常有价值,可以在应用程序的许多地方使用。此外,来自面向对象背景的人可能会发现,在设计 Julia 应用程序时,这一章是不可或缺的。
首先,我们详细介绍了委派模式,它可以用来创建新的功能,并允许我们重用现有对象的功能。一般技术涉及定义一个新的数据类型,该类型包含一个父对象。然后,定义转发函数,以便我们可以重用父对象的功能。我们了解到通过使用由 Lazy.jl 包提供的 @forward,可以大大简化实现委派。
然后,我们研究了神圣的特质模式,这是一种正式定义对象行为的方式。其思路是将特质定义为原生类型,并利用 Julia 的内置调度机制来调用正确的方法实现。我们意识到特质在使代码更可扩展方面很有用。我们还了解到来自 SimpleTraits.jl 包的宏可以使特质编码更容易。
最后,我们探讨了参数化类型模式及其如何被用来简化代码的设计。我们了解到参数化类型可以减小我们代码的大小。我们还看到参数可以在参数化函数的主体中使用。
在下一章中,我们将讨论一个吸引许多人学习 Julia 编程语言的重要主题——性能模式!
问题
-
委派模式是如何工作的?
-
特质的目的是什么?
-
特质总是二元的吗?
-
特质能否用于不同类型层次的对象?
-
参数化类型的优点是什么?
-
我们如何存储参数化类型的信息?
第六章:性能模式
本章包括与提高系统性能相关的模式。高性能是科学计算、人工智能、机器学习和大数据处理的主要要求。为什么是这样?
在过去十年中,由于云的可扩展性,数据几乎呈指数级增长。想想看物联网(IoT)。传感器无处不在——家庭安全系统、个人助理,甚至是室温控制都在持续收集大量数据。此外,收集到的数据被希望构建更智能产品的公司存储和分析。这样的用例需要更多的计算能力和速度。
我曾经与一位同事就使用云计算技术来解决计算密集型问题进行了辩论。云计算中确实有计算资源,但它们不是免费的。因此,设计计算机程序以更加高效和优化,以避免在云中产生不必要的成本,这一点非常重要。
幸运的是,Julia 编程语言允许我们轻松地充分利用 CPU 资源。只要遵循一些规则,让事情变得快速并不困难。在线的 Julia 参考手册已经包含了一些技巧。本章提供了由经验丰富的 Julia 开发者广泛使用的进一步模式,以提升性能。
我们将探讨以下设计模式:
-
全局常量
-
数组结构
-
共享数组
-
缓存
-
障碍函数
让我们开始吧!
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
全局常量模式
全局变量通常被认为是有害的。我不是在开玩笑——它们确实是有害的。如果你不相信我,只需在谷歌上搜索一下。它们之所以不好,有很多原因,但在 Julia 语言中,它们也可能成为应用性能不佳的诱因。
我们为什么要使用全局变量?在 Julia 语言中,变量要么在全局作用域,要么在局部作用域。例如,模块顶层所有的变量赋值都被认为是全局的。出现在函数内部的变量是局部的。考虑一个连接外部系统的应用程序——在连接时通常会创建一个句柄对象。这样的句柄对象可以保存在全局变量中,因为模块中的所有函数都可以访问这个变量,而无需将其作为函数参数传递。这就是便利性因素。此外,这个句柄对象只需要创建一次,然后可以在后续操作中随时使用。
不幸的是,全局变量也伴随着成本。一开始可能不明显,但它确实会影响性能——在某些情况下,影响相当严重。在本节中,我们将讨论全局变量如何影响性能,以及如何通过使用全局常量来解决这个问题。
使用全局变量进行性能基准测试
有时,使用全局变量很方便,因为它们可以从代码的任何地方访问。然而,当使用全局变量时,应用程序的性能可能会受到影响。让我们一起找出性能受到了多么严重的影响。这是一个非常简单的函数,它只是将两个数字相加:
variable = 10
function add_using_global_variable(x)
return x + variable
end
为了基准测试这段代码,我们将使用伟大的BenchmarkTools.jl包,它可以多次运行代码并报告一些性能统计数据。让我们开始吧:

对于仅仅加两个数字来说,这似乎有点慢。让我们去掉全局变量,只使用两个函数参数来加这些数字。我们可以定义新的函数如下:
function add_using_function_arg(x, y)
return x + y
end
让我们来基准测试这个新函数:

这真是太令人难以置信了!移除对全局变量的引用使函数的速度提高了近 900 倍。为了了解性能下降的原因,我们可以使用 Julia 的内置内省工具来查看生成的 LLVM 代码。
这是更快版本的生成代码。它很干净,只包含一个add指令:

另一方面,使用全局变量的函数生成了以下丑陋的代码:

为什么会这样?编译器不应该更聪明吗?答案是编译器实际上无法假设全局变量总是整数。因为它是一个变量,这意味着它可以随时更改,编译器必须生成能够处理任何数据类型的代码,以确保安全。好吧,这种额外的灵活性在这种情况下引入了巨大的开销。
享受全局常量的速度
为了提高性能,让我们使用const关键字创建一个全局常量。然后,我们可以定义一个新的函数来访问这个常量,如下所示:
const constant = 10
function add_using_global_constant(x)
return constant + x
end
让我们现在基准测试它的性能:

这是完美的! 如果我们再次内省这个函数,我们得到以下整洁的代码:

接下来,我们将讨论如何使用全局变量(不是常量)并使其稍微好一些。
使用类型信息注释变量
当我们只需使用全局常量时,这是最好的。但如果变量在应用程序的生命周期中确实需要更改呢?例如,它可能是一个全局计数器,用于跟踪网站上的访问者数量。
起初,我们可能会想这样做,但很快我们就意识到 Julia 不支持用类型信息注释全局变量:

相反,我们可以做的是在函数内部注释变量类型,如下所示:
function add_using_global_variable_typed(x)
return x + variable::Int
end
让我们看看它的性能如何:

与未类型化的 31 纳秒版本相比,这已经是一个相当大的速度提升了!然而,它仍然远远落后于全局常量解决方案。
理解常数如何帮助性能
由于以下原因,编译器在处理常数时拥有更多的自由度:
-
值不会改变。
-
常数的类型不会改变。
在我们查看一些简单的例子之后,这会变得清楚。
让我们看看以下函数:
function constant_folding_example()
a = 2 * 3
b = a + 1
return b > 1 ? 10 : 20
end
如果我们只遵循逻辑,那么不难看出它总是返回值为10。让我们快速展开它:
-
a变量有一个值为6。 -
b变量有一个值为a + 1,即7。 -
因为
b变量大于1,它返回10。
从编译器的角度来看,a变量可以被推断为常数,因为它被赋值但从未改变,同样对于b变量也是如此。
我们可以看看 Julia 为这个生成的代码:

Julia 编译器会经过几个阶段。在这种情况下,我们可以使用@code_typed宏,它显示了所有类型信息都已解决的生成的代码。
哇! 编译器已经全部弄明白了,并只为这个函数返回了一个值为10。
我们意识到这里发生了一些事情:
-
当编译器看到两个常数值的乘法(
2 * 3)时,它计算了a的最终值为6。这个过程被称为常数折叠。 -
当编译器推断出
a的值为6时,它计算出b的值为7。这个过程被称为常数传播。 -
当编译器推断出
b的值为7时,它从if-then-else操作中剪除了else分支。这个过程被称为死代码消除。
Julia 的编译器优化真正是处于一流水平。这些只是我们可以自动获得性能提升的一些例子,而无需重构大量代码。
将全局变量作为函数参数传递
另一种解决全局变量问题的方法是,在一个性能敏感的函数中,而不是直接访问全局变量,我们可以将全局变量作为参数传递给函数。
让我们通过添加第二个参数来重构本节中较早的代码,如下所示:
function add_by_passing_global_variable(x, v)
return x + v
end
现在,我们可以通过传递变量来调用函数。让我们按照以下方式基准测试代码:

太棒了! 它的速度和将其视为常量一样快。魔法在哪里?实际上,Julia 的编译器会根据其参数的类型自动生成专门的函数。在这种情况下,当我们以整数值传递变量时,函数被编译为最优化版本,因为参数的类型是已知的。它现在之所以快,是因为和用常量一样的原因。
当然,你可能会争辩说这违背了使用全局变量的初衷。然而,这种灵活性确实存在,并且在你真正需要获得最佳性能时可以加以利用。
当使用BenchmarkTools.jl宏时,我们必须使用美元符号前缀来插值全局变量。否则,引用全局变量所需的时间将包括在性能测试中。
在全局常量中隐藏变量
在我们结束本节之前,还有一个替代方案可以在不损失太多性能的情况下保持全局变量的灵活性。我们可以称之为全局变量占位符。
到现在为止,你可能已经清楚,Julia 可以在编译时知道变量类型的情况下生成高度优化的代码。因此,解决这个问题的方法之一是创建一个常量占位符并在其中存储值。
考虑以下代码:
# Initialize a constant Ref object with the value of 10
const semi_constant = Ref(10)
function add_using_global_semi_constant(x)
return x + semi_constant[]
end
全局常量被分配了一个Ref对象。在 Julia 中,Ref对象不过是一个占位符,其中包含的对象类型是已知的。你可以在 Julia REPL 中尝试这个操作:

如我们所见,根据类型签名Base.RefValue{Int64},Ref(10)内部的值类型为Int64。同样,Ref("abc")内部的值类型为String。
要获取Ref对象内部的值,我们可以使用不带参数的索引运算符。因此,在前面的代码中,我们使用了semi_constant[]。
这种额外的间接引用会增加多少性能开销?让我们像往常一样对代码进行基准测试:

这并不坏。虽然它的性能远未达到使用全局常量的最优性能,但它仍然比使用普通全局变量快大约 15 倍。
因为Ref对象只是一个占位符,所以底层值也可以被赋值:

总结来说,使用Ref允许我们在不牺牲太多性能的情况下模拟全局变量。
转向一些现实生活中的例子
在 Julia 包中,全局常量非常常见。这并不令人惊讶,因为常量也用于避免在函数中直接硬编码值。
示例 1 – SASLib.jl 包
在SASLib.jl包中,大多数常量都定义在位于github.com/tk3369/SASLib.jl/blob/master/src/constants.jl的constants.jl文件中。
下面是代码片段:
# default settings
const default_chunk_size = 0
const default_verbose_level = 1
const magic = [
b"\x00\x00\x00\x00\x00\x00\x00\x00" ;
b"\x00\x00\x00\x00\xc2\xea\x81\x60" ;
b"\xb3\x14\x11\xcf\xbd\x92\x08\x00" ;
b"\x09\xc7\x31\x8c\x18\x1f\x10\x11" ]
const align_1_checker_value = b"3"
const align_1_offset = 32
const align_1_length = 1
const align_1_value = 4
使用这些常量可以使文件读取函数表现良好。
示例 2 – PyCall.jl 包
PyCall.jl包的文档建议用户使用全局变量占位符技术存储 Python 对象。以下摘录可以在其文档中找到:
“对于类型稳定的全局常量,在顶层将常量初始化为PyNULL(),然后在模块的__init__函数中使用copy!函数将其修改为其实际值。”
类型稳定的全局常量通常是高性能代码所希望的。基本上,当模块初始化时,这个全局常量可以用PyNULL()的值初始化。这个常量实际上只是一个占位符对象,稍后可以用实际值修改。
这种技术与在隐藏全局常量中的变量部分中提到的使用Ref类似。
考虑事项
如果一个全局变量可以被替换为全局常量,那么它应该始终这样做。这样做的原因不仅仅是性能。常量有一个很好的特性,即保证它们在整个应用程序生命周期中的值保持不变。一般来说,全局状态变化越少,程序越健壮。修改状态是传统上难以发现的错误来源。
有时,我们可能会遇到不得不使用全局变量的情况。这很糟糕。然而,在我们为此感到悲伤之前,我们也可以检查系统性能是否受到了实质性影响。
在先前的加法示例中,访问全局变量成本相对较高,因为实际操作非常简单和高效。因此,在获取全局变量的访问方面做了更多的工作。另一方面,如果我们有一个更复杂的函数,耗时更长,比如 500 纳秒,那么额外的 25 纳秒开销就变得不那么重要了。在这种情况下,我们可以忽略这个问题,因为开销变得微不足道。
最后,我们应该始终注意当使用过多的全局变量时。当使用更多全局变量时,问题会成倍增加。多少算太多?这完全取决于你的情况,但思考应用程序设计和问自己应用程序是否设计得当是有益的。
在下一节中,我们将讨论一种通过在内存中不同布局数据来提高系统性能的模式。
数组结构模式
近年来,为了满足今天的需要,现代 CPU 架构变得更加复杂。由于各种物理限制,达到更高的处理器速度变得更加困难。许多英特尔处理器现在支持一种称为单指令多数据(SIMD)的技术。通过利用流式 SIMD 扩展(SSE)和高级向量扩展(AVX)寄存器,可以在单个 CPU 周期内执行多个数学运算。
这很好,但使用这些花哨的 CPU 指令的一个先决条件是确保数据最初位于连续的内存块中。这把我们带到了这里的话题。我们如何将数据定位在连续的内存块中?你可能会在这个部分找到解决方案。
与业务领域模型一起工作
在设计应用程序时,我们通常会创建一个对象模型,该模型模仿业务领域概念。目的是以对程序员来说最自然的形式清晰地阐述数据。
假设我们需要从关系型数据库中检索客户数据。客户记录可能存储在 CUSTOMER 表中,每个客户作为表中的一行存储。当我们从数据库中检索客户数据时,我们可以构建一个 Customer 对象并将其推入一个数组。同样,当我们与 NoSQL 数据库一起工作时,我们可能会以 JSON 文档的形式接收数据,并将它们放入对象数组中。在这两种情况下,我们可以看到数据被表示为对象的数组。应用程序通常被设计成使用 struct 语句定义的对象进行操作。
让我们看看分析来自纽约市出租车数据的用例。这些数据作为几个 CSV 文件公开可用。为了说明目的,我们已经下载了 2018 年 12 月的数据,并将其截断到 100,000 条记录。
完整的数据文件可以从 data.cityofnewyork.us/Transportation/2018-Yellow-Taxi-Trip-Data/t29m-gskq 下载。
为了方便起见,一个包含 100,000 条记录的小文件可以从我们的 GitHub 网站获取,网址为 github.com/PacktPublishing/Hands-On-Design-Patterns-with-Julia-1.0/raw/master/Chapter06/StructOfArraysPattern/yellow_tripdata_2018-12_100k.csv。
首先,我们定义一个名为 TripPayment 的类型,如下所示:
struct TripPayment
vendor_id::String
tpep_pickup_datetime::String
tpep_dropoff_datetime::String
passenger_count::Int
trip_distance::Float64
fare_amount::Float64
extra::Float64
mta_tax::Float64
tip_amount::Float64
tolls_amount::Float64
improvement_surcharge::Float64
total_amount::Float64
end
为了将数据读入内存,我们将利用 CSV.jl 包。让我们定义一个函数来将文件读入一个向量:
function read_trip_payment_file(file)
f = CSV.File(file, datarow = 3)
records = Vector{TripPayment}(undef, length(f))
for (i, row) in enumerate(f)
records[i] = TripPayment(row.VendorID,
row.tpep_pickup_datetime,
row.tpep_dropoff_datetime,
row.passenger_count,
row.trip_distance,
row.fare_amount,
row.extra,
row.mta_tax,
row.tip_amount,
row.tolls_amount,
row.improvement_surcharge,
row.total_amount)
end
return records
end
现在,当我们获取数据时,我们最终得到一个数组。在这个例子中,我们下载了 100,000 条记录,如下面的截图所示:

现在,假设我们需要分析这个数据集。在许多数据分析用例中,我们只是计算支付记录中某些属性的统计信息。例如,我们可能想找到平均车费金额,如下所示:

这应该是一个相当快的操作,因为它使用了生成器语法并避免了分配。
一些 Julia 函数接受生成器语法,可以像数组推导式一样编写,无需使用方括号。因为它避免了为中间对象分配内存,所以它非常节省内存。
唯一需要注意的是,它需要为每条记录访问fare_amount字段。如果我们对函数进行基准测试,它将显示以下结果:

我们如何知道它是否以最佳速度运行?除非我们尝试以不同的方式做,否则我们不知道。因为我们所做的只是计算 10 万个浮点数的平均值,我们可以很容易地用简单的数组来复制这个操作。让我们在单独的数组中复制数据:
fare_amounts = [r.fare_amount for r in records];
然后,我们可以通过直接传递数组来基准测试mean函数:

哇! 这里发生了什么?它的速度比之前快了 24 倍。
在这种情况下,编译器能够利用更高级的 CPU 指令。因为 Julia 数组是密集数组,也就是说数据紧凑地存储在连续的内存块中,这使得编译器能够完全优化操作。
将数据转换为数组似乎是一个不错的解决方案。然而,想象一下,你必须为每个单独的字段创建这些临时数组。这样做不再有趣,因为有可能在这个过程中遗漏一个字段。有没有更好的方法来解决这个问题?
使用不同的数据布局来提高性能
我们刚才看到的问题是由使用结构数组引起的。我们真正想要的是数组结构。注意结构数组与数组结构的区别?
在结构数组中,为了访问对象的字段,程序必须首先索引到对象,然后通过内存中的预定偏移量找到字段。例如,TripPayment对象中的passenger_count字段是结构中的第四个字段,前面的三个字段是Int64、String和String类型。因此,第四个字段的偏移量是 24。结构数组具有行导向的布局,因为每一行都存储在连续的内存块中。
我们现在介绍数组结构的概念。在数组结构中,我们采用列导向的方法。在这种情况下,我们只为整个数据集维护一个单一的对象。在对象内部,每个字段代表原始记录中特定字段的数组。例如,fare_amount字段将在这个对象中以票价金额的数组形式存储。列导向的格式针对高性能计算进行了优化,因为数组中的数据值都具有相同的类型。此外,它们在内存中也更加紧凑。
在 64 位系统中,结构通常被对齐到 8 字节内存块。例如,只包含两个字段Int32和Int16类型的结构仍然消耗 8 字节,尽管只需要 6 字节来存储数据。额外的两个字节用于填充数据结构以达到 8 字节的边界。
在接下来的章节中,我们将探讨如何实现这种模式,并确认性能是否有所提高。
构建数组结构
构造数组结构既简单又直接。毕竟,我们之前能够快速为一个单个字段做到这一点。为了完整性,这是我们可以设计的新数据类型,用于以列格式存储相同的行程付款数据。以下代码显示这种模式有助于提高性能:
struct TripPaymentColumnarData
vendor_id::Vector{Int}
tpep_pickup_datetime::Vector{String}
tpep_dropoff_datetime::Vector{String}
passenger_count::Vector{Int}
trip_distance::Vector{Float64}
fare_amount::Vector{Float64}
extra::Vector{Float64}
mta_tax::Vector{Float64}
tip_amount::Vector{Float64}
tolls_amount::Vector{Float64}
improvement_surcharge::Vector{Float64}
total_amount::Vector{Float64}
end
注意,每个字段都已转换为 Vector{T},其中 T 是特定字段的原始数据类型。这看起来相当丑陋,但我们愿意为了性能牺牲这一点。
一般原则是,我们应该保持简单(KISS)。在特定情况下,当我们确实需要更高的运行时性能时,我们可以稍微弯曲一下。
现在,尽管我们有一个更优化性能的数据类型,但我们仍然需要用数据填充它以进行测试。在这种情况下,可以使用数组推导语法轻松实现:
columar_records = TripPaymentColumnarData(
[r.vendor_id for r in records],
[r.tpep_pickup_datetime for r in records],
[r.tpep_dropoff_datetime for r in records],
[r.passenger_count for r in records],
[r.trip_distance for r in records],
[r.fare_amount for r in records],
[r.extra for r in records],
[r.mta_tax for r in records],
[r.tip_amount for r in records],
[r.tolls_amount for r in records],
[r.improvement_surcharge for r in records],
[r.total_amount for r in records]
);
当我们完成时,我们可以证明给自己,新的对象结构确实得到了优化:

是的,它现在具有我们预期的出色性能。
使用 StructArrays 包
前一列结构的不美观让我们感到非常不满意。我们不仅需要创建一个包含大量 Vector 字段的新数据类型,还必须创建一个构造函数来将我们的结构体数组转换为新的类型。
当我们使用 Julia 生态系统中的强大包时,我们可以认识到 Julia 的强大之处。为了完全实现这种模式,我们将引入 StructArrays.jl 包,该包自动处理将结构体数组转换为数组结构的大部分繁琐任务。
实际上,StructArrays 的使用非常简单:
using StructArrays
sa = StructArray(records)
让我们快速查看其内容。首先,我们可以像处理原始数组一样处理 sa——例如,我们可以像以前一样取数组的头三个元素:

如果我们只选择一条记录,它将返回原始的 TripPayment 对象:

为了确保没有错误,我们还可以检查第一条记录的类型:

因此,新的 sa 对象仍然像以前一样工作。现在,当我们需要从单个字段访问所有数据时,差异就出现了。例如,我们可以如下获取 fare_amount 字段:

因为类型已经作为 密集数组 实现了,所以我们可以在进行数值或统计分析时期待该字段有出色的性能,如下所示:

什么是 DenseArray?它实际上是一个抽象类型,其中数组的所有元素都分配在连续的内存块中。DenseArray 是数组的超类型。
Julia 默认支持动态数组,这意味着当我们向数组中推送更多数据时,数组的大小可以增长。当它分配更多内存时,它会将现有数据复制到新的内存位置。
为了避免过多的内存重新分配,当前实现使用了一种复杂的算法来增加内存分配的大小——足够快以避免过多的重新分配,但足够保守以避免过度分配内存。
理解空间与时间的权衡
StructArrays.jl包提供了一个方便的机制,可以快速将结构数组转换为数组结构。我们必须认识到我们付出的代价是在内存中数据的额外副本。因此,我们再次陷入了计算中的经典空间与时间的权衡。
让我们再次快速查看我们的用例。我们可以在 Julia REPL 中使用Base.summarysize函数来查看内存占用:

Base.summarysize函数返回对象的字节数。我们将数字1024除以两次,得到兆字节单位。有趣的是,数组结构sa比原始结构数组records更节省内存。然而,我们在内存中有两个数据副本。
幸运的是,如果我们想节省内存,我们确实有一些选择。首先,如果我们不再需要该结构中的数据,我们可以简单地丢弃records变量中的原始数据。我们甚至可以强制垃圾收集器运行,如下所示:

其次,当我们完成计算后,我们可以丢弃sa变量。
处理嵌套对象结构
上述示例案例适用于任何平面数据结构。如今,设计包含其他复合类型的类型并不罕见。让我们深入探讨一下,看看我们如何处理这种嵌套结构。
首先,假设我们想要将与票价相关的字段分离到单独的复合数据类型中:
struct TripPayment
vendor_id::String
tpep_pickup_datetime::String
tpep_dropoff_datetime::String
passenger_count::Int
trip_distance::Float64
fare::Fare
end
struct Fare
fare_amount::Float64
extra::Float64
mta_tax::Float64
tip_amount::Float64
tolls_amount::Float64
improvement_surcharge::Float64
total_amount::Float64
end
我们可以稍微调整文件读取器:
function read_trip_payment_file(file)
f = CSV.File(file, datarow = 3)
records = Vector{TripPayment}(undef, length(f))
for (i, row) in enumerate(f)
records[i] = TripPayment(row.VendorID,
row.tpep_pickup_datetime,
row.tpep_dropoff_datetime,
row.passenger_count,
row.trip_distance,
Fare(row.fare_amount,
row.extra,
row.mta_tax,
row.tip_amount,
row.tolls_amount,
row.improvement_surcharge,
row.total_amount))
end
return records
end
在我们读取数据后,行程支付数据的数组将如下所示:

如果我们像以前一样只创建StructArray,我们就无法提取fare_amount字段:

为了在更深层次上达到相同的结果,我们可以使用unwrap选项:

unwrap关键字参数的值基本上是一个接受特定字段数据类型的函数。如果函数返回true,则该特定字段将使用嵌套StructArray构建。
我们现在可以通过另一层间接访问fare_amount字段,如下所示:

使用 unwrap 关键字参数,我们可以轻松地遍历整个数据结构,并创建一个允许我们访问紧凑数组结构中任何数据元素的 StructArray 对象。从这一点开始,应用性能可以得到提升。
考虑事项
在设计应用程序时,我们应该确定用户最重视的是什么。同样,在从事数据分析或数据科学项目时,我们应该考虑我们最关心的是什么。在任何决策过程中,以客户为中心的方法都是至关重要的。
假设我们的优先级是实现更好的性能。那么,下一个问题是系统的哪个部分需要优化?如果部分由于使用结构体数组而变慢,我们采用结构体数组模式时能获得多少速度提升?性能提升是否明显——是按毫秒、分钟、小时还是天数来衡量的?
此外,我们还需要考虑系统限制。我们喜欢认为天空是极限。但回到现实中,我们在系统资源方面到处受限——CPU 核心数、可用内存和磁盘空间,以及其他系统管理员强加的限制,例如,最大打开文件数和进程数。
虽然 struct of arrays 可以提高性能,但为新数组分配内存会有开销。如果数据量很大,分配和数据复制操作也会花费一些时间。
在下一节中,我们将探讨另一种有助于节省内存并允许分布式计算的模式——共享数组。
共享数组模式
现代操作系统可以处理许多并发进程并充分利用所有处理器核心。当涉及到分布式计算时,通常将更大的任务分解成更小的任务,以便多个进程可以并发执行任务。有时,这些个别执行的结果可能需要合并或汇总以供最终交付。这个过程被称为归约。
这个概念以各种形式重生。例如,在函数式编程中,通常使用 map-reduce 来实现数据处理。映射过程将列表应用于每个元素,而归约过程则合并结果。在大数据处理中,Hadoop 使用类似的 map-reduce 形式,但它在集群中的多台机器上运行。DataFrames 包含执行 Split-Apply-Combine 模式的函数。这些都基本上是相同的概念。
有时,并行工作进程需要相互通信。通常,进程可以通过某种形式的进程间通信(IPC)相互交谈。有很多种方法可以做到这一点——套接字、Unix 域套接字、管道、命名管道、消息队列、共享内存和内存映射。
Julia 附带一个名为SharedArrays的标准库,该库与操作系统的共享内存和内存映射接口进行交互。这种设施允许 Julia 进程通过共享中央数据源相互通信。
在本节中,我们将探讨如何使用SharedArrays进行高性能计算。
介绍风险管理用例
在风险管理用例中,我们想要使用蒙特卡洛模拟过程来估计投资组合收益的波动性。概念相当简单。首先,我们根据历史数据开发一个风险模型。其次,我们使用该模型以 10,000 种方式预测未来。最后,我们查看投资组合中证券收益的分布,并评估在每种情景下投资组合的收益或损失。
投资组合通常与基准进行比较。例如,股票投资组合可能以标准普尔 500 指数为基准。原因是投资组合经理通常因获得alpha而获得奖励,alpha 是描述超过基准收益的超额收益的术语。换句话说,投资组合经理因其在挑选正确股票方面的技能而获得奖励。
在固定收益市场中,问题要稍微复杂一些。与股市不同,典型的固定收益基准规模相当大,高达 10,000 个债券。在评估投资组合风险时,我们通常想要分析收益的来源。投资组合的价值上升是因为它在牛市中乘风破浪,还是因为大家都开始抛售而下降?与市场波动相关的风险被称为系统性风险。收益的另一个来源与个别债券有关。例如,如果债券发行商经营良好,盈利丰厚,那么债券的风险就会降低,价格也会上涨。这种由于特定个别债券引起的波动被称为特定风险。对于全球投资组合,一些债券还面临着汇率风险。从计算复杂性的角度来看,为了估计 10,000 个基准指数的收益,我们必须进行10,000 个未来情景 x 10,000 个证券 x 3 个收益来源 = 3 亿次定价计算。
回到我们的模拟示例,我们可以生成 10,000 种可能的投资组合未来情景,结果基本上是一组所有这些情景的收益数据。收益数据存储在磁盘上,现在已准备好进行进一步分析。然而,问题来了——资产管理员必须分析超过 1,000 个投资组合,每个投资组合可能需要访问 10,000 到 50,000 个债券的收益数据,具体取决于基准指数的大小。不幸的是,生产服务器内存有限,但 CPU 资源充足。我们如何充分利用我们的硬件,尽可能快地完成分析?
让我们快速总结一下我们的问题:
-
硬件:
-
16 个 vCPU
-
32 GB RAM
-
-
安全收益数据:
-
存储在 100,000 个单独的文件中
-
每个文件包含一个 10,000 x 3 的矩阵(10,000 个未来状态和 3 个回报来源)
-
总内存占用约为 ~22 GB
-
-
任务:
-
对 10,000 个未来状态中的所有证券回报计算统计指标(标准差、偏度和峰度)。
-
尽快完成这项工作!
-
最简单的方法就是按顺序加载所有文件。不用说,无论文件多小,逐个加载 100,000 个文件都不会很快。我们将使用 Julia 分布式计算功能来完成这项工作。
准备示例数据
要遵循后续代码中的此模式,我们可以准备一些测试数据。在运行这里的代码之前,请确保你有足够的磁盘空间来存储测试数据。你需要大约 22 GB 的空闲空间。
而不是将 100,000 个文件放在单个目录中,我们可以将它们分成 100 个子目录。所以,让我们首先创建这些目录。为此创建了一个简单的函数:
function make_data_directories()
for i in 0:99
mkdir("$i")
end
end
我们可以假设每个证券都有一个介于 1 和 100,000 之间的数值索引。让我们定义一个函数来生成查找文件的路径:
function locate_file(index)
id = index - 1
dir = string(id % 100)
joinpath(dir, "sec$(id).dat")
end
该函数被设计为将文件哈希到 100 个子目录之一。让我们看看它是如何工作的:
julia> locate_file.(vcat(1:2, 100:101))
4-element Array{String,1}:
"0/sec0.dat"
"1/sec1.dat"
"99/sec99.dat"
"0/sec100.dat"
因此,前 100 个证券位于名为 0、1、...、99 的目录中。第 101 个证券开始循环并回到目录 0。出于一致性原因,文件名包含证券索引减 1。
现在我们已经准备好生成测试数据。让我们定义一个如下所示的功能:
function generate_test_data(nfiles)
for i in 1:nfiles
A = rand(10000, 3)
file = locate_file(i)
open(file, "w") do io
write(io, A)
end
end
end
要生成所有测试文件,我们只需通过传递 nfiles 参数值为 100,000 调用此函数。在这个练习结束时,你应该会在所有 100 个子目录中散布着测试文件。请注意,generate_test_data 函数生成所有测试数据需要几分钟时间。我们现在就来做这件事:

当它完成时,让我们快速查看终端中的数据文件:

现在我们准备使用共享数组模式来解决这个问题。让我们开始吧。
高性能解决方案概述
SharedArrays 的美妙之处在于数据保持为单个副本,并且多个进程可以同时具有读写访问权限。这是我们问题的完美解决方案。
在这个解决方案中,我们将执行以下操作:
-
主程序创建一个共享数组。
-
使用分布式
for循环,主程序命令工作进程将每个单独的文件读入数组的特定段。 -
再次,使用分布式
for循环,主程序命令工作进程执行统计分析。
由于我们有 16 个 vCPU,我们可以利用它们全部。
在实践中,我们可能应该使用更少的 vCPUs,这样我们就可以为操作系统本身留出一些空间。你的使用情况可能会根据同一服务器上运行的其他内容而有所不同。最佳方法是测试各种配置并确定最佳设置。
在共享数组中填充数据
安全返回文件分布在 100 个不同的目录中。它们存储的位置基于一个简单的公式:文件索引 modulus 100,其中文件索引是每个安全的数值标识符,编号在 1 到 100,000 之间。
每个数据文件都采用简单的二进制格式。上游进程已经为 10,000 个未来状态计算了 3 个源返回,就像一个 10,000 x 3 的矩阵。布局是列导向的,这意味着前 10,000 个数字用于第一个返回源,接下来的 10,000 个数字用于第二个返回源,依此类推。
在我们开始使用分布式计算函数之前,我们必须启动工作进程。Julia 提供了一个方便的命令行选项(-p),用户可以事先指定工作进程的数量,如下所示:

当 REPL 启动时,我们已经有 16 个进程正在运行并准备就绪。nworkers函数确认所有 16 个工作进程都是可用的。
现在我们来看看代码。首先,我们必须加载Distributed和SharedArrays包:
using Distributed
using SharedArrays
为了确保工作进程知道在哪里找到文件,我们必须在它们中更改目录:
@everywhere cd(joinpath(ENV["HOME"], "julia_book_ch06_data"))
@everywhere 宏会在所有工作进程中执行该语句。
主程序看起来是这样的:
nfiles = 100_000
nstates = 10_000
nattr = 3
valuation = SharedArray{Float64}(nstates, nattr, nfiles)
load_data!(nfiles, valuation)
在这种情况下,我们正在创建一个三维共享数组。然后,我们调用load_data!函数来读取所有 100,000 个文件并将数据推入估值矩阵。load_data!函数是如何工作的?让我们看看:
function load_data!(nfiles, dest)
@sync @distributed for i in 1:nfiles
read_val_file!(i, dest)
end
end
这是一个非常简单的for循环,它只是用索引号调用read_val_file!函数。注意这里使用了两个宏——@distributed和@sync。首先,@distributed宏通过将for循环的主体发送到工作进程来实现魔法。一般来说,这里的 master 程序不会等待工作进程返回。然而,@sync宏会阻塞,直到所有作业都完全完成。
它实际上是如何读取二进制文件的?让我们看看:
# Read a single data file into a segment of the shared array `dest`
# The segment size is specified as in `dims`.
@everywhere function read_val_file!(index, dest)
filename = locate_file(index)
(nstates, nattrs) = size(dest)[1:2]
open(filename) do io
nbytes = nstates * nattrs * 8
buffer = read(io, nbytes)
A = reinterpret(Float64, buffer)
dest[:, :, index] = A
end
end
在这里,函数首先定位数据文件的位置。然后,它打开文件并将所有二进制数据读取到一个字节数组中。由于数据只是 64 位浮点数,我们使用reinterpret函数将数据解析为一个Float64值的数组。我们预计每个文件中都有 30,000 个Float64值,代表 10,000 个未来状态和 3 个源返回。当数据准备好后,我们只需将它们保存到特定索引的数组中。
我们还使用@everywhere宏来确保函数被定义并可供所有工作进程使用。locate_file函数稍微有点无趣。它被包含在这里以示完整性:
@everywhere function locate_file(index)
id = index - 1
dir = string(id % 100)
return joinpath(dir, "sec$(id).dat")
end
为了并行加载数据文件,我们可以定义一个load_data!函数,如下所示:
function load_data!(nfiles, dest)
@sync @distributed for i in 1:nfiles
read_val_file!(i, dest)
end
end
在这里,我们只是在for循环前放置了@sync和@distributed宏。Julia 会自动调度并将调用分配给所有工作进程。现在一切准备就绪,我们可以运行程序:
nfiles = 100_000
nstates = 10_000
nattr = 3
valuation = SharedArray{Float64}(nstates, nattr, nfiles)
我们简单地创建一个估值SharedArray对象。然后,我们将其传递给load_data!函数进行处理:

仅需大约三分钟,就使用 16 个并行进程将 10 万个文件加载到内存中。这相当不错!
如果你尝试在自己的环境中运行程序但遇到错误,那可能是因为系统限制。请参考后面的部分,配置系统设置以使用共享内存,获取更多信息。
结果表明,这个练习仍然是 I/O 受限的。在加载过程中,CPU 利用率始终在 5%左右。如果问题需要增量计算,我们可能可以通过启动其他异步进程来利用剩余的 CPU 资源,这些进程在数据被加载到内存后操作数据。
在共享数组上直接分析数据
使用共享数组允许我们在单个内存空间上对数据进行并行操作。只要我们不修改数据,这些操作就可以独立运行,不会发生冲突。这种类型的问题被称为令人尴尬的并行。
为了说明多进程的强大功能,我们先对一个非常简单的函数进行基准测试,该函数计算所有证券的回报率的标准差:
using Statistics: std
# Find standard deviation of each attribute for each security
function std_by_security(valuation)
(nstates, nattr, n) = size(valuation)
result = zeros(n, nattr)
for i in 1:n
for j in 1:nattr
result[i, j] = std(valuation[:, j, i])
end
end
return result
end
n的值代表证券的数量。nattr的值代表回报来源的数量。让我们看看单个进程需要多少时间。最佳时间记录为 5.286 秒:

@benchmark宏提供了一些关于性能基准的统计数据。有时,查看分布并了解 GC 对性能的影响是有用的。
seconds=30参数被指定是因为这个函数需要秒来运行。默认参数值是 5 秒,这不会允许基准测试收集足够的样本以进行报告。
我们现在可以并行运行程序了。首先,我们需要确保所有子进程都已加载了依赖的包:
@everywhere using Statistics: std
然后,我们可以定义一个分布式函数,如下所示:
function std_by_security2(valuation)
(nstates, nattr, n) = size(valuation)
result = SharedArray{Float64}(n, nattr)
@sync @distributed for i in 1:n
for j in 1:nattr
result[i, j] = std(valuation[:, j, i])
end
end
return result
end
这个函数看起来与上一个非常相似,有一些例外:
-
我们已经分配了一个新的共享数组
result来存储计算数据。这个数组是二维的,因为我们把第三维减少到一个标准差值。这个数组可以被所有工作进程访问。 -
在
for循环前面的@distributed宏用于自动将工作(换句话说,for循环的主体)分布到工作进程中。 -
在
for循环前面的@sync宏使得系统等待直到所有工作完成。
我们现在可以使用相同的 16 个工作进程来基准测试这个新函数的性能:

与单个进程的性能相比,这比之前快了大约 6 倍。
理解并行处理的开销
你有没有注意到这里有什么有趣的地方?由于我们有 16 个工作进程,我们本期望并行处理函数的速度接近 16 倍。但结果只达到了大约 6 倍,这比我们预期的要少。为什么?
答案是这只是规模问题。使用并行处理设施会有一些性能开销。通常,这种开销可以忽略不计,因为它与正在执行的工作量相比微不足道。在这个特定的例子中,计算标准差是一项非常简单的计算。因此,从相对意义上讲,协调远程函数调用和收集结果的开销超过了实际工作本身。
也许我们应该证明这一点。让我们再做一些工作,除了计算标准差之外,还要计算偏度和峰度:
using Statistics: std, mean, median
using StatsBase: skewness, kurtosis
function stats_by_security(valuation, funcs)
(nstates, nattr, n) = size(valuation)
result = zeros(n, nattr, length(funcs))
for i in 1:n
for j in 1:nattr
for (k, f) in enumerate(funcs)
result[i, j, k] = f(valuation[:, j, i])
end
end
end
return result
end
并行处理版本类似:
@everywhere using Statistics: std, mean, median
@everywhere using StatsBase: skewness, kurtosis
function stats_by_security2(valuation, funcs)
(nstates, nattr, n) = size(valuation)
result = SharedArray{Float64}((n, nattr, length(funcs)))
@sync @distributed for i in 1:n
for j in 1:nattr
for (k, f) in enumerate(funcs)
result[i, j, k] = f(valuation[:, j, i])
end
end
end
return result
end
让我们现在比较一下它们的性能:

如前所述,并行处理现在快了 9 倍。
配置系统设置以使用共享内存
SharedArrays 的魔法来自于操作系统中对内存映射和共享内存功能的利用。当处理大量数据时,我们可能需要配置系统以处理数据量。
调整系统内核参数
Linux 操作系统对共享内存的大小有限制。要找出这个限制是多少,我们可以使用 ipcs 命令:

E 单位可能看起来有些不熟悉。它是以艾字节为单位的,基本上意味着 18 个零:kilo、mega、giga、tera、peta 和 exa。明白了吗?所以,我们很幸运,因为限制如此之高,我们可能永远也达不到。然而,如果你看到一个很小的数字,那么你可能需要重新配置系统。三个内核参数如下:
-
最大段数(SHMMNI)
-
最大段大小(SHMMAX)
-
最大总共享内存(SHMALL)
我们可以使用 sysctl 命令找到实际值:

为了调整值,我们再次可以使用 sysctl 命令。例如,要将最大段大小(shmmax)设置为 128 GiB,我们可以这样做:

我们可以看到内核设置已经更新。
配置共享内存设备
仅如前所述更改系统限制是不够的。实际上,Linux 内核将/dev/shm设备用作共享内存的内存后端存储。我们可以使用常规的df命令来找出设备的大小:

在当前状态下,如前所述,/dev/shm设备未被使用。整个块设备的大小为 16 GiB。作为一个练习,现在让我们打开一个 Julia REPL 并创建SharedArray:

重新运行df命令,我们可以看到/dev/shm现在正在使用:

既然我们知道SharedArray使用的是/dev/shm设备,我们该如何增加其大小以适应我们的问题,该问题需要超过 22 GiB 的空间?可以使用带有新大小的mount命令来实现:

/dev/shm的大小现在清楚地显示为28G。
调试共享内存大小问题
如果我们忘记按照前面描述的方式增加大小,而超出了共享内存设备的大小,会发生什么?比如说,我们需要分配 20 GiB,但只有 16 GiB:

即使超出了限制,我们也没有错误!我们是不是在免费乘坐?答案是,不是。实际上,Julia 并不知道限制已被违反。我们甚至可以与 16 GiB 标记附近的数组进行“亲密接触”:

前面的代码只是将前 15 GiB 的内存设置为0x01。到目前为止没有显示错误。回到 shell 中,我们再次检查/dev/shm的大小。显然,15 GiB 正在使用中:

现在,如果我们继续给数组后部分赋值,我们会得到一个难看的总线错误和长长的堆栈跟踪:

你可能会想知道为什么 Julia 不能更聪明一些,提前告诉你没有足够的共享内存空间。实际上,如果你使用了底层操作系统的mmap函数,也会有同样的行为。坦白说,Julia 对系统约束没有任何更多信息。
有时候,一个 C 函数的手册页可能会有用,并提供一些提示。例如,关于mmap调用的文档表明,当程序尝试访问内存缓冲区中不可达的部分时,将会抛出一个 SIGBUS 信号。手册页可以在linux.die.net/man/2/mmap找到。
确保工作进程可以访问代码和数据
在开发并行计算时,初学者经常会遇到以下问题:
-
工作者进程中未定义的函数:这可能表明库包未加载,或者一个仅在当前进程中定义但未在工作者进程中定义的函数。这两个问题都可以通过使用前面示例中显示的
@everywhere宏来解决。 -
工作者进程中不可用的数据:这可能表明数据作为变量存储在当前进程中,但没有传递给工作者进程。
SharedArray非常方便,因为它会自动提供给工作者进程。对于其他情况,程序员通常有两个选择:-
明确通过函数参数传递数据。
-
如果数据存储在全局变量中,则可以使用
@everywhere宏进行传输,如下所示:
-
@everywhere my_global_var = whatever_value
对于更高级的使用案例,ParallelDataTransfer.jl 包提供了一些有用的函数,以促进主进程和工作者进程之间的数据传输。
避免并行过程中的竞态条件
SharedArrays 提供了一种简单的方法,可以在多个进程之间共享数据。同时,SharedArray 按设计是所有工作者进程的全局变量。对于每个并行程序,通常的规则是,在数组被变异时应该给予极大的关注。如果需要多个进程写入相同的内存地址,那么这些操作必须同步,否则程序可能会轻易崩溃。
最佳选择是尽可能避免变异。
另一种选择是为每个工作者分配数组中互斥的槽位,这样他们就不会相互冲突。
与共享数组的约束一起工作
SharedArray 中的元素必须是位类型。这意味着什么?位类型的正式定义可以总结如下:
-
类型是不可变的。
-
该类型只包含原始类型或其他位类型。
以下 OrderItem 类型是位类型,因为所有字段都是原始类型:
struct OrderItem
order_id::Int
item_id::Int
price::Float64
quantity::Int
end
以下 Customer 类型不是位类型,因为它包含对 String 的引用,而 String 既不是原始类型也不是位类型:
struct Customer
name::String
age::Int
end
让我们尝试为位类型创建 SharedArray。以下代码确认它工作正常:

如果我们尝试使用非位类型(如可变结构类型)创建 SharedArray,则会导致错误:

总结来说,Julia 的共享数组是向多个并行进程分配数据以进行高性能计算的好方法。编程接口也非常易于使用。
在下一节中,我们将探讨一种通过利用时空权衡来提高性能的模式。
缓存模式
在 1968 年,发表了一篇有趣的文章——它设想计算机应该在执行过程中从经验中学习并提高自己的效率。
在软件开发过程中,我们经常面临执行速度受多种因素限制的情况。可能是一个函数需要从磁盘(也称为 I/O 绑定)读取大量历史数据。或者,一个函数只需要执行一些耗时较多的复杂计算(也称为 CPU 绑定)。当这些函数被反复调用时,应用程序的性能可能会受到严重影响。
记忆化是一个强大的概念,用于解决这些问题。近年来,随着函数式编程变得越来越主流,它变得越来越流行。这个想法真的很简单。当一个函数第一次被调用时,其返回值被存储在缓存中。如果函数再次以与之前完全相同的参数被调用,我们可以从缓存中查找该值并立即返回结果。
如您在本节后面将看到的,记忆化是一种特定的缓存形式,其中函数调用的返回数据根据传递给函数的参数进行缓存。
引入斐波那契函数
在函数式编程中,递归是计算中的一种常见技术。有时,我们可能无意中陷入性能陷阱。一个经典的例子是生成斐波那契序列,它被定义为如下:

它在函数式编程中效果很好,但效率不高。为什么?因为它是以递归方式定义的函数,并且多次以相同的参数调用相同的函数。让我们看看寻找第六个斐波那契数时的计算图,其中每个f(n)节点代表对fib函数的调用:

如您所见,函数被多次调用,尤其是那些位于序列开头部分的函数。为了计算fib(6),我们最终调用了该函数 15 次!而且这就像一个雪球,迅速恶化。
提高斐波那契函数的性能
首先,让我们通过修改函数以跟踪执行次数来分析性能有多糟糕。代码如下:
function fib(n)
if n < 3
return (result = 1, counter = 1)
else
result1, counter1 = fib(n - 1)
result2, counter2 = fib(n - 2)
return (result = result1 + result2, counter = 1 + counter1 + counter2)
end
end
每次调用fib函数时,它都会跟踪一个计数器。如果n的值小于3,则返回1的计数以及结果。如果n是一个更大的数字,则从对fib函数的递归调用中聚合计数。
让我们用不同的输入值运行它几次:

这个简单的例子仅仅说明了当计算机没有关于之前做了什么的记忆时,它会如何迅速变成灾难。一个高中生只需用 18 次加法就能手动计算fib(20),不考虑序列的前两个数字。我们这个不错的小函数会调用自己超过 13,000 次!
现在,让我们恢复原始代码并基准测试该函数。为了说明问题,我将从fib(40)开始:

对于这个任务,函数应该立即返回。430 毫秒在计算机时间上感觉就像永恒!
我们可以使用缓存来解决这个问题。这是我们的第一次尝试:
const fib_cache = Dict()
_fib(n) = n < 3 ? 1 : fib(n-1) + fib(n-2)
function fib(n)
if haskey(fib_cache, n)
return fib_cache[n]
else
value = _fib(n)
fib_cache[n] = value
return value
end
end
首先,我们创建了一个名为fib_cache的字典对象来存储之前计算的结果。然后,斐波那契数列的核心逻辑被捕获在这个私有函数_fib中。
fib函数通过首先从fib_cache字典中查找输入参数来工作。如果找到值,则返回该值。否则,它调用私有函数_fib,并在返回值之前更新缓存。
性能应该会更好。让我们快速测试一下:

到现在为止,我们应该对性能结果感到非常满意。
我们在这里使用了一个Dict对象来缓存计算结果,以供演示。实际上,我们可以通过使用数组作为缓存来进一步优化它。从数组中查找应该比字典键查找快得多。
注意,数组缓存对于fib函数来说效果很好,因为它接受一个正整数参数。对于更复杂的函数,使用Dict缓存会更合适。
自动化构建缓存
虽然我们对前面实现的结果相当满意,但它感觉有点不满意,因为我们每次需要缓存新函数时都必须编写相同的代码。如果缓存可以自动维护,那不是很好吗?现实情况下,我们只需要为每个想要缓存函数的函数维护一个缓存。
所以,让我们稍微改变一下方法。想法是,我们应该能够构建一个高阶函数,它接受一个现有函数并返回其缓存版本。在我们到达那里之前,让我们首先将我们的fib函数重新定义为匿名函数,如下所示:
fib = n -> begin
println("called")
return n < 3 ? 1 : fib(n-1) + fib(n-2)
end
目前,我们添加了一个println语句,只是为了验证我们实现的正确性。如果它工作正常,fib不应该被调用数百万次。继续前进,我们可以定义一个memoize函数,如下所示:
function memoize(f)
memo = Dict()
x -> begin
if haskey(memo, x)
return memo[x]
else
value = f(x)
memo[x] = value
return value
end
end
end
memoize函数首先创建一个名为memo的局部变量来存储之前的返回值。然后,它返回一个捕获memo变量的匿名函数,执行缓存查找,并在需要时调用f函数。这种在匿名函数中捕获变量的编码风格称为闭包。现在,我们可以使用memoize函数来构建一个缓存感知的fib函数:
fib = memoize(fib)
让我们也证明它不会调用原始的fib函数太多次。例如,运行fib(6)应该不超过 6 次调用:

这看起来很令人满意。如果我们再次运行函数,并使用任何小于或等于 6 的输入,那么原始逻辑根本不应该被调用,所有结果都应该直接从缓存中返回。然而,如果输入大于 6,那么它将计算大于 6 的数值。现在让我们试试看:

在我们对新代码进行基准测试之前,我们不能断定我们所做的是否足够好。现在让我们来做:

原始函数计算fib(400)花费了 433 毫秒。这个缓存的版本只用了 50 纳秒。这是一个巨大的差异。
理解通用函数的约束
前述方法的缺点之一是我们必须将原始函数定义为匿名函数而不是通用函数。这似乎是一个主要的限制。问题是为什么它不能与通用函数一起工作?
让我们通过启动一个新的 Julia REPL,再次定义原始的fib函数,并用相同的memoize函数包装它来进行快速测试:

问题在于fib已经被定义为通用函数,并且不能绑定到一个新的匿名函数上,这正是memoize函数返回的内容。为了解决这个问题,我们可能会想将缓存的函数赋予一个新的名称:
fib_fast = memoize(fib)
然而,这实际上并没有起作用,因为原始的fib函数是对自身进行递归调用,而不是对新缓存的版本进行调用。为了更清楚地看到这一点,我们可以展开调用如下:
-
将函数调用为
fib_fast(6)。 -
在
fib_fast函数中,它检查缓存是否包含一个等于 6 的键。 -
答案是否定的,所以它调用
fib(5)。 -
在
fib函数中,由于n是5并且大于3,它递归地调用fib(4)和fib(3)。
如您所见,原始的fib函数被调用,而不是缓存的版本,所以我们回到了之前的问题。因此,如果被缓存的函数使用递归,那么我们必须将函数写成匿名函数。否则,可以创建一个带有新名称的缓存的函数。
支持接受多个参数的函数
在实践中,我们可能会遇到比这更复杂的函数。例如,需要加速的函数可能需要多个参数,以及可能的键控参数。我们之前章节中的memoize函数假设只有一个参数,所以它可能不会正常工作。
修复这个问题的一个简单方法如下所示:
function memoize(f)
memo = Dict()
(args...; kwargs...) -> begin
x = (args, kwargs)
if haskey(memo, x)
return memo[x]
else
value = f(args...; kwargs...)
memo[x] = value
return value
end
end
end
现在返回的匿名函数覆盖了任何数量的位置参数和关键字参数,正如在展开参数args...和kwargs...中指定的那样。我们可以用一个虚拟函数快速测试这一点如下:
# Simulate a slow function with positional arguments and keyword arguments
slow_op = (a, b = 2; c = 3, d) -> begin
sleep(2)
a + b + c + d
end
然后,我们可以创建快速版本如下:
op = memoize(slow_op)
让我们用几个不同的案例来测试缓存的函数:

它运行得很好!
处理参数中的可变数据类型
到目前为止,我们没有过多关注传递给函数的参数或关键字参数。当这些参数中的任何一个可变时,必须小心处理。为什么?因为我们的当前实现使用参数作为字典缓存的键。如果我们更改字典的键,可能会导致意外的结果。
假设我们有一个运行需要 2 秒的函数:
# This is a slow implementation
slow_sum_abs = (x::AbstractVector{T} where {T <: Real}) -> begin
sleep(2)
sum(abs(v) for v in x)
end
知道它相当慢,我们很高兴地像往常一样进行备忘录:
sum_abs = memoize(slow_sum_abs)
初始时,它似乎工作得完美,因为它一直是这样的:

然而,我们对以下观察感到震惊:

糟糕! 它返回的不是21的值,而是像没有向数组中插入-6一样返回了之前的结果。出于好奇,让我们向数组中再推入一个值并再次尝试:

它又正常工作了。为什么会这样呢?为了理解这一点,让我们回顾一下memoize函数是如何编写的:
function memoize(f)
memo = Dict()
(args...; kwargs...) -> begin
x = (args, kwargs)
if haskey(memo, x)
return memo[x]
...
如您所见,我们正在使用(args, kwargs)元组作为字典对象的键来缓存数据。问题是传递给备忘录sum_abs函数的参数是一个可变对象。当键被更改时,字典对象会变得困惑。在这种情况下,它可能不再定位到键。
当我们将-6添加到数组中时,它在字典中找到了相同的对象并返回了缓存的值。当我们向数组中添加7时,它找不到对象。因此,该函数并不总是 100%有效。
为了解决这个问题,我们需要确保考虑的是参数的内容,而不仅仅是容器的内存地址。一个常见的做法是将我们希望用作字典键的东西应用一个hash函数。以下是一个实现示例:
function hash_all_args(args, kwargs)
h = 0xed98007bd4471dc2
h += hash(args, h)
h += hash(kwargs, h)
return h
end
h变量的初始值是随机选择的。在 64 位系统上,我们可以通过调用rand(UInt64)来生成它。hash函数是在Base模块中定义的通用函数。为了说明目的,我们将保持这里的简单性。实际上,一个更好的实现将支持 32 位系统。
现在,memoize函数可以被重写以利用这种哈希方案:

我们可以更广泛地测试它。让我们再次使用新的memoize函数重新定义sum_abs函数。然后,我们运行一个循环并捕获计算结果和计时。
结果如下所示:

太棒了! 即使输入数据已经更改,它现在也能返回正确的结果。
使用宏备忘录通用函数
之前,我们讨论了泛型函数不能由 memoize 函数支持。如果在定义函数时就能将其标记为记忆化的,那将是最棒的。例如,语法将如下所示:
@memoize fib(n) = n < 3 ? 1 : fib(n-1) + fib(n-2)
结果表明,已经有一个名为 Memoize.jl 的出色包可以完成完全相同的功能。这确实非常方便:

在这里,我们可以观察到以下情况:
-
fib(40)的第一次调用已经非常快了,这表明缓存已经被利用。 -
fib(40)的第二次调用几乎是瞬间的,这意味着结果只是缓存查找。 -
fib(39)的第三次调用几乎是瞬间的,这意味着结果只是缓存查找。
应该提醒您,Memoize.jl 也不支持可变数据作为参数。它携带了我们在上一节中描述的相同问题,因为它使用对象的内存地址作为字典的键。
转到现实生活中的例子
记忆化在有些开源包中被使用。在私有应用程序和数据分析中,实际使用可能更为常见。在接下来的几节中,我们将看看记忆化的使用案例。
Symata.jl
Symata.jl 包提供了对斐波那契多项式的支持。正如我们可能已经意识到的,斐波那契多项式的实现也像我们在本节前面讨论的斐波那契序列问题一样是递归的。Symata.jl 使用 Memoize.jl 包创建 _fibpoly 函数,如下所示:
fibpoly(n::Int) = _fib_poly(n)
let myzero = 0, myone = 1, xvar = Polynomials.Poly([myzero,myone]), zerovar = Polynomials.Poly([myzero]), onevar = Polynomials.Poly([myone])
global _fib_poly
@memoize function _fib_poly(n::Int)
if n == 0
return zerovar
elseif n == 1
return onevar
else
return xvar * _fib_poly(n-1) + _fib_poly(n-2)
end
end
end
Omega.jl
Omega.jl 包实现了它自己的记忆化缓存。有趣的是,它使用 Core.Compiler.return_type 函数确保从缓存查找中返回正确的返回类型。这样做是为了避免类型不稳定性问题。在本章后面的“屏障函数模式”部分,我们将更详细地讨论类型不稳定性问题以及如何处理这个问题。查看以下代码示例:
@inline function memapl(rv::RandVar, mω::TaggedΩ)
if dontcache(rv)
ppapl(rv, proj(mω, rv))
elseif haskey(mω.tags.cache, rv.id)
mω.tags.cache[rv.id]::(Core.Compiler).return_type(rv, typeof((mω.taggedω,)))
else
mω.tags.cache[rv.id] = ppapl(rv, proj(mω, rv))
end
end
注意事项
记忆化只能应用于 纯 函数。
什么是纯函数?当一个函数对于相同的输入总是返回相同的值时,我们称之为纯函数。对于每个函数都按这种方式行为可能看起来很直观,但在实践中,这并不那么简单。有些函数由于以下原因不是纯函数:
-
一个函数使用随机数生成器,并期望返回随机结果。
-
一个函数依赖于来自外部源的数据,该数据在不同时间产生不同的数据。
因为记忆化模式使用函数参数作为内存缓存的键,所以对于相同的键,它总是会返回相同的结果。
另一个考虑因素是我们应该意识到由于使用缓存而导致的额外内存开销。对于特定的用例,选择正确的缓存失效策略非常重要。典型的缓存失效策略包括 最近最少使用(LRU)、先进先出(FIFO)和基于时间的过期。
利用 Caching.jl 包
有几个包可以使记忆化更容易。其中一些在此处被提及:
-
Memoize.jl提供了一个@memoize宏。它非常容易使用。 -
Anamnesis.jl提供了一个@anamnesis宏。它比Memoize.jl具有更多的功能。 -
Caching.jl是带着提供更多功能如持久化到磁盘、压缩和缓存大小管理的雄心创建的。
在这里,我们可以看看 Caching.jl,因为它最近开发出来,并且具有许多优秀特性。
让我们按照以下方式构建一个记忆化的 CSV 文件读取器:

@cache 宏创建了一个 read_csv 函数的记忆化版本。为了确认文件只被读取了一次,我们插入了一个 println 语句并计时文件读取操作。
为了演示目的,我们已经从纽约市下载了一份电影许可文件的副本。该文件可在 catalog.data.gov/dataset/film-permits 获取。现在让我们读取数据文件:

在这里,我们可以看到文件只被读取了一次。如果我们再次使用相同的文件名调用 read_csv,那么相同的对象会立即返回。
我们可以检查缓存。在这样做之前,让我们看看 read_csv 支持哪些属性:

不看手册,我们可以猜测 cache 属性代表缓存。让我们快速看一下:

我们还可以将缓存持久化到磁盘。让我们检查缓存文件的名字和大小:

缓存文件的存储位置可以在 filename 属性中找到。文件在未使用 @persist! 宏将数据持久化到磁盘之前不存在。我们也可以通过仅检查从 REPL 的函数 itself 来查看内存或磁盘上存在多少对象:

@empty! 宏可以用来清除内存中的缓存:

有趣的是,因为磁盘上的缓存仍然存在,我们仍然可以不重新填充内存缓存来利用它:

最后,我们可以同步内存和磁盘缓存:

Caching.jl 包具有更多在此处未展示的功能。希望我们已经对它的能力有了基本的了解。
接下来,我们将探讨一种可以用来解决类型不稳定性问题(这是一个常见的导致性能问题的原因)的模式。
障碍函数模式
虽然 Julia 被设计为一种动态语言,但它也旨在实现高性能。其魔力来自于其最先进的编译器。当函数中变量的类型已知时,编译器可以生成高度优化的代码。然而,当变量的类型不稳定时,编译器必须编译更通用的代码,这些代码可以与任何数据类型一起工作。在某种程度上,Julia 可以宽恕——即使它对运行时性能有所牺牲,它也不会让你失败。
什么使得变量的类型不稳定?这意味着在某些情况下,变量可能是一种类型,而在其他情况下,它可能是另一种类型。本节将讨论这种类型不稳定性问题,它可能如何产生,以及我们可以做些什么。
障碍函数模式是一种可以用来解决由于类型不稳定性引起的性能问题的模式。那么,让我们看看如何实现这一点。
识别类型不稳定的函数
在 Julia 中,没有必要指定变量的类型。更准确地说,变量是没有类型的。变量仅仅是与值绑定的,而值是有类型的。这就是 Julia 程序动态性的原因。然而,这种灵活性是有代价的。因为编译器必须生成支持在运行时可能出现的所有可能类型的代码,因此它无法生成优化代码。
考虑一个简单的函数,它只返回一个随机数数组:
random_data(n) = isodd(n) ? rand(Int, n) : rand(Float64, n)
如果n参数是奇数,则返回一个随机的Int值数组。否则,返回一个随机的Float64值数组。
这个看似无辜的函数实际上是类型不稳定的。我们可以使用@code_warntype功能进行检查:

@code_warntype宏显示了代码的中间表示(IR)。编译器在理解了代码中每一行的流程和数据类型后,会生成一个 IR。对于我们这里的用途,我们不需要理解屏幕上打印出的所有内容,但我们可以关注与代码生成数据类型相关的突出文本。一般来说,当你看到红色文本时,它也会是一个红色的警告标志。
在这种情况下,编译器已经推断出这个函数的结果可以是一个Float64类型的数组或一个Int64类型的数组。因此,返回类型只是Union{Array{Float64,1}, Array{Int64,1}}。
通常,@code_warntype输出的红色标志越多,代码中的类型不稳定性问题就越多。
函数确实做了我们想要做的事情。但是当它在另一个函数的主体中使用时,类型不稳定性问题进一步影响了运行时性能。我们可以使用一个障碍函数来解决这个问题。
理解性能影响
当一个函数被调用时,其参数的类型是已知的,然后函数会根据其参数的确切数据类型进行编译。这被称为专业化。那么什么是屏障函数呢?它只是简单地利用 Julia 的函数专业化来稳定变量类型,作为函数调用的一部分。我们将继续前面的例子来说明这项技术。
首先,让我们创建一个简单的函数,该函数使用前面提到的类型不稳定的函数:
function double_sum_of_random_data(n)
data = random_data(n)
total = 0
for v in data
total += 2 * v
end
return total
end
double_sum_of_random_data 函数只是一个简单的函数,它返回由 random_data 函数生成的双倍随机数的和。如果我们只用奇数或偶数参数来基准测试这个函数,它将返回以下结果:

当输入值为 100001 时,调用时间更好,这很可能是由于 Int 的随机数生成器比 Float64 的更好。让我们看看 @code_warntype 对这个函数的反馈:

如您所见,周围有很多红色标记。单个函数的类型不稳定性问题对其使用的其他函数影响更大。
开发屏障函数
屏障函数涉及将现有函数中的一段逻辑重构到一个新的、独立的函数中。完成之后,所有需要的新函数的数据都将作为函数参数传递。继续前面的例子,我们可以将计算数据双倍和的逻辑提取出来如下:
function double_sum(data)
total = 0
for v in data
total += 2 * v
end
return total
end
然后,我们只需修改原始函数以利用这个函数:
function double_sum_of_random_data(n)
data = random_data(n)
return double_sum(data)
end
它真的提高了性能吗?让我们运行测试:

对于 Float64 的情况,这显示出巨大的差异——经过时间从 347 纳秒减少到 245 纳秒。比较浮点数求和与整数求和的情况,结果也完全合理,因为通常整数求和比浮点数求和更快。
处理类型不稳定的输出变量
我们还没有注意到另一个与累加器相关的类型不稳定性问题。在前面的例子中,double_sum 函数有一个 total 变量,用于跟踪双倍数字。问题是该变量被定义为整数,但数组可能包含浮点数。这个问题可以通过对两种情况运行 @code_warntype 来轻松揭示。
当将整数数组传递到函数中时,@code_warntype 的输出如下:

与传递 Float64 数组时的输出进行比较:

如果我们用一个整数数组调用该函数,那么类型是稳定的。如果我们用一个浮点数数组调用该函数,那么我们会看到类型不稳定性问题。
我们该如何解决这个问题呢?嗯,有标准的 Base 函数可以创建类型稳定的零或一。例如,我们不必将 total 的初始值硬编码为整数零,而是可以这样做:
function double_sum(data)
total = zero(eltype(data))
for v in data
total += 2 * v
end
return total
end
如果我们查看 double_sum_of_random_data 函数的 @code_warntype 输出,它比之前好得多。我将让你做这个练习,并将 @code_warntype 输出与之前的一个进行比较。
类似的解决方案使用了参数化方法:
function double_sum(data::AbstractVector{T}) where {T <: Number}
total = zero(T)
for v in data
total += v
end
return total
end
类型参数 T 用于将 total 变量初始化为正确的零值类型。
这种性能问题有时很难捕捉到。为了确保生成优化的代码,始终使用以下函数作为累加器或存储输出值的数组是一个好习惯:
-
zero和zeros为所需类型创建一个 0 或 0 的数组。 -
one和ones为所需类型创建一个 1 或 1 的数组。 -
similar创建一个与数组参数具有相同类型的数组。
例如,我们可以为任何数值类型创建一个 0 或 0 的数组,如下所示:

同样,one 和 ones 函数以相同的方式工作:

如果我们想要创建一个看起来像另一个数组(换句话说,具有相同的类型、形状和大小)的数组,那么我们可以使用 similar 函数:

注意,similar 函数不会将数组的内容清零。
当我们需要创建一个与另一个数组具有相同维度的零数组时,axes 函数可能很有用:

接下来,我们将探讨一种调试类型不稳定性问题的方法。
使用 @inferred 宏
Julia 在 Test 包中提供了一个方便的宏,可以用来检查函数的返回类型是否与函数的 推断 返回类型匹配。推断的返回类型简单地是我们从 @code_warntype 输出中看到的类型。
例如,我们可以检查本节开头的臭名昭著的 random_data 函数:

该宏在实际返回类型与推断返回类型不同时报告错误。它可以作为一个有用的工具,作为持续集成管道中自动化测试套件的一部分来验证类型不稳定性问题。
使用屏障函数的主要原因是提高存在类型不稳定性问题的性能。如果我们更深入地思考,它还有副作用,即迫使我们创建更小的函数。较小的函数更容易阅读和调试,并且性能更好。
我们现在已经总结了本章的所有模式。
摘要
在本章中,我们探讨了与性能相关的几个模式。
首先,我们讨论了全局变量如何影响性能以及全局常量模式的技术。我们研究了编译器如何通过常数折叠、常数传播和死分支消除来优化性能。我们还学习了如何创建一个常数占位符来包装全局变量。
我们讨论了如何利用数组结构模式将结构体数组转换为数组结构体。这种数据结构的新布局允许更好的 CPU 优化,从而提高性能。我们利用了一个非常有用的包StructArrays来自动化这种数据结构转换。我们回顾了一个金融服务用例,其中需要将大量数据加载到内存中并由多个并行进程使用。我们实现了共享数组模式,并介绍了一些在操作系统中正确配置共享内存的技巧。
我们学习了记忆化模式用于缓存函数调用结果。我们使用字典缓存进行了一个示例实现,使其能够与接受各种参数和关键字参数的函数一起工作。我们还找到了一种支持可变对象作为函数参数的方法。最后,我们讨论了屏障函数模式。我们看到了类型不稳定的变量如何降低性能。我们了解到将逻辑拆分到单独的函数中可以让编译器生成更优化的代码。
在下一章中,我们将探讨几个提高系统可维护性的模式。
问题
-
为什么全局变量的使用会影响性能?
-
当全局变量不能被常数替换时,使用什么作为替代方案?
-
为什么数组结构比结构体数组表现更好?
-
SharedArray有哪些局限性? -
使用并行进程之外,多核计算的替代方案是什么?
-
使用记忆化模式时需要注意哪些事项?
-
提高性能的屏障函数背后的魔法是什么?
第七章:可维护性模式
本章将介绍与提高代码可读性和易于维护相关的几种模式。这些方面有时会被忽视,因为程序员总是认为他们知道自己在做什么。实际上,程序员并不总是编写其他人可读的代码。有时,代码可能过于杂乱,难以跟随,或者文件可能没有很好地组织。这些问题通常可以通过重构来缓解。
元编程可以是一个进一步提高可读性和可维护性的好方法。在某些情况下,我们可以利用今天现有的宏。如果我们不探索这样的机会,那将是一件遗憾的事情。我们知道优秀的程序员总是有着不懈的追求卓越的渴望,因此学习这些技术将是一项有益的练习。在接下来的章节中,我们将探讨以下模式:
-
子模块模式
-
关键字定义模式
-
代码生成模式
-
领域特定语言模式
到本章结束时,你将学会如何更好地组织你的代码。你将能够减少杂乱,并编写非常简洁的代码。此外,如果你正在处理一个具有特定行业领域的难题,你可以构建自己的领域特定语言(DSL)来进一步清晰地用你自己的语法表达你的问题。
让我们开始吧!
技术要求
代码在 Julia 1.3.0 环境中进行测试。
子模块模式
当一个模块变得太大时,它可能难以管理和理解。通常,当程序员不断地向应用程序添加更多功能时,这种情况会自然发生。那么,多大才算太大?这很难说,因为它因编程语言、问题领域以及应用程序维护者的技能集而异。尽管如此,专业人士普遍认为,较小的模块更容易管理,尤其是在代码由多个开发者维护的情况下。
在本节中,我们将探讨将大型模块的源代码拆分为单独管理的子模块的想法。我们将讨论如何做出这个决定以及如何正确地执行。作为我们旅程的一部分,我们将查看一些示例,并看看其他专家如何在他们的包中这样做。
理解何时需要子模块
我们应该在什么时候考虑创建子模块?有几个因素需要考虑:
-
首先,我们可以考虑应用程序的大小。大小是一个抽象概念,可以通过多种方式来衡量,其中一些方式在这里被提及:
-
代码行数:这是理解应用程序大小的最简单方法。源文件中的代码行数越多,应用程序就越大。这类似于一本书的页数。一本书的页数越多,你阅读和理解内容所需的时间就越长。
-
函数数量:当单个模块中有太多函数时,理解和学习所有这些函数会更加困难。当函数数量过多时,函数之间的交互数量自然增加,使得应用程序更容易出现混乱的意大利面代码。
-
数据类型数量:每种数据类型都代表一种对象。对于开发者来说,理解作用于大量数据类型的所有函数是比较困难的,因为人脑不能同时处理太多概念。
-
-
我们还应该考虑关注点的分离。当我们查看由各种组件组成的应用程序时,我们可能会逻辑上认为它们是独立的事物,可以独立管理。人类是一种优秀的物种,知道如何处理小而有序的项目。
-
最后,我们可以考虑物质复杂性。有时候,你看一下源代码,会发现逻辑难以理解。这可能是因为领域知识。或者,它可能是一个复杂的算法。尽管应用程序的大小并不大,但将代码物理上拆分成单独的文件仍然是有意义的。
到目前为止,我们还没有为任何先前的因素设定任何具体的阈值。这是因为判断某事物是否庞大或复杂是非常主观的。一个常见的方法是让几位软件工程师进行讨论,并做出集体决定。这样做可以帮助我们克服原始开发者偏见,即某人已经对一切了如指掌,因此,这个人可能会倾向于认为应用程序既不太大也不太复杂。
假设你已经准备好跳入水中,将部分代码拆分成子模块。接下来的挑战是如何正确地做到这一点。这项工作既需要艺术也需要科学。为了将源代码拆分成子模块的过程形式化,我们首先将讨论耦合的概念。
理解输入和输出耦合
在将代码拆分成单独的组件之前,第一步是分析现有的代码结构。是否存在任何独立存在的高级领域概念?例如,一个银行应用程序可能涉及账户管理、存款/取款、转账、客户通知等。这些领域概念中的每一个都可能被拆分成单独的组件。
我们还必须了解组件之间是如何相互作用的。在这里,我们将讨论起源于面向对象编程的两个概念:
-
输入耦合 - 依赖于当前实体的外部实体数量
-
输出耦合 - 当前实体所依赖的外部实体的数量
让我们看看这个图:

在这个例子中,我们可以得出以下观察结果:
-
组件 A 有两个输出耦合。
-
组件 B 有一个输入耦合和一个输出耦合。
-
组件 C 有一个输出耦合。
-
组件 D 有三个输入耦合。
因此,如果一个组件被许多外部组件使用,那么这个组件就有高输入耦合。另一方面,如果一个组件使用许多外部组件,那么它就有高输出耦合。
这些耦合特性帮助我们理解组件的稳定性要求。具有高输入耦合的组件需要尽可能稳定,因为在这个组件中做出更改可能具有更高的风险,会破坏其他组件。在前面例子中的组件 D 就是这种情况。
同样,具有高输出耦合的组件可能由于它所依赖的组件的许多可能变化而更加不稳定。前面例子中的组件 A 就是这种情况。因此,尽可能减少耦合是最佳选择,无论是输入耦合还是输出耦合。解耦系统通常具有最少数量的输入和输出耦合。
设计子模块时也适用相同的概念。当我们把代码分成独立的子模块时,如果输入/输出耦合最小化,那就最理想了。现在,我们将首先看看组织子模块文件的最佳实践。
组织子模块
组织子模块文件通常有两种模式。让我们看看每种模式:
- 第一种情况涉及一个更简单的情况,即每个子模块完全包含在一个源文件中,如下所示:
module MyPackage
include("sub_module1.jl")
include("sub_module2.jl")
include("sub_module3.jl")
end
- 第二种情况涉及较大的子模块,其中每个子模块可能有几个源文件。在这种情况下,子模块的源代码位于子目录中:
# MyPackage.jl
module MyPackage
include("sub_module1/sub_module1.jl")
include("sub_module2/sub_module2.jl")
include("sub_module3.jl")
end
- 当然,子模块的目录可能包含多个文件。在前面例子中,
sub_module1可能包含更多源文件,这些文件在下面的代码片段中显示:
# sub_module1.jl
module SubModule1
include("file1.jl")
include("file2.jl")
include("file3.jl")
end
接下来,我们将探讨如何在模块和这些子模块之间引用符号和函数。
在模块和子模块之间引用符号和函数
模块可以使用常规的using或import语句访问其子模块。实际上,子模块与外部包的工作方式没有区别,只是引用方式不同。
也许我们可以回忆起第二章中的例子,模块、包和类型概念。当时,我们创建了一个Calculator模块,它定义了两个与利率相关的函数和一个Mortgage子模块,该子模块定义了一个支付计算器函数。Calculator模块文件具有以下源代码:
# Calculator.jl
module Calculator
include("Mortgage.jl")
export interest, rate
function interest(amount, rate)
return amount * (1 + rate)
end
function rate(amount, interest)
return interest / amount
end
end # module
此外,子模块包含以下代码:
# Mortgage.jl
module Mortgage
function payment(amount, rate, years)
# TODO code to calculate monthly payment for the loan
return 100.00
end
end # module
让我们来看看如何从子模块引用函数和符号,反之亦然。
从子模块引用定义的符号
首先,我们可以通过payment函数的真实实现来完成Mortgage子模块的实现。
让我们看看这是如何工作的:
payment函数接受贷款金额、年利率、贷款年数,并计算贷款的月付款额,如下面的代码所示:
# Mortgage.jl
module Mortgage
function payment(amount, rate, years)
monthly_rate = rate / 12.0
factor = (1 + monthly_rate) ^ (years * 12.0)
return amount * (monthly_rate * factor / (factor - 1))
end
end # module
- 到目前为止,
Calculator模块应该能够像使用另一个模块一样使用Mortgage子模块,只是访问子模块的表示法需要一个以点表示法为前缀的相对路径:
# Calculator.jl
module Calculator
# include sub-modules
include("Mortgage.jl")
using .Mortgage: payment
# functions for the main module
include("funcs.jl")
end # module
这里,我们通过using .Mortgage: payment将payment函数引入了子模块的当前作用域。
- 为了更好地组织我们的代码,我们还把函数移动到了一个名为
funcs.jl的单独文件中。代码如下:
# funcs.jl - common calculation functions
export interest, rate, mortgage
function interest(amount, rate)
return amount * (1 + rate)
end
function rate(amount, interest)
return interest / amount
end
# uses payment function from Mortgage.jl
function mortgage(home_price, down_payment, rate, years)
return payment(home_price - down_payment, rate, years)
end
如我们所见,新的mortgage函数现在可以使用来自 Mortgage 子模块的payment函数。
从父模块引用符号
如果子模块需要访问父模块中的任何符号,那么子模块可以在添加..作为父模块名称的前缀的同时使用import或using语句。以下代码展示了这一点:
# Mortgage.jl
module Mortgage
# access to parent module's variable
using ..Calculator: days_per_year
end # module
现在,Mortgage子模块可以访问父模块中的days_per_year常量。
能够在模块和子模块之间引用符号和函数,使我们能够将代码重新组织到各种子模块中,同时保持其原有的工作状态。然而,最初将代码分离到子模块中的原因是为了让开发者能够独立地在每个模块中工作。此外,双向引用可能会导致混淆和混乱的意大利面条式代码。
接下来,我们将讨论如何减少模块和子模块之间的这种耦合。
移除双向耦合
当一个模块(或子模块)引用另一个子模块,反之亦然时,会增加这些组件之间的耦合。一般来说,最好避免父模块和子模块之间的双向依赖,因为它引入了紧密耦合,使得代码难以理解和调试。我们该如何解决这个问题?让我们接下来探讨这个问题。
将数据作为函数参数传递
第一种解决方案是将所需数据作为函数参数传递。假设Mortgage子模块中的payment函数可以接受一个名为days_per_year的关键字参数,那么Calculator模块可以按如下方式传递值:
# Calculator.jl
module Calculator
const days_per_year = 365
include("Mortgage.jl")
using .Mortgage: payment
function do_something()
return payment(1000.00, 3.25, 5; days_per_year = days_per_year)
end
end # module
因此,Mortgage子模块实际上不再需要从Calculator引用days_per_year符号,从而减少了任何不必要的依赖。
将通用代码作为另一个子模块进行因式分解
另一个解决方案是将依赖成员拆分到单独的子模块中,并让两个现有模块都依赖于这个新的子模块。
假设我们已经以它们相互使用函数的方式设置了两个子模块。考虑以下图中描述的场景:

来自第一个子模块的func1函数使用了来自其他子模块的func6。此外,来自其他子模块的func4函数需要调用第一个模块中的func3函数。显然,这两个模块之间存在高度的耦合。
考虑这些模块之间的依赖关系,看起来像是一个循环,因为第一个子模块依赖于第二个子模块,反之亦然。为了解决这个问题,我们可以引入一个新的子模块来打破这个循环,如下所示:

打破循环的好处是拥有更清晰的依赖图。它也使代码更容易理解。
考虑拆分为顶层模块
如果我们已经在考虑创建子模块,那么可能是时候考虑将代码拆分为顶层模块了。这些顶层模块可以组合成独立的 Julia 包。
让我们来看看创建新的顶层模块的好处和潜在问题:
拥有独立顶层模块的好处如下:
-
每个包都可以有自己的发布生命周期和版本。可以修改包并仅发布该部分。
-
Julia 的
Pkg系统强制执行版本兼容性。一个包的新版本可能被发布,并且只要包版本兼容,它就可以被另一个包使用。 -
包更可重用,因为它们可以被其他应用程序使用。
顶层模块的潜在问题如下:
-
由于每个包都将独立维护和发布,因此管理开销更大。
-
部署可能更困难,因为必须安装多个包,并且相互依赖的包必须遵守版本兼容性要求。
理解使用子模块的反驳意见
建议在以下条件下避免此模式:
-
当现有的代码库不够大时,过早地将代码拆分为子模块会阻碍开发速度。我们应该避免过早地进行此操作。
-
当源代码中存在高度耦合时,拆分代码可能很困难。在这种情况下,尝试重构代码以减少耦合,然后稍后再重新考虑将代码拆分为子模块。
创建子模块的想法确实迫使程序员思考代码依赖关系。当应用程序最终变得更大时,这是一个必要的步骤。
接下来,我们将讨论关键字定义模式,它允许我们用更易读的代码构建对象。
关键字定义模式
在 Julia 中,你可以使用默认构造函数创建一个对象,该构造函数接受为结构体中定义的每个字段提供一个位置参数列表。对于小型对象,这应该是简单直接的。对于大型对象,这会变得令人困惑,因为不参考结构体定义就很难记住哪个参数对应哪个字段,每次编写创建此类对象的代码时都需要这样做。
在 1956 年,心理学家乔治·米勒发表了一项研究,研究一个人在任何时候能记住多少随机数字,这样贝尔系统就可以决定电话号码格式的数字使用数量。他发现,大多数人一次只能记住五到九个数字。
如果记住数字已经足够困难,那么记住具有不同名称和类型的字段应该更加困难。
我们将讨论如何减少在编写 Julia 代码时产生的这种压力,以及如何使用@kwdef宏来实现,这样代码就易于阅读和维护。
重新审视结构体定义和构造函数
让我们先看看结构体是如何定义的,以及提供了什么构造函数。考虑文本编辑应用程序中文本样式配置的使用案例。
我们可以这样定义一个结构体:
struct TextStyle
font_family
font_size
font_weight
foreground_color
background_color
alignment
rotation
end
默认情况下,Julia 提供了一个构造函数,它为结构体中定义的所有字段提供位置参数,顺序与它们在结构体中定义的顺序相同。因此,创建TextStyle对象的唯一方法是执行以下操作:
style = TextStyle("Arial", 11, "Bold", "black", "white", "left", 0)
这里没有错误,但我们可以说代码的可读性并不高。每次我们编写代码来创建一个TextStyle对象时,我们必须确保所有参数都按正确的顺序指定。特别是,作为一个开发者,我必须记住前三个参数代表字体设置,然后是两个颜色,其中前景色在前,以此类推。最后,我只好放弃,重新回到结构体定义中去。
另一个问题是我们可能希望某些字段有默认值。例如,我们希望alignment字段默认值为"left",而rotation字段默认为0。默认构造函数并没有提供一种简单的方式来做到这一点。
创建具有许多参数的对象的更合理的语法是在构造函数中使用关键字参数。让我们尝试实现这一点。
在构造函数中使用关键字参数
我们可以始终添加新的构造函数,使其更容易创建对象。使用关键字参数解决了以下两个问题:
-
代码可读性
-
指定默认值的能力
让我们继续定义一个新的构造函数,如下所示:
function TextStyle(;
font_family,
font_size,
font_weight = "Normal",
foreground_color = "black",
background_color = "white",
alignment = "left",
rotation = 0)
return TextStyle(
font_family,
font_size,
font_weight,
foreground_color,
background_color,
alignment,
rotation)
end
在这里,我们选择为大多数字段提供默认值,除了 font_family 和 font_size。它被简单地定义为一个函数,为结构体中的所有字段提供关键字参数。创建 TextStyle 对象变得更加容易,代码现在也更易读。事实上,我们获得了额外的好处,即参数可以按任何顺序指定,如下所示:
style = TextStyle(
alignment = "left",
font_family = "Arial",
font_weight = "Bold",
font_size = 11)
这确实是一个非常简单的配方。我们只需为每个结构体创建这种构造函数,问题就解决了。对吗?好吧,是的,也有不是的。虽然创建这些构造函数相当容易,但要在每个结构体上这样做确实很麻烦。
此外,构造函数定义必须在函数参数中指定所有字段名称,并且这些字段在函数体中重复。因此,开发和维护变得相当困难。接下来,我们将介绍一个宏来简化我们的代码。
使用 @kwdef 宏简化代码
由于关键字定义模式解决了一个相当常见的用例,Julia 已经提供了一个宏来帮助定义接受关键字参数的构造函数的结构体。该宏目前尚未导出,但您可以直接使用如下所示:
Base.@kwdef struct TextStyle
font_family
font_size
font_weight = "Normal"
foreground_color = "black"
background_color= "white"
alignment = "center"
rotation = 0
end
基本上,我们只需在类型定义前放置 Base.@kwdef 宏。作为类型定义的一部分,我们还可以提供默认值。该宏会自动定义结构体以及相应的带关键字参数的构造函数。我们可以通过以下方式看到:

从输出中,我们可以看到第一个方法是接受关键字参数的方法。第二个方法是默认构造函数,它需要位置参数。现在,创建新对象就像我们希望的那样方便:

我们应该注意,前面的定义没有为 font_family 和 font_size 指定任何默认值。因此,在创建 TextStyle 对象时,这些字段是必需的:

使用这个宏可以极大地简化对象构造,并使代码更易读。没有理由不将其用于所有地方。
截至 Julia 版本 1.3,@kwdef 宏尚未导出。有一个功能请求导出它。如果您觉得使用未导出的功能不舒服,请考虑使用 Parameters.jl 包。
接下来,我们将讨论代码生成模式,它允许我们动态创建新函数,从而避免编写重复的样板代码。
代码生成模式
新的 Julia 程序员常常对语言的简洁性感到惊讶。令人惊讶的是,一些非常流行的 Julia 包是用非常少的代码编写的。这有多个原因,但一个主要的贡献因素是 Julia 能够动态生成代码。
在某些用例中,代码生成可以非常有帮助。在本节中,我们将探讨一些代码生成示例,并尝试解释如何正确地完成它。
介绍文件记录器用例
让我们考虑一个构建文件记录功能的用例。
假设我们想要提供一个基于一组日志级别的日志消息到文件的 API。默认情况下,我们将支持三个级别:info、warning 和 error。提供了一个记录器设施,以便只要消息带有足够高的日志级别,它就会被导向文件。
功能需求可以总结如下:
-
一个 info 级别的记录器接受带有 info、warning 或 error 级别的消息。
-
警告级别记录器只接受带有 warning 或 error 级别的消息。
-
错误级别记录器只接受具有错误级别的消息。
要实现文件记录器,我们首先为三个日志级别定义一些常量:
const INFO = 1
const WARNING = 2
const ERROR = 3
这些常量被设计成按数字顺序排列,这样我们就可以很容易地确定消息的日志级别是否高于记录器可以接受的水平。接下来,我们按照以下方式定义Logger设施:
struct Logger
filename # log file name
level # minimum level acceptable to be logged
handle # file handle
end
Logger对象携带日志文件的文件名、记录器可以接受消息的最小级别以及用于保存数据的文件句柄。我们可以为Logger提供一个构造函数如下:
Logger(filename, level) = Logger(filename, level, open(filename, "w"))
构造函数会自动打开指定的文件以进行写入。现在,我们可以开发第一个用于 info 级别消息的日志函数:
using Dates
function info!(logger::Logger, args...)
if logger.level <= INFO
let io = logger.handle
print(io, trunc(now(), Dates.Second), " [INFO] ")
for (idx, arg) in enumerate(args)
idx > 0 && print(io, " ")
print(io, arg)
end
println(io)
flush(io)
end
end
end
此函数旨在仅在INFO级别足够高以被记录器接受时将消息写入文件。它还使用now()函数和日志文件中的[INFO]标签打印当前时间。然后,它将所有参数通过空格分隔并最终刷新 I/O 缓冲区。
我们可以快速测试到目前为止的代码。首先,我们将使用info_logger:

消息被正确地记录在/tmp/info.log文件中。如果我们向错误级别记录器发送 info 级别的消息会发生什么?让我们看看:

现在,这有点更有趣。正如预期的那样,因为错误级别记录器只接受具有ERROR级别或更高级别的消息,它没有捕获 info 级别的消息。
在这一点上,我们可能会想快速完成另外两个函数:warning!和error!,然后结束。如果我们决心这样做,warning!函数将看起来就像info!,只是做了一些小的改动:

这两个日志函数之间有什么区别?让我们看看:
-
函数名不同:
info!与warning!。 -
日志级别常量不同:
INFO与WARNING。 -
标签不同:
[INFO]与[WARNING]。
除了这些,两个函数共享了完全相同的代码。当然,我们可以继续进行,通过以相同的方式编写error!来结束项目。然而,这并不是最好的解决方案。想象一下,如果核心日志逻辑需要更改,例如,日志消息的格式,那么我们必须在三个不同的函数中进行相同的更改。更糟糕的是,如果我们忘记修改所有这些函数,那么我们最终会得到不一致的日志格式。毕竟,我们已经违反了不要重复自己(DRY)原则。
函数定义的代码生成
如前所述,代码生成是解决此问题的一种方法。我们将构建定义函数的语法,然后将它放入循环中以定义所有三个日志函数。以下是代码可能的样子:
for level in (:info, :warning, :error)
lower_level_str = String(level)
upper_level_str = uppercase(lower_level_str)
upper_level_sym = Symbol(upper_level_str)
fn = Symbol(lower_level_str * "!")
label = " [" * upper_level_str * "] "
@eval function $fn(logger::Logger, args...)
if logger.level <= $upper_level_sym
let io = logger.handle
print(io, trunc(now(), Dates.Second), $label)
for (idx, arg) in enumerate(args)
idx > 0 && print(io, " ")
print(io, arg)
end
println(io)
flush(io)
end
end
end
end
上述代码的解释如下:
-
由于我们需要为三个日志级别定义函数,我们创建了一个循环,遍历以下符号列表:
:info、:warning和:error。 -
在循环内部,我们可以看到函数名为
fn,标签为label,以及用于日志级别比较的常量(如INFO、WARN或ERROR)为upper_level_sym。 -
我们使用
@eval宏来定义日志函数,其中fn变量、label和upper_level_sym被插入到函数体中。
在 Julia REPL 中运行代码后,应该已经定义了三个函数:info!、warning!和error!。为了测试,我们可以使用三种不同的日志记录器调用这些函数。
让我们先试一下info_logger:

如预期,所有消息都被记录到文件中,因为info_logger可以接受任何级别的消息。接下来,让我们测试error_logger:

在这种情况下,只有错误级别的消息被写入日志文件。error_logger代码有效地过滤掉了任何低于错误级别的消息。
虽然我们对生成的代码相当满意,但我们是否知道幕后实际发生了什么?我们如何调试我们甚至看不到的代码?让我们看看下一个例子。
调试代码生成
由于代码是在幕后生成的,当我们甚至看不到生成的代码将是什么样子时,可能会感觉有点尴尬。我们如何保证在所有变量的插值之后生成的代码确实是我们预期的?
幸运的是,有一个名为CodeTracking的包可以使调试代码生成更容易。我们将在这里看看它是如何工作的。
在上一节中,我们应该已经生成了三个函数:info!、warning!和error!。由于这些被定义为通用函数,我们可以检查每个函数定义了哪些方法。以error!为例:

在这种情况下,我们只有一个方法。我们可以使用first函数来获取方法对象本身:

一旦我们有了方法对象的引用,我们就可以依靠CodeTracking来揭示生成函数的源代码。特别是,我们可以使用definition函数,它接受一个方法对象并返回一个表达式对象。为了使用此函数,我们还需要加载Revise包。话不多说,让我们尝试以下操作:

在这里,我们可以清楚地看到变量被正确地插值;logger.level变量与ERROR常量进行比较,日志标签正确地包含了[ERROR]字符串。
我们还可以看到行号包含在输出中。由于我们从 REPL 定义了函数,行号就不是很有用。如果我们从存储在文件中的模块生成函数,文件名和行号信息就会更有趣。
然而,这里的行号节点似乎有点分散注意力。我们可以很容易地使用MacroTools包中的rmlines函数来移除它们:

MacroTools.postwalk函数用于将rmlines函数应用于抽象语法树中的每个节点。postwalk函数是必要的,因为rmlines函数只与当前节点一起工作。
现在我们已经了解了如何正确地进行代码生成,让我们反过来问自己——代码生成真的必要吗?还有其他替代方案吗?让我们在下一节中看看。
考虑除了代码生成以外的选项
在本节中,我们一直专注于代码生成技术。前提是我们可以轻松地添加一个新函数,它的工作方式与现有函数类似,但略有不同。在实践中,代码生成并不是我们手头唯一的选项。
让我们用相同的例子继续我们的讨论。正如我们回忆的那样,我们想在定义info!逻辑之后添加warning!和error!函数。如果我们退一步,我们可以泛化info!函数,使其能够处理不同的日志级别。这可以按以下方式完成:
function logme!(level, label, logger::Logger, args...)
if logger.level <= level
let io = logger.handle
print(io, trunc(now(), Dates.Second), label)
for (idx, arg) in enumerate(args)
idx > 0 && print(io, " ")
print(io, arg)
end
println(io)
flush(io)
end
end
end
logme!函数看起来与info!完全一样,除了它还接受两个额外的参数:level和label。这些变量在函数体中被获取和使用。现在我们可以定义所有三个日志函数如下:
info! (logger::Logger, msg...) = logme!(INFO, " [INFO] ", logger, msg...)
warning!(logger::Logger, msg...) = logme!(WARNING, " [WARNING] ", logger, msg...)
error! (logger::Logger, msg...) = logme!(ERROR, " [ERROR] ", logger, msg...)
如我们所见,我们已经使用常规的结构化编程技术解决了原始问题,并尽可能减少了重复代码。
在这种情况下,这些函数之间唯一的区别是简单类型:一个常量和一个字符串。在另一种情况下,我们可能需要在函数体内部调用不同的函数。这也是可以的,因为在 Julia 中函数是一等公民,所以我们只需传递函数的引用。
我们能做得更好吗?是的。可以使用闭包技术稍微简化代码。为了说明这个概念,让我们定义一个新的make_log_func函数如下:
function make_log_func(level, label)
(logger::Logger, args...) -> begin
if logger.level <= level
let io = logger.handle
print(io, trunc(now(), Dates.Second), " [", label, "] ")
for (idx, arg) in enumerate(args)
idx > 0 && print(io, " ")
print(io, arg)
end
println(io)
flush(io)
end
end
end
end
此函数接受level和label参数,并返回一个包含主要日志逻辑的匿名函数。level和label参数在闭包中被捕获并在匿名函数中使用。因此,我们现在可以更容易地定义日志函数,如下所示:
info! = make_log_func(INFO, "INFO")
warning! = make_log_func(WARNING, "WARNING")
error! = make_log_func(ERROR, "ERROR")
因此,这里定义了三个匿名函数:info!、warning!和error!,它们都同样有效。
在计算机科学术语中,闭包是一等函数,它从封装环境中捕获变量。
从技术角度讲,结构化编程解决方案与闭包之间有一个非平凡的差异。前者技术定义了通用函数,这些函数是在模块内命名的函数,可以扩展。相比之下,匿名函数是独特的,不能扩展。
在本节中,我们学习了如何在 Julia 中进行代码生成以及如何调试此代码。我们还讨论了如何重构代码以实现相同的效果,而无需使用代码生成技术。两种选择都是可用的。
接下来,我们将讨论领域特定语言(DSL),这是一种为特定领域使用构建语法的技巧,从而使代码更容易阅读和编写。
领域特定语言模式
Julia 是一种通用编程语言,可以有效地用于任何领域问题。然而,Julia 也是少数允许开发者构建适合特定领域使用的新的语法的编程语言之一。
因此,领域特定语言(DSL)是结构化查询语言(SQL)的一个例子。SQL 被设计用来处理二维表结构中的数据。它非常强大,但只有在需要处理表格数据时才适用。
Julia 生态系统中有几个显著的领域广泛使用了 DSL。其中最突出的是DifferentialEquations包,它允许你以非常接近原始数学符号的形式编写微分方程。例如,考虑以下洛伦兹系统方程:

定义这些方程的代码可以写成如下形式:
@ode_def begin
dx = σ * (y - x)
dy = x * (ρ - z) - y
dz = x * y - β * z
end σ ρ β
正如我们所见,语法几乎与数学方程式相匹配。
在此之后,在下一节中,我们将探讨如何构建用于计算机图形学中实际用例的 L-System 的自己的领域特定语言(DSL)。
介绍 L-System
L-System,也称为林德曼系统,是一种用于描述生物体如何通过简单模式进化的形式语法。它最初由匈牙利生物学家和植物学家 Aristid Lindenmayer 于 1968 年提出。L-System 可以生成有趣的模式,这些模式模仿现实生活中的形状和形式。一个著名的例子是特定藻类的生长,它可以如下建模:
Axiom: A
Rule: A -> AB
Rule: B -> A
这是它的工作原理。我们始终从公理开始,在这个例子中,是字符A。对于每一代,我们将规则应用于字符串中的每个字符。如果字符是 A,则将其替换为AB。同样,如果字符是B,则将其替换为 A。让我们通过前五次迭代来操作:
-
A -
AB -
ABA -
ABAAB -
ABAABABA
你可能会想知道,它怎么会看起来像藻类呢?以下是从第一代到第五代生长的可视化:

有许多软件可以根据 L-Systems 生成有趣的图形可视化。一个例子是我开发的 iOS 应用My Graphics。该应用可以生成多种模式,例如前面的藻类示例。一个有趣的样本称为科赫曲线,如下所示:

话已说尽。从我们目前所知的情况来看,这个概念相当简单。我们接下来要做的是为 L-System 设计一个 DSL。
为 L-System 设计 DSL
DSL 的特点是源代码应该看起来像领域概念的原始表示。在这种情况下,领域概念是由一个公理和一组规则来描述的。以藻类生长的例子来说,它应该看起来像以下这样:
Axiom: A
Rule: A -> AB
Rule: B -> A
如果我们尝试用纯 Julia 语言来编写它们,我们可能会得到如下代码:
model = LModel("A")
add_rule!(model, "A", "AB")
add_rule!(model, "B", "A")
如我们所见,这并不理想。虽然代码既不长也不难读,但它看起来没有 L-System 语法那么整洁。我们真正想要的是构建一个 DSL,让我们能够如下指定模型:
model = @lsys begin
axiom : A
rule : A → AB
rule : B → A
end
这将是我们的 DSL 的目标语法。
审查 L-System 核心逻辑
作为这个例子的一部分,我们将一起开发一个 L-System 包。在我们深入到 DSL 实现之前,让我们先快速了解一下核心逻辑是如何工作的。对 API 的了解使我们能够正确地设计和测试 DSL。
开发 LModel 对象
要开发LModel对象,请执行以下步骤:
- 让我们首先创建一个名为
LModel的类型来跟踪公理和规则集。结构可以定义如下:
struct LModel
axiom
rules
end
- 然后,我们可以添加一个构造函数来填充
axiom字段并初始化rules字段:
"Create a L-system model."
LModel(axiom) = LModel([axiom], Dict())
- 设计上,公理是一个单元素数组。规则被捕获在一个字典中,以便快速查找。还编写了一个
add_rule!函数来向模型中添加新规则:
"Add rule to a model."
function add_rule!(model::LModel, left::T, right::T) where {T <: AbstractString}
model.rules[left] = split(right, "")
return nothing
end
我们已经使用了split函数将字符串转换成一个单字符字符串的数组。
- 最后,我们添加一个
Base.show函数,以便我们可以在终端上优雅地显示模型:
"Display model nicely."
function Base.show(io::IO, model::LModel)
println(io, "LModel:")
println(io, " Axiom: ", join(model.axiom))
for k in sort(collect(keys(model.rules)))
println(io, " Rule: ", k, " → ", join(model.rules[k]))
end
end
定义了这些函数后,我们可以快速验证我们的代码如下:

接下来,我们将致力于核心逻辑,该逻辑接受一个模型并跟踪迭代的当前状态。
开发状态对象
为了模拟 L-System 模型的增长,我们可以开发一个LState类型,它跟踪增长当前状态。这是一个简单的类型,它只保留对模型的引用、当前增长的迭代次数和当前结果。为此,考虑以下代码:
struct LState
model
current_iteration
result
end
构造函数只需要接受模型作为唯一参数。它默认current_iteration为1,默认result为模型的公理,如下所示:
"Create a L-system state from a `model`."
LState(model::LModel) = LState(model, 1, model.axiom)
我们需要一个函数来推进到增长的下一个阶段。所以我们只提供了一个next函数:
function next(state::LState)
new_result = []
for el in state.result
# Look up `el` from the rules dictionary and append to `new_result`.
# Just default to the element itself when it is not found
next_elements = get(state.model.rules, el, el)
append!(new_result, next_elements)
end
return LState(state.model, state.current_iteration + 1, new_result)
end
基本上,给定当前状态,它迭代当前结果的全部元素,并使用模型中的规则扩展每个元素。get函数在字典中查找元素。如果没有找到,它默认为自身。扩展的元素只是追加到new_results数组中。
最后,创建一个新的LState对象,带有下一个迭代次数和新结果。为了在终端中更好地显示,我们可以为LState添加一个Base.show方法,如下所示:
"Compact the result suitable for display"
result(state::LState) = join(state.result)
Base.show(io::IO, s::LState) =
print(io, "LState(", s.current_iteration, "): ", result(s))
result函数只是将数组的所有元素组合成一个单一的字符串。show函数显示当前迭代次数和结果字符串。
我们现在应该有一个完全功能性的系统。让我们尝试模拟藻类的生长:

太棒了! 现在功能已经构建完成,我们可以继续本章的有趣部分——如何使用 L-System 语法创建一个领域特定语言(DSL)。
实现 L-System 的领域特定语言(DSL)
回想一下上一节,我们想要有一个干净的语法来定义 L-System 模型。从元编程的角度来看,我们只需要将代码从一棵抽象语法树翻译到另一棵。以下图表直观地显示了所需的翻译类型:

结果表明,翻译相当直接。当我们遇到公理时,我们将代码翻译成构建一个新的LModel对象。当我们遇到规则时,我们将代码翻译成add_rule!函数调用。
虽然看起来很简单,但这类源到源翻译可以通过使用现有的工具大大简化。特别是,MacroTools 包包含一些非常实用的宏和函数,用于处理这些情况。让我们首先了解这个工具,然后我们可以在开发我们的 DSL 时利用它们。
使用@capture 宏
MacroTools包提供了一个名为@capture的宏,可以用来将表达式与模式匹配。作为匹配过程的一部分,它还为开发者希望捕获匹配值的变量分配变量。
@capture宏接受两个参数;第一个是需要匹配的表达式,第二个是用于匹配的模式。考虑以下示例:

当模式可以匹配时,宏返回true,否则它只返回false。当模式匹配时,以下划线结尾的变量将在当前环境中分配,并将下划线从变量名称中删除。在先前的例子中,因为x = 1与x = val_匹配,所以它返回了true:

因为模式匹配成功,所以还分配了一个val变量,其值为1。
匹配公理和规则语句
我们可以使用同样的技巧从公理和规则语句中提取有用的信息。让我们对由单词 axiom、冒号和符号组成的axiom语句进行快速实验:

匹配rule语句同样简单。唯一的区别是我们想要匹配原始符号和相应的替换符号,如下所示:

匹配后,original和replacement变量被分配了规则中的相应符号。我们还可以观察到匹配的变量是符号而不是字符串。由于LModel编程接口需要字符串,我们将在开发 DSL 的宏部分中展示从walk函数中的符号进行额外的数据转换。
使用 postwalk 函数
为了遍历整个抽象语法树,我们可以使用 MacroTool 的postwalk函数。为了理解它是如何工作的,我们可以通过以下步骤进行一个简单的示例:
- 让我们创建一个表达式对象,如下所示:

在这里,我们使用了rmlines函数来删除行号节点,因为我们在这个练习中不需要它们。
- 然后,我们可以使用
postwalk函数遍历树并显示它所遇到的一切:

postwalk函数接受一个函数作为其第一个参数和一个表达式作为第二个参数。在遍历树的过程中,它使用正在访问的子表达式调用该函数。我们可以看到它考虑了每个单个叶子节点(例如,:x)以及表达式中的每个子树,例如:(x = 1)。它还包括顶层表达式,正如我们在输出底部所看到的。
如果我们稍微注意一下遍历的顺序,我们会意识到 postwalk 函数是从下往上工作的,从叶节点开始。
MacroTools 还提供了一个 prewalk 函数,它也可以遍历树。prewalk 和 postwalk 的区别在于 prewalk 将从上往下工作,而不是从下往上。鼓励大家尝试一下,并了解它们之间的区别。
对于我们的用例,我们可以使用任何一个。
现在我们知道了如何匹配表达式和遍历树,我们的工具箱中已经拥有了所有东西来开发我们的 DSL。这是最有意思的部分。让我们开始吧!
开发 DSL 的宏
为了支持 LModel 语法,我们必须匹配公理和规则语句,使其与模型中的写法相匹配。
让我们从创建 lsys 宏开始,如下所示:
macro lsys(ex)
return MacroTools.postwalk(walk, ex)
end
宏简单地使用 postwalk 遍历抽象语法树。返回的表达式原样返回。主要的翻译逻辑实际上位于以下 walk 函数中:
function walk(ex)
match_axiom = @capture(ex, axiom : sym_)
if match_axiom
sym_str = String(sym)
return :( model = LModel($sym_str) )
end
match_rule = @capture(ex, rule : original_ → replacement_)
if match_rule
original_str = String(original)
replacement_str = String(replacement)
return :(
add_rule!(model, $original_str, $replacement_str)
)
end
return ex
end
让我们逐部分分析前面的代码。
walk 函数使用 @capture 宏来匹配 axiom 和 rule 模式。当匹配成功时,相应的符号会被转换成字符串,然后插入到相应的表达式中,并返回最终的表达式。考虑以下这段代码:
match_axiom = @capture(ex, axiom : sym_)
@capture 宏调用试图匹配表达式与 axiom : sym_ 模式,这是一个 axiom 符号,后面跟着一个冒号,然后是另一个符号。由于 sym_ 目标符号以下划线结尾,如果匹配成功,sym 变量将被分配匹配的值。在 开发状态对象 部分的 藻类模型 示例中,我们期望 sym 被分配为 :A 符号。一旦匹配成功,以下代码将被执行:
if match_axiom
sym_str = String(sym)
return :( model = LModel($sym_str) )
end
目标表达式简单地构建一个 LModel 对象,并将其分配给 model 变量。使用藻类模型,我们可以期望翻译后的表达式看起来像这样:
model = LModel("A")
同样,可以使用以下模式匹配 rule 语句:
match_rule = @capture(ex, rule : original_ → replacement_)
original 和 replacement 变量被分配,转换为字符串,并插入到目标表达式的 add_rule! 语句中。
从 lsys 宏中,walk 函数被 postwalk 函数多次调用——每次调用针对抽象语法树的每个节点和子树。要查看 postwalk 如何生成代码,我们可以从 REPL 进行测试:

实际上,我们还没有完全完成,因为翻译后的语句位于一个 quote 块内,而块的返回值将来自块的最后一个表达式,由于 add_rule! 函数没有返回任何有意义的值,所以这个值是零。
这个最后的改变实际上是简单的部分。让我们再次修改 @lsys 宏,如下所示:
macro lsys(ex)
ex = MacroTools.postwalk(walk, ex)
push!(ex.args, :( model ))
return ex
end
使用了push!函数在块的末尾添加了:( model )表达式。让我们测试宏展开,看看它看起来像什么:

现在好了!终于,我们可以直接使用宏如下:

太棒了!现在,我们可以使用我们的小 DSL 来构建algae_model示例。事实证明,开发 DSL 根本不难。有了像 MacroTools 这样的优秀工具,我们可以快速提出一系列转换模式,并将抽象语法树操纵成我们想要的任何形式。
DSL 是一种简化代码并使其更容易维护的绝佳方式。它在特定的领域区域中非常有用。
摘要
在本章中,我们探讨了与提高应用程序可读性和可维护性相关的几种模式。
首先,我们学习了当模块变得太大时以及何时应该考虑对其进行重新组织。我们意识到,在将代码拆分为单独的模块时,耦合是一个重要的考虑因素。接下来,我们讨论了构建具有许多字段的对象的问题。我们确定使用基于关键字的构造函数可以使代码更易读,并提供额外的灵活性,以支持默认值。我们了解到,Julia Base 模块已经提供了一个宏。
然后,我们探讨了如何进行代码生成,这是一种方便的技术,可以在不重复代码的情况下动态定义许多类似的功能。我们从CodeTracking中选取了一个实用工具来审查生成的源代码。
最后,我们详细讨论了如何开发 DSL。这是一种通过模仿领域概念的原生形式来简化代码的好方法。我们以 L-System 为例来开发 DSL。我们从 MacroTools 包中选取了几个实用工具,通过匹配模式来转换我们的源代码。我们学习了如何使用postwalk函数来检查和转换源代码。而且,令人愉快的是,我们用很少的代码就完成了练习。
在下一章中,我们将讨论与代码安全性相关的一系列模式。享受阅读!
问题
-
输入耦合和输出耦合之间的区别是什么?
-
从可维护性的角度来看,为什么双向依赖是坏的?
-
有什么简单的方法可以即时生成代码?
-
代码生成的替代方案是什么?
-
在什么情况下以及为什么你应该考虑构建一个 DSL?
-
可用于开发领域特定语言(DSL)的工具有哪些?
第八章:健壮性模式
本章将介绍几种可以提高软件健壮性的模式。当我们提到健壮性时,我们指的是质量方面,即软件能否正确执行其功能?是否妥善处理了所有可能的场景?这是在编写关键任务系统代码时需要考虑的一个极其重要的因素。
根据最小权限原则(POLP),我们会考虑隐藏不必要的实现细节给接口的客户端。然而,Julia 的数据结构是透明的——所有字段都自动暴露并可访问。这可能会带来潜在的问题,因为任何不当的使用或修改都可能破坏系统。此外,通过直接访问字段,代码与对象的底层实现耦合得更紧密。那么,如果需要更改字段名怎么办?如果需要用另一个字段替换它怎么办?因此,有必要应用抽象,将对象实现与其官方接口解耦。我们应该采用更通用的定义——不仅希望覆盖尽可能多的代码行,还希望涵盖所有可能的场景。代码覆盖率的提高将使我们更有信心地确保代码的正确性。
我们将这些技术分为以下几部分:
-
访问者模式
-
属性模式
-
代码块模式
-
异常处理模式
到本章结束时,你将能够通过开发自己的访问器函数和属性函数来封装数据访问。你还将能够隐藏模块外的意外访问的全局变量。最后,你还将了解各种异常处理技术,并理解如何重试失败的操作。
让我们开始吧!
技术要求
本章的示例源代码可以在github.com/PacktPublishing/Hands-on-Design-Patterns-and-Best-Practices-with-Julia/tree/master/Chapter08找到。
代码在 Julia 1.3.0 环境中进行了测试。
访问者模式
Julia 对象是透明的。这是什么意思呢?目前,Julia 语言没有能力对对象的字段应用访问控制。因此,来自 C++或 Java 背景的人可能会觉得有点不习惯。在本节中,我们将探讨多种方法,使语言更易于那些寻求更多访问控制的用户接受。
因此,我们可能首先应该定义我们的需求。在我们编写需求的同时,我们也会问自己为什么一开始就要有这些需求。让我们考虑一下 Julia 程序中的任何对象:
-
一些字段需要对外界隐藏:一些字段被认为是公共接口的一部分,因此它们被完全文档化和支持。其他字段被认为是实现细节,它们可能不会被使用,因为它们可能会在未来发生变化。
-
一些字段在修改之前需要验证:一些字段可能只能接受一定范围内的值。例如,一个
Person对象的age字段可能会拒绝小于 0 或大于 120 的任何值!避免无效数据对于构建健壮的系统至关重要。 -
一些字段在读取之前需要触发器:一些字段可能是延迟加载的,这意味着它们只有在读取值时才会被加载。另一个原因是,一些字段可能包含敏感数据,并且为了审计目的,必须记录对这些字段的用途。
我们现在将讨论如何满足这些要求。
识别对象的隐含接口
在我们深入具体模式之前,让我们先快速地绕道一下,讨论一下我们最初是如何以及为什么会有这个问题。
假设我们已经定义了一个名为 Simulation 的数据类型,用于跟踪一些科学实验数据和相关的统计信息。它的语法如下:
mutable struct Simulation{N}
heatmap::Array{Float64, N}
stats::NamedTuple{(:mean, :std)}
end
一个 Simulation 对象包含一个 N 维浮点值数组和统计值的命名元组。为了演示目的,我们将创建一个简单的函数来执行模拟并创建一个对象,如下所示:
using Distributions
function simulate(distribution, dims, n)
tp = ntuple(i -> n, dims)
heatmap = rand(distribution, tp...)
return Simulation{dims}(heatmap, (mean = mean(heatmap), std = std(heatmap)))
end
使用用户提供的分布的 rand 函数生成的模拟数据称为 heatmap。dims 参数表示数组中的维度数,而 n 的值表示每个维度的尺寸。以下是如何模拟一个大小为 1000 x 1000 的正态分布二维热图的示例:
sim = simulate(Normal(), 2, 1000);
在这一点上,我们可以轻松地访问对象的 heatmap 和 stats 字段,如下所示:

让我们暂停一下。直接访问字段是否可以?我们可以在这里争论说这不行。主要原因是存在一个隐含的假设,即字段名称代表对象的公共接口。
不幸的是,这样的假设在现实中可能有点脆弱。正如任何经验丰富的程序员都会指出的,软件总是需要改变。总是需要改变。世界不是静态的,需求也不是一成不变的。例如,以下是一些肯定会破坏我们的编程接口的可能变化:
-
将
heatmap字段的名称更改为heatspace,因为新的名称更适合三维或更高维度的数据 -
将
stats字段的数据类型从命名元组更改为新的struct类型,因为它已经增长到包括更复杂的统计度量,并且我们希望随着它开发新的函数 -
完全移除
stats字段,并在运行时计算它
如您所见,编程接口不能轻率对待。为了构建持久的软件,我们需要对每个接口都清楚,并了解如何在未来支持它们。
提供对象接口的一种方法是通过创建评估函数,在其他编程语言中有时称为获取器和设置器。因此,在接下来的几节中,让我们看看如何使用它们。
实现获取器函数
在主流面向对象的语言中,我们经常实现获取器来访问对象的字段。在 Julia 中,我们也可以创建获取器函数。在实现获取器函数时,我们可以选择哪些字段作为应用程序编程接口(API)的一部分进行暴露。在我们的示例中,我们将为两个字段都实现获取器函数,如下所示:
get_heatmap(s::Simulation) = s.heatmap
get_stats(s::Simulation) = s.stats
在这里我们选择的函数名称对于 Julia 语言来说有些非习惯用法。更好的约定是直接使用名词:
heatmap(s::Simulation) = s.heatmap
stats(s::Simulation) = s.stats
因此,当我们阅读使用heatmap函数的代码时,我们可以将其视为模拟的热图。同样,当使用stats函数时,我们可以将其视为模拟的统计数据。
这些获取器函数的目的是为对象定义一个正式的数据检索接口。如果我们需要更改底层字段的名称(甚至类型),只要公共接口不变,那就没问题。此外,我们甚至可以删除stats字段,并在stats函数中直接实现统计计算。现在,我们可以轻松地维护任何使用此对象的程序的向后兼容性。
接下来,我们将探讨对象的写访问权限。
实现设置器函数
对于可变类型,我们可能需要实现设置器。其范围包括只能被修改的字段。对于我们的模拟项目,假设我们希望允许客户端程序对热图进行一些转换并将其放回对象中。我们可以轻松地支持这个用例,如下面的代码片段所示:
function heatmap!(
s::Simulation{N},
new_heatmap::AbstractArray{Float64, N}) where {N}
s.heatmap = new_heatmap
s.stats = (mean = mean(new_heatmap), std = std(new_heatmap))
return nothing
end
设置器函数heatmap!接受一个Simulation对象和一个新的热图数组。因为stats字段包含底层热图的统计数据,我们必须通过重新计算统计数据并更新字段来在对象内部保持一致性。请注意,这种一致性的保证只有在提供设置器函数时才可能。否则,如果我们允许用户直接修改对象中的heatmap字段,对象就会处于不一致的状态。
另一个好处是,我们可以在设置器函数中执行数据验证。例如,我们可以控制地图的大小,并在热图的大小包含奇形状时抛出错误:
function heatmap!(
s::Simulation{N},
new_heatmap::AbstractArray{Float64, N}) where {N}
if length(unique(size(new_heatmap))) != 1
error("All dimensions must have same size")
end
s.heatmap = new_heatmap
s.stats = (mean = mean(new_heatmap), std = std(new_heatmap))
return nothing
end
在这里,我们首先确定 new_heatmap 的大小,它应该作为一个元组返回。然后,我们找出这个元组中有多少唯一的值。如果元组中只有一个唯一的数字,那么我们知道数组是正方形、立方体等等。否则,我们只需将错误抛回调用者。
就像获取函数一样,设置函数充当一个公共接口,其中对象的数據可能会被修改。在我们有了获取和设置函数之后,我们可以期待调用者通过接口进行操作。但原始字段仍然可以直接访问。那么,我们如何阻止这种情况发生呢?让我们在下一节中探讨这个问题。
阻止直接访问字段
虽然获取和设置函数很方便,但很容易忘记这些函数,所以程序最终会直接访问字段。那会很糟糕,因为我们刚刚花费了所有这些努力创建获取和设置函数,但它们最终被绕过了。
一种可能的解决方案是通过对字段重命名,使其看起来显然是私有的,来阻止直接访问字段。一个常见的约定是在字段名前加上下划线。
对于我们的示例,我们可以将结构体重新定义为以下内容:
mutable struct Simulation{N}
_heatmap::Array{Float64, N}
_stats::NamedTuple{(:mean, :std)}
end
这些奇特命名的字段将仅在 Simulation 类型的实现中使用,所有外部使用都将避免它们。这样的约定可以阻止程序员直接访问字段。
然而,我们中的一些人可能对这种解决方案不太满意,因为使用编码约定来强制正确使用编程接口是一种非常薄弱的方法。这种担忧是非常合理的,尤其是当我们对自己软件的鲁棒性有更高的标准时。因此,在下一节中,我们将探讨一种更强大的技术,这将使我们能够通过编程方式控制访问。
属性模式
在本节中,我们将深入探讨如何通过使用属性接口来对对象的字段实施更细粒度的控制。Julia 的属性接口允许你为字段访问中使用的点符号提供自定义实现。通过覆盖标准行为,我们可以对引用或分配的字段应用任何类型的访问控制和验证。为了说明这个概念,我们将在下面处理一个新的用例——实现懒文件加载器。
引入懒文件加载器
假设我们正在开发一个支持懒加载的文件加载功能。这里的“懒”指的是在需要内容之前不加载文件。让我们看看以下代码:
mutable struct FileContent
path
loaded
contents
end
FileContent 结构体包含三个字段:
-
path: 文件的位置 -
loaded: 一个布尔值,表示文件是否已被加载到内存中 -
contents: 包含文件内容的字节数组
这里是该结构体的构造函数:
function FileContent(path)
ss = lstat(path)
return FileContent(path, false, zeros(UInt8, ss.size))
end
就像我们的当前设计一样,我们预先为文件分配内存,但我们不会在之后读取文件内容。文件的大小由对 lstat 函数的调用确定。在创建 FileContent 对象时,我们将 loaded 字段初始化为 false 值——这表明文件尚未加载到内存中。
最终,我们必须加载文件内容,所以我们提供了一个单独的函数来读取文件到预先分配的字节缓冲区:
function load_contents!(fc::FileContent)
open(fc.path) do io
readbytes!(io, fc.contents)
fc.loaded = true
end
nothing
end
让我们运行一个快速测试来看看它的工作情况:

这里,我们刚刚创建了一个新的 FileContent 对象。显然,loaded 字段包含一个 false 值,因为我们还没有读取文件。content 字段也充满了零。
现在让我们加载文件内容:

现在,contents 字段包含了一些真实数据,而 loaded 字段具有 true 的值。当然,我们现在只是照看并手动运行代码。我们的想法是实现懒加载。我们需要一种方法来拦截对 contents 字段的任何 读取 操作,以便及时加载文件内容。理想情况下,这应该发生在有人使用 fc.contents 表达式时。为了 劫持 获取 fc.contents 的调用,我们首先必须理解 Julia 的点符号是如何工作的。让我们现在绕道而行,回顾一下。
理解字段访问的点符号
通常,当我们需要访问对象的特定字段时,我们可以方便地将其写成 object.fieldname。实际上,这种表示法是 getproperty 函数调用的 语法糖,即一些 甜蜜 的语法。为了清楚起见,每当我们在以下格式中编写代码:
object.fieldname
它被转换为一个对 getproperty 的函数调用:
getproperty(object, :fieldname)
对于我们的懒加载文件示例,fc.path 实际上与 getproperty(fc, :path) 相同。
所有这些魔法都是由 Julia 编译器自动执行的。关于 Julia 的一个好处是这种魔法相当透明。我们可以通过使用 Meta.lower 函数来实际看到编译器做了什么,如下所示:

类似地,当我们向对象的字段赋值时,也会发生类似的转换:

从前面的结果中,我们可以看到当代码将字符串赋值给 fc.path 时,它只是转换为一个 setproperty!(fc, :path, "/etc/hosts") 函数调用。
我们不要就此止步。getproperty 和 setproperty! 函数具体是做什么的呢?嗯,它们恰好是定义在 Base 模块中的普通 Julia 函数。理解它们是如何工作的最佳方式是检查 Julia 的源代码本身。从 Julia REPL 中,我们可以轻松地调出源代码,如下所示:

从前面的代码中,我们可以看到@edit宏用于定位被调用函数的源代码——在本例中是getproperty。从 REPL 终端,它应该打开你的编辑器并显示如下代码:

啊哈! 我们看到getproperty函数只是将调用转发到getfield,后者用于从对象中提取数据。同一源文件中的下一行显示了setproperty!的定义。setproperty!的实现稍微有趣一些。除了使用setfield!函数来修改对象中的字段外,它还将v值转换为对象x中字段的类型,这是通过调用fieldtype确定的。
getfield函数是一个用于从现有对象获取任何字段值的内置函数。它接受两个参数——一个对象和一个符号。例如,要从FileContent对象获取路径,我们可以使用getfield(fc, :path)。同样,setfield!函数用于更新现有对象的任何字段。getfield和setfield!都是 Julia 实现中的低级函数。
类型转换很方便,尤其是对于数值类型。例如,一个对象存储一个Float64字段,但代码恰好传递了一个整数。当然,转换逻辑比仅仅数值类型更通用。对于自定义类型,只要定义了一个convert函数,相同的自动转换过程就可以正常工作。
现在我们已经了解了点符号是如何转换为getproperty和setproperty!函数调用的,我们可以为我们的文件加载器开发懒加载功能。
实现读写访问和懒加载
为了实现懒加载,我们可以扩展getproperty函数。在调用过程中,我们可以检查文件内容是否已经加载。如果没有,我们就在返回数据给调用者之前加载文件内容。
扩展getproperty函数就像简单地使用FileContent类型和一个符号作为函数参数来定义它一样简单。以下代码展示了这一点:
function Base.getproperty(fc::FileContent, s::Symbol)
direct_passthrough_fields = (:path, )
if s in direct_passthrough_fields
return getfield(fc, s)
end
if s === :contents
!getfield(fc, :loaded) && load_contents!(fc)
return getfield(fc, :contents)
end
error("Unsupported property: $s")
end
重要的是我们定义了Base.getproperty函数而不是仅仅getproperty。这是因为编译器会将点符号转换为Base.getproperty而不是你自己的模块中的getproperty函数。如果这还不清楚,你被鼓励回顾第二章中“理解命名空间、模块和包”部分的理解命名空间、模块和包。
我们选择在函数名定义中使用Base作为前缀。这种编码风格更受欢迎,因为它从函数定义中可以清楚地看出,我们正在扩展Base包中的getproperty函数。
从另一个包扩展函数的另一种方法是首先导入第三方包。对于前面的例子,我们可以这样写。这种编码风格不推荐,因为它不太明显地表明正在定义的 getproperty 函数是 Base 函数的扩展:
import Base: getproperty
function getproperty(fc::FileContent, s::Symbol)
....
end
与之相反,getproperty 函数必须处理所有可能的属性名称。让我们首先考虑以下代码段:

在这种情况下,我们必须支持 :path 和 :contents。如果 s 符号是我们想要直接传递的字段之一,那么我们只需将调用转发给 getfield 函数。
现在,让我们考虑下一段代码:

如果符号是 :contents,那么我们检查 loaded 字段的值。如果 loaded 字段包含 false,那么我们调用 load_contents! 函数将文件内容加载到内存中。
注意,我们在整个函数中到处都使用了 getfield。如果我们使用正常的点符号编写代码,例如 fc.loaded,那么它将开始调用 getproperty 函数,我们可能会陷入无限递归。
如果字段名称不是支持的名称之一,那么我们只需抛出一个异常,如下所示:

一个有趣的观察是,我们决定只支持两个属性名称——path 和 contents——并且我们放弃了 loaded 属性的支持。这样做的原因是 loaded 字段实际上被用作对象的内部状态。没有理由将其作为公共编程接口的一部分暴露出来。正如我们在本章讨论软件健壮性时,我们也可以欣赏只暴露必要信息的代码开发。
一个类比是数据总是 分类 的,但只能根据 需要了解 的原则释放,这是政府官员通常用来描述高度敏感数据的方式。
我们几乎完成了。唯一剩下的工作是将 load_content! 函数重构为使用 getfield 和 setfield! 而不是点符号:
# lazy load
function load_contents!(fc::FileContent)
open(getfield(fc, :path)) do io
readbytes!(io, getfield(fc, :contents))
setfield!(fc, :loaded, true)
end
nothing
end
我们现在可以测试懒加载功能:

对 path 和 contents 字段的两次引用都工作正常。特别是,对 fc.contents 的引用触发了文件加载,然后返回了正确的内容。那么 loaded 字段发生了什么?让我们试试:

Voila! 我们已经成功阻止了直接访问 loaded 字段。
属性接口使我们能够管理读访问并实现懒加载功能。接下来,我们将探讨如何管理写访问。
控制对象字段的写访问
为了管理对对象字段的写访问,我们可以像控制读访问一样扩展setproperty!函数。
让我们回顾一下FileContent数据类型是如何设计的:
mutable struct FileContent
path
loaded
contents
end
假设我们想要允许用户通过将path字段修改为新文件位置来切换到不同的文件。除此之外,我们还想防止直接使用点符号修改loaded和contents字段。为了实现这一点,我们可以扩展setproperty!函数,如下所示:
function Base.setproperty!(fc::FileContent, s::Symbol, value)
if s === :path
ss = lstat(value)
setfield!(fc, :path, value)
setfield!(fc, :loaded, false)
setfield!(fc, :contents, zeros(UInt8, ss.size))
println("Object re-initialized for $value (size $(ss.size))")
return nothing
end
error("Property $s cannot be changed.")
end
为了扩展setproperty!函数,我们必须在函数定义中每次需要更改对象中的任何字段时使用setfield!。
在这种情况下,当用户尝试将值赋给path字段时,我们只需像在构造函数中做的那样重新初始化对象。这涉及到设置path和loaded字段的值,以及为文件内容预分配内存空间。让我们现在就进行测试:

如果用户尝试将值赋给任何其他字段,将会抛出错误:

通过扩展setproperty!函数,我们已经成功控制了对任何对象的任何字段的写访问。
虽然可以控制对单个字段的访问,但我们无法防止对字段底层数据的额外更改。例如,contents属性只是一个字节数组,程序员应该能够更改数组中的元素。如果我们想保护数据不被修改,我们可以在getproperty调用中返回contents字节数组的副本。
到目前为止,我们已经知道如何实现getproperty和setproperty!函数,以便我们可以控制对对象各个字段的访问。接下来,我们将探讨如何记录可用的属性。
报告可访问字段
开发环境经常可以帮助程序员正确输入字段名。在 Julia 的 REPL 中,当我输入点字符后按两次Tab键,它将尝试自动完成并显示可用的字段名:

现在我们已经实现了getproperty和setproperty!函数,列表就不再准确了。更具体地说,loaded字段不应显示,因为它既不能被访问也不能被修改。为了修复这个问题,我们可以简单地扩展propertynames函数,如下所示:
function Base.propertynames(fc::FileContent)
return (:path, :contents)
end
propertynames函数只需返回一个有效符号的元组。函数定义后,REPL 将只显示有效的字段名,如下所示:

在本节中,我们学习了如何利用 Julia 的属性接口来控制对对象任何字段的读和写访问。这是一个编写健壮程序的基本技术。
虽然使用属性接口似乎满足了我们之前设定的要求的大部分,但它并不是万无一失的。
例如,没有任何东西阻止程序直接在任何对象上调用getfield和setfield!函数。除非语言更新以支持粒度字段访问控制,否则不可能完全从程序员那里隐藏这一点。这样的功能可能在将来可用。
接下来,我们将探讨一些与限制变量作用域相关的模式,这样我们就可以最小化私有变量对外界的暴露。
块模式
本章的反复主题是学习如何改进并更多地控制公共 API 中数据和函数的可见性和可访问性。通过强制编程接口的访问,我们可以保证程序的利用方式。此外,我们可以专注于测试宣传的接口。
目前,Julia 在模块内封装实现细节方面提供的帮助很少。虽然我们可以使用export关键字将某些函数和变量暴露给其他模块,但它并不是为了成为一个访问控制或数据封装功能。你总是可以窥视模块并访问任何变量或函数,即使它们没有被导出。
在本节中,我们将继续这一趋势,并介绍我们可以用来限制模块中变量或函数访问的一些策略。在这里,我们将使用网络爬虫用例来说明问题和可能的解决方案。
介绍网络爬虫用例
假设我们必须构建一个可以用于从各种网站索引内容的网络爬虫。这个过程涉及设置目标网站列表并启动爬虫。让我们创建一个具有以下结构的模块:
module WebCrawler
using Dates
# public interface
export Target
export add_site!, crawl_sites!, current_sites, reset_crawler!
# == insert global variables and functions here ==
end # module
我们的编程接口相当简单。让我们看看如何做到这一点:
-
Target是一种表示正在被爬取的网站的数据类型。然后,我们可以使用add_site!函数将新的目标网站添加到列表中。 -
准备就绪后,我们只需调用
crawl_sites!函数来访问所有网站。 -
为了方便,可以使用
current_sites函数来查看当前目标网站列表及其爬取状态。 -
最后,可以使用
reset_crawler!函数来重置网络爬虫的状态。
现在让我们看看数据结构。Target类型用于维护目标网站的 URL。它还包含一个关于状态和完成爬取时间的布尔变量。结构体定义如下:
Base.@kwdef mutable struct Target
url::String
finished::Bool = false
finish_time::Union{DateTime,Nothing} = nothing
end
为了跟踪当前的目标网站,使用了一个全局变量:
const sites = Target[]
为了完成网络爬虫的实现,我们在模块中定义了以下函数:
function add_site!(site::Target)
push!(sites, site)
end
function crawl_sites!()
for s in sites
index_site!(s)
end
end
function current_sites()
copy(sites)
end
function index_site!(site::Target)
site.finished = true
site.finish_time = now()
println("Site $(site.url) crawled.")
end
function reset_crawler!()
empty!(sites)
end
要使用网络爬虫,首先,我们可以添加一些网站,如下所示:

然后,我们只需运行爬虫并在之后检索结果:

当前的实现并不糟糕,但它有两个与访问相关的问题:
-
全局变量
sites对外部世界可见,这意味着任何人都可以获取变量的控制权并破坏它,例如,通过插入恶意网站。 -
index_site!函数应被视为私有函数,不应包含在公共 API 中。
既然我们已经设定了场景,我们将在下一节中展示如何解决这些问题。
使用闭包隐藏私有变量和函数
我们的目标是隐藏全局常量 sites 和辅助函数 index_site!,以便它们在公共 API 中不可见。为了实现这一点,我们可以利用 let 块。
在模块的主体中,我们可以将所有函数包裹在一个 let 块中,如下所示:

现在,让我们看看都发生了哪些变化:
-
sites常量在let块的开始处被替换为一个绑定变量。let块中的变量仅在块的作用域内绑定,对外部世界不可见。 -
需要公开给 API 的函数以
global关键字为前缀。这包括add_site!、crawl_sites!、current_sites和reset_crawler!。index_site!函数保持原样,以便它不会被公开。
global 关键字允许我们将函数名称暴露给模块的全局作用域,这样就可以导出并从公共 API 中访问。
重新加载模块后,我们可以确认 sites 和 index_site! 都不可从 API 中访问,如下面的输出所示:

如您所见,let 块是控制模块中全局变量或函数访问的有效方式。我们有封装函数或变量的能力,以防止它们从模块外部访问。
在 let 块中包装函数时可能会产生性能开销。在使用此模式之前,您可能想要在代码的任何性能关键部分运行性能测试。
由于 let 块在限制作用域方面非常有用,我们经常可以在较长的脚本和函数中使用它。接下来,我们将看看它在实际中的应用。
限制长脚本或函数的变量作用域
let 块的另一种用法是在长 Julia 脚本或函数中限制变量的作用域。在一个长脚本或函数中,如果我们声明一个变量在顶部并在整个主体中使用它,代码可能会难以跟踪。相反,我们可以编写一系列 let 块,它们独立操作并拥有自己的绑定变量。通过在较小的块中限制绑定变量,我们可以更容易地跟踪代码。
虽然编写长脚本/函数通常不是推荐的做法,但在测试代码中偶尔会遇到,这通常相当重复。在测试脚本中,我们可能有多个测试用例被分组在同一个测试集中。以下是从 GtkUtilities 包中的一个示例:
# Source: GtkUtilities.jl/test/utils.jl
let c = Canvas(), win = Window(c, "Canvas1")
Gtk.draw(c) do widget
fill!(widget, RGB(1,0,0))
end
showall(win)
end
let c = Canvas(), win = Window(c, "Canvas2")
Gtk.draw(c) do widget
w, h = Int(width(widget)), Int(height(widget))
randcol = reshape(reinterpret(RGB{N0f8}, rand(0x00:0xff, 3, w*h)), w, h)
copy!(widget, randcol)
end
showall(win)
end
let c = Canvas(), win = Window(c, "Canvas3")
Gtk.draw(c) do widget
w, h = Int(width(widget)), Int(height(widget))
randnum = reshape(reinterpret(N0f8, rand(0x00:0xff, w*h)),w,h)
copy!(widget, randnum)
end
showall(win)
end
从前面的代码中,我们有以下几点观察:
-
c变量每次都会绑定到一个新的Canvas对象。 -
win变量绑定到一个新的Window对象,每次都会有一个不同的标题。 -
w、h、randcol和randnum变量是局部变量,它们不会从它们各自的let块中逃逸。
通过使用 let 块,测试脚本的长度并不重要。每个 let 块都维护自己的作用域,并且不应该有任何东西从一个块泄漏到下一个块。这种编程风格在测试代码的质量方面立即为程序员提供了一些安慰,因为每个测试单元都是相互独立的。
接下来,我们将介绍一些异常处理技术。虽然做编程项目更有趣,但异常处理并不是我们可以忽视的东西。所以,让我们接下来看看它。
异常处理模式
稳健的软件需要稳健的错误处理实践。事实上,错误可能在任何时候发生,有时甚至出人意料。作为一个负责任的程序员,我们需要确保计算过程中的每一条路径都得到妥善处理,包括快乐路径和不快乐路径。快乐路径指的是程序按预期正常运行的情况。不快乐路径则是指由于错误条件导致的意外结果。
在本节中,我们将探讨几种捕获异常和有效恢复失败的方法。
捕获和处理异常
捕获异常的一般技术是将任何逻辑包裹在 try-catch 块中。这是确保意外错误得到处理的最简单方法:
try
# do something that may possible raise an error
catch ex
# recover from failure depending on the type of condition
end
然而,一个常见的问题是这个 try-catch 块应该放在哪里。当然,我们可以将每一行代码都包裹起来,但这并不实用。毕竟,并不是每一行代码都会抛出错误。
我们确实希望在选择捕获异常的位置时表现得聪明一些。我们知道添加异常处理会增加代码的大小。此外,每一行代码都需要维护。讽刺的是,我们写的代码越少,引入错误的机会就越小。毕竟,我们不应该通过尝试捕获问题来引入更多的问题,对吧?
接下来,我们将看看我们应该考虑进行错误处理的场景类型。
处理各种类型的异常
将 try-catch 块包裹起来的最明显的地方是在我们需要获取网络资源的代码块中,例如查询数据库或连接到网络服务器。每当涉及网络时,遇到问题的可能性比在相同计算机上本地执行某事要高得多。
理解可能抛出的错误类型很重要。假设我们继续从上一节开发网络爬虫用例。现在index_sites!函数是使用 HTTP 库实现的,如下所示:
function index_site!(site::Target)
response = HTTP.get(site.url)
site.finished = true
site.finish_time = now()
println("Site $(site.url) crawled. Status=", response.status)
end
HTTP.get函数用于从网站检索内容。代码看起来很无辜,但它没有处理任何错误条件。例如,如果网站的 URL 错误或网站关闭,会发生什么?在这些情况下,我们会遇到运行时异常,如下所示:

因此,至少我们应该处理IOError。实际上,HTTP 库做得更多。如果远程网站返回 400 或 500 系列中的任何 HTTP 状态码,它也会包装错误码并抛出StatusError异常,如下所示:

那么,我们如何确定可能会抛出哪些类型的错误呢?嗯,我们总是可以阅读详细手册或所谓的 RTFM。从 HTTP 包的文档中,我们可以看到在发起 HTTP 请求时可能会抛出以下异常:
-
HTTP.ExceptionRequest.StatusError -
HTTP.Parsers.ParseError -
HTTP.IOExtras.IOError -
Sockets.DNSError
在 Julia 中,try-catch 块会捕获所有异常,无论异常类型如何。因此,我们应该有能力处理任何其他异常,即使我们不知道它。以下是一个正确处理异常的函数示例:
function try_index_site!(site::Target)
try
index_site!(site)
catch ex
println("Unable to index site: $site")
if ex isa HTTP.ExceptionRequest.StatusError
println("HTTP status error (code = ", ex.status, ")")
elseif ex isa Sockets.DNSError
println("DNS problem: ", ex)
else
println("Unknown error:", ex)
end
end
end
从前面的代码中我们可以看到,在catch块的主体中,我们可以检查异常类型并相应地处理它。块的else部分确保捕获所有类型的异常,无论我们是否了解它们。让我们将crawl_site!函数连接到这个新函数:
global function crawl_sites!()
for s in sites
try_index_site!(s)
end
end
我们现在可以测试错误处理代码了:

这效果很好!
因此,这是一个实例;我们还想在哪些地方注入异常处理逻辑?让我们接下来探索这个问题。
在顶层处理异常
另一个通常处理异常的地方是程序的最高层。为什么?一个原因是我们可能不想让程序因为未捕获的异常而崩溃。程序的最高层是最后一个捕获任何异常的地方,程序有从失败中恢复(例如进行软重置)或优雅地关闭所有资源并关闭的选项。
当计算机程序执行完毕时,它通常会返回一个退出状态给调用程序的那个 shell。在 Unix 中,通常的约定是用零状态表示成功终止,用非零状态表示不成功终止。
考虑以下伪代码:
try
# 1\. do some work related to reading writing files
# 2\. invoke an HTTP request to a remote web service
# 3\. create a status report in PDF and save in a network drive
catch ex
if ex isa FileNotFoundError
println("Having trouble with reading local file")
exit(1)
elseif ex isa HTTPRequestError
println("Unable to communicate with web service")
exit(2)
elseif ex isa NetworkDriveNotReadyError
println("All done, except that the report cannot be saved")
exit(3)
else
println("An unknown error has occurred, please report. Error=", ex)
exit(255)
end
end
我们可以从之前的代码中看到,按照设计,我们可以为不同的错误条件退出程序并返回一个特定的状态码,这样调用程序就可以正确地处理异常。
接下来,我们将看看如何从深层嵌套的执行帧中确定异常最初是从哪里抛出的。
沿着堆栈帧前进
通常,异常是从一个函数中抛出的,但它没有被正确处理。然后异常会传播到父调用函数。如果那个函数也没有捕获异常,它又会传播到下一个父调用函数。这个过程会一直持续,直到一个try-catch块捕获了异常。在这个点上,程序当前的堆栈帧——即代码当前正在运行的执行上下文——处理了异常。
如果我们能看到异常最初是在哪里抛出的,那将非常有用。为了做到这一点,让我们首先尝试理解如何检索一个堆栈跟踪,它是一个堆栈帧数组。让我们创建一组简单的嵌套函数调用,这样它们在最后会抛出一个错误。考虑以下代码:
function foo1()
foo2()
end
function foo2()
foo3()
end
function foo3()
throw(ErrorException("bad things happened"))
end
现在,如果我们执行foo1函数,我们应该得到一个错误,如下所示:

如您所见,堆栈跟踪显示了执行序列的反向顺序。堆栈跟踪的顶部是foo3函数。因为我们是在 REPL 中做的,所以我们看不到源文件名;然而,数字 2,即REPL[17]:2,表示错误是从foo3函数的第 2 行抛出的。
现在,让我们介绍stacktrace函数。这个函数是Base包的一部分,它可以用来获取当前的堆栈跟踪。由于stacktrace函数返回一个StackFrame数组,如果能创建一个函数来优雅地显示它那就更好了。我们可以定义一个函数来打印堆栈跟踪,如下所示:
function pretty_print_stacktrace(trace)
for (i,v) in enumerate(trace)
println(i, " => ", v)
end
end
由于我们想要正确地处理异常,我们现在将更新foo1函数,通过将foo2的调用包裹在一个try-catch块中来实现。在catch块中,我们还将打印堆栈跟踪,这样我们可以进一步调试问题:
function foo1()
try
foo2()
catch
println("handling error gracefully")
pretty_print_stacktrace(stacktrace())
end
end
现在,让我们运行foo1函数:

哎呀!foo2和foo3怎么了?异常是从foo3抛出的,但我们已经无法在堆栈跟踪中看到它们了。这是因为我们已经捕获了异常,从 Julia 的角度来看,它已经被处理,当前的执行上下文已经在foo1中。
为了解决这个问题,Base包中还有一个名为catch_backtrace的函数。它给我们提供了当前异常的堆栈跟踪,这样我们就能知道异常最初是在哪里抛出的。我们只需要更新foo1函数如下:
function foo1()
try
foo2()
catch
println("handling error gracefully")
pretty_print_stacktrace(stacktrace(catch_backtrace()))
end
end
然后,如果我们再次运行foo1,我们会得到以下结果,其中foo3和foo2又回到了堆栈跟踪中:

注意,catch_backtrace的使用必须在catch块内。如果它被调用在catch块之外,它将返回一个空的回溯。
接下来,我们将探讨异常处理的不同方面——性能影响。
理解异常处理对性能的影响
实际上,使用 try-catch 块会有性能开销。特别是如果应用程序在一个紧密的循环中执行某些操作,那么在循环中捕获异常是一个坏主意。为了理解影响,让我们尝试一个简单的例子。
考虑以下代码,它简单地计算数组中每个数字的平方根之和:
function sum_of_sqrt1(xs)
total = zero(eltype(xs))
for i in eachindex(xs)
total += sqrt(xs[i])
end
return total
end
知道sqrt函数对于负数可能会抛出DomainError异常,我们的第一次尝试可能是尝试在循环中捕获这样的异常:
function sum_of_sqrt2(xs)
total = zero(eltype(xs))
for i in eachindex(xs)
try
total += sqrt(xs[i])
catch
# ignore error intentionally
end
end
return total
end
这样做会对性能产生什么影响?让我们使用BenchmarkTools包来测量这两个函数的性能:

结果表明,仅仅将代码包裹在 try-catch 块中已经使循环速度慢了 5 倍!也许这并不是一个好的交易。那么在这种情况下我们应该怎么做呢?嗯,我们总是可以在调用sqrt函数之前主动检查数字,避免负值的问题。让我们编写一个新的sum_of_sqrt3函数,如下所示:
function sum_of_sqrt3(xs)
total = zero(eltype(xs))
for i in eachindex(xs)
if xs[i] >= 0.0
total += sqrt(xs[i])
end
end
return total
end
让我们再次测量性能:

太棒了! 我们现在已经恢复了性能。这个故事的意义在于我们应该明智地使用 try-catch 块,尤其是在性能是一个关注点的时候。如果有可能避免 try-catch 块,那么在需要更高性能的情况下,这肯定是一个更好的选择。
接下来,我们将探讨如何执行重试,这是一种常用的从失败中恢复的策略。
重试操作
有时,异常是由于意外的中断或所谓的嗝而抛出的。对于高度集成其他系统或服务的系统来说,这不是一个不常见的场景。例如,证券交易所的交易系统可能需要将交易执行数据发布到消息系统进行下游处理。但如果消息系统只是短暂的中断,那么操作可能会失败。在这种情况下,最常见的方法是等待一段时间,然后回来再次尝试。如果重试再次失败,那么操作将在系统完全恢复后再次重试。
这样的重试逻辑并不难编写。在这里,我们将玩一个例子。假设我们有一个随机失败的功能:
using Dates
function do_something(name::AbstractString)
println(now(), " Let's do it")
if rand() > 0.5
println(now(), " Good job, $(name)!")
else
error(now(), " Too bad :-(")
end
end
在一个美好的日子里,我们会看到这样一条可爱的消息:

在一个糟糕的日子里,我们可能会得到以下结果:

天真地,我们可以开发一个新的函数,它包含了重试逻辑:
function do_something_more_robustly(name::AbstractString;
max_retry_count = 3,
retry_interval = 2)
retry_count = 0
while true
try
return do_something(name)
catch ex
sleep(retry_interval)
retry_count += 1
retry_count > max_retry_count && rethrow(ex)
end
end
end
这个函数只是调用do_something函数。如果遇到异常,它将等待 2 秒,如retry_interval关键字参数中指定的那样,然后再次尝试。它会在retry_count中跟踪一个计数器,因此默认情况下它将重试最多 3 次,如max_retry_count关键字参数所示:

当然,这段代码相当直接且易于编写。但如果我们要对许多函数重复这样做,很快就会感到无聊。结果发现,Julia 自带一个retry函数,可以很好地解决这个问题。我们可以用一行代码实现完全相同的功能:
retry(do_something, delays=fill(2.0, 3))("John")
retry函数将一个函数作为第一个参数。delays关键字参数可以是任何支持迭代接口的对象。在这种情况下,我们提供了一个包含 3 个元素的数组,每个元素包含数字 2.0。retry函数的返回值是一个匿名函数,它接受任意数量的参数。这些参数将被传递到需要调用的原始函数中,在这种情况下是do_something。以下是使用retry函数的示例:

由于delays参数可以包含任何数字,我们可以利用不同的策略,以不同的等待时间返回。一种常见的用法是,我们希望在开始时快速重试(即睡眠时间短),但随着时间的推移逐渐减慢。在连接到远程系统时,远程系统可能只是出现短暂的故障,或者可能正在进行长时间的停机。在后一种情况下,向系统发送快速请求是没有意义的,因为这会浪费系统资源,并且当系统已经处于混乱状态时,会使问题更加复杂。
实际上,delays参数的默认值是ExponentialBackOff,它会通过指数增加延迟时间进行迭代。在非常不幸的一天,使用ExponentialBackOff会产生以下模式:

让我们关注重试之间的等待时间。结果应该与ExponentialBackOff的默认设置相匹配,正如其签名所示:
ExponentialBackOff(; n=1, first_delay=0.05, max_delay=10.0, factor=5.0, jitter=0.1)
关键字参数n表示重试次数,在前面的代码中我们使用了 10 这个值。第一次重试在 0.05 秒后进行。然后,对于每次重试,延迟时间以 5 的倍数增长,直到达到最大值 10 秒。增长速率可能会有 10%的波动。
retry函数经常被忽视,但它是一种非常方便且强大的方法,可以使系统更加健壮。
当出现问题时很容易抛出异常。但这并不是处理错误条件的唯一方式。在下一节中,我们将讨论异常与正常负面条件的概念。
选择不抛出异常
考虑到 try-catch 块强大的功能,有时会诱使我们用Exception类型处理所有负面情况。在实践中,我们想要非常清楚什么是真正的异常,什么只是正常的负面情况。
我们可以将match函数作为一个例子。Base包中的match函数可以用来将正则表达式与字符串匹配。如果找到匹配项,则返回一个包含捕获结果的RegexMatch对象,否则返回nothing。以下示例说明了这种效果:

第一个match函数调用返回了一个RegexMatch对象,因为它发现google.com以.com结尾。第二个调用没有找到任何匹配项,因此返回了nothing。
按照设计,match函数不会抛出任何异常。为什么?一个原因是因为这个函数经常被用来检查一个字符串是否包含另一个字符串,然后程序决定如何处理。这样做需要一个简单的if语句;例如,参考以下代码:
url = "http://google.com"
if match(r"\.com$", url) !== nothing
# do something about .com sites
elseif match(r"\.org$", url) !== nothing
# do something about .org sites
else
# do something different
end
如果它抛出一个异常,那么我们的代码将不得不有所不同,如下所示:
url = "http://google.com"
try
match(r"\.com$", url)
# do something about .com sites
catch ex1
try
match(r"\.org$", url)
# do something about .org sites
catch ex2
# do something different
end
end
如您所见,使用 try-catch 块,代码可以很快变得非常丑陋。
在设计编程接口时,我们应始终考虑一个异常是否真的是一个异常,或者它是否可能只是负面状态。在匹配函数的情况下,负面情况有效地由nothing表示。
在本节中,我们学习了在代码中放置 try-catch 块的位置。现在我们应该能够正确地捕获异常并检查堆栈帧。
我们已经更好地理解了性能可能受到异常处理代码的影响。基于我们的理解,我们应该能够设计和开发出更健壮的软件。
摘要
在本章中,我们学习了构建健壮软件的各种模式和技巧。虽然 Julia 是一种非常适合快速原型和研发项目的语言,但它具有构建健壮、关键任务系统的所有功能。
我们开始我们的旅程,想法是将数据与访问器函数封装起来,这允许我们设计一个我们可以支持的正式 API。我们还讨论了一种命名约定,它鼓励人们不要访问对象的内部状态。
我们研究了 Julia 的属性接口,它允许我们在使用字段访问点符号时实现新的含义。通过扩展getproperty和setproperty!函数,我们能够控制对象字段的读写访问。
我们还学习了如何隐藏模块中定义的特定变量或函数。这种策略可以在我们想要更紧密地控制模块变量和函数可见性时使用。
最后,我们希望认真对待异常处理!我们知道健壮的软件需要能够处理各种异常。我们深入研究了 try-catch 过程,并学会了如何正确地确定堆栈跟踪。我们已经证明,性能可能会因使用 try-catch 块而受到负面影响,因此我们需要谨慎地考虑在哪里应用异常处理逻辑。我们还学会了如何使用标准的 retry 函数作为恢复策略。
在下一章中,我们将介绍一些在 Julia 程序中常用的更多杂项模式。
问题
-
开发评估函数的好处是什么?
-
有什么简单的方法可以阻止使用对象的内部字段?
-
哪些函数可以作为属性接口的一部分进行扩展?
-
我们如何捕获异常被捕获后的捕获块中的堆栈跟踪?
-
对于需要最佳性能的系统,避免 try-catch 块对性能产生影响的最佳方式是什么?
-
使用重试函数的好处是什么?
-
我们如何隐藏模块内部使用的全局变量和函数?
第九章:其他模式
本章将介绍一些在构建大型应用程序中非常有用的其他设计模式。这些模式提供了额外的工具,我们可以利用这些工具来补充之前章节中看到的重大模式。简而言之,我们将探索以下三个模式:
-
单例类型分派模式
-
模拟/存根模式
-
函数管道模式
单例类型分派模式利用了 Julia 的多重分派特性,这使得你可以在不修改现有代码的情况下添加新功能。
模拟/存根模式可以用来单独测试软件组件。也有可能在不实际使用它们的情况下测试外部依赖。这使得自动化测试变得容易得多。
函数管道模式利用管道操作符来表示执行流程的线性流。这是一种在许多数据处理管道中采用的编程方式。有些人认为这种线性执行的概念更直观。我们将探讨一些关于它们如何使用此模式的好例子。
让我们开始吧!
技术要求
本章中的代码已在 Julia 1.3.0 环境中进行了测试。
单例类型分派模式
Julia 支持动态分派,这是其多重分派系统的一个特定特性。动态分派允许程序在运行时根据函数参数的类型分派到适当的函数。如果你熟悉面向对象编程语言中的多态,那么这个概念是相似的。在本节中,我们将解释什么是单例类型以及它们如何用于实现动态分派。
首先,让我们考虑一个桌面应用程序用例,其中系统响应用户点击事件。以下是图形用户界面(GUI)可能的样子:

我们将首先尝试用简单的逻辑实现处理函数,然后看看如何使用单例类型分派模式来改进它。
开发命令处理器
我们第一次尝试实现一个可能看起来类似的命令处理过程:
function process_command(command::String, args)
if command == "open"
# open a file
elseif command == "close"
# close current file
elseif command == "exit"
# exit program
elseif command == "help"
# pops up a help dialog
else
error("bug - this should have never happened.")
end
end
process_command函数简单地接受命令作为字符串。然后,根据字符串的值,它将调用相应的函数。args参数可能由 GUI 代码传递以提供更多信息;例如,正在打开或关闭的文件路径。
从逻辑角度来看,这段代码没有问题,但可以按照以下方式改进:
-
代码包含一系列 if-then-else 语句。在这个例子中,我们只需要支持四个函数。在实践中,我们可能需要处理更多的函数。拥有这样一个大的 if-then-else 块会使代码非常丑陋且难以维护。
-
每当我们需要添加一个新命令时,我们必须修改这个函数以包含一个新条件。
幸运的是,我们可以通过使用单例类型和动态分派来使其变得更好。我们将在下一节中介绍这一点。
理解单例类型
单例类型只是一个设计为具有单个实例的数据类型。在 Julia 中,可以通过定义一个没有任何字段的类型来轻松实现:
struct OpenCommand end
要创建此类数据类型的一个单例实例,我们可以使用以下默认构造函数:
OpenCommand()
与某些面向对象编程语言不同,这个构造函数在多次调用时返回的实例完全相同。换句话说,它已经是一个单例。我们可以这样证明:

创建了两个OpenCommand实例之后,我们使用===运算符比较它们,这告诉我们这两个实例确实指向同一个对象。因此,我们已经实现了单例的创建。
接下来,我们可以采取相同的方法,为每个命令创建一个单例类型,即CloseCommand、ExitCommand、HelpCommand等。此外,我们还可以创建一个新的抽象类型AbstractCommand,它可以作为所有这些命令类型的超类型。
每个命令都创建一个新类型似乎非常冗长。处理这种情况的更好方法是使用参数化类型。由于这是一个相当常见的用例,Julia 预定义了一个名为Val的类型。让我们看看它。
使用 Val 参数化数据类型
Val是一个在 Julia Base 包中定义的参数化数据类型。它的目的是为我们提供一个使用单例类型进行分派的简单方法。Val类型定义如下:
struct Val{x} end
我们如何创建一个单例对象?我们可以使用Val构造函数并传递任何值。例如,我们可以创建一个包含值为 1 的单例类型,如下所示:

让我们确认这样一个对象的数据类型:

在这里,我们可以看到Val(1)和Val(2)有自己的类型——分别是Val{1}和Val{2}。有趣的是,传递给构造函数的值最终出现在类型签名中。同样,我们可以通过调用两次Val构造函数并比较它们的身份来证明这些确实是单例:

如您所见,Val构造函数也可以接受一个符号作为参数。请注意,Val只能接受位类型的数据,因为它会传递到类型签名。大多数用例涉及在类型参数中使用整数和符号的Val类型。如果我们尝试使用非位类型创建新的Val对象,那么我们会得到一个错误,如下所示:

你可能想知道为什么我们要花费这么多时间去讨论单例类型。这是因为单例类型可以用于动态调度。现在我们已经知道了如何创建单例,让我们学习如何利用它们进行调度。
使用单例类型和动态调度
在 Julia 中,当函数被调用时,函数调用是根据参数的类型来调度的。为了快速介绍这个机制,请参阅第三章,设计函数和接口。
让我们回顾一下本章前面提到的关于命令处理器函数的使用案例。使用原始实现,我们有一个大的 if-then-else 块,根据命令字符串调度到不同的函数。让我们尝试使用单例类型实现相同的功能。
对于每个命令,我们可以定义一个接受单例类型的函数。例如,Open和Close事件的函数签名如下:
function process_command(::Val{:open}, filename)
println("opening file $filename")
end
function process_command(::Val{:close}, filename)
println("closing file $filename")
end
我们不需要为第一个参数指定任何名称,因为我们不需要使用它。然而,我们指定第一个参数的类型为Val{:open}或Val{:close}。给定这样的函数签名,我们可以处理Open事件,如下所示:

基本上,我们创建一个单例并将其传递给函数。因为类型签名匹配,Julia 将会调度到我们在上一张截图中所定义的函数。现在,假设我们已经定义了所有其他函数,我们可以编写主调度器的代码如下:
function process_command(command::String, args...)
process_command(Val(Symbol(command)), args...)
end
在这里,我们只是将命令转换为符号,然后通过传递给Val构造函数来创建单例类型对象。在运行时,相应的process_command函数将被调度。让我们快速测试一下:

太棒了! 现在,让我们暂停一下,思考一下我们刚刚取得的成就。特别是,我们可以做出以下两个观察:
-
上一张截图中的主调度器函数不再有 if-then-else 块。它只是利用动态调度来确定调用哪个底层函数。
-
每当我们需要添加一个新的命令时,我们只需定义一个新的带有新
Val单例的process_command函数。主调度器函数不再有任何变化。
有可能创建自己的参数化类型,而不是使用标准的Val类型。这可以非常简单地实现,如下所示:
# A parametric type that represents a specific command
struct Command{T} end
# Constructor function to create a new Command instance from a string
Command(s::AbstractString) = Command{Symbol(s)}()
构造函数接受一个字符串,并创建一个具有Symbol类型参数的Command单例对象,该参数从字符串转换而来。有了这样的单例类型,我们可以定义我们的调度函数和相应的操作如下:
# Dispatcher function
function process_command(command::String, args...)
process_command(Command(command), args...)
end
# Actions
function process_command(::Command{:open}, filename)
println("opening file $filename")
end
function process_command(::Command{:close}, filename)
println("closing file $filename")
end
这种代码风格在 Julia 编程中相当常见——由于它被函数调度所取代,因此不再有条件分支。此外,你还可以通过定义新函数来扩展系统的功能,而无需修改任何现有代码。当我们需要从第三方库扩展函数时,这是一个相当有用的特性。
接下来,我们将进行一些实验并测量动态调度的性能。
理解调度的性能优势
使用单例类型很棒,因为我们可以避免编写条件分支。另一个副作用是性能可以大大提高。一个有趣的例子可以在 Julia 的 Base 包中的ntuple函数中找到。
ntuple函数用于通过在 1 到 N 的序列上应用函数来创建 N 个元素的元组。例如,我们可以创建一个偶数元组如下:

第一个参数是一个匿名函数,它将值加倍。由于我们在第二个参数中指定了 10,它映射了从 1 到 10 的范围,并给出了 2,4,6,... 20。如果我们查看源代码,我们会发现这个有趣的定义:
function ntuple(f::F, n::Integer) where F
t = n == 0 ? () :
n == 1 ? (f(1),) :
n == 2 ? (f(1), f(2)) :
n == 3 ? (f(1), f(2), f(3)) :
n == 4 ? (f(1), f(2), f(3), f(4)) :
n == 5 ? (f(1), f(2), f(3), f(4), f(5)) :
n == 6 ? (f(1), f(2), f(3), f(4), f(5), f(6)) :
n == 7 ? (f(1), f(2), f(3), f(4), f(5), f(6), f(7)) :
n == 8 ? (f(1), f(2), f(3), f(4), f(5), f(6), f(7), f(8)) :
n == 9 ? (f(1), f(2), f(3), f(4), f(5), f(6), f(7), f(8), f(9)) :
n == 10 ? (f(1), f(2), f(3), f(4), f(5), f(6), f(7), f(8), f(9), f(10)) :
_ntuple(f, n)
return t
end
虽然代码缩进得相当好,但我们清楚地看到它通过使用?和:三联运算符硬编码短路分支来支持最多 10 个元素。如果超过 10 个,则调用另一个函数来创建元组:
function _ntuple(f, n)
@_noinline_meta
(n >= 0) || throw(ArgumentError(string("tuple length should be ≥ 0, got ", n)))
([f(i) for i = 1:n]...,)
end
这个_ntuple函数预计会表现不佳,因为它使用列表推导创建一个数组,然后将结果展开到一个新的元组中。当我们比较创建一个 10 个元素的元组与一个 11 个元素的元组的性能基准测试结果时,你可能会非常惊讶:

ntuple函数旨在在元素数量较少时表现最佳,即 10 个或更少的元素。理论上,我们可以将ntuple函数更改为硬编码更多的内容,但这将非常繁琐,并且生成的代码将非常丑陋。
可能更令人惊讶的是,当使用Val单例类型时,Julia 实际上还提供了同一函数的另一种变体,如下面的截图所示:

10 个和 11 个元素之间实际上没有区别。事实上,即使有 100 个元素,性能也非常合理(17 纳秒),与非Val版本(820 纳秒)相比。让我们看看它是如何实现的。以下是从 Julia 源代码中摘录的:
# Using singleton type dynamic dispatch
# inferrable ntuple (enough for bootstrapping)
ntuple(f, ::Val{0}) = ()
ntuple(f, ::Val{1}) = (@_inline_meta; (f(1),))
ntuple(f, ::Val{2}) = (@_inline_meta; (f(1), f(2)))
ntuple(f, ::Val{3}) = (@_inline_meta; (f(1), f(2), f(3)))
@inline function ntuple(f::F, ::Val{N}) where {F,N}
N::Int
(N >= 0) || throw(ArgumentError(string("tuple length should be ≥ 0, got ", N)))
if @generated
quote
@nexprs $N i -> t_i = f(i)
@ncall $N tuple t
end
else
Tuple(f(i) for i = 1:N)
end
end
从前面的代码中,我们可以看到定义了一些用于元素少于四个的元组的函数。之后,该函数使用元编程技术即时生成代码。在这种情况下,它使用一个特殊的结构,允许编译器在代码生成和其通用实现之间进行选择,通用实现用代码中的 if 块和 else 块表示。关于 @generated、@nexprs 和 @ncalls 宏的工作原理超出了本节的范围,但鼓励您从 Julia 参考手册中了解更多信息。
根据我们之前的性能测试,使用 Val(100) 调用 ntuple 非常快,因此看起来编译器已经选择了代码生成路径。
总结一下,我们已经学习了如何使用参数化类型来创建新的单例(singletons)并创建由这些单例类型调用的函数。我们可以在需要处理此类条件分支时应用此模式。
接下来,我们将学习如何有效地使用存根和模拟来开发自动化测试代码。
Stubbing/Mocking 模式
Julia 提供了构建自动化单元测试的优秀工具。当程序员遵循良好的设计模式和最佳实践时,软件很可能会由许多可以单独测试的小函数组成。
不幸的是,某些测试用例处理起来比较困难。它们通常涉及测试具有特定依赖项的组件,这些依赖项难以包含在自动化测试中。常见问题包括以下内容:
-
性能:依赖项可能是一个耗时的过程。
-
成本:依赖项每次被调用时可能会产生财务成本。
-
随机性:依赖项每次被调用时可能会产生不同的结果。
Stubbing/Mocking 是解决这些问题的常用策略。在本节中,我们将探讨如何在测试 Julia 代码时应用存根(stubs)和模拟(mocks)。
测试替身是什么?
在我们深入探讨存根/模拟的具体内容之前,回顾一些行业标准术语会有所帮助。首先,有 测试替身(testing doubles) 的概念。有趣的是,这个术语来源于与特技拍摄相关的电影制作技术。当表演危险动作时,特技演员会代替演员或女演员来完成工作。从观众的角度来看,这会让人误以为原演员或女演员在表演。测试替身在此意义上是相同的,即在测试中使用一个假组件来代替真实组件。
测试替身有多种类型,但最有用的是 存根(stubs) 和 模拟(mocks),我们将在本节中重点关注。在面向对象编程中,这些概念用类和对象来表示。在 Julia 中,我们将使用相同的术语来表示函数。与函数一起工作的一个好处是我们可以将所有精力集中在测试单一事物上。
存根是一个模仿真实函数的假函数,也称为协作函数。根据测试目标的要求,它们可以非常简单,总是返回相同的结果,或者它们可以稍微聪明一些,根据输入参数返回不同的值。无论它们有多聪明,出于一致性的原因,返回值几乎总是硬编码的。在测试期间,当被测试函数(FUT)被调用时,存根会替换协作函数。当 FUT 完成执行后,我们可以确定返回值的正确性。这被称为状态验证。这些函数之间的交互可以表示如下:

模拟也是一个模仿协作函数的假函数。与存根(stubs)相比,模拟关注行为验证。而不是仅仅检查 FUT 的状态,模拟会跟踪所有被调用的函数。它可以用来验证行为,例如模拟预期被调用的次数、模拟期望传递的参数的类型和值等。这被称为行为验证。在它们的执行结束时,我们可以进行状态验证和行为验证。如下所示:

在接下来的章节中,我们将重点介绍如何在测试中应用存根和模拟。
介绍信用审批用例
在本节中,我们将介绍一个与信用审批相关的示例用例。假设你正在开发一个系统,该系统能够在背景调查成功后为客户开设新的信用卡账户。你可以创建一个具有以下结构的 Julia 模块:
module CreditApproval
# primary function to open an account
function open_account(first_name, last_name, email) end
# supportive functions
function check_background(first_name, last_name) end
function create_account(first_name, last_name, email) end
function notify_downstream(account_number) end
end
现在,让我们实现每个函数。我们将从 check_background 函数开始,该函数只是记录事件并返回 true,表示背景调查成功。考虑以下代码:
# Background check.
# In practice, we would call a remote service for this.
# For this example, we just return true.
function check_background(first_name, last_name)
println("Doing background check for $first_name $last_name")
return true
end
create_account 函数与此类似。在这种情况下,预期的行为是返回一个账户号,即一个整数值,它指的是刚刚创建的账户。对于这个例子,我们只返回一个硬编码的值 1,如下所示:
# Create an account.
# In practice, we would actually create a record in database.
# For this example, we return an account number of 1.
function create_account(first_name, last_name, email)
println("Creating an account for $first_name $last_name")
return 1
end
notify_customer 函数的目的是向客户发送电子邮件。出于测试目的,我们只需记录事件;不需要返回任何内容:
# Notify downstream system by sending a message.
# For this example, we just print to console and returns nothing.
function notify_downstream(account_number)
println("Notifying downstream system about new account $account_number")
return nothing
end
最后,open_account 函数如下:
# Open a new account.
# Returns `:success` if account is created successfully.
# Returns `:failure` if background check fails.
function open_account(first_name, last_name, email)
check_background(first_name, last_name) || return :failure
account_number = create_account(first_name, last_name, email)
notify_downstream(account_number)
return :success
end
这是我们示例中的 FUT。逻辑涉及检查客户的背景,并在背景调查成功时创建账户,并通知下游关于新账户的信息。
让我们思考如何测试 open_account 函数。需要我们注意的明显事情是背景检查代码。更具体地说,我们期望有两个可能的执行路径——当背景检查成功和当背景检查失败时。如果我们需要覆盖这两个案例,那么我们需要能够模拟 check_background 函数的不同返回值。我们将使用存根来实现这一点。
使用存根执行状态验证
我们的目标是测试 open_account 函数的两个场景,其中 check_background 函数返回 true 或 false。当背景检查成功时,我们期望 open_account 返回 :success。否则,它应该返回 :failure。
使用我们的术语,open_account 是被测试的函数,而 check_background 是协作函数。我们无法真正控制协作函数的行为,这有点不幸。实际上,这个函数甚至可能调用背景检查服务,而我们对此几乎没有影响力。事实上,我们不想在每次测试我们的软件时都调用远程服务。
现在我们已经将原始的 CreditApproval 模块复制到一个名为 CreditApprovalStub 的新模块中,我们可以继续下一步。
由于我们是聪明的程序员,我们可以创建一个存根来替换协作函数。由于在 Julia 中函数是一等公民,我们可以重构 open_account 函数,使其能够接受任何来自关键字参数的背景检查函数,如下所示:
function open_account(first_name, last_name, email; checker = check_background)
checker(first_name, last_name) || return :failure
account_number = create_account(first_name, last_name, email)
notify_downstream(account_number)
return :success
end
新的 checker 关键字参数接受一个用于为客户执行背景检查的函数。我们已将其默认值设置为原始的 check_background 函数,因此它应该与之前的行为相同。现在,函数更容易进行测试。
在我们的测试套件中,我们现在可以练习两个执行路径,如下所示:
@testset "CreditApprovalStub.jl" begin
# stubs
check_background_success(first_name, last_name) = true
check_background_failure(first_name, last_name) = false
# testing
let first_name = "John", last_name = "Doe", email = "jdoe@julia-is-awesome.com"
@test open_account(first_name, last_name, email, checker = check_background_success) == :success
@test open_account(first_name, last_name, email, checker = check_background_failure) == :failure
end
在这里,我们为背景检查创建了两个存根:check_background_success 和 check_background_failure。它们分别返回 true 和 false 来模拟成功和失败的背景检查。然后,当我们需要测试 open_account 函数时,我们只需通过 checker 关键字参数传递这些存根函数。
现在我们来运行测试:

到目前为止,我们仅在 open_account 函数中为存根启用了 check_background 函数。如果我们想对 create_account 和 notify_downstream 函数做同样的事情怎么办?如果我们创建了两个额外的关键字参数并完成它,这将会同样简单。这不是一个坏选项。然而,你可能对我们的代码需要不断更改以进行新测试的事实不太满意。此外,这些关键字参数仅仅是为了测试而添加的,而不是调用接口的一部分。
在下一节中,我们将探讨 Mocking 包的使用,这是一个在源代码上改动不大的优秀工具,用于应用占位符和模拟。
使用 Mocking 包实现占位符
实现占位符的一个很好的替代方案是 Mocking 包。这个包使用起来相当简单。我们将快速概述如何使用 Mocking 来应用之前应用过的相同占位符。
为了跟随这个练习,你可以将 CreditApproval 模块中的代码复制到一个名为 CreditApprovalMockingStub 的新模块中。现在,按照以下步骤操作:
- 首先,确保 Mocking 包已安装。然后,修改测试函数,如下所示:
using Mocking
function open_account(first_name, last_name, email)
@mock(check_background(first_name, last_name)) || return :failure
account_number = create_account(first_name, last_name, email)
notify_downstream(account_number)
return :success
end
@mock 宏创建了一个注入点,在这里可以应用占位符,替换现有的协作函数调用,即 check_background。在正常执行条件下,@mock 宏简单地调用协作函数。
- 然而,在测试期间,可以应用占位符。为了实现这种行为,我们需要在测试脚本顶部激活模拟,如下所示:
using Mocking
Mocking.activate()
- 接下来,我们可以使用
@patch宏来定义占位符函数:
check_background_success_patch =
@patch function check_background(first_name, last_name)
println("check_background stub ==> simulating success")
return true
end
check_background_failure_patch =
@patch function check_background(first_name, last_name)
println("check_background stub ==> simulating failure")
return false
end
@patch 宏可以直接放在函数定义的前面。函数名必须与原始协作函数名匹配。同样,函数参数也应该匹配。
@patch宏返回一个匿名函数,可以应用到 FUT(Future of the Under Test)中的调用位置。要应用补丁,我们使用apply函数,如下所示:
# test background check failure case
apply(check_background_failure_patch) do
@test open_account("john", "doe", "jdoe@julia-is-awesome.com") == :failure
end
# test background check successful case
apply(check_background_success_patch) do
@test open_account("peter", "doe", "pdoe@julia-is-awesome.com") == :success
end
apply函数接受一个占位符并将其应用到测试函数中由@mock宏标识的协作函数被调用之处。让我们从交互式解释器(REPL)中运行这个测试:

- 现在,让我们确保在正常执行条件下不会应用占位符。从交互式解释器(REPL)中,我们可以直接调用该函数:

太棒了! 从前面的输出中,我们可以看到原始的协作函数 check_background 被调用了。
接下来,我们将扩展同样的想法,并将多个占位符应用到同一个函数上。
将多个占位符应用到同一个函数上
在我们的例子中,open_account 函数调用几个依赖函数。它为客户执行背景检查,创建账户,并通知下游系统。实际上,我们可能想要为所有这些函数创建占位符。我们如何应用多个占位符?Mocking 包支持这个功能。
通常情况下,我们需要为想要应用到占位符中的每个函数用 @mock 宏进行装饰。以下代码展示了这一点:
function open_account(first_name, last_name, email)
@mock(check_background(first_name, last_name)) || return :failure
account_number = @mock(create_account(first_name, last_name, email))
@mock(notify_downstream(account_number))
return :success
end
现在,我们已经准备好创建更多的占位符。为了演示目的,我们将为 create_account 函数定义另一个占位符,如下所示:
create_account_patch =
@patch function create_account(first_name, last_name, email)
println("create_account stub is called")
return 314
end
作为其设计的一部分,这个存根函数必须返回一个账户号码。因此,我们只是返回一个假的值 314。为了测试同时应用check_background_success_patch和create_account_patch的场景,我们可以将它们作为数组传递给apply函数:
apply([check_background_success_patch, create_account_patch]) do
@test open_account("peter", "doe", "pdoe@julia-is-awesome.com") == :success
end
注意,我们没有为notify_downstream函数提供任何存根。当没有提供存根时,将使用原始的协作函数。因此,我们在测试套件中应用存根函数时拥有所有想要的灵活性。在open_account函数中,由于我们在三个不同的注入点放置了@mock,我们可以从技术上测试八个不同的场景,对于每个场景,每个存根都是启用或禁用的。
FUT(Future of Testing)测试的复杂性会随着函数内部使用的分支和函数数量的指数级增长。这也是我们想要编写小函数的原因之一。因此,将大函数分解成较小的函数是一个好主意,这样它们就可以独立进行测试。
使用存根,我们可以轻松验证函数的预期返回值。另一种方法是模拟,它将重点放在验证 FUT 及其协作函数的行为上。我们将在下一节中探讨这一点。
使用模拟对象进行行为验证
模拟对象与存根对象不同——我们不仅仅测试函数的返回值,而是从协作函数的角度来测试期望。协作函数期望进行哪些活动?以下是一些针对我们用例的示例:
-
从
check_background函数的角度来看,它是否在每次open_account调用时只被调用一次? -
从
create_account函数的角度来看,当背景检查成功时,它是否被调用? -
从
create_account函数的角度来看,当背景检查失败时,它是否没有被调用? -
从
notify_account函数的角度来看,它是否被调用,并且账户号码大于 0?
设置模拟对象启用测试的过程涉及四个主要步骤:
-
设置测试期间将使用的模拟函数。
-
为协作函数建立期望。
-
运行测试。
-
验证我们之前设置的期望。
现在,让我们尝试开发自己的模拟测试。在这里,我们将练习为新账户打开的成功路径。在这种情况下,我们可以期望check_background、create_account和notify_downstream函数被精确地调用一次。此外,我们还可以期望传递给notify_downstream函数的账户号码应该是一个大于 1 的数字。记住这些信息,我们将创建一个带有绑定变量的 let-block 来跟踪我们想要测试的所有期望:
let check_background_call_count = 0,
create_account_call_count = 0,
notify_downstream_call_count = 0,
notify_downstream_received_proper_account_number = false
# insert more code here...
end
前三个变量将用于跟踪即将创建的三个模拟的调用次数。最后一个变量将用于记录在测试期间notify_downstream函数是否收到了正确的账户号码。在这个 let 块中,我们将实现之前概述的四个步骤。让我们首先定义模拟函数:
check_background_success_patch =
@patch function check_background(first_name, last_name)
check_background_call_count += 1
println("check_background mock is called, simulating success")
return true
end
在这里,我们只是在模拟函数中增加check_background_call_count计数器,以便我们可以跟踪模拟函数被调用的次数。同样,我们可以以相同的方式定义create_account_patch模拟函数:
create_account_patch =
@patch function create_account(first_name, last_name, email)
create_account_call_count += 1
println("create account_number mock is called")
return 314
end
最后一个模拟函数notify_downstream_patch覆盖了两个期望。它不仅跟踪调用次数,还验证传递的账户号码是否正确,如果是,则更新布尔标志。以下代码展示了这一点:
notify_downstream_patch =
@patch function notify_downstream(account_number)
notify_downstream_call_count += 1
if account_number > 0
notify_downstream_received_proper_account_number = true
end
println("notify downstream mock is called")
return nothing
end
第二步是正式建立我们的期望。这可以定义为以下简单函数:
function verify()
@test check_background_call_count == 1
@test create_account_call_count == 1
@test notify_downstream_call_count == 1
@test notify_downstream_received_proper_account_number
end
verify函数包含一系列期望,正式定义为常规 Julia 测试。现在,我们已经准备好通过应用所有三个模拟函数来执行我们的测试:
apply([check_background_success_patch, create_account_patch, notify_downstream_patch]) do
@test open_account("peter", "doe", "pdoe@julia-is-awesome.com") == :success
end
最后,作为最后一步,我们将针对我们的期望进行测试。这只是一个调用我们之前定义的verify函数:
verify()
现在,我们已经准备好运行模拟测试。相应的结果如下:

结果统计显示总共有五个测试用例,并且所有测试都通过了。其中四个测试来自verify函数的行为验证,而另一个测试来自对open_account函数返回值的state verification。
如您所见,模拟与存根有很大不同,因为它们用于执行行为和状态验证。
接下来,我们将探讨一个与如何更直观地构建数据管道相关的模式。
功能管道模式
有时,在构建应用程序时,我们会遇到需要复杂计算和数据转换的大问题。使用结构化编程技术,我们通常可以将大问题分解为中等规模的问题,然后再进一步将这些中等规模的问题分解为小规模的问题。当问题足够小的时候,我们可以编写函数来单独处理每个问题。
当然,这些函数并不是独立工作的——更有可能的是,一个函数的结果会输入到另一个函数中。在本节中,我们将探讨功能管道模式,它允许数据在数据管道中无缝传递。这在函数式编程语言中并不罕见,但在 Julia 中却较少见。尽管如此,我们仍将探讨它,看看它是如何实现的。
首先,我们将回顾一个与下载最近 Hacker News 故事进行分析相关的示例用例。然后,我们将逐步重构代码以使用功能管道模式。让我们开始吧!
介绍 Hacker News 分析用例
Hacker News 是一个流行的在线论坛,被软件开发者所使用。这个论坛上的主题通常与技术相关,但并不总是如此。故事根据用户投票数、相关时效性和其他因素进行排名。每个故事都有一个与之相关的评分。
在本节中,我们将开发一个程序,用于从 Hacker News 检索顶级故事并计算这些故事的平均评分。有关 Hacker News API 的更多信息,请参阅以下 GitHub 仓库:github.com/HackerNews/API。在这里,您可以快速了解检索故事和每个故事详情的过程。
获取 Hacker News 上的顶级故事 ID
首先,我们将创建一个名为HackerNewsAnalysis的模块。第一个函数将用于从 Hacker News 检索顶级故事。该代码如下:
using HTTP
using JSON3
function fetch_top_stories()
url = "https://hacker-news.firebaseio.com/v0/topstories.json"
response = HTTP.request("GET", url)
return JSON3.read(String(response.body))
end
它是如何工作的?让我们试试看:

让我们分几个步骤来剖析这个函数的逻辑。顶级故事可以从一个固定的 URL 中检索。在这里,我们使用了 HTTP 包从网络服务中获取数据。如果HTTP.request函数调用成功,它将返回一个HTTP.Message.Response对象。这很容易在 REPL 中验证:

那么,我们如何从Response对象中获取内容呢?它可以从body字段中获取。实际上,body字段只是一个字节数组。为了理解这些数据的意义,我们可以将其转换为String,如下所示:

从输出中可以看出,它是以 JSON 格式。我们也可以通过在浏览器中访问网络 URL 来验证这一点。从 API 文档中,我们知道这些数字代表 Hacker News 的故事 ID。为了将数据解析为可用的 Julia 数据类型,我们可以利用JSON3包:

JSON3.Array对象是数组的懒加载版本。按照设计,JSON3 不会提取值,直到你请求它。我们可以将其用作普通的 Julia 数组。有关更多信息,请访问 GitHub 上的 JSON3 文档:github.com/quinnj/JSON3.jl/blob/master/README.md。
现在我们有了故事 ID 数组,我们将开发一个用于检索 Hacker News 故事详细信息的函数。
获取故事详情
给定一个故事 ID,我们可以使用 Hacker News API 的item端点检索关于该故事的信息。在我们编写函数之前,让我们定义一个类型来存储数据:
struct Story
by::String
descendants::Union{Nothing,Int}
score::Int
time::Int
id::Int
title::String
kids::Union{Nothing,Vector{Int}}
url::Union{Nothing,String}
end
Story的字段是根据 Hacker News API 网站上的 JSON 模式设计的。有时,某些字段可能对某些故事类型不可用,在这种情况下,我们将它们在对象中留为nothing。这些可选字段包括descendants、kids和url。最后,每篇故事都有一个唯一的标识符id。
我们需要一个构造函数来创建Story对象。因为 JSON3 返回一个类似字典的对象,我们可以直接提取单个字段并将它们传递给构造函数。构造函数的函数可以定义为以下内容:
# Construct a Story from a Dict (or Dict-compatible) object
function Story(obj)
value = (x) -> get(obj, x, nothing)
return Story(
obj[:by],
value(:descendants),
obj[:score],
obj[:time],
obj[:id],
obj[:title],
value(:kids),
value(:url))
end
通常,我们可以使用索引操作符(方括号)从Dict对象中提取字段。然而,我们需要处理某些字段可能不在对象中的事实。为了避免意外的KeyError,我们可以定义一个名为value的闭包函数来提取字段,或者在键不在对象中时返回nothing。
现在,让我们看看获取单个故事详情的函数:
function fetch_story(id)
url = "https://hacker-news.firebaseio.com/v0/item/$(id).json"
response = HTTP.request("GET", url)
return Story(JSON3.read(response.body))
end
再次,我们使用HTTP.request检索数据。在收到响应后,我们可以使用 JSON3 解析数据并相应地构建一个Story对象。以下是它是如何工作的:

接下来,我们将介绍计算 Hacker News 前 N 篇故事平均分数的主程序。
计算前 N 篇故事的平均分数
现在我们有了找到前 N 篇故事并检索每篇故事详情的能力,我们可以创建一个新的函数来计算前 N 篇故事的平均分数。
average_score函数如下:
using Statistics: mean
function average_score(n = 10)
story_ids = fetch_top_stories()
println(now(), " Found ", length(story_ids), " stories")
top_stories = [fetch_story(id) for id in story_ids[1:min(n,end)]]
println(now(), " Fetched ", n, " story details")
avg_top_scores = mean(s.score for s in top_stories)
println(now(), " Average score = ", avg_top_scores)
return avg_top_scores
end
这个函数有三个部分:
-
第一部分使用
fetch_top_stories函数找到前 N 篇故事的 ID。 -
然后,它使用
fetch_story函数检索前n个故事的详情。 -
最后,它只从这些故事中计算平均分数。然后,将平均分数返回给调用者。
为了获取前n个故事 ID,我们选择使用索引操作符和范围1:min(n,end)。min函数用于处理n大于数组大小的情况。
让我们运行这个函数看看会发生什么:

从结果中,我们可以看到 Hacker News 的前n篇故事平均得分为 125.4。请注意,你可能得到不同的结果,因为这个数字会随着 Hacker News 用户对他们的最爱故事进行投票而实时变化。
现在用例已经确定,我们将跃进并尝试用不同的方式编写相同的程序。我们称这种编程风格为函数式管道。
理解函数式管道
在 Julia 中,有一个管道操作符可以用来从一个函数传递数据到另一个函数。这个概念非常简单。首先,让我们看看一些例子。
在上一节中,我们开发了一个fetch_top_stories函数,用于从 Hacker News 获取当前的热门故事。返回值是一个看起来像整数数组的JSON3.Array对象。假设我们想从数组中找到第一个故事 ID。为此,我们可以创建一个管道操作,如下所示:

管道操作符|>实际上在 Julia 中被定义为一个常规函数,就像+被定义为一个函数一样。请注意,前面的代码在语法上等同于以下代码:

此外,我们可以在表达式中使用多个管道操作符。例如,我们可以通过在管道末尾附加fetch_story函数来检索第一个故事的详细信息:

由于数据自然地从左到右流动,这被称为函数管道模式。这可以在以下图中看到:

注意,每个跟随管道操作符的函数都必须接受单个参数。在前面的例子中,我们可以看到以下内容:
-
first函数接受一个数组并返回第一个元素。 -
fetch_story函数接受一个故事 ID 的整数并返回一个Story对象。
这是一个非常重要的观点,所以让我再说一遍——函数管道只向单参数函数提供数据。
我们将在稍后学习如何处理这个限制。现在,我们将讨论一个与函数管道语法相反的类似模式。这个概念被称为可组合性,是一种导致高度可重用代码的设计技术。
设计可组合函数
有时候,你可能会听到其他 Julia 程序员谈论可组合性。那是什么意思?
可组合性用来描述函数如何以不同的方式轻松组装以实现不同的结果。让我们来看一个类比。我会说乐高有一个高度可组合的设计。这是因为几乎每一块乐高都可以与其他任何一块乐高组合,即使它们的形状不同。因此,任何孩子都可以用乐高搭建几乎任何可以想象的东西。
当谈到系统设计时,我们也可以考虑可组合性。如果我们能构建我们的函数,使它们可以轻松组合,那么我们就有了构建许多不同事物的灵活性。在 Julia 中,我们可以非常容易地组合函数。
让我们使用上一节中的相同示例。我们将创建一个新的函数top_story_id,用于从 Hacker News 获取第一个故事 ID:

从前面的代码中,我们可以看到 top_story_id 函数被定义为匿名函数。Unicode 圆形符号(∘,输入为 \circ)是 Julia 中的组合操作符。与管道操作符不同,我们是从右到左读取组合函数的顺序。在这种情况下,我们首先应用 fetch_top_stories 函数,然后应用 first 函数。直观上,我们可以像通常一样使用 top_story_id 函数:

我们也可以组合多个函数。为了获取最上面的故事详情,我们可以组合一个新的函数,称为 top_story,如下所示:

太棒了! 我们拿出了三个随机的乐高积木,用它们搭建了一个新的事物。top_story函数是一个由三个较小的积木组成的新事物:

让我们更进一步,创建一个新的函数来检索最上面的故事标题。现在,我们遇到了一点麻烦。还没有定义从 Story 对象返回故事标题的函数。然而,我们可以通过利用我们在第八章中描述的访问器模式来解决此问题,鲁棒性模式。
让我们定义一个标题字段的访问器,然后组合一个新的 top_story_title 函数,如下所示:

这个新函数工作得非常好,正如预期的那样:

组合操作符允许我们创建一个由多个其他函数组成的新函数。在某种程度上,它比管道操作符更方便,因为组合函数不需要立即执行。
与功能管道类似,组合操作符也期望单参数函数。话虽如此,这也是为什么单参数函数更易于组合的原因。
接下来,我们将回到 Hacker News 的 average_score 函数,看看我们如何可以将代码重构为功能管道风格。
开发用于平均分数函数的功能管道
首先,让我们回顾一下 average_score 函数是如何编写的:
function average_score(n = 10)
story_ids = fetch_top_stories()
println(now(), " Found ", length(story_ids), " stories")
top_stories = [fetch_story(id) for id in story_ids[1:min(n,end)]]
println(now(), " Fetched ", n, " story details")
avg_top_scores = mean(s.score for s in top_stories)
println(now(), " Average score = ", avg_top_scores)
return avg_top_scores
end
虽然代码看起来相当整洁且易于理解,但让我指出一些潜在的问题:
-
最上面的故事是通过数组推导语法检索的。逻辑有点复杂,我们将无法独立于
average_score函数测试这段代码。 -
println函数用于日志记录,但看起来我们似乎在复制代码来显示当前的时间戳。
现在,我们将重构代码。逻辑基本上是线性的,这使得它成为功能管道的良好候选者。从概念上讲,这是我们关于计算的想法:

设计一个像这样工作的函数会很好:
average_score2(n = 10) =
fetch_top_stories() |>
take(n) |>
fetch_story_details |>
calculate_average_score
这是该函数的第二个版本,所以我们将其命名为 average_score2。
现在,我们只是忽略日志记录方面,以保持简单。我们稍后会回到这一点。由于我们已定义了 fetch_top_stories 函数,我们只需开发其他三个函数,如下所示:
take(n::Int) = xs -> xs[1:min(n,end)]
fetch_story_details(ids::Vector{Int}) = fetch_story.(ids)
calculate_average_score(stories::Vector{Story}) = mean(s.score for s in stories)
从前面的代码中,我们可以看到以下内容:
-
take函数接收一个整数n,并返回一个匿名函数,该函数从数组中返回前n个元素。 -
min函数用于确保它不会超过数组的实际大小。 -
fetch_story_details函数接收一个故事 ID 数组,并使用点符号广播fetch_story函数。 -
calculate_average_score函数接收一个Story对象数组,并计算分数的平均值。
作为快速提醒,所有这些函数都接受单个参数作为输入,这样它们就可以参与函数式管道操作。
现在,让我们回到日志记录。在函数式管道中,日志记录扮演着有趣的角色。它被设计用来产生副作用,并且不会影响计算结果。在某种意义上,它是“滑稽”的,因为它只是返回从输入接收到的相同数据。由于标准的 println 函数不返回任何内容,我们无法直接在管道操作中使用它。相反,我们必须创建一个足够聪明的日志函数,它能够打印我们想要的内容,同时返回传递给它的相同数据。
此外,我们还想能够使用通过系统传递的数据来格式化输出。为此,我们可以利用 Formatting 包。它包含一个灵活且高效的格式化工具。让我们构建自己的日志函数,如下所示:
using Formatting: printfmtln
logx(fmt::AbstractString, f::Function = identity) = x -> begin
let y = f(x)
print(now(), " ")
printfmtln(fmt, y)
end
return x
end
logx 函数接收一个格式字符串和一个可能的转换函数 f。它返回一个匿名函数,该函数将转换后的值传递给 printfmln 函数。它还自动在日志前加上当前的时间戳。最重要的是,这个匿名函数返回参数的原始值。
要查看这个日志函数是如何工作的,我们可以尝试一些示例:

在前一个截图所示的第一例中,logx 函数仅使用一个格式字符串被调用,因此通过管道传入的输入将直接用于日志。第二个例子将 length 函数作为 logx 的第二个参数传递。然后,length 函数被用来转换用于日志记录的输入值。
将所有这些放在一起,我们可以在新的 average_score3 函数中引入日志记录,如下所示:
average_score3(n = 10) =
fetch_top_stories() |>
logx("Number of top stories = {}", length) |>
take(n) |>
logx("Limited number of stories = $n") |>
fetch_story_details |>
logx("Fetched story details") |>
calculate_average_score |>
logx("Average score = {}")
有时,函数式管道可以使代码更容易理解。因为管道操作不允许使用条件语句,所以逻辑总是线性的。
你可能想知道如何在函数式管道设计中处理条件逻辑。我们将在下一节中学习这一点。
在函数式管道中实现条件逻辑
由于逻辑流程相当线性,我们如何处理条件逻辑?
假设我们想要通过检查平均分数与阈值的比较来确定热门新闻的热度。如果平均分数高于 100,则被认为是高热度;否则,被认为是低热度。因此,字面上,我们需要一个 if 语句来决定接下来执行什么。
我们可以使用动态调度来解决此问题。我们将自下而上构建此函数,如下所示。
- 创建
hotness函数,它通过分数确定 Hacker News 网站的热度。它返回Val{:high}或Val{:low}参数类型的一个实例。内置的Val数据类型是创建可用于调度目的的新参数类型的一种方便方式:
hotness(score) = score > 100 ? Val(:high) : Val(:low)
- 根据参数类型
Val创建两个celebrate函数。它们简单地使用logx函数来打印一些文本。我们通过传递v的值来调用它,这样在庆祝之后如果我们想要做更多的工作,热度的参数就可以向下传递:
celebrate(v::Val{:high}) = logx("Woohoo! Lots of hot topics!")(v)
celebrate(v::Val{:low}) = logx("It's just a normal day...")(v)
- 构建
check_hotness函数,它使用函数式管道模式。它使用average_score3函数来计算平均分数。然后,它使用hotness函数来确定如何改变执行路径。最后,它通过多态调度机制调用celebrate函数:
check_hotness(n = 10) =
average_score3(n) |> hotness |> celebrate
让我们来测试一下:

这个简单的例子演示了如何在函数式管道设计中实现条件逻辑。当然,在现实中,我们的逻辑会比仅仅在屏幕上打印一些内容要复杂得多。
一个重要的观察是,函数式管道只处理线性执行。因此,每当执行条件分支时,我们就会为每个可能的路径创建一个新的管道。以下图表展示了如何使用函数式管道设计执行路径。每个执行分支的分裂都是由对单个参数类型的调度来实现的:

从概念上看,函数式管道看起来相当简单和直接。然而,它们有时因为需要在管道的每个组件之间传递中间数据而被批评,这会导致不必要的内存分配和速度减慢。在下一节中,我们将介绍如何使用广播来克服这个问题。
沿着函数式管道进行广播
在数据处理管道中,我们可能会遇到一种情况,即函数可以融合成一个单一的循环。这被称为广播,并且可以通过使用点符号方便地启用。使用广播可能会对数据密集型应用程序的性能产生巨大影响。
考虑以下场景,其中已经定义了两个向量化函数,如下所示:
add1v(xs) = [x + 1 for x in xs]
mul2v(xs) = [2x for x in xs]
add1v函数接受一个向量并将所有元素加 1。同样,mul2v函数接受一个向量并将每个元素乘以 2。现在,我们可以组合这些函数来创建一个新的函数,它接受一个向量并将其传递到管道中的add1v和随后的mul2v:
add1mul2v(xs) = xs |> add1v |> mul2v
然而,从性能角度来看,add1mul2v函数并不最优。原因是每个操作必须完全完成然后传递到下一个函数。虽然中间结果只需要临时存储,但必须在内存中分配:

如前图所示,除了输入向量和输出向量外,还必须分配一个中间向量来存储add1v函数的结果。
为了避免分配中间结果,我们可以利用广播。让我们创建另一组函数,这些函数操作单个元素而不是数组,如下所示:
add1(x) = x + 1
mul2(x) = 2x
我们原始的问题仍然需要取出一个向量,对每个元素加 1,然后乘以 2。因此,我们可以使用点符号定义这样的函数,如下所示:
add1mul2(xs) = xs .|> add1 .|> mul2
在管道操作符之前的点字符表示xs中的元素将被广播到add1和mul2函数,将整个操作融合成一个循环。数据流现在看起来更像是以下这样:

在这里,中间结果变成一个单独的整数,消除了对临时数组的需要。为了欣赏我们从广播中获得性能提升,我们可以运行两个函数的性能基准测试,如下面的截图所示:

如您所见,在这种情况下,广播版本比矢量化版本快了两倍。
在下一节中,我们将回顾一些关于使用功能管道的注意事项。
关于使用功能管道的注意事项
在您对功能管道过于兴奋之前,让我们确保我们理解使用功能管道的优缺点。
从可读性的角度来看,功能管道可能会使代码更容易阅读和跟踪。这是因为逻辑必须是线性的。相反,有些人可能会觉得它不太直观,更难阅读,因为与嵌套函数调用相比,计算的方向是相反的。
功能管道需要单参数函数,它们可以很容易地与其他函数组合。当涉及到需要多个参数的函数时,一般的解决方案是创建柯里化函数——一种固定一个参数的高阶函数。之前,我们定义了一个take函数,它从一个集合中取出前几个元素:
take(n::Int) = xs -> xs[1:min(n,end)]
take 函数是由 getindex 函数(使用方便的方括号语法)构成的柯里化函数。getindex 函数接受两个参数:一个集合和一个范围。由于参数数量已减少到 1,它现在可以参与功能管道。
从另一方面来看,我们不能为单参数函数使用多重分派。当你处理需要考虑多个参数的逻辑时,这可能会是一个巨大的缺点。
虽然函数只能接受单个参数,但可以通过使用元组来解决这个问题。元组具有复合类型签名,可用于分派。然而,不推荐这样做,因为定义只接受单个元组参数的函数而不是多个参数的函数相当尴尬。
然而,在某些情况下,函数式管道可以是一个有用的模式。任何适合线性过程风格的数据处理任务都可能是一个很好的匹配。
摘要
在本章中,我们学习了在应用设计中可能非常有用的几种模式。
我们从单例类型分发模式开始。通过使用命令处理器示例,我们成功地将代码重构为从使用 if-then-else 条件语句到利用动态分发的形式。我们学习了如何使用标准的 Val 类型或自己实现参数化类型来创建新的单例类型。
然后,我们转换了话题,讨论了如何有效地使用存根/模拟模式来实现自动化测试。我们以一个简单的信用审批流程为例,并尝试了一种简单的方法来使用关键字参数注入存根。我们对需要更改 API 以进行测试的需求并不满意,因此我们依赖于 Mocking 包来实现更流畅的方法。然后我们学习了如何在测试套件中用存根和模拟替换函数调用,以及它们如何不同工作。
最后,我们学习了功能管道模式以及它如何使代码更容易阅读和跟踪。我们学习了组合性以及组合操作符如何与管道操作符类似工作。我们讨论了如何使用功能管道和广播来开发高效的代码。最后,我们讨论了使用功能管道的优缺点以及其他相关考虑因素。
在下一章中,我们将转过头来探讨一些 Julia 编程的反模式。
问题
-
可以使用什么预定义的数据类型方便地创建新的单例类型?
-
使用单例类型分发的优点是什么?
-
为什么我们要创建存根?
-
模拟和存根之间的区别是什么?
-
组合性意味着什么?
-
使用功能管道的主要约束是什么?
-
函数式管道有什么用途?
第十章:反模式
在过去的五章中,我们详细探讨了可重用性、性能、可维护性、安全性和一些杂项设计模式。这些模式非常有用,可以应用于各种不同类型的应用程序的不同场景。虽然了解最佳实践很重要,但了解要避免的陷阱也同样有益。为此,我们将在本章中介绍几个反模式。
反模式是程序员可能无意中采取的坏做法。有时,这些问题可能不够严重,不足以造成麻烦;然而,由于设计不当,应用程序可能会变得不稳定或性能下降。在本章中,我们将涵盖以下主题:
-
盗版反模式
-
窄参数类型反模式
-
非具体字段类型反模式
到本章结束时,您将学会如何避免开发盗版函数。您还将对指定函数参数类型时的抽象级别更加警觉和明智。最后,您将能够在设计自己的复合类型时利用更多的参数化类型,以实现高性能应用。
让我们从最有趣的话题——盗版!开始吧。
技术要求
示例源代码位于github.com/PacktPublishing/Hands-on-Design-Patterns-and-Best-Practices-with-Julia/tree/master/Chapter10.
代码在 Julia 1.3.0 环境中进行了测试。
盗版反模式
在第二章“模块、包和数据类型概念”中,我们学习了如何使用模块创建新的命名空间。如您所回忆的那样,模块用于定义函数,以便它们在逻辑上分离。因此,我们可以定义两个不同的函数——一个在模块 X 中,另一个在模块 Y 中,这两个函数具有完全相同的名称。实际上,这些函数甚至不需要具有相同的意义。例如,在一个数学包中,我们可以为矩阵定义一个trace函数。在计算机图形学包中,我们可以定义一个用于进行光线追踪工作的trace函数。这两个trace函数执行不同的操作,并且它们不会相互干扰。
另一方面,一个函数也可以设计成可以从另一个包扩展。例如,在Base包中,AbstractArray接口被设计成可以扩展。以下是一个例子:
# My own array-like type for tracking scores
struct Scores <: AbstractVector{Float64}
values::Vector{Float64}
end
# implement AbstractArray interface
Base.size(s::Scores) = (length(s.values),)
Base.getindex(s::Scores, i::Int) = s.values[i]
在这里,我们扩展了来自 Base 包的 size 和 getindex 函数,以便它们可以与我们的自定义数据类型一起工作。这是 Julia 语言的一个非常好的用法;然而,当我们没有正确扩展其他包的函数时,可能会出现问题。特别是,“盗版”指的是第三方函数被错误地替换或扩展的情况。这是一个反模式,因为它可能导致系统行为变得非确定性。为了方便起见,我们可以定义三种不同类型的盗版:
-
类型 I 盗版行为:功能被重新定义
-
类型 II 盗版行为:在没有任何参数中使用自己的类型扩展函数
-
类型 III 盗版行为:函数被扩展但用于不同的目的
我们现在将更详细地探讨每一个。
类型 I – 重新定义函数
类型 I 盗版行为指的是程序员在自己的模块中重新定义第三方函数的情况。也许你不喜欢第三方模块中的原始实现,并用你自己的实现替换了该函数。
类型 I 盗版行为最糟糕的形式是,在不遵守原始函数接口的情况下替换函数。让我们做一个实验看看会发生什么。我们将使用 Base 中的 + 函数作为例子。正如你所知,当 + 函数传递两个 Int 参数时,它应该返回一个 Int 类型的结果。如果我们替换函数使其返回一个字符串会发生什么?让我们打开一个 REPL 并试一试:

砰! Julia REPL 在定义函数的瞬间立即崩溃。这是因为这个 + 函数的返回值预期是一个整数。当我们返回一个字符串时,它违反了这个函数的契约,并且所有依赖于 + 函数的功能都会受到负面影响。鉴于 + 是一个常用的函数,它立即导致系统崩溃。
为什么 Julia 甚至允许我们这样做?在某些情况下,这种能力可能是有用的。比如说,你发现了一个第三方包中特定函数的 bug—you 可以立即注入修复,而不必等待上游的 bug 修复。同样,你可以用更高效的版本替换一个慢函数。理想情况下,这些更改应该发送到上游,但你也有立即实施更改的灵活性。
唯一的要求是,被替换的函数应该遵守最初打算的相同契约。因此,需要对第三方包的设计有深入的了解。实际上,如果你在应用盗版之前能联系到原始作者并讨论更改,那就更好了。
权力越大,责任越大。如果我们想利用类型 I 盗版行为,就必须非常小心。
接下来,我们将探讨类型 II 盗版行为,这在 Julia 生态系统中的包中更为常见。
类型 II 盗版 – 没有使用自己的类型进行扩展
类型 II 盗版在 Julia 开发者社区中通常被称为 类型盗版。它指的是在没有使用程序员自己的类型作为任何函数参数的情况下扩展第三方函数的情况。这通常发生在您想通过注入自己的代码来扩展第三方包时。让我们通过一个假设的例子来探讨。
假设您想在 JavaScript 中模拟将字符串和数字相加的行为,其中值像字符串一样连接:

要在 Julia 中实现这一点,我们可能会在 MyModule 中做以下操作:
module MyModule
import Base.+
(+)(s::AbstractString, n::Number) = "$s$n"
end
我们可以在 REPL 中粘贴前面的代码并进行快速测试:

这看起来工作得很好!但是,这种方法还有一些隐藏的问题。让我们看看为什么这仍然是一个坏主意。
与另一个海盗冲突
现在我们正在使用 + 函数的增强版本,我们能依赖这个函数始终如我们所期望的那样工作吗?也许令人惊讶的是,答案是否定的。
假设我们找到一个名为 AnotherModule 的开源包,我们想在我们的 MyModule 模块中使用它。AnotherModule 模块恰好也做了同样的类型 II 盗版;然而,作者决定做正确的事情——不是像字符串一样连接参数,而是将字符串参数解析为数字,然后将两个数字相加。代码如下:
module AnotherModule
import Base: +, -, *, /
(+)(s::AbstractString, n::T) where T <: Number = parse(T, s) + n
(-)(s::AbstractString, n::T) where T <: Number = parse(T, s) - n
(*)(s::AbstractString, n::T) where T <: Number = parse(T, s) * n
(/)(s::AbstractString, n::T) where T <: Number = parse(T, s) / n
end
如果我们回到 REPL 并定义这个模块,那么我们会得到新的定义:

现在我们有两个具有完全相同签名的相同函数实现,但它们返回的结果不同。谁将获胜?是定义在 MyModule 中的那个还是定义在 AnotherModule 中的那个?只有一个可以生效。这意味着 AnotherModule 或 MyModule 中将有一个会出问题。这个问题可能导致灾难性的情况,并且难以发现的错误。
避免类型 II 盗版的另一个原因是未来兼容性问题。我们将在下一节讨论这个问题。
代码的未来兼容性
假设我们已经将 Base 中的 + 函数扩展如下:
module MyModule
import Base.+
(+)(s::AbstractString, n::Number) = "$s$n"
end
今天这看起来可能是一个很好的补充;然而,并不能保证在未来的 Julia 版本中相同的函数不会被实现。可以想象(这并不意味着它可能或不可能)+ 函数将来会被增强以支持字符串。
此外,这类更改将被视为非破坏性更改,这意味着 Julia 开发团队只需通过小版本发布即可添加此功能。不幸的是,现在您的应用程序因为非破坏性的 Julia 升级而崩溃。这不是我们通常期望的事情。
如果您想使代码具有未来兼容性,那么请不要成为海盗!
避免类型盗版
通过创建自己的类型并使用它们作为函数参数,可以减轻Ⅱ级海盗行为。在这种情况下,也许我们应该考虑创建一个包装类型来保存字符串,并使用这个新类型进行分发:
module MyModule
export @str_str
import Base: +, show
struct MyString
value::AbstractString
end
macro str_str(s::AbstractString)
MyString(s)
end
show(io::IO, s::MyString) = print(io, s.value)
(+)(s::MyString, n::Number) = MyString(s.value * string(n))
(+)(n::Number, s::MyString) = MyString(string(n) * s.value)
(+)(s::MyString, t::MyString) = MyString(s.value * t.value)
end
在这里,我们重新定义了模块,使用新的 MyString 类型来保存字符串。然后,我们仍然可以扩展 + 函数以将 MyString 与任何数量的字符串连接起来。为了完整性,我们已定义了三个 + 函数变体,用于接受任意顺序的 MyString 和 Number 参数,以及另一个接受两个 MyString 参数的变体。我们还定义了一个 str_str 宏以方便使用。新的模块按如下方式正常工作:

通过在函数参数中使用自己的类型,我们可以避免与其他依赖包发生冲突,并为 Julia 升级提供未来保障。
最后一种海盗行为稍微轻微一些,但仍值得一看。让我们看看下一个。
Ⅲ级海盗行为 – 使用你自己的类型,但用于不同的目的
Ⅲ级海盗行为指的是扩展了函数,但用于不同的目的的情况。这是扩展代码的正确程序,但以错误的方式执行。这种海盗行为也被 Julia 开发者称为 pun。为了理解它是什么,让我们在这里考虑一个有趣的例子。
假设我们正在开发一个简单的派对注册应用程序。类型定义和构造函数如下所示:
# A Party just contains a title and guest names
struct Party
title::String
guests::Vector{String}
end
# constructor
Party(title) = Party(title, String[])
Party 类型仅包含一个标题和一组嘉宾名称。构造函数仅接受标题并将嘉宾数组初始化为空数组。现在,为了显得可爱,我们可以定义一个如下所示的参加派对的函数:
Base.join(name::String, party::Party) = push!(party.guests, name)
这是 Base 中 join 方法的扩展。我们为什么要这样做呢?好吧,如果我们在我们自己的命名空间中创建 join 函数,那么我们可能会与标准 join 函数发生命名冲突。为了避免处理这种冲突,也许直接从 Base 扩展函数更容易。
初看起来,它应该按预期工作:

然而,这里有一个隐藏的陷阱。如果我们让多个人同时参加派对,那么我们很容易陷入麻烦:

发生了什么?让我们看看 join 函数的原始含义,如 help 屏幕所示:
help?> join
join([io::IO,] strings, delim, [last])
join 函数的目的是将多个字符串组合在一起,并用某种分隔符分隔。因此,前面代码中对 join 函数的调用最终使用了 Party 对象作为分隔符。
让我们稍微思考一下我们是如何陷入麻烦的。当我们使用自己的类型(Party)定义函数时,我们没有预料到我们的函数会被除我们自己的代码之外的任何代码使用。然而,这里并非如此。我们的函数显然被 Base 包中的字符串连接逻辑所使用。
结果表明,我们是不幸的鸭子类型的受害者。如果你查看 Julia 的源代码,你会发现一些join函数在参数中未指定任何类型。因此,当我们向join函数传递Party对象时,它就会泄露到原始的join逻辑中。更糟糕的是,没有抛出错误,因为一切只是正常工作。
最好完全避免类型 III 盗用。在前面的例子中,我们可以在自己的模块中定义join函数,而不是扩展Base中的函数。如果我们被名称冲突问题所困扰,我们也可以选择不同的函数名——例如,register。我们必须意识到,加入一个派对的意义并不等同于将字符串连接起来。
所有三种盗用类型都是不好的,它们可能导致难以找到或调试的 bug。我们应该尽可能地避免它们。
接下来,我们将讨论与函数定义中指定参数类型相关的另一种反模式。
狭义参数类型反模式
在 Julia 中设计函数时,我们有很多关于是否以及如何提供参数类型的选择。狭义参数类型反模式指的是参数类型被过于狭窄地指定,导致函数不必要的无用。
让我们考虑一个简单的示例函数,该函数用于计算两个向量的乘积之和:
function sumprod(A::Vector{Float64}, B::Vector{Float64})
return sum(A .* B)
end
这个设计没有问题,只是功能只能在参数是Float64值向量时使用。其他可能的选择有哪些?让我们接下来看看。
考虑参数类型的各种选项
Julia 的调度机制可以在传递的参数类型与函数签名匹配的情况下选择正确的函数来调用。基于类型层次结构,我们可以指定抽象类型,并且函数仍然会被正确选择。
这种灵活性给我们提供了很多选择。我们可以考虑以下任何一种:
-
sumprod(A::Vector{Float64}, B::Vector{Float64}) -
sumprod(A::Vector{Number}, B::Vector{Number}) -
sumprod(A::Vector{T}, B::Vector{T}) where T <: Number -
sumprod(A::Vector{S}, B::Vector{T}) where {S <: Number, T <: Number} -
sumprod(A::Array{S,N}, B::Array{T,N}) where {N, S <: Number, T <: Number} -
sumprod(A::AbstractArray{S,N}, B::AbstractArray{T,N}) where {N, S <: Number, T <: Number} -
sumprod(A, B)
我们的功能最合适的选项是哪一个?我们还没有确定,但我们可以始终回顾我们的需求,在得出结论之前进行一些测试。
让我们首先定义我们计划支持的场景。正如我们所期望的,这只是一个数值计算:我们希望支持任何支持广播的数值容器。广播是必需的,因为我们使用点符号来计算前面代码中 A 和 B 的乘积。
我们的测试场景涉及以下参数组合:
| 场景 | 参数 1 | 参数 2 |
|---|---|---|
| 1 | Array{Float64, 1} |
Array{Float64, 1} |
| 2 | Array{Int64, 1} |
Array{Int64, 1} |
| 3 | Array{Int, 1} |
Array{Float64, 1} |
| 4 | Array{Float64, 2} |
Array{Float64, 2} |
| 5 | Array{Number,1} |
Array{Number,1} |
为了测试各种函数签名选项的这些场景,我们可以构建一个测试框架函数,如下所示:
function test_harness(f, scenario, args...)
try
f(args...)
println(f, " #$(scenario) success")
catch ex
if ex isa MethodError
println(f, " #$(scenario) failure (method not selected)")
else
println(f, " #$(scenario) failure (unknown error $ex)")
end
end
end
测试框架将函数 f 与提供的参数 args 对特定 scenario 进行应用。如果函数被调度,它将在控制台显示成功消息;否则,它将显示失败消息。由于我们想要测试前面列出的场景,我们可以定义一个额外的函数,这样我们就可以轻松地执行我们的测试:
function test_sumprod(f)
test_harness(f, 1, [1.0,2.0], [3.0, 4.0]);
test_harness(f, 2, [1,2], [3,4]);
test_harness(f, 3, [1,2], [3.0,4.0]);
test_harness(f, 4, rand(2,2), rand(2,2));
test_harness(f, 5, Number[1,2.0], Number[3.0, 4]);
end
test_sumprod 函数接受一个函数并执行前五个测试用例。
现在我们已经准备好了。让我们分析每个选项,看看它们对我们有多有效。
选项 1 – Float64 值的向量
第一个选项是我们在这个部分开始时使用的。它具有最具体的参数类型。缺点是它只能与 Float64 值的向量一起工作。
让我们按照以下方式定义我们的函数,以便我们可以将其传递给测试函数:
sumprod_1(A::Vector{Float64}, B::Vector{Float64}) = sum(A .* B)
我们现在可以尝试我们的测试框架了:

如预期的那样,这个函数可以在两个参数都是 Float64 值的向量时与第一个场景一起工作。因此,它并不满足我们的所有要求。让我们尝试下一个选项。
选项 2 – Number 实例的向量
第二个选项稍微有趣一些。我们将类型参数从 Float64 更改为 Number,这是数值类型层次结构中最顶层的抽象类型:
sumprod_2(A::Vector{Number}, B::Vector{Number}) = sum(A .* B)
现在让我们测试一下:

初看之下,使用 Number 作为类型参数似乎会使它更通用。但实际上,它只能接受 Number 类型的数组,这意味着它必须是一个异构数组,其中每个元素可以是不同类型,只要所有元素类型都是 Number 的子类型。因此,Float64 值的向量不是 Number 值向量的子类型。请检查以下代码片段:

因此,除了最后一个选项之外,没有任何场景成功,最后一个选项接受 Number 类型的向量作为参数。所以这个选项也不是一个好的选择。让我们继续前进!
选项 3 – 类型为 T 的向量,其中 T 是 Number 的子类型
第三个选项是取类型为 T 的向量,其中 T 只是 Number 的子类型。
函数可以定义如下:
sumprod_3(A::Vector{T}, B::Vector{T}) where T <: Number = sum(A .* B)
让我们先试一下:

由于类型参数 T 可以是 Number 的任何子类型,这个函数可以舒适地处理 Float64、Int64 以及甚至 Number 类型的向量。不幸的是,它不能处理不同类型的参数,但我们应该能够进一步改进它。让我们尝试下一个选项。
选项 4 – 类型为 S 和 T 的向量,其中 S 和 T 是 Number 的子类型
这个选项与选项 3 的区别仅在于参数类型是分别指定的。因此,函数可以接受第一和第二个参数的不同类型。函数定义如下:
sumprod_4(A::Vector{S}, B::Vector{T}) where {S <: Number, T <: Number} = sum(A .* B)
我们现在可以尝试一下:

我们现在已经解决了混合参数类型的问题。我们越来越接近最终目标。场景 4 是参数是矩阵而不是向量的情况。我们当然知道如何解决这个问题,所以让我们接下来做。
选项 5 – 类型为 S 和 T 的数组,其中 S 和 T 是 Number 的子类型
由于 Julia 数组支持广播,我们可以将函数参数从 Vector{T} 通用化到 Array{T,N} 签名,以支持多维数组。现在让我们定义函数如下:
sumprod_5(A::Array{S,N}, B::Array{T,N}) where {N, S <: Number, T <: Number} =
sum(A .* B)
我们相当有信心这会起作用。现在让我们测试它:

太棒了! 我们终于满足了测试场景中列出的所有要求。我们完成了吗?也许还没有。为了辩论,我们可能希望支持其他类型的容器,这些容器不一定是密集数组。如果输入是稀疏矩阵怎么办?让我们再次改进这个函数。
选项 6 – 抽象数组
AbstractArray 是所有 Julia 数组容器的抽象类型。许多 Julia 包实现了数组接口,并成为 AbstractArray 的子类型。如果我们把 sumprod 函数做得足够通用,却不能支持稀疏矩阵或其他类型的数组容器,那就太遗憾了。为了使其更通用,让我们将函数定义从 Array 转换为 AbstractArray,如下所示:
sumprod_6(A::AbstractArray{S,N}, B::AbstractArray{T,N}) where
{N, S <: Number, T <: Number} = sum(A .* B)
签名与上一个选项相同,只是函数可以使用任何 AbstractArray 容器类型进行分发。让我们确保函数按预期工作:

函数在我们的现有案例中运行正常。让我们再次尝试使用稀疏矩阵类型来测试它:

太棒了! 现在它运行得很好,甚至是非密集数组类型。我们几乎完成了。让我们看看我们的最后一个选项——鸭式类型。
选项 7 – 鸭式类型
我们最后一个选项基本上跳过了函数参数中的类型。这也被称为鸭式类型,因为只要提供了两个参数,函数就会被分发。Julia 将针对不同参数类型的变体进行特化和编译新版本。函数简单地定义为如下:
sumprod_7(A, B) = sum(A .* B)
为了完整性,我们将再次运行测试:

这个选项的好处是函数在签名中没有类型信息,看起来非常干净。然而,缺点是函数可以针对任何类型进行分发——甚至不是数组或数值。当垃圾数据传递给函数时,输出也是垃圾,或者当传递的对象没有定义*运算符函数时,函数会抛出错误。
现在我们已经考虑了所有选项并执行了相应的测试,让我们总结一下到目前为止我们已经做了什么,以及我们接下来想做什么。
总结所有选项
让我们现在总结一下到目前为止我们已经考虑的所有选项:
| 选项 | 签名 | 所有测试都通过吗? |
|---|---|---|
| 1 | sumprod(A::Vector{Float64}, B::Vector{Float64}) |
否 |
| 2 | sumprod(A::Vector{Number}, B::Vector{Number}) |
否 |
| 3 | sumprod(A::Vector{T}, B::Vector{T}) where T <: Number |
否 |
| 4 | sumprod(A::Vector{S}, B::Vector{T}) where {S <: Number, T <: Number} |
否 |
| 5 | sumprod(A::Array{S,N}, B::Array{T,N}) where {N, S <: Number, T <: Number} |
是 |
| 6 | sumprod(A::AbstractArray{S,N}, B::AbstractArray{T,N}) where {N, S <: Number, T <: Number} |
是 |
| 7 | sumprod(A, B) |
是 |
从技术上来说,选项 5、6 或 7 可以适用于所有数组类型。选项 6 和 7 支持其他数组容器,例如稀疏矩阵。选项 7 与非AbstractArray类型一起工作,只要类型支持广播乘法和加法。
在我们得出结论之前,让我们从性能的角度进行最后一次测试。你是否想知道让函数接受更通用的类型是否会牺牲性能?了解这一点唯一的方法是通过实际实验来证明。让我们接下来这么做。
评估性能
当我们在函数参数中接受更通用的类型时,我们会牺牲性能吗?让我们进行一些基准测试,看看它们的性能如何。
在这里,我们将使用完全相同的输入:两个包含 10,000 个元素的Float64向量,对选项 1、5、6 和 7 中的函数进行基准测试:
using BenchmarkTools
A = rand(10_000);
B = rand(10_000);
@btime sumprod_1($A, $B);
@btime sumprod_5($A, $B);
@btime sumprod_6($A, $B);
@btime sumprod_7($A, $B);
下面是测试结果:

如您所见,这些选项之间没有实质性的差异。如何指定参数类型不会影响函数的运行时性能。
总结来说,我们关于这种反模式学到的经验是,函数参数不应该无必要地设置得太窄。当范围广泛时,一个函数可以更加有用。一个可以接受和支持更多输入类型的函数自动具有更高的可重用性。
我们下一个反模式与设计数据类型时如何选择字段类型有关。这是一个极其重要的话题,因为它可以显著影响系统性能。
非具体字段类型反模式
非具体字段类型的反模式是一种结构字段不是具体类型的反模式。对于字段的非具体类型的主要问题是它们可能会引起重大的性能问题。为了理解为什么,让我们看看具有非具体类型与具体类型组合类型的内存布局,然后设计和比较这两个。
理解复合数据类型的内存布局
让我们先看看一个用于跟踪点坐标的复合类型的简单例子:
struct Point
x
y
end
当字段类型未指定时,它隐式地解释为所有类型的超类型 Any,因此前面的代码在语法上等同于以下代码(除了我们将类型名称重命名为 Point2 以避免混淆):
struct Point2
x::Any
y::Any
end
字段 x 和 y 有 Any 类型,这意味着它们可以是任何东西:Int64、Float64 或任何其他数据类型。为了比较内存布局和利用率,值得创建一个新的点类型,它使用小的具体类型,如 UInt8:
struct Point3
x::UInt8
y::UInt8
end
如我们所知,UInt8 应该占用单个字节的存储空间。x 和 y 字段同时存在应该只消耗两个字节的存储空间。也许我们应该亲自证明这一点。检查以下代码:

明显地,一个单独的 Point3 对象只占用两个字节。让我们用原始的 Point 对象做同样的操作:

Point 对象占用 16 字节,尽管我们只想存储两个字节。正如我们所知,Point 对象可以在 x 和 y 字段中存储任何数据类型。现在,让我们用更大的数据类型,如 Int128,来做同样的练习:

Int128 是一个 128 位整数,在内存中占用 16 字节。有趣的是,尽管我们在 Point 中携带了两个 Int128 字段,但对象的大小仍然保持在 16 字节。
为什么?这是因为 Point 实际上存储了两个 64 位指针,每个指针占用八字节的存储空间。我们可以这样可视化 Point 对象的内存:

当字段类型是具体的时,Julia 编译器确切地知道内存布局看起来像什么。对于两个 UInt8 字段,它以紧凑的方式用两个字节表示。对于两个 Int128 字段,它将占用 32 字节。让我们在 REPL 中尝试一下:

Point4 的内存布局紧凑,如下面的图所示:

现在我们知道了内存布局的差异,我们可以立即看到使用具体类型的优势。每次我们需要访问x或y字段时,如果它是具体类型,那么数据就在那里。如果字段只是指针,那么我们必须取消引用指针以找到数据。此外,x和y的物理内存位置可能甚至不相邻,这可能导致硬件缓存未命中,从而进一步影响性能。
那么,我们是否只是遵循在字段定义中直接使用具体类型的规则?不一定。我们还有其他可以考虑的选项,我们将在接下来的章节中讨论。
考虑具体类型设计复合类型
也许我们最初在字段中使用抽象类型的原因是为了支持字段中的不同类型数据。以上一节中的Point类型为例,我们可以看到这种类型在计算机游戏环境中非常有用,因为在游戏中坐标是通过屏幕上的整数像素位置来识别的。另一方面,我们也认为同样的类型可能对存储建筑图纸中形状的坐标也很有用,在这种情况下,我们可能需要使用浮点值。
如果我们想要更灵活,我们希望支持任何Real类型的子类型的Point字段。从概念上讲,我们希望得到如下所示的东西:
struct Point
x::Real
y::Real
end
然而,由于Real是一个抽象类型,我们预计性能会较差,就像使用Any一样。为了在不牺牲支持其他数值类型灵活性的情况下利用具体类型,我们可以将Point转换为参数化类型。让我们重新启动 REPL 并定义新的Point类型,如下所示:
struct Point{T <: Real}
x::T
y::T
end
将其设计为参数化类型的好处是它是具体的。我们可以很容易地从 REPL 中检查这一点。以下是一个基本的语法实现:

以下代码展示了另一个示例:

到目前为止,我们一直假设在struct字段中,具体类型会比非具体类型表现更好。了解这种差异有多大会有所帮助。现在让我们试试看。
比较混凝土与非混凝土字段类型之间的性能
我们可以使用这两种不同的类型进行性能测试,如下所示:
我们的基准测试函数将计算数组中所有点的中心,如下所示:
using Statistics: mean
function center(points::AbstractVector{T}) where T
return T(
mean(p.x for p in points),
mean(p.y for p in points))
end
此外,我们还将定义一个函数,可以用于为任何我们想要的类型创建点的数组:
make_points(T::Type, n) = [T(rand(), rand()) for _ in 1:n]
让我们从PointAny类型开始。
我们将生成 100,000 个点,并使用BenchmarkTools来测量时间:

接下来,我们将对Point类型进行性能测试:

如我们所见,两者之间存在着巨大的差异。使用参数化Point类型比使用Any作为字段类型的速度快约 25 倍。
从这个反模式中我们学到的经验是,我们应该为在复合类型中定义的字段使用具体类型。将我们想要的抽象类型提取出来作为一个类型参数是非常容易的。这样做可以让我们在不牺牲支持其他数据类型能力的情况下,从具体类型中获得性能上的好处。
摘要
在本章中,我们了解了 Julia 编程中的一些反模式。当我们详细研究每个反模式时,我们也找到了应用替代设计解决方案的方法。
我们从盗版反模式开始,它指的是与从第三方模块扩展函数相关的坏习惯。为了方便起见,我们将盗版反模式分为三种不同类型——I 型、II 型和 III 型。每种类型都会在导致系统不稳定或未来可能引起问题的过程中带来不同的问题。
接下来,我们研究了狭隘的参数类型反模式。当函数参数过于狭窄时,它们的可重用性会降低。因为 Julia 可以为各种参数类型对函数进行特殊化,所以尽可能使参数类型通用化,利用抽象类型,这样做更有益。我们详细探讨了几个设计选项,并得出结论:最通用的类型可以在不牺牲性能的情况下使用。
最后,我们回顾了非具体字段类型反模式。我们证明了由于产生的低效内存布局结构,拥有非具体类型会带来性能问题。我们推测,这个问题可以通过使用参数类型,将具体类型指定为类型参数的一部分来轻松解决。
在下一章中,我们将关注传统的面向对象设计模式,并探讨它们如何在 Julia 编程中应用。系好安全带:如果你曾经是面向对象程序员,你的旅程可能会有些颠簸!
问题
-
类型 I 盗版的风险和潜在好处是什么?
-
类型 II 盗版可能引发什么问题?
-
类型 III 盗版是如何引起麻烦的?
-
指定函数参数时我们应该注意什么?
-
使用抽象函数参数会如何影响系统性能?
-
使用抽象字段类型为复合类型时,系统性能会受到怎样的影响?
第十一章:传统面向对象模式
到目前为止,我们已经学习了成为一名有效的 Julia 程序员所需了解的许多设计模式。前几章中提出的案例包括了我们可以通过编写惯用的 Julia 代码解决的问题。有些人可能会问,经过这么多年,我已经学习和适应了面向对象编程(OOP)范式;我如何在 Julia 中应用同样的概念?一般的回答是,你不会以同样的方式解决问题。用 Julia 编写的解决方案将看起来不同,反映了不同的编程范式。尽管如此,思考如何在 Julia 中采用一些 OOP 技术仍然是一个有趣的练习。
在本章中,我们将涵盖经典《四人帮》(GoF)《设计模式》书中所有的 23 种设计模式。我们将保持传统,以下章节中组织主题:
-
创建型模式
-
行为模式
-
结构模式
到本章结束时,你将了解这些模式如何在 Julia 中应用,与面向对象方法相比。
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
创建型模式
创建型模式指的是构建和实例化对象的各种方式。由于 OOP 将数据和操作组合在一起,并且由于一个类可能从祖先类继承结构和行为,因此在构建大型系统时涉及额外的复杂性。设计上,Julia 已经通过不允许在抽象类型中声明字段和不允许从具体类型创建新子类型来解决了许多问题。尽管如此,在某些情况下,这些模式可能有所帮助。
创建型模式包括工厂方法、抽象工厂、单例、建造者和原型模式。我们将在以下章节中详细讨论它们。
工厂方法模式
工厂方法模式的想法是提供一个单一接口来创建符合接口的不同类型的对象,同时隐藏实际实现细节。这种抽象将客户端与功能提供者的底层实现解耦。
例如,一个程序可能需要在输出中格式化一些数字。在 Julia 中,我们可能想使用Printf包来格式化数字,如下所示:

也许我们不想与 Printf 包耦合,因为我们希望在将来能够切换并使用不同的格式化包。为了使应用程序更加灵活,我们可以设计一个接口,其中数字可以根据它们的类型进行格式化。以下接口在文档字符串中描述:
"""
format(::Formatter, x::T) where {T <: Number}
Format a number `x` using the specified formatter.
Returns a string.
"""
function format end
format 函数接受一个 formatter 和一个数值 x,并返回一个格式化的字符串。Formatter 类型定义如下:
abstract type Formatter end
struct IntegerFormatter <: Formatter end
struct FloatFormatter <: Formatter end
然后,工厂方法基本上创建用于调度的单例类型:
formatter(::Type{T}) where {T <: Integer} = IntegerFormatter()
formatter(::Type{T}) where {T <: AbstractFloat} = FloatFormatter()
formatter(::Type{T}) where T = error("No formatter defined for type $T")
默认实现可能如下所示,利用 Printf 包:
using Printf
format(nf::IntegerFormatter, x) = @sprintf("%d", x)
format(nf::FloatFormatter, x) = @sprintf("%.2f", x)
将所有内容放入 FactoryExample 模块中,我们可以运行以下测试代码:
function test()
nf = formatter(Int)
println(format(nf, 1234))
nf = formatter(Float64)
println(format(nf, 1234))
end
输出如下:

如果我们未来想要更改格式化器,我们只需提供一个新实现,其中定义了我们要支持的数值类型的格式化函数。当我们有很多数字格式化代码时,这很有用。切换到不同的格式化器实际上只需要两行代码的改变(在这个例子中)。
让我们看看抽象工厂模式。
抽象工厂模式
抽象工厂模式用于通过一组工厂方法创建对象,这些方法从具体实现中抽象出来。抽象工厂模式可以看作是工厂的工厂。
我们可以探索构建一个支持 Microsoft Windows 和 macOS 的多平台 GUI 库的例子。由于我们想要开发跨平台的代码,我们可以利用这个设计模式。这种设计在以下 UML 图中描述:

简而言之,我们在这里展示了两种类型的 GUI 对象:Button 和 Label。对于 Microsoft Windows 和 macOS 平台,概念是相同的。客户端不关心这些对象是如何实例化的;相反,它要求一个抽象工厂 GUIFactory 返回支持多个工厂方法的工厂(即 MacOSFactory 或 WindowsFactory),以创建平台相关的 GUI 对象。
Julia 的实现可以通过适当的抽象和具体类型简单地建模。让我们从操作系统级别开始:
abstract type OS end
struct MacOS <: OS end
struct Windows <: OS end
我们原本打算使用 MacOS 和 Windows 作为后续调度目的的单例类型。现在,让我们继续并定义抽象类型 Button 和 Label,如下所示。此外,我们分别为每种类型定义了 show 方法:
abstract type Button end
Base.show(io::IO, x::Button) =
print(io, "'$(x.text)' button")
abstract type Label end
Base.show(io::IO, x::Label) =
print(io, "'$(x.text)' label")
我们确实需要为这些 GUI 对象提供具体实现。现在让我们定义它们:
# Buttons
struct MacOSButton <: Button
text::String
end
struct WindowsButton <: Button
text::String
end
# Labels
struct MacOSLabel <: Label
text::String
end
struct WindowsLabel <: Label
text::String
end
为了简单起见,我们只保留一个文本字符串,无论是按钮还是标签。由于工厂方法是平台相关的,我们可以利用 OS 特性和多重分派来调用正确的 make_button 或 make_label 函数:
# Generic implementation using traits
current_os() = MacOS() # should get from system
make_button(text::String) = make_button(current_os(), text)
make_label(text::String) = make_label(current_os(), text)
为了测试,我们硬编码了current_os函数以返回MacOS()。实际上,这个函数应该通过检查适当的系统变量来返回MacOS()或Windows()以识别平台。最后,我们需要按如下方式实现每个平台的具体函数:
# MacOS implementation
make_button(::MacOS, text::String) = MacOSButton(text)
make_label(::MacOS, text::String) = MacOSLabel(text)
# Windows implementation
make_button(::Windows, text::String) = WindowsButton(text)
make_label(::Windows, text::String) = WindowsLabel(text)
我们简单的测试只是调用make_button函数:

通过多态,我们可以轻松扩展到新的平台或新的 GUI 对象,只需为特定的操作系统定义新函数即可。
接下来,我们将探讨单例模式。
单例模式
单例模式用于创建对象的单个实例并在任何地方重用它。单例对象通常在应用程序启动时构建,或者可以在对象首次使用时懒加载创建。对于多线程应用程序,单例模式有一个有趣的要求,即单例对象的实例化只能发生一次。如果对象创建函数从多个线程中懒加载,这可能会成为一个挑战。
假设我们想要创建一个名为AppKey的单例,该单例用于应用程序中的加密:
# AppKey contains an app id and encryption key
struct AppKey
appid::String
value::UInt128
end
初始时,我们可能会倾向于使用全局变量。鉴于我们已经了解了全局变量的性能影响,我们可以应用在第六章“性能模式”中学到的全局常量模式。本质上,创建了一个Ref对象作为占位符,如下所示:
# placeholder for AppKey object.
const appkey = Ref{AppKey}()
appkey全局常量最初创建时没有分配任何值,但随后可以在单例实例化时更新。单例的构建可以按如下方式进行:
function construct()
global appkey
if !isassigned(appkey)
ak = AppKey("myapp", rand(UInt128))
println("constructing $ak")
appkey[] = ak
end
return nothing
end
当只有一个线程时,此代码运行正常。如果我们用多个线程测试它,那么isassigned检查就成问题了。例如,两个线程可能会同时检查密钥是否已分配,并且两个线程都可能会认为需要实例化单例对象。在这种情况下,我们最终会构建单例两次。
测试代码如下所示:
function test_multithreading()
println("Number of threads: ", Threads.nthreads())
global appkey
Threads.@threads for i in 1:8
construct()
end
end
我们可以演示以下问题。让我们用四个线程启动 Julia REPL:

然后,我们可以运行测试代码:

如您所见,这里的单例被构建了两次。
那么,我们该如何解决这个问题呢?我们可以使用锁来同步单例构造逻辑。让我们首先创建另一个全局常量来持有锁:
const appkey_lock = Ref(ReentrantLock())
要使用锁,我们可以按如下方式修改construct函数:

在检查appkey[]是否已经被分配之前,我们必须首先获取锁。当我们完成单例对象的构建(或者如果它已经被创建,则跳过它)后,我们释放锁。请注意,我们将代码的关键部分包裹在一个try块中,并将unlock函数放在finally块中。这样做是为了确保无论单例对象的构建是否成功,锁都会被释放。
我们的新测试显示单例对象只被构建一次:

当我们需要保持一个单一对象时,单例模式很有用。实际的应用场景包括数据库连接或其他外部资源的引用。接下来,我们将探讨建造者模式。
建造者模式
建造者模式用于通过逐步构建更简单的部分来构建复杂对象。我们可以想象工厂装配线将以类似的方式工作。在这种情况下,产品将逐步组装,越来越多地添加部件,并在装配线末端,产品完成并准备好。
这种模式的优点之一是建造者代码看起来像线性数据流,对于某些人来说更容易阅读。在 Julia 中,我们可能想要编写如下内容:
car = Car() |>
add(Engine("4-cylinder 1600cc Engine")) |>
add(Wheels("4x 20-inch wide wheels")) |>
add(Chassis("Roadster Chassis"))
实质上,这正是第九章中描述的精确功能管道模式,杂项模式。对于这个例子,我们可以为构建每个部分(如轮子、引擎和底盘)开发高阶函数。以下代码演示了如何创建一个用于创建轮子的 curry(高阶)函数:
function add(wheels::Wheels)
return function (c::Car)
c.wheels = wheels
return c
end
end
add函数只是返回一个匿名函数,该函数接受一个Car对象作为输入并返回一个增强的Car对象。同样,我们可以为Engine和Chassis类型开发类似的功能。一旦这些函数准备就绪,我们只需通过链式调用这些函数来构建一辆车。
接下来,我们将讨论原型模式。
原型模式
原型模式通过从现有对象或原型对象克隆字段来创建新对象。其理念是,某些对象难以构建或构建耗时,因此制作一个对象的副本并进行少量修改将其称为新对象会很有用。
由于 Julia 将数据和逻辑分开,复制对象实际上等同于复制内容。这听起来很简单,但我们不应忽视浅拷贝和深拷贝之间的区别。
对象的浅拷贝仅仅是一个从另一个对象复制所有字段的简单对象。对象的深拷贝是通过递归进入对象的字段并复制它们的底层字段来创建的。因此,浅拷贝可能不是理想的选择,因为某些数据可能与原始对象共享。
为了说明这一点,让我们考虑以下银行账户示例的结构定义:
mutable struct Account
id::Int
balance::Float64
end
struct Customer
name::String
savingsAccount::Account
checkingAccount::Account
end
现在,假设我们有一个从该函数返回的Customer对象数组:
function sample_customers()
a1 = Account(1, 100.0)
a2 = Account(2, 200.0)
c1 = Customer("John Doe", a1, a2)
a3 = Account(3, 300.0)
a4 = Account(4, 400.0)
c2 = Customer("Brandon King", a3, a4)
return [c1, c2]
end
sample_customer函数返回两个客户的数组。为了测试目的,让我们构建一个测试框架来更新第一个客户的余额,如下所示:
function test(copy_function::Function)
println("--- testing ", string(copy_function), " ---")
customers = sample_customers()
c = copy_function(customers)
c[1].checkingAccount.balance += 500
println("orig: ", customers[1].checkingAccount.balance)
println("new: ", c[1].checkingAccount.balance)
end
如果我们使用内置的copy和deepcopy函数对测试框架进行练习,我们会得到以下结果:

意外地,我们在orig输出中得到了错误的结果,因为我们本应该给新客户增加$500。为什么原始客户记录和新客户记录的余额相同呢?这是因为当使用copy函数时,从客户数组中创建了一个浅拷贝。当这种情况发生时,客户记录在原始数组和新数组之间实际上是共享的。这意味着修改新记录也会影响原始记录。
在结果的第二部分中,只有客户记录的新副本被更改。这是因为使用了deepcopy函数。根据定义,原型模式要求对副本进行修改。如果应用此模式,进行深拷贝可能更安全。
我们已经涵盖了所有五个创建型模式。这些模式允许我们以有效的方式构建新对象。
接下来,我们将介绍一组行为设计模式。
行为模式
行为模式指的是对象如何被设计来相互协作和通信。从面向对象范式中有 11 个 GoF 模式。我们将在这里通过一些有趣的动手示例涵盖所有这些模式。
责任链模式
责任链(CoR)模式用于使用请求处理链来处理请求,其中每个处理程序都有自己的独特和独立的责任。
这种模式在许多应用中都很常见。例如,Web 服务器通常使用所谓的中间件来处理 HTTP 请求。每个中间件部分负责执行特定的任务——例如,验证请求、维护 cookie、验证请求和执行业务逻辑。关于责任链模式的一个特定要求是,链的任何部分都可以在任何时候被打破,从而导致过程的早期退出。在前面的 Web 服务器示例中,认证中间件可能已经决定用户未通过认证,因此用户应该被重定向到另一个网站进行登录。这意味着除非用户通过了认证步骤,否则将跳过其余的中间件。
我们如何在 Julia 中设计这样的东西?让我们看看一个简单的例子:
mutable struct DepositRequest
id::Int
amount::Float64
end
DepositRequest对象包含客户想要存入其账户的金额。我们的营销部门希望我们如果存款金额超过$100,000,就向客户提供感谢信。为了处理此类请求,我们设计了三个函数,如下所示:
@enum Status CONTINUE HANDLED
function update_account_handler(req::DepositRequest)
println("Deposited $(req.amount) to account $(req.id)")
return CONTINUE
end
function send_gift_handler(req::DepositRequest)
req.amount > 100_000 &&
println("=> Thank you for your business")
return CONTINUE
end
function notify_customer(req::DepositRequest)
println("deposit is finished")
return HANDLED
end
这些函数的职责是什么?
-
update_account_handler函数负责使用新的存款更新账户。 -
send_gift_handler函数负责向客户发送感谢信,以感谢其大额存款。 -
notify_customer函数负责在存款完成后通知客户。
这些函数也返回一个枚举值,要么是CONTINUE,要么是HANDLED,以指示在当前处理程序完成后是否应将请求传递给下一个处理程序。
应该很清楚,这些函数以特定的顺序运行。特别是,notify_customer函数应在交易结束时运行。因此,我们可以建立一个函数数组:
handlers = [
update_account_handler,
send_gift_handler,
notify_customer
]
我们还可以有一个函数来按顺序执行这些处理程序:
function apply(req::DepositRequest, handlers::AbstractVector{Function})
for f in handlers
status = f(req)
status == HANDLED && return nothing
end
end
作为这个设计的一部分,如果任何处理程序返回HANDLED值,循环将立即结束。我们用于测试向 VIP 客户发送感谢信功能的测试代码如下所示:
function test()
println("Test: customer depositing a lot of money")
amount = 300_000
apply(DepositRequest(1, amount), handlers)
println("\nTest: regular customer")
amount = 1000
apply(DepositRequest(2, amount), handlers)
end
运行测试给出以下结果:

我将把这个任务留给你,在链中构建另一个函数以执行早期退出。但到目前为止,让我们继续到下一个模式——中介者模式。
中介者模式
中介者模式用于促进应用程序中不同组件之间的通信。这样做的方式是使各个组件相互解耦。在大多数应用程序中,一个组件的变化可能会影响另一个组件。有时,也会有级联效应。中介者可以承担在组件发生变化时被通知的责任,并且它可以通知其他组件关于该事件的详细信息,以便进行进一步的下游更新。
例如,我们可以考虑图形用户界面(GUI)的使用案例。假设我们有一个屏幕,其中包含三个字段,用于我们的最喜欢的银行应用程序:
-
金额:账户中的当前余额。
-
利率:以百分比表示的当前利率。
-
利息金额:利息金额。这是一个只读字段。
它们是如何相互作用的?如果金额发生变化,那么利息金额需要更新。同样,如果利率发生变化,那么利息金额也需要更新。
为了模拟 GUI,我们可以为屏幕上的单个 GUI 对象定义以下类型:
abstract type Widget end
mutable struct TextField <: Widget
id::Symbol
value::String
end
Widget 是一个抽象类型,它可以作为所有 GUI 对象的超类型。这个应用程序只需要文本字段,所以我们只定义了一个 TextField 小部件。文本字段通过 id 来标识,并包含一个 value。为了从文本字段小部件中提取和更新值,我们可以定义如下函数:
# extract numeric value from a text field
get_number(t::TextField) = parse(Float64, t.value)
# set text field from a numeric value
function set_number(t::TextField, x::Real)
println("* ", t.id, " is being updated to ", x)
t.value = string(x)
return nothing
end
从前面的代码中,我们可以看到 get_number 函数从文本字段小部件中获取值,并将其作为浮点数返回。set_number 函数使用提供的数值填充文本字段小部件。现在,我们还需要创建应用程序,所以我们方便地定义了一个结构体如下:
Base.@kwdef struct App
amount_field::TextField
interest_rate_field::TextField
interest_amount_field::TextField
end
对于这个例子,我们将实现一个 notify 函数来模拟用户输入值后发送到文本字段小部件的事件。在现实中,GUI 平台通常会执行这个功能。让我们称它为 on_change_event,如下所示:
function on_change_event(widget::Widget)
notify(app, widget)
end
on_change_event 函数除了向中介(应用程序)传达这个小部件刚刚发生了某些事情之外,没有做其他任何事情。至于应用程序本身,以下是它处理通知的方式:
# Mediator logic - handling changes to the widget in this app
function notify(app::App, widget::Widget)
if widget in (app.amount_field, app.interest_rate_field)
new_interest = get_number(app.amount_field) * get_number(app.interest_rate_field)/100
set_number(app.interest_amount_field, new_interest)
end
end
如您所见,它只是检查正在更新的小部件是否是金额或利率字段。如果是,它计算新的利息金额,并用新值填充利息金额字段。让我们快速测试一下:
function test()
# Show current state before testing
print_current_state()
# double principal amount from 100 to 200
set_number(app.amount_field, 200)
on_change_event(app.amount_field)
print_current_state()
end
test 函数显示应用程序的初始状态,更新金额字段,并显示新状态。为了简洁起见,这里没有显示 print_current_state 函数的源代码,但可以在本书的 GitHub 网站上找到。测试程序的输出如下所示:

使用 2 中介模式的优点是每个对象都可以专注于自己的职责,而不用担心下游的影响。一个中心的中介承担组织活动和处理事件以及通信的责任。
接下来,我们将探讨备忘录模式。
备忘录模式
备忘录模式是一种状态管理技术,您可以在需要时将工作恢复到先前的状态。一个常见的例子是文字处理应用程序的撤销功能。在做出 10 次更改后,我们总是可以撤销先前的操作,并返回到这 10 次更改之前的原始状态。同样,一个应用程序可能会记住最近打开的文件,并提供一个选择菜单,以便用户可以快速重新打开之前打开的文件。
在 Julia 中实现备忘录模式非常简单。我们只需将先前状态存储在数组中,在做出更改时,我们可以将新状态推送到数组中。当我们想要撤销操作时,我们可以通过从数组中弹出来恢复先前的状态。为了说明这个想法,让我们考虑一个博客文章编辑应用程序的案例。我们可以定义数据类型如下:
struct Post
title::String
content::String
end
struct Blog
author::String
posts::Vector{Post}
date_created::DateTime
end
如您所见,一个Blog对象包含一个Post对象的数组。按照惯例,数组中的最后一个元素是博客文章的当前版本。如果数组中有五个帖子,那么这意味着已经进行了四次更改。创建一个新的博客就像以下代码所示:
function Blog(author::String, post::Post)
return Blog(author, [post], now())
end
默认情况下,一个新的博客对象只包含一个版本。随着用户进行更改,数组将增长。为了方便,我们可以提供一个version_count函数,该函数返回用户迄今为止所做的修订次数。
version_count(blog::Blog) = length(blog.posts)
要获取当前帖子,我们可以简单地取数组的最后一个元素:
current_post(blog::Blog) = blog.posts[end]
现在,当我们需要更新博客时,我们必须将新版本推送到数组中。以下是用来用新标题或内容更新博客的函数:
function update!(blog::Blog;
title = nothing,
content = nothing)
post = current_post(blog)
new_post = Post(
something(title, post.title),
something(content, post.content)
)
push!(blog.posts, new_post)
return new_post
end
update!函数接受一个Blog对象,并且可以可选地接受更新后的title、content或两者。基本上,它创建一个新的Post对象并将其推入posts数组。撤销操作如下:
function undo!(blog::Blog)
if version_count(blog) > 1
pop!(blog.posts)
return current_post(blog)
else
error("Cannot undo... no more previous history.")
end
end
我们可以用以下test函数来测试它:
function test()
blog = Blog("Tom", Post("Why is Julia so great?", "Blah blah."))
update!(blog, content = "The reasons are...")
println("Number of versions: ", version_count(blog))
println("Current post")
println(current_post(blog))
println("Undo #1")
undo!(blog)
println(current_post(blog))
println("Undo #2") # expect failure
undo!(blog)
println(current_post(blog))
end
输出如下所示:

如您所见,实现备忘录模式相当简单。我们将在下一节介绍观察者模式。
观察者模式
观察者模式对于将观察者注册到对象中非常有用,以便在该对象中所有状态变化都会触发向观察者发送通知。在支持一等函数的语言中——例如,Julia——可以通过维护一个在对象状态变化前后可以调用的函数列表来轻松实现此类功能。有时,这些函数被称为钩子。
Julia 中观察者模式的实现可能包括两个部分:
-
扩展对象的
setproperty!函数以监控状态变化并通知观察者。 -
维护一个可以用来查找要调用的函数的字典。
对于这个演示,我们将再次使用银行账户示例:
mutable struct Account
id::Int
customer::String
balance::Float64
end
这是维护观察者的数据结构:
const OBSERVERS = IdDict{Account,Vector{Function}}();
在这里,我们选择使用IdDict而不是常规的Dict对象。IdDict是一种特殊类型,它使用 Julia 的内部对象 ID 作为字典的键。为了注册观察者,我们提供了以下函数:
function register(a::Account, f::Function)
fs = get!(OBSERVERS, a, Function[])
println("Account $(a.id): registered observer function $(Symbol(f))")
push!(fs, f)
end
现在,让我们扩展setproperty!函数:
function Base.setproperty!(a::Account, field::Symbol, value)
previous_value = getfield(a, field)
setfield!(a, field, value)
fs = get!(OBSERVERS, a, Function[])
foreach(f -> f(a, field, previous_value, value), fs)
end
这个新的setproperty!函数不仅更新了对象的字段,而且在字段更新后还调用观察者函数,传递了前一个状态和当前状态。为了测试目的,我们将创建一个观察者函数如下:
function test_observer_func(a::Account, field::Symbol, previous_value, current_value)
println("Account $(a.id): $field was changed from $previous_value to $current_value")
end
我们的test函数编写如下:
function test()
a1 = Account(1, "John Doe", 100.00)
register(a1, test_observer_func)
a1.balance += 10.00
a1.customer = "John Doe Jr."
return nothing
end
当运行测试程序时,我们得到以下输出:

从输出中,我们可以看到每次属性更新时都会调用test_observer_func函数。观察者模式是一个容易开发的东西。接下来,我们将探讨状态模式。
状态模式
状态模式用于对象根据其内部状态表现出不同行为的情况。网络服务是一个很好的例子。一个基于网络服务的典型实现是监听特定的端口号。当远程进程连接到服务时,它会建立连接,并且它们使用它进行通信,直到会话结束。当网络服务当前处于监听状态时,它应该允许打开新的连接;然而,在连接打开之前不应允许任何数据传输。然后,在连接打开后,我们应该能够发送数据。相比之下,如果连接已经关闭,则不应允许通过网络连接发送任何数据。
在 Julia 中,我们可以使用多重分派来实现状态模式。让我们首先定义以下对网络连接有意义的类型:
abstract type AbstractState end
struct ListeningState <: AbstractState end
struct EstablishedState <: AbstractState end
struct ClosedState <: AbstractState end
const LISTENING = ListeningState()
const ESTABLISHED = EstablishedState()
const CLOSED = ClosedState()
在这里,我们利用了单例类型模式。至于网络连接本身,我们可以定义类型如下:
struct Connection{T <: AbstractState,S}
state::T
conn::S
end
现在,让我们开发一个send函数,它用于通过连接发送消息。在我们的实现中,send函数除了收集连接的当前状态并将调用转发到特定状态send函数之外,不做任何事情:
# Use multiple dispatch
send(c::Connection, msg) = send(c.state, c.conn, msg)
# Implement `send` method for each state
send(::ListeningState, conn, msg) = error("No connection yet")
send(::EstablishedState, conn, msg) = write(conn, msg * "\n")
send(::ClosedState, conn, msg) = error("Connection already closed")
你可能认识这是神圣的特质模式。对于单元测试,我们可以为创建具有指定消息的新Connection和向Connection对象发送消息开发一个test函数:
function test(state, msg)
c = Connection(state, stdout)
try
send(c, msg)
catch ex
println("$(ex) for message '$msg'")
end
return nothing
end
然后,测试代码简单地运行了三次test函数,每次对应一个可能的状态:
function test()
test(LISTENING, "hello world 1")
test(CLOSED, "hello world 2")
test(ESTABLISHED, "hello world 3")
end
当运行test函数时,我们得到以下输出:

只有第三条消息成功发送,因为连接处于ESTABLISHED状态。现在,让我们看看策略模式。
策略模式
策略模式允许客户端在运行时选择最佳的算法。而不是将客户端与预定义的算法耦合,当需要时,客户端可以配置为特定的算法(策略)。此外,有时算法的选择不能提前确定,因为决策可能取决于输入数据、环境或其他因素。
在 Julia 中,我们可以使用多重分派来解决这个问题。让我们考虑斐波那契数列生成器的例子。正如我们从第六章,“性能模式”中学到的,当我们递归实现时,计算第n个斐波那契数可能很棘手,因此我们的第一个算法(策略)可能是记忆化。此外,我们还可以使用不使用任何递归的迭代算法来解决这个问题。
为了支持记忆化和迭代算法,让我们创建以下一些新类型:
abstract type Algo end
struct Memoized <: Algo end
struct Iterative <: Algo end
Algo 抽象类型是所有斐波那契算法的超类型。目前,我们只有两种算法可供选择:Memoized 或 Iterative。现在,我们可以定义 fib 函数的备忘录版本如下:
using Memoize
@memoize function _fib(n)
n <= 2 ? 1 : _fib(n-1) + _fib(n-2)
end
function fib(::Memoized, n)
println("Using memoization algorithm")
_fib(n)
end
首先定义一个备忘录函数 _fib。然后定义一个包装函数 fib,它将 Memoized 对象作为第一个参数。相应的迭代算法可以如下实现:
function fib(algo::Iterative, n)
n <= 2 && return 1
prev1, prev2 = 1, 1
local curr
for i in 3:n
curr = prev1 + prev2
prev1, prev2 = curr, prev1
end
return curr
end
在这次讨论中,算法的实际工作方式并不重要。由于第一个参数是 Iterative 对象,我们知道这个函数将被相应地调度。
从客户端的角度来看,它可以选择备忘录版本或迭代函数,具体取决于其需求。由于备忘录版本以 O(1) 的速度运行,当 n 较大时应该更快;然而,对于 n 的较小值,迭代版本会更好。我们可以以下列方式调用 fib 函数:
fib(Memoized(), 10)
fib(Iterative(), 10)
如果客户端选择实现算法选择过程,可以很容易地做到,如下所示:
function fib(n)
algo = n > 50 ? Memoized() : Iterative()
return fib(algo, n)
end
成功的测试结果如下所示:

如您所见,实现策略模式相当简单。多分派的不合理有效性再次拯救了! 接下来,我们将讨论另一个称为模板方法的行性行为模式。
模板方法模式
模板方法模式用于创建一个定义良好的过程,可以使用不同类型的算法或操作。作为一个模板,它可以根据客户端的需求定制任何算法或函数。
在这里,我们将探讨如何在机器学习(ML)管道用例中利用模板方法模式。对于那些不熟悉 ML 管道的人来说,以下是数据科学家可能采取的简化版本:

首先,将数据集分成两个单独的数据集,用于训练和测试。训练数据集被输入到一个过程中,将数据拟合到统计模型中。然后,validate 函数使用该模型来预测测试集(也称为目标)变量中的响应变量。最后,它将预测值与实际值进行比较,以确定模型的准确性。
假设我们已经将管道设置为如下所示:
function run(data::DataFrame, response::Symbol, predictors::Vector{Symbol})
train, test = split_data(data, 0.7)
model = fit(train, response, predictors)
validate(test, model, response)
end
为了简洁起见,具体的函数 split_data、fit 和 validate 在这里没有展示;如果您想查看它们,可以在本书的 GitHub 网站上查找。然而,管道概念在前面的逻辑中得到了演示。让我们快速尝试预测波士顿房价:

在这个例子中,响应变量是 :MedV,我们将基于 :Rm、:Tax 和 :Crim 建立一个统计模型。
波士顿住房数据集包含美国人口普查局收集的有关马萨诸塞州波士顿地区住房的数据。它在大量统计分析教育文献中被广泛使用。我们在这个例子中使用到的变量有:
MedV: 房主自住房屋的中位数(单位:千美元)
Rm: 每套住宅的平均房间数
Tax: 每$10,000 的完整价值财产税率
Crim: 每镇的人均犯罪率
模型的准确性由rmse变量(表示均方根误差)捕捉。默认实现使用线性回归作为拟合函数。
要实现模板方法模式,我们应该允许客户端插入过程的任何部分。因此,我们可以通过关键字参数修改函数:
function run2(data::DataFrame, response::Symbol, predictors::Vector{Symbol};
fit = fit, split_data = split_data, validate = validate)
train, test = split_data(data, 0.7)
model = fit(train, response, predictors)
validate(test, model, response)
end
在这里,我们添加了三个关键字参数:fit、split_data和validate。函数被命名为run2以避免混淆,因此客户端应该能够通过传递自定义函数来自定义任何一个参数。为了说明它是如何工作的,让我们创建一个新的fit函数,该函数使用广义线性模型(GLM):
using GLM
function fit_glm(df::DataFrame, response::Symbol, predictors::Vector{Symbol})
formula = Term(response) ~ +(Term.(predictors)...)
return glm(formula, df, Normal(), IdentityLink())
end
现在我们已经自定义了拟合函数,我们可以通过传递fit关键字参数来重新运行程序:

如您所见,客户端可以通过传递函数来轻松自定义管道。这是可能的,因为 Julia 支持一等函数。
在下一节中,我们将回顾一些其他传统的行为模式。
命令、解释器、迭代器和访问者模式
命令、解释器和访问者模式被归入本节,仅仅是因为我们已经在本书的早期部分讨论了它们的使用案例。
命令模式用于参数化将要执行的操作。在第九章杂项模式部分中的单例类型分派模式部分,我们探讨了 GUI 调用不同命令并响应用户请求的特定操作的使用案例。通过定义单例类型,我们可以利用 Julia 的多分派机制来执行适当的函数。我们可以通过简单地添加接受新单例类型的新函数来扩展到新的命令。
解释器模式用于为特定领域模型建模抽象语法树。结果证明,我们已经在第七章中这样做过,即可维护性模式部分中的领域特定语言部分。每个 Julia 表达式都可以被建模为抽象语法树,而无需任何额外的工作,因此我们可以使用常规元编程设施(如宏和生成函数)来开发领域特定语言(DSL)。
迭代器模式用于使用标准协议遍历一组对象。在 Julia 中,已经有一个官方建立的迭代接口,任何集合框架都可以实现。只要为自定义对象定义了一个iterate函数,对象中的元素就可以作为任何循环结构的一部分进行迭代。更多信息可以在官方 Julia 参考手册中找到。
最后,访问者模式用于在面向对象范式中扩展现有类的功能。在 Julia 中,通过泛型函数的扩展,可以轻松地向现有系统添加新功能。例如,Julia 生态系统中有许多类似数组的包,如OffsetArrays、StridedArrays和NamedArrays。所有这些都是对现有的AbstractArray框架的扩展。
我们现在已经完成了行为模式。让我们继续前进,看看最后一组——结构模式。
结构模式
结构设计模式用于将对象组合在一起以形成更大的东西。随着你继续开发系统并添加功能,其大小和复杂性也在增长。我们不仅想要将组件集成在一起,同时我们也希望尽可能多地重用组件。通过学习本节中描述的结构模式,我们在项目中遇到类似情况时有一个遵循的模板。
在本节中,我们将回顾传统的面向对象模式,包括适配器、桥接、组合、装饰器、外观、享元和代理模式。让我们从适配器模式开始。
适配器模式
适配器模式用于使一个对象与另一个对象协同工作。比如说,我们需要集成两个子系统,但它们不能相互通信,因为接口要求没有得到满足。在现实生活中,你可能遇到过去不同国家旅行麻烦的情况,因为电源插头不同。为了解决这个问题,你可能需要带一个通用电源适配器,它作为中介使你的设备能够与外国的电源插座工作。同样,通过使用适配器,不同的软件可以被制作成相互兼容。
只要与子系统交互的接口是清晰的,那么创建适配器就可以是一个直接的任务。在 Julia 中,我们可以使用委托模式来包装一个对象,并提供符合所需接口的附加功能。
让我们想象一下,我们正在使用一个执行计算并返回链表的库。链表是一个方便的数据结构,它支持非常快的 O(1)速度的插入。现在,假设我们想要将数据传递给另一个需要我们符合AbstractArray接口的子系统。在这种情况下,我们不能直接传递链表,因为它不合适!
我们如何解决这个问题?首先,让我介绍一下LinkedList的实现:

这是一个相当标准的双向链表设计。每个节点包含一个数据值,同时也维护对前一个和后一个节点的引用。这种链表的典型用法如下所示:

通常,我们可以通过使用prev和next函数来遍历链表。当我们插入3的值时需要调用next(LL)的原因是我们希望将其插入到第二个节点之后。
由于使用链表没有实现AbstractArray接口,我们实际上无法通过索引引用任何元素,也无法确定元素的数量:

在这种情况下,我们可以构建一个符合AbstractArray接口的包装器(或称为适配器)。首先,让我们创建一个新的类型,并使其成为AbstractArray的子类型:
struct MyArray{T} <: AbstractArray{T,1}
data::Node{T}
end
由于我们只需要支持单维数组,我们已将超类型定义为AbstractArray{T,1}。底层数据只是对链表Node对象的引用。为了符合AbstractArray接口,我们应该实现Base.size和Base.getindex函数。下面是size函数的样子:
function Base.size(ar::MyArray)
n = ar.data
count = 0
while next(n) !== nothing
n = next(n)
count += 1
end
return (1 + count, 1)
end
该函数通过使用next函数遍历链表来确定数组的长度。为了支持索引元素,我们可以定义getindex函数如下:
function Base.getindex(ar::MyArray, idx::Int)
n = ar.data
for i in 1:(idx-1)
next_node = next(n)
next_node === nothing && throw(BoundsError(n.data, idx))
n = next_node
end
return value(n)
end
这就是我们需要为包装器做的所有事情。现在让我们试运行一下:

现在我们已经在链表之上有了可索引的数组,我们可以将其传递给任何期望数组作为输入的库。
在需要数组变动的情形下,我们只需实现Base.setindex!函数即可。或者,我们可以将链表物理地转换为数组。数组具有 O(1)快速索引的性能特征,但在插入时相对较慢。
使用适配器使我们更容易使组件相互通信。接下来,我们将讨论组合模式。
组合模式
组合模式用于模拟可以组合在一起同时又能像单个对象一样被处理的对象。这种情况并不少见——例如,在一个绘图应用程序中,我们可能能够绘制不同类型的形状,如圆形、矩形和三角形。每个形状都有一个位置和大小,因此我们可以确定它们在屏幕上的位置以及它们的大小。当我们把几个形状组合在一起时,我们仍然可以确定组合后的大对象的位姿。此外,还可以对单个形状对象以及组合对象应用调整大小、旋转和其他变换功能。
在投资组合管理中也会出现类似的情况。我有一个由多个共同基金组成的退休投资账户。每个共同基金可能投资于股票、债券或两者兼有。然后,一些基金也可能投资于其他共同基金。从会计角度来看,我们可以始终确定股票、债券、股票基金、债券基金和基金组合的市场价值。在 Julia 中,我们可以通过为不同类型的工具实现market_value函数来解决这个问题,无论是股票、债券还是基金。现在让我们看看一些代码。
假设我们为股票/债券持仓定义了以下类型:
struct Holding
symbol::String
qty::Int
price::Float64
end
Holding类型包含交易符号、数量和当前价格。我们可以定义投资组合如下:
struct Portfolio
symbol::String
name::String
stocks::Vector{Holding}
subportfolios::Vector{Portfolio}
end
投资组合由一个符号、一个名称、一个持仓数组和一个subportfolios数组来标识。为了测试,我们可以创建一个示例投资组合:
function sample_portfolio()
large_cap = Portfolio("TOMKA", "Large Cap Portfolio", [
Holding("AAPL", 100, 275.15),
Holding("IBM", 200, 134.21),
Holding("GOOG", 300, 1348.83)])
small_cap = Portfolio("TOMKB", "Small Cap Portfolio", [
Holding("ATO", 100, 107.05),
Holding("BURL", 200, 225.09),
Holding("ZBRA", 300, 257.80)])
p1 = Portfolio("TOMKF", "Fund of Funds Sleeve", [large_cap, small_cap])
p2 = Portfolio("TOMKG", "Special Fund Sleeve", [Holding("C", 200, 76.39)])
return Portfolio("TOMZ", "Master Fund", [p1, p2])
end
从缩进输出的结构中可以更清楚地可视化:

由于我们希望支持在任何级别计算市场价值的能力,我们只需要为每种类型定义market_value函数。最简单的一个是对于持仓:
market_value(s::Holding) = s.qty * s.price
市场价值不过是数量乘以价格。计算投资组合的市场价值稍微复杂一些:
market_value(p::Portfolio) =
mapreduce(market_value, +, p.stocks, init = 0.0) +
mapreduce(market_value, +, p.subportfolios, init = 0.0)
在这里,我们使用mapreduce函数来计算单个股票(或subportfolios)的市场价值,并将它们加起来。由于一个投资组合可能包含多个持仓和多个subportfolios,我们需要对两者都进行计算并将它们相加。由于每个子投资组合也是一个portfolio对象,这段代码自然会递归地深入到子-subportfolios,依此类推。
复合体并没有什么特别之处。因为 Julia 支持泛型函数,所以我们可以为单个对象以及分组对象提供实现。
我们将在下一节讨论飞点模式。
飞点模式
飞点模式用于通过共享相似/相同对象的内存来有效地处理大量细粒度对象。
处理字符串的一个很好的例子涉及字符串。在数据科学领域,我们经常需要读取和分析以表格格式表示的大量数据。在许多情况下,某些列可能包含大量重复的字符串。例如,人口普查调查可能有一个表示性别的列,因此它将包含Male或Female。
与其他一些编程语言不同,Julia 中的字符串不会被内部化。这意味着Male这个词的 10 个副本将被反复存储,占用 10 倍于单个Male字符串的内存空间。我们可以很容易地从 REPL 中看到这个效果,如下所示:

因此,存储 100,000 个Male字符串副本大约占用 800 KB 的内存。这相当浪费内存。解决这个问题的常见方法是通过维护一个池化数组。我们不需要存储 100,000 个字符串,而只需编码数据并存储 100,000 字节,这样0x01对应男性,0x00对应女性。我们可以通过以下方式将内存占用减少八倍:

你可能会想知道为什么报告了额外的 40 字节。这 40 字节实际上是数组容器使用的。现在,鉴于性别列在这种情况下是二进制的,我们实际上可以通过存储位而不是字节来进一步压缩它,如下所示:

再次强调,我们通过使用BitArray来存储性别值,将内存使用量大约减少了八倍(从 1 字节减少到 1 位)。这是一种对内存使用的激进优化。但是,我们仍然需要将Male和Female字符串存储在某个地方,对吧?这是一个简单的任务,因为我们知道它们可以在任何数据结构中追踪,例如字典:

总结来说,我们现在能够在 12,568 + 370 = 12,938 字节内存中存储 100,000 个性别值。与直接存储字符串的原始笨拙方式相比,我们节省了超过 98%的内存消耗!我们是如何实现如此巨大的节省的呢?因为所有记录都共享相同的两个字符串。我们唯一需要维护的是指向那些字符串的引用数组。
因此,这就是享元模式的概念。同样的技巧在许多地方被反复使用。例如,CSV.jl包使用一个名为CategoricalArrays的包,它提供了本质上相同类型的内存优化。
接下来,我们将回顾最后几个传统模式——桥接模式、装饰器模式和外观模式。
桥接模式、装饰器模式和外观模式
让我解释一下桥接模式、装饰器模式和外观模式是如何工作的。在这个阶段,我们不会为这些模式提供更多的代码示例,仅仅是因为它们相对容易实现,因为你已经从之前的设计模式章节中获得了许多想法。也许不会太令人惊讶,你迄今为止学到的一些技巧——委托、单例类型、多重分派、一等函数、抽象类型和接口——都是你可以用来解决任何类型问题的。
桥接模式用于解耦抽象与其实现,以便它们可以独立演变。在 Julia 中,我们可以为实施者构建一个抽象类型的层次结构,他们可以开发符合这些接口的软件。
Julia 的数值类型是这样一个系统如何设计的良好例子。有许多抽象类型可供选择,例如Integer、AbstractFloat和Real。然后,还有由Base包提供的具体实现,如Int和Float64。这种抽象设计得如此之好,以至于人们可以提供数字的替代实现。例如,SaferInteger包为整数提供了一个更安全的实现,避免了数值溢出。
装饰器模式也易于实现。它可以用来增强现有对象的新功能,因此得名装饰器。假设我们购买了一个第三方库,但我们并不完全满意其功能。使用装饰器模式,我们可以通过用新函数包装现有库来增加价值。
这可以通过委托模式自然地完成。通过用新类型包装现有类型,我们可以通过委托到基础对象来重用现有功能。然后,我们可以在新类型中添加新函数以获得新能力。我们看到这个模式被反复使用。
外观模式用于封装复杂的子系统,并为客户端提供一个简化的接口。在 Julia 中我们如何做到这一点?到目前为止,我们应该已经一次又一次地看到了这个模式;我们所需做的只是创建一个新的类型,并提供一个简单的 API 来操作这个新类型。我们可以使用委托模式将请求转发到其他封装的类型。
现在我们已经审视了所有传统的面向对象模式。你可能已经注意到,许多用例可以用这本书中描述的标准 Julia 特性和模式来解决。这不是巧合——这只是处理 Julia 中的复杂问题如此简单。
摘要
在本章中,我们广泛地讨论了传统的面向对象设计模式。我们从这样一个谦卑的信念开始,即面向对象编程中的相同模式通常需要在 Julia 编程中应用。
我们开始回顾创建型设计模式,包括工厂方法、抽象工厂、单例、建造者和原型模式。这些模式涉及创建对象的各种技术。当涉及到 Julia 时,我们可以主要使用抽象类型、接口和多重分派来解决这些问题。
我们还投入了大量精力研究行为设计模式。这些模式旨在处理应用程序组件之间的协作和通信。我们研究了 11 个模式:责任链、中介者、备忘录、观察者、状态、策略、模板方法、命令、解释器、迭代器和访问者。这些模式可以使用特性、接口、多重分派和一等函数在 Julia 中实现。
最后,我们回顾了几个结构化设计模式。这些模式通过复用现有组件来构建更大的组件。这包括适配器、组合、享元、桥接、装饰器和外观模式。在 Julia 中,它们可以通过抽象类型、接口和委托设计模式来处理。
我希望你们已经相信,构建软件并不一定困难。尽管面向对象编程让我们相信我们需要所有这些复杂性来设计软件,但这并不意味着我们在 Julia 中必须这样做。本章中提出的问题的解决方案大多需要你在本书中找到的基本软件设计技能和模式。
在下一章中,我们将深入探讨有关数据类型和分发的更高级主题。准备好迎接挑战!
问题
-
我们可以使用什么技术来实现抽象工厂模式?
-
我们如何防止在多线程应用程序中多次初始化单例?
-
Julia 中实现观察者模式的关键特性是什么?
-
我们如何使用模板方法模式来自定义一个操作?
-
我们如何制作一个适配器来实现目标接口?
-
享元模式的好处是什么?我们可以使用什么策略来实现它?
-
我们可以使用 Julia 的哪个特性来实现策略模式?
第四部分:高级主题
本节的目标是为您提供对 Julia 语言更深入的分析。理解这些高级概念将有助于您提出更好的设计方案。
本节包含以下章节:
- 第十二章,继承与变体
第十二章:继承和可变性
如果我们不得不选择在 Julia 或任何编程语言中最重要的学习内容,那么它必定是数据类型的概念。抽象类型和具体类型协同工作,为程序员提供了一种强大的工具来模拟解决方案,以解决现实世界的问题。多重分派依赖于定义良好的数据类型来调用正确的函数。参数化类型被用来使我们能够重用具有特定物理数据表示的对象的基本结构。正如你所看到的,在软件工程实践中,对数据类型进行周密的设计至关重要。
在第二章,模块、包和数据类型概念中,我们学习了抽象类型和具体类型的基础知识以及如何基于类型之间的继承关系构建类型层次结构。在第三章设计函数和接口和第五章重用模式中,我们也简要提到了参数化类型和参数化方法。为了有效地利用这些概念和语言特性,我们需要很好地理解子类型是如何工作的。它听起来可能类似于继承,但它在本质上是有区别的。
在本章中,我们将更深入地探讨子类型及其相关主题的含义,包括以下主题:
-
实现继承和行为子类型
-
协方差、反协方差和不变性
-
参数化方法和对角线规则
到本章结束时,你将对 Julia 中的子类型有很好的理解。你将更有能力设计自己的数据类型层次结构,并更有效地利用多重分派。
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
实现继承和行为子类型
当我们学习继承时,我们意识到抽象类型可以用来描述现实世界概念。我们可以相当自信地说,我们已经知道如何通过父子关系来分类概念。有了这些知识,我们可以在这些概念周围构建类型层次结构。例如,来自第二章,模块、包和数据类型概念的个人资产类型层次结构看起来如下:

在前面的图中展示的所有数据类型都是抽象类型。从下往上,我们知道House和Apartment都是Property的子类型,我们也知道Property和Investment都是Asset的子类型。这些都是基于我们日常生活中对这些概念的讨论的合理解释。
我们还讨论了具体类型,它们是抽象概念的物理实现。对于这个相同的例子,我们最终得到Stock作为Equity的子类型,Bond作为FixedIncome的子类型。如您所回忆的那样,Stock类型可以定义为以下内容:
struct Stock <: Equity
symbol::String
name::String
end
在那时,我们没有强调不能在抽象类型中声明任何字段的事实,这是某些面向对象编程(OOP)语言(如 Java)中固有的。如果您来自 OOP 背景,那么您可能会错误地感觉到这是 Julia 继承系统中的一个巨大限制。Julia 为什么被设计成这样?在本节中,我们将尝试更深入地分析继承并回答这个问题。
与继承相关联的两个重要概念非常相似,但本质上不同——实现继承和行为子类型。我们将在接下来的几节中讨论这两个概念。让我们从实现继承开始。
理解实现继承
实现继承允许子类从其超类继承字段和方法。由于 Julia 不支持实现继承,我们将暂时改变语言,以下是用 Java 提供的示例。这是一个提供容器以容纳任意数量对象的类:
import java.util.ArrayList;
public class Bag
{
ArrayList<Object> items = new ArrayList<Object>();
public void add(final Object object) {
this.items.add(object);
}
public void addMany(final Object[] objects) {
for (Object obj : objects) {
this.add(obj);
}
}
}
Bag类基本上维护了一个对象列表在items字段中,并提供两个方便的函数,add和addMany,用于向包中添加单个对象或对象数组。
为了展示代码重用,我们可以开发一个新的CountingBag类,它从Bag继承并提供了跟踪包中存储了多少项的附加功能:
public class CountingBag extends Bag
{
int count = 0;
public void add(Object object) {
super.add(object);
this.count += 1;
}
public int size() {
return count;
}
}
在这个CountingBag类中,我们有一个新的字段count,用于跟踪包的大小。每当向包中添加新项目时,count变量就会增加。size函数用于报告包的大小。那么CountingBag的情况如何?让我们快速总结:
-
count字段在此处定义可用。 -
items字段作为从Bag继承而来是可用的。 -
add方法覆盖了父类的实现,但它也通过super.add重用了父类的方法。 -
addMany方法作为从Bag继承而来是可用的。 -
size方法在此处定义可用。
由于字段和方法都是继承的,这被称为实现继承。其效果几乎等同于将超类中的代码复制到子类中。
接下来,让我们谈谈行为子类型。
理解行为子类型
行为子类型有时被称为接口继承。为了避免与重载的单词继承混淆,我们在这里将避免使用接口继承这个术语。行为子类型表示子类型仅从超类型继承行为。
当我们将语言切换回 Julia 时,我们将引用类型而不是类。
Julia 支持行为子类型。每个数据类型都继承为其超类型定义的函数。让我们在 Julia 的 REPL 中进行一个快速有趣的练习:

在这里,定义了一个抽象类型Vehicle及其子类型Car。我们还为Vehicle定义了一个move函数。当我们向move函数传递一个Car对象时,它仍然可以正常工作,因为Car是Vehicle的子类型。这与 Liskov 替换原则一致,该原则表示接受类型 T 的程序也可以接受 T 的任何子类型,并且可以继续正常工作,而不会出现任何意外的结果。
现在,方法的继承可以在多个级别上传播得很远。让我们创建另一个抽象级别:

我们刚刚定义了一个新的FlyingVehicle抽象类型和一个Helicopter结构体。move函数可以通过从Vehicle继承而来在直升机中使用,liftoff函数也可以使用,因为它是从FlyingVehicle继承而来的。
可以为更具体的类型定义额外的方法,并且会选择最具体的方法进行调度。这样做本质上与实现继承中的方法覆盖具有相同的效果。以下是一个例子:

到目前为止,我们已经定义了两种起飞方法——一种接受FlyingVehicle,另一种用于Helicopter。当将Helicopter对象传递给函数时,它会被分配到为Helicopter定义的方法,因为它是最具体的方法,适用于直升机。
这种关系可以用以下图表来总结:

根据行为子类型,汽车应该像车辆一样行为,飞行车辆应该像车辆一样行为,直升机应该像飞行车辆一样行为,也像车辆一样行为。行为子类型允许我们重用为超类型已定义的行为。
在 Java 中,可以使用接口实现行为子类型。
现在我们已经了解了实现继承和行为子类型,我们可以回顾我们之前的问题:为什么 Julia 不支持实现继承?不遵循其他主流面向对象编程语言的原因是什么?为了理解这一点,我们可以回顾一些与实现继承相关的一些知名问题。让我们从正方形-矩形问题开始。
正方形-矩形问题
Julia 不支持实现继承。让我们列出不支持实现继承的原因:
-
所有具体类型都是最终的,因此无法从另一个具体类型创建新的子类型。因此,不可能从任何地方继承对象字段。
-
在抽象类型中,你不能声明任何字段,否则它将不再是抽象的,而是具体的。
Julia 编程语言的核心开发者出于多个原因,在早期设计决策中决定避免实现继承。其中之一就是所谓的正方形-矩形问题,有时也称为圆-椭圆问题。
正方形-矩形问题对实现继承提出了一个明显的挑战。正如常识所知,每个正方形都是一个矩形,它有一个额外的约束,即两边的长度相等。为了在面向对象的语言中通过类来建模这些概念,我们可能会尝试创建一个Rectangle类和一个Square子类:

很快,我们就意识到我们已经使自己陷入了麻烦。如果Square必须从其父类继承所有字段,那么它就会继承width和height。但我们真正想要的是一个名为length的单个字段。
有时,完全相同的问题被表述为圆-椭圆问题。在这种情况下,圆是椭圆,但只有一个半径而不是主轴和副轴长度。
我们如何解决这个问题?好吧,一种方法是不理会这个问题,创建一个没有任何字段定义的Square子类。然后,当用特定的长度实例化Square时,width和height字段都填充了相同的值。这足够好吗?答案是不足够的。鉴于Square还继承了Rectangle的方法,我们可能需要提供覆盖方法,例如setWidth和setHeight,以便我们可以保持两个字段具有相同的值。最终,我们得到了一个似乎在功能上可行但性能和内存使用都很差的解决方案。
但我们最初是如何陷入麻烦的呢?为了进一步分析,我们应该意识到,虽然正方形可以被归类为矩形,但在本质上它是一个更严格的矩形版本。这已经开始听起来不太直观了——通常,当我们创建子类时,我们会扩展父类并添加更多的字段和功能。我们什么时候想在子类中删除字段或功能?这似乎在逻辑上是倒退的。也许我们应该让Rectangle成为Square的子类?这听起来也不太合理。
我们陷入了一个困境。一方面,我们希望在代码中正确地建模现实世界概念。另一方面,代码并不适合,不会引起维护或性能问题。到目前为止,我们不禁要问自己,我们是否真的想编写绕过实现继承问题的代码。我们不想。
也许你还没有 100%确信实现继承比抽象继承更糟糕。让我们看看另一个问题。
不稳定的基类问题
实现继承的另一个问题是,对基类(父类)的更改可能会破坏其子类的功能。从早期的 Java 示例中,我们有一个从Bag类扩展的CountingBag类。让我们看看完整的源代码,包括main函数:

程序简单地创建了一个CountingBag对象。然后使用add方法添加apple,并使用addMany方法添加banana和orange。最后,它打印出包中的项目和包的大小。输出如下代码所示:

目前一切看起来都很正常。但假设Bag类的原始作者意识到可以通过直接向items数组列表中添加对象来改进addMany方法:

不幸的是,这个看似安全的父类更改最终导致了CountingBag的灾难:

发生了什么?当设计CountingBag时,假设在向包中添加新项目时总是会调用add方法。当addMany方法停止调用add方法时,这个假设就不再适用了。
这是谁的错?当然,Bag类的开发者无法预见谁会继承这个类。addMany方法的变化并没有违反任何契约;提供的功能相同,只是在底层有不同的实现。CountingBag类的开发者认为跟随并利用addMany已经调用add方法的事实是明智的,因此只需要覆盖add方法以使计数工作。
这提出了实现继承的第二个问题。子类开发者对父类的实现了解得太多。覆盖父类add方法的能力也违反了封装原则。
面向对象编程是如何解决这个问题呢?在 Java 中,有多种设施可以防止前面示例中提出的问题:
-
可以使用
final关键字注解方法以防止子类覆盖该方法。 -
可以使用
private关键字注解字段以防止子类访问该字段。
问题在于,开发者必须预测类将如何在未来被继承。必须仔细检查方法,以确定是否允许子类访问或覆盖它。同样适用于字段。正如你所见,这个问题之所以被称为不稳定的基类问题,是有充分的理由的。
希望我们已经向您展示了实现继承弊大于利。为了参考,在 GoF 设计模式书中,也建议优先使用组合而非继承。Julia 采取了更为激进的策略,完全禁止了实现继承。
接下来,我们将进一步探讨一种特定的行为子类型,称为鸭子类型。
回顾鸭子类型
实现行为子类型有两种方式:名义子类型和结构子类型:
-
在名义子类型中,你必须明确定义类型与其超类型之间的关系。Julia 使用名义子类型,其中类型在函数参数中明确标注。这就是为什么需要构建类型层次结构来表达类型关系。
-
在结构子类型中,只要子类型实现了超类型所需的功能,关系就隐式地推导出来。当函数使用参数定义而没有标注任何类型时,Julia 支持结构子类型。
Julia 通过鸭子类型支持结构子类型。我们首次在第三章中提到了鸭子类型,设计函数和接口。说法如下:
“如果它走路像鸭子,叫起来也像鸭子,那么它就是一只鸭子。”
在动态类型语言中,我们有时更关注我们是否得到了想要的行为,而不是确切的类型。如果我们只想听到嘎嘎声,谁会在意我们得到的是青蛙?只要它能发出嘎嘎声,我们就会满意。
有时,我们出于良好原因想要使用鸭子类型。例如,我们通常不会把马视为交通工具;然而,想想过去马被用于运输的日子。在我们的定义中,任何实现了move函数的东西都可以被视为交通工具。所以,如果我们有任何需要移动对象的算法,就没有理由不能将horse对象传递给该算法:

对于一些人来说,鸭子类型有点宽松,因为你不能轻易地判断一个类型是否支持接口(如move)。一般的补救方法是使用第五章中描述的圣物特质模式,可重用性模式。
接下来,我们将探讨一个重要的概念,称为可变性。
协变、不变性和逆变
实际上,子类型的规则并不非常直接。当你查看一个简单的类型层次结构时,你可以立即通过追踪层次结构中数据类型之间的关系来判断一个类型是否是另一个类型的子类型。当涉及到参数化类型时,情况变得更加复杂。在本节中,我们将探讨 Julia 是如何设计以可变性为依据的,这是一个解释参数化类型子类型关系的概念。
让我们先回顾一下不同类型的可变性。
理解不同类型的可变性
计算机科学文献中描述了四种不同的方差类型。我们首先将它们以正式的方式描述,然后回来进行更多的动手练习,以加强我们的理解。
假设 S 是 T 的子类型,那么有四种不同的方式来推理参数化类型 P{S} 和 P{T} 之间的关系:
-
协变:
P{S}是P{T}的子类型 (co这里表示相同方向) -
反协变:
P{T}是P{S}的子类型 (contra这里表示相反方向) -
不变量:既不是协变的也不是反协变的
-
双协变:既协变又反协变
我们在什么时候会发现方差有用?也许不会太令人惊讶,方差是当多态发生作用时的关键成分。根据 Liskov 替换原则,语言运行时必须在分派到方法之前确定传递的对象是否是方法参数的子类型。
有趣的是,方差是不同编程语言之间经常存在差异的东西。有时,这有历史原因,有时则取决于语言的目标用例。在接下来的几节中,我们将从几个角度探讨这个主题。我们将从参数化类型开始。
参数化类型是不变的
为了说明,我们将考虑一些面向对象文献中使用的流行类型层次结构——动物王国!每个人都喜欢猫和狗。我还包括鳄鱼来解释相关概念:

构建此类层次结构的相应代码如下:
abstract type Vertebrate end
abstract type Mammal <: Vertebrate end
abstract type Reptile <: Vertebrate end
struct Cat <: Mammal
name
end
struct Dog <: Mammal
name
end
struct Crocodile <: Reptile
name
end
为了方便起见,我们也可以为这些新类型定义 show 函数:
Base.show(io::IO, cat::Cat) = print(io, "Cat ", cat.name)
Base.show(io::IO, dog::Dog) = print(io, "Dog ", dog.name)
Base.show(io::IO, croc::Crocodile) = print(io, "Crocodile ", croc.name)
给定这样的类型层次结构,我们可以通过以下 adopt 函数验证子类型是如何处理的。由于没有人想领养鳄鱼(至少我不这么认为),我们限制函数参数只接受 Mammal 的子类型:
function adopt(m::Mammal)
println(m, " is now adopted.")
return m
end
如预期的那样,我们只能采用猫和狗,但不能采用鳄鱼:

如果我们想同时采用许多宠物呢?直观上,我们可以定义一个新的函数,它接受一个哺乳动物数组,如下所示:
adopt(ms::Array{Mammal,1}) = "adopted " * string(ms)
不幸的是,它已经未能通过我们为采用费利克斯和加菲尔德所做的第一次测试:

发生了什么事?我们知道猫是哺乳动物,那么为什么一个猫的数组不能传递给接受哺乳动物数组的函数呢?答案是简单的——参数化类型是不变的。这对于来自面向对象背景的人来说是一个非常大的惊喜,因为参数化类型通常是协变的。
通过不变性,尽管 Cat 是 Mammal 的子类型,但我们不能说 Array{Cat,1} 是 Array{Mammal,1} 的子类型。此外,Array{Mammal,1} 实际上代表一个 Mammal 对象的一维数组,其中每个对象可以是 Mammal 的任何子类型。由于每个具体类型可能有不同的内存布局要求,这个数组必须存储指针而不是实际值。另一种说法是,对象是 装箱 的。
为了调度到这个方法,我们必须创建一个 Array{Mammal,1}。这可以通过在数组构造函数前加上 Mammal 来实现,如下所示:
adopt(Mammal[Cat("Felix"), Cat("Garfield")])
在实践中,当我们必须处理同一类型的对象数组时,这种情况更为常见。在 Julia 中,我们可以使用类型表达式 Array{T,1} where T 来表达这样的同质数组。这意味着我们可以定义一个新的 adopt 方法,只要它们是同一类型的哺乳动物,就可以接受多个哺乳动物:
function adopt(ms::Array{T,1}) where {T <: Mammal}
return "accepted same kind:" * string(ms)
end
现在让我们测试新的 adopt 方法。结果如下所示:

如预期的那样,新的 adopt 方法根据数组是否包含 Mammal 指针或猫或狗的实际值相应地调度。
在 Julia 中,选择使参数化类型不变是一个出于实际考虑的自觉设计决策。当一个数组包含具体的类型对象时,内存可以以非常紧凑的方式分配来存储这些对象。另一方面,当一个数组包含装箱对象时,每个元素的引用都会涉及解引用一个指向对象的指针,因此性能会受到影响。
确实有一个地方 Julia 使用了协变,那就是方法参数。我们将在下面讨论这些。
方法参数是协变的
方法参数是协变的应该是相当直观的,因为这就是今天多态工作的方式。考虑以下函数:
friend(m::Mammal, f::Mammal) = "$m and $f become friends."
在 Julia 中,方法参数正式表示为一个元组。在上面的例子中,方法参数仅仅是 Tuple{Mammal,Mammal}。
当我们用类型为 S 和 T 的两个参数调用此函数时,它只有在 S <: Mammal 和 T <: Mammal 的情况下才会被调度。在这种情况下,我们应该能够传递任何哺乳动物的组合——狗/狗、狗/猫、猫/狗和猫/猫。以下截图证明了这一点:

让我们也检查鳄鱼是否能参加派对:

如预期的那样,Tuple{Cat,Crocodile} 不是 Tuple{Mammal,Mammal} 的子类型,因为 Crocodile 不是 Mammal。
接下来,让我们转向一个更复杂的场景。众所周知,函数是 Julia 中的第一公民。我们在调度期间如何确定一个函数是否是另一个函数的子类型?
函数类型的剖析
在 Julia 中,函数是一等公民。这意味着函数可以作为变量传递,并且可以出现在方法参数中。由于我们已经学习了方法参数的协变属性,那么当函数作为参数传递时,我们该如何处理这种情况呢?
理解这个问题的最好方法就是看看函数通常是如何传递的。让我们从一个简单的 Base 示例中挑选一个:

all 函数可以用来检查数组中所有元素是否都评估为 true 的条件。为了使其更加灵活,它可以接受一个自定义谓词函数。例如,我们可以检查数组中所有数字是否都是奇数,如下所示:

虽然我们知道它被正确调度了,但我们也可以确认 isodd 的类型是 Function 的子类型,如下所示:

结果表明,所有 Julia 函数都有它们自己的唯一类型,如下面的代码中显示的 typeof(isodd),并且它们都有一个超类型 Function:

由于 all 方法被定义为接受任何 Function 对象,我们实际上可以传递任何函数,Julia 会乐意调度到该方法。不幸的是,这可能会导致不希望的结果,如下面的截图所示:

我们在这里遇到了错误,因为传递给 all 函数的函数应该接受一个元素并返回一个布尔值。由于 println 总是返回 nothing,所以 all 函数只是抛出了一个异常。
在需要更强类型的情况下,可以强制指定特定的函数类型。以下是如何创建一个更安全的 all 函数的方法:
const SignFunctions = Union{typeof(isodd),typeof(iseven)};
myall(f::SignFunctions, a::AbstractArray) = all(f, a);
SignFunctions 常量是一个联合类型,仅由 isodd 和 iseven 函数的类型组成。因此,myall 方法只有在第一个参数是 isodd 或 iseven 时才会被调度;否则,将抛出一个方法错误,如下面的截图所示:

当然,这样做严重限制了函数的实用性。我们还必须枚举所有可能传递的函数,而这并不总是可行的。因此,处理函数参数的手段似乎有些有限。
回到方差的话题,当所有函数都是最终的,并且它们只有一个超类型时,实际上真的没有什么可说的。
在实践中,当我们设计软件时,我们确实关心函数的类型。正如前一个例子所示,all函数只能与接受单个参数并返回布尔值的函数一起工作。这应该是接口合同。然而,我们如何强制执行这个合同呢?最终,我们需要对函数和调用者与被调用者之间的合同有更好的理解。合同可以被视为方法参数和返回类型的组合。让我们在下一节中找出是否有更好的方法来处理这个问题。
确定函数类型的变异性
在本节中,我们将尝试理解如何推理函数类型。虽然 Julia 在形式化函数类型方面没有提供太多帮助,但它并没有阻止我们自行进行分析。在一些强类型、静态 OOP 语言中,函数类型被更正式地定义为方法参数和返回类型的组合。
假设一个函数接受三个参数并返回一个单一值。然后我们可以用以下符号来描述该函数:

让我们继续动物王国的例子,并定义一些新的变量和函数,如下所示:
female_dogs = [Dog("Pinky"), Dog("Pinny"), Dog("Moonie")]
female_cats = [Cat("Minnie"), Cat("Queenie"), Cat("Kittie")]
select(::Type{Dog}) = rand(female_dogs)
select(::Type{Cat}) = rand(female_cats)
在这里,我们定义了两个数组——一个用于母狗,另一个用于母猫。select函数可以用来随机选择一只狗或猫。接下来,让我们考虑以下函数:
match(m::Mammal) = select(typeof(m))
match函数接受一个Mammal并返回相同类型的对象。这是它的工作方式:

由于match函数只能返回Dog或Cat,我们可以这样推理函数类型:

假设我们定义了两个额外的函数,如下所示:
# It's ok to kiss mammals :-)
kiss(m::Mammal) = "$m kissed!"
# Meet a partner
function meet_partner(finder::Function, self::Mammal)
partner = finder(self)
kiss(partner)
end
meet_partner函数接受一个finder函数作为第一个参数。然后,它调用finder函数来找到一个伴侣,并最终与伴侣kiss。按照设计,我们将传递之前代码中定义的match函数。让我们看看它是如何工作的:

到目前为止,一切顺利。从meet_partner函数的角度来看,它期望finder函数接受一个Mammal参数并返回一个Mammal对象。这正是match函数的设计方式。现在,让我们看看我们能否通过定义一个不返回哺乳动物的函数来搞砸它:
neighbor(m::Mammal) = Crocodile("Solomon")
尽管neighbor函数可以接受一个哺乳动物作为参数,但它返回的是鳄鱼,而鳄鱼是一种爬行动物,不是哺乳动物。如果我们尝试将其传递给meet_partner函数,我们就会遇到灾难:

我们刚刚证明的内容相当直观。由于finder函数的返回类型预期为Mammal,任何返回Mammal任何子类型的其他finder函数也会起作用。因此,函数类型的返回类型是协变的。
现在,关于函数类型的参数是什么?再次,meet_partner函数预期将任何哺乳动物传递给finder函数。finder函数必须能够接受dog或cat对象。如果finder函数只接受猫或狗,那么它将不起作用。让我们看看如果有一个更限制性的finder函数会发生什么:
buddy(cat::Cat) = rand([Dog("Astro"), Dog("Goofy"), Cat("Lucifer")])
在这里,buddy函数接受一只猫并返回一个哺乳动物。如果我们将其传递给meet_partner函数,那么当我们想要为我们的狗Chef找到一个伴侣时,它将不起作用:

因此,函数类型的参数不是协变的。它们可能是反协变的吗?嗯,为了是反协变的,finder函数必须接受Mammal的超类型。在我们的动物王国中,唯一的超类型是Vertebrate;然而,Vertebrate是一个抽象类型,不能被实例化。如果我们实例化任何其他是Vertebrate子类型的具体类型,它就不会是哺乳动物(否则,它已经被认为是哺乳动物了)。因此,函数参数是不变的。
更正式地说,这看起来如下所示:

函数g是函数f的子类型,只要T是Mammal,且S是Mammal的子类型。关于这一点有一句话:"在接受方面要宽容,在产生方面要保守。"
虽然做这种分析很有趣,但考虑到 Julia 运行时不支持像我们所见的那样细粒度的函数类型,我们是否真的获得了什么?似乎我们可以自己模拟一个类型检查的效果,这是下一节的主题。
实现我们自己的函数类型分派
正如我们在本节前面所看到的,Julia 为每个函数创建一个唯一的函数类型,它们都是Function抽象类型的子类型。我们似乎错过了一个多态的机会。以Base中的all函数为例,如果我们能够设计一个表示谓词函数的类型,而不是让all在传递不兼容的函数时失败,那将会非常棒。
为了绕过这个限制,让我们定义一个名为PredicateFunction的参数化类型,如下所示:
struct PredicateFunction{T,S}
f::Function
end
PredicateFunction参数化类型只是包装了一个函数f。类型参数T和S用于表示函数参数的类型,并分别返回f的类型。例如,iseven函数可以被包装如下,因为我们知道该函数可以接受一个数字并返回一个布尔值:
PredicateFunction{Number,Bool}(iseven)
便利的是,由于 Julia 支持可调用的结构体,我们可以使PredicateFunction结构体可以被调用,就像它本身是一个函数一样。为了实现这一点,我们可以定义以下函数:
(pred::PredicateFunction{T,S})(x::T; kwargs...) where {T,S} =
pred.f(x; kwargs...)
如您所见,这个函数只是将调用转发到包装的pred.f函数。一旦定义了它,我们就可以做一些小实验来看看它是如何工作的:

看起来相当不错。让我们定义我们自己的 safe 版本的 all 函数,如下所示:
function safe_all(pred::PredicateFunction{T,S}, a::AbstractArray) where
{T <: Any, S <: Bool}
all(pred, a)
end
safe_all 函数接受一个 PredicteFunction{T,S} 作为第一个参数,约束条件是 T 是 Any 的子类型,而 S 是 Bool 的子类型。这正是我们想要的谓词函数的类型签名。知道 Number <: Any 和 Bool <: Bool,我们可以肯定地将 iseven 函数传递给 safe_all。现在让我们测试一下:

Bravo! 我们已经创建了一个安全的 all 函数版本。第一个参数必须是一个接受任何内容并返回布尔值的谓词函数。我们不再需要接受一个通用的 Function 参数,现在我们可以强制严格的类型匹配并参与多重分发。
关于变异性就讲这么多。接下来,我们将继续并重新审视参数化方法调用的规则。
参数化方法重新审视
根据子类型关系进行分发的功能是 Julia 语言的一个关键特性。我们最初在 第三章 设计函数和接口 中介绍了参数化方法的概念。在本节中,我们将更深入地探讨一些关于方法选择分发的微妙情况。
让我们从基础开始:我们如何为参数化方法指定类型变量?
指定类型变量
当我们定义一个参数化方法时,我们使用 where 子句来引入类型变量。让我们来看一个简单的例子:
triple(x::Array{T,1}) where {T <: Real} = 3x
triple 函数接受一个 Array{T},其中 T 是 Real 的任何子类型。这段代码非常易于阅读,这是大多数 Julia 开发者选择来指定类型参数的格式。那么 T 的值可能是什么?它可以是具体类型、抽象类型,或者两者都是?
为了回答这个问题,我们可以在 REPL 中测试它:

因此,该方法确实在抽象类型(Real)和具体类型(Int64)上进行了分发。值得一提的是,where 子句也可以放在方法参数旁边:
triple(x::Array{T,1} where {T <: Real}) = 3x
从函数式编程的角度来看,无论 where 子句是放在内部还是外部,都是相同的。
然而,有一些细微的差别。当 where 子句放在外部时,你将获得两个额外的优势:
-
类型变量
T在方法体内部是可访问的。 -
类型变量
T可以用来强制多个方法参数具有相同的值。
结果表明,第二点导致了 Julia 分发系统中一个有趣的功能。我们将在下一节中介绍这一点。
匹配类型变量
当一个类型变量在方法签名中多次出现时,它被用来强制所有出现位置具有相同的类型。考虑以下函数:
add(a::Array{T,1}, x::T) where {T <: Real} = (T, a .+ x)
add 函数接受一个 Array{T} 和一个类型为 T 的值。它返回一个包含 T 和将值添加到数组后的结果的元组。直观上,我们希望 T 在两个参数中保持一致。换句话说,我们希望函数在调用时针对 T 的每个实现进行特殊化。显然,当类型一致时,函数工作得很好:

在第一种情况下,T 被确定为 Int64,而在第二种情况下,T 被确定为 Float64。也许并不令人意外,当类型不匹配时,我们可能会得到一个方法错误:

由于我们说 T 可以是一个抽象类型,我们能否将方法分派到这个方法上,因为 T 可以被认为是 Real?答案是不了,因为参数化类型是 不变的!一个 Real 对象的数组不等于一个 Int64 值的数组。更正式地说,Array{Int} 不是 Array{Real} 的子类型。
当 T 是数组中的抽象类型时,事情会变得更有趣。让我们试试这个:

在这里,T 明确设置为 Signed,并且由于 Int8 是 Signed 的子类型,方法被正确分派。
接下来,我们将探讨另一个独特的类型特性,称为对角线规则。
理解对角线规则
如我们之前所学的,能够匹配类型变量并在方法参数中保持一致性是一个很好的特性。在实践中,有些情况下我们希望在确定每个类型变量的正确类型时更加具体。
考虑这个函数:
diagonal(x::T, y::T) where {T <: Number} = T
diagonal 函数接受两个相同类型的参数,其中类型 T 必须是 Number 的子类型。类型变量 T 简单地返回给调用者。
当 T 是具体类型时,很容易推理出类型是一致的。例如,我们可以传递一对 Int64 值或一对 Float64 值给函数,并期望看到相应的具体类型返回:

直观上,我们也期望当类型不一致时这会失败:

虽然看起来直观,但我们可能会争辩说类型变量 T 是一个抽象类型,比如 Real。由于 1 的值是 Int64 且 Int64 是 Real 的子类型,以及 2.0 的值是 Float64 且 Float64 是 Real 的子类型,那么方法是否仍然应该被分派?为了使这一点更加清晰,我们甚至可以在调用函数时将参数注释为如下:

结果表明,Julia 被设计成给我们更直观的行为。这也是引入对角线规则的真正原因。对角线规则指出,当一个类型变量在协变位置(即方法参数)中多次出现时,该类型变量将被限制仅与具体类型匹配。
在这种情况下,类型变量T被视为对角线变量,因此T必须是一个具体类型。
尽管存在对角线规则的例外。我们将在下一节讨论这个问题。
对角线规则的例外
对角线规则指出,当一个类型变量在协变位置(即方法参数)中多次出现时,该类型变量将被限制仅与具体类型匹配;然而,该规则有一个例外——当类型变量可以从不变位置明确确定时,它允许是抽象类型而不是具体类型。
考虑以下例子:
not_diagonal(A::Array{T,1}, x::T, y::T) where {T <: Number} = T
与上一节中的diagonal函数不同,这个函数允许T是抽象的。我们可以这样证明:

原因是T出现在参数类型的第一参数中。正如我们所知,参数类型是不变的,我们已经确定T是Signed。因为Int64是Signed的子类型,所以一切匹配。
在下一节中,我们将讨论类型变量的可用性。
类型变量的可用性
参数方法的一个重要特性是,where子句中指定的类型变量也可以从方法体中访问。与您可能认为的相反,这并不总是正确的。在这里,我们将展示一个类型变量在运行时不可用的例子。
考虑以下函数:
mytypes1(a::Array{T,1}, x::S) where {S <: Number, T <: S} = T
mytypes2(a::Array{T,1}, x::S) where {S <: Number, T <: S} = S
我们可以使用mytypes1和mytypes2函数来实验 Julia 运行时推导出的类型变量。让我们从一个愉快的例子开始:

然而,情况并不总是如此美好。在其他情况下,它可能并不总是 100%有效。以下是一个例子:

为什么S在这里没有定义?首先,我们已经知道T是Signed,因为参数类型是不变的。作为where子句的一部分,我们也知道T是S的子类型。因此,S可以是Integer、Real、Number甚至Any。由于可能的答案太多,Julia 运行时决定不对S分配任何值。
这个故事的意义是,不要假设类型变量总是被定义并且可以从方法中访问,尤其是在这种更复杂的情况下。
摘要
在本章中,我们学习了与子类型、变体和调度相关的各种主题。这些概念是创建更大、更复杂应用程序的基本构建块。
我们首先讨论了实现继承和行为子类型化以及它们之间的区别。我们推理出,由于各种问题,实现继承不是一个很好的设计模式。我们得出结论,Julia 的类型系统是为了避免我们在其他编程语言中看到的问题而设计的。
然后,我们回顾了不同种类的变异性,这些不过是解释参数化类型之间子类型关系的方法。我们详细地解释了参数化类型是如何不变的,方法参数是如何协变的。然后我们更进一步讨论了函数类型的变异性以及我们如何可以构建自己的数据类型来封装函数以实现分派目的。
最后,我们重新审视了参数化方法,并探讨了在分派过程中类型变量是如何指定和匹配的。我们了解了对角线规则,这是 Julia 语言中的一个关键设计特性,它允许我们以直观的方式强制方法参数的类型一致性。
我们现在已经完成了这一章节和整本书。感谢您阅读它!
问题
-
实现继承和行为子类型化有何不同?
-
实现继承有哪些主要问题?
-
什么是鸭子类型?
-
方法参数的变异性是什么,为什么?
-
为什么在 Julia 中参数化类型是不变的?
-
对角线规则何时适用?
第十三章:评估
第一章
使用设计模式有哪些好处?
设计模式帮助程序员将已经证明有效的方法应用于常见问题。在次优实现之后,将节省更多时间用于寻找适当的解决方案或修复设计问题。反模式为避免常见设计缺陷提供了额外的指导。
有哪些关键的设计原则?
关键设计原则包括 SOLID、DRY、KISS、POLA、YAGNI 和 POLP。这些原则被广泛认为是面向对象编程的良好指导,但它们同样适用于其他编程范式。
开放/封闭原则解决了什么问题?
开放/封闭原则鼓励程序员设计一个易于扩展的系统,而无需修改正在扩展的组件。它促进了软件组件更好的重用性。
为什么接口分离对于软件重用很重要?
接口分离促进了接口的最简设计,以便软件组件更容易实现相应的接口。一个庞大而复杂的接口难以实现,并且使组件的可重用性降低。
开发可维护的软件的最简单方法是什么?
最简单的方式是遵守 KISS、DRY、POLA 和 SOLID 等通用设计原则。
避免过度设计和臃肿软件的好习惯是什么?
避免过度设计和臃肿软件的最佳方式是根据 YAGNI 原则仅实现绝对必要的功能。同时,保持简单(KISS)并避免重复代码(DRY)。
内存使用如何影响系统性能?
当系统分配更多内存时,它也会更频繁地触发垃圾回收器(GC)。垃圾回收是一个相对昂贵的操作,因此,它可能会减慢系统。避免过度内存分配通常是优化应用程序性能的最佳方法之一。
第二章
我们如何创建一个新的命名空间?
命名空间是通过模块块创建的。通常,模块被定义为 Julia 包的一部分。
我们如何将模块的功能暴露给外部世界?
可以使用导出语句将模块内定义的函数和其他对象暴露出来。
当同一函数名从不同的包导出时,我们如何引用正确的函数?
我们可以直接在函数名前加上包名。作为替代方案,我们可以对一个包使用using语句,对另一个使用导入语句,这样我们就可以直接使用第一个包的函数名,而对其他包使用前缀语法。
我们在什么时候将代码分离成多个模块?
当代码变得太大,难以管理时,是时候考虑将代码分离成模块了。我们期望进行一些重构,以确保模块之间适当的耦合级别。
为什么语义版本化在管理包依赖时很重要?
语义版本化定义了在新版本中引入破坏性更改时的明确合同。当正确且一致地使用时,它有助于程序员确定更改是否与现有软件兼容,以及是否需要额外的测试。
定义抽象类型的函数行为有何用途?
为抽象类型定义函数行为是有用的,因为可以将相同的行为应用于相应的子类型。
何时应该使类型可变?
当预期数据类型的一些部分需要更改时,将类型设置为可变是合适的。出于性能原因需要减少内存分配时,这也很有用。
参数类型有何用途?
参数类型允许在不硬编码字段类型的情况下定义具体类型,因此可以使用相同的类型为不同的目的生成新的变体。
第三章
位置参数与关键字参数有何不同?
位置参数必须按照它们在函数签名中定义的顺序传递。它们通常是必需的,但可以通过提供默认值来使其可选。关键字参数可以按它们书写的任何顺序传递,并且当未提供默认值时是可选的。
展开和吸入有何区别?
展开和吸入具有相同的语法,但在不同的上下文中意味着不同的事情。展开指的是从元组或数组自动分配函数参数。吸入指的是将多个函数参数传递为一个单一的元组变量,该变量可以从函数体中访问。
do-syntax 的目的是什么?
Do-syntax 是一种方便的方式来格式化需要作为匿名函数包装并传递给另一个函数的代码块。这使得代码更加易于阅读。
有什么工具可以检测与多重分派相关的方法歧义?
可以使用来自Test包的detect_ambiguities函数来检测单个模块或多个模块内的方法歧义。
我们如何确保在参数方法中传递相同的具体类型?
确保函数的参数传递相同的具体类型的一个方便方法是将这些参数指定为类型参数(例如,T)。请注意,只要类型参数作为独立类型使用,而不是参数类型的一部分,例如,AbstractVector{T},这就会起作用。
在没有正式语言语法的情况下如何实现接口?
即使 Julia 没有指定接口的正式语法,也可以根据接口设计者的规范实现接口。
我们如何实现特质,特质有何用途?
特质可以通过一个函数来实现,该函数接受特定的数据类型并返回一个标志。通常,特质被定义为返回布尔值,即特质是否存在。然而,它也可以设计为返回多个值以指示各种特质。如果开发者需要通过编程方式确定数据类型(或数据类型的组合)是否具有特定行为,特质是有用的。
第四章
有哪两种方法可以引用表达式以便稍后进行代码操作?
一种方法是将表达式用:(和)括起来。另一种方法是将代码放在quote和end关键字之间。一般来说,quote 块用于多行表达式。
eval函数在哪个作用域中执行代码?
eval函数评估全局作用域中的代码。因此,如果它从一个模块内的函数中使用,那么被评估的代码将位于模块的作用域内。
我们如何将物理符号插入到引号表达式中,而不是将其误解释为源代码?
要将符号插入到引号表达式,创建一个QuoteNode对象并正常插入该对象。
定义非标准字符串字面量的宏的命名约定是什么?
非标准字符串字面量定义为以_str结尾的宏。例如,当为 IP 地址定义ip_str宏时,它可以这样写:ip"192.168.1.1"。
何时使用esc函数?
esc函数需要确保引号表达式在调用点被评估,这可能是函数的局部作用域。
生成的函数与宏有何不同?
生成的函数可以访问参数的类型。它们是按定义是函数,因此与宏不同,它们没有访问源代码的能力。宏在语法级别上操作,并且没有任何运行时信息。生成的函数和宏都应返回表达式。
我们如何调试元编程代码?
调试宏可能具有挑战性。这归结于确保返回的表达式是正确的。我们可以使用@macroexpand宏(或相应的macroexpand函数)来验证结果。此外,由于宏或生成的函数是使用常规 Julia 代码定义的,因此可以使用相同的调试技术,例如println。
第五章
委托模式是如何工作的?
委托模式可以通过将父对象包装在新对象中来实现。新对象的功能可以转发(或委托)给父对象。
特质的目的何在?
特质的目的是正式定义某些对象的行为。一旦定义了特质,我们就可以通过编程方式检查一个对象是否具有该特质。
特质总是二元的吗?
特性通常是二元的,但没有强制性要求。只要特性是互斥的,那就没问题。Julia 的Base.IteratorSize特性就是一个多值特性的好例子。
能否将特性用于不同类型层次结构中的对象?
是的,特性不受抽象类型层次结构定义方式的限制。相同的特性可以分配给来自不同类型层次结构的对象。
参数类型有哪些好处?
参数类型允许我们为数据类型定义一个模板。可以通过填充参数来程序性地创建新的数据类型。参数类型的主要好处是代码变得更短,因为我们不需要列出每个可能的具体类型。
我们如何使用参数类型来存储信息?
可以将额外的信息作为参数存储在类型本身中。访问此类数据非常方便,因为它是第一类数据,并且可以在接受参数类型参数的函数中使用。
第六章
为什么使用全局变量会影响性能?
全局变量是无类型的。每次使用时,编译器都必须生成可以处理可能遇到的所有数据类型的代码。因此,编译器不能生成高度优化的代码。
当无法用常量替换时,使用全局变量的良好替代方案是什么?
我们可以定义一个有类型的全局常量作为占位符。Ref类型也可以用来保存变量的单个值。因为Ref包含数据类型,编译器可以生成更优化的代码。
为什么数组结构体比结构体数组表现更好?
现代 CPU 可以并行执行许多数值计算。当内存对齐并打包成数组时,硬件缓存可以快速查找它们。结构体数组可能将对象散布在内存中,这会损害性能。
SharedArray的局限性是什么?
SharedArray只支持位类型。如果我们需要并行处理非位类型数据,那么就不能使用 SharedArrays。
除了使用并行进程之外,有什么是替代多核计算的方法?
一种替代方案是使用多线程功能。Julia 1.3 版本实现了一个支持多级并行的最先进的多线程调度器。
在使用缓存模式时,必须注意哪些问题?
缓存化以空间换取时间。使用缓存需要更多的内存空间。根据函数结果,它可能或可能不会影响应用程序的内存占用。如果系统中的内存已经受限,这可能不是最佳选择。
性能提升中屏障函数背后的魔法是什么?
当使用barrier函数时,编译器可以根据传递给函数的参数类型来专门化函数。即使参数类型不稳定,当遇到新类型时,也会自动编译一个新的专门化函数。
第七章
什么是输入耦合和输出耦合?
输入耦合表示有多少外部组件依赖于当前组件。相比之下,输出耦合表示当前组件依赖于多少外部组件。这些测量有助于确定当前组件与其他组件的耦合程度。
从可维护性的角度来看,双向依赖为什么不好?
双向依赖往往会引入混乱的意大利面条式代码。为了理解单个组件,开发者必须处理并理解它所使用和依赖的其他组件。
有什么简单的方法可以即时生成代码?
@eval宏可以用来生成代码。例如,它可以在for循环中使用,以便将变量插入到函数的定义中。结果是定义了多个函数,它们在代码结构和逻辑方面都相似。
代码生成的替代方案是什么?
有时,不需要代码生成。相反,开发者可以选择使用函数式编程技术,如闭包,来重用现有逻辑。代码生成可能会增加程序的大小,并使程序更难调试。因此,在深入代码生成技术之前,开发者考虑其他选项将是明智的。
何时以及为什么我们应该考虑构建一个特定领域的语言?
特定领域语言(DSL)通常用于编写特定领域内清晰且易于理解代码。例如,DifferentialEquations包允许开发者使用与相应数学方程非常相似的语法编写代码。由于语法友好,它允许开发者专注于数学建模而不是编码方面。
开发特定领域语言有哪些可用的工具?
MacroTools包提供了几个方便的宏,这些宏在编写宏和特定领域语言中非常有帮助。@capture宏允许用户执行模式匹配和解析源代码。prewalk和postwalk函数允许我们在抽象语法树中手术性地替换表达式。@capture和prewalk/postwalk的组合使其成为开发特定领域语言的一个非常强大的工具。
第八章
开发评估函数有哪些好处?
评估函数是向特定对象的用户提供官方 API 的绝佳方式。因此,底层实现与接口解耦。如果实现有任何变化,只要评估函数的契约保持不变,就不会对对象的用户产生任何影响。
有什么简单的方法可以阻止使用对象的内部字段?
最简单的方法是使用特殊的命名约定来阻止使用对象的内部字段。常用的约定是将下划线作为字段名称的前缀。如果程序员尝试使用该字段,那么他们会提醒自己该字段应该是私有的。
哪些函数可以作为属性接口的一部分进行扩展?
Base 包中有三个函数可以被扩展以提供特定功能,用于字段访问的点表示法。这些函数是 getproperty、setproperty! 和 propertynames。一个需要记住的重要点是,一旦这些函数被定义,所有直接的字段访问都必须改为 getfield 和 setfield! 以避免递归问题。
如何在捕获异常后从 catch-block 中捕获堆栈跟踪?
一旦捕获到异常,我们可以使用 catch_backtrace 函数来捕获异常被捕获之前的堆栈帧。然后我们可以将结果传递给 stacktrace 函数以检索 StackFrame 对象的数组。
如何避免对需要最佳性能的系统中的 try-catch 块的性能影响?
避免使用 try-catch 块的性能影响最好的方法是根本不使用它。我们应该找到其他处理异常的方法。例如,我们可以检查任何可能导致后续函数失败的条件。在这种情况下,我们可以主动处理这种情况。另一种选择是在循环外部捕获异常;因此,我们会在更高的级别处理异常。
使用 retry 函数有哪些好处?
retry 函数是一种自动重复可能失败的操作的绝佳方式。这样做可以确保重要任务能够得到保证完成,除非有其他类型的不可恢复异常。
我们如何隐藏模块内部使用的全局变量和函数?
我们可以使用 let-block,这样全局变量就被绑定在 let-block 中,而不是暴露在模块的全局作用域中。当需要将函数暴露给模块时,定义在 let-block 内部的函数可以被声明为全局。
第九章
可以使用哪种预定义的数据类型方便地创建新的单例类型?
内置的 Val 类型可以轻松地创建新的单例类型。Val 构造函数可以接受任何位类型值,并返回类型为 Val{X} 的单例,其中 X 是传递给构造函数的值。
使用单例类型分发的优点是什么?
使用单例类型分发,我们可以消除依赖于数据类型的条件语句。它还允许我们通过仅定义新函数来添加新功能,而无需修改现有函数。因为 Julia 原生支持分派,所以不需要创建任何自定义函数仅用于分派。
为什么我们要创建模拟器?
模拟器在自动化测试中非常有用。首先,如果一个函数需要连接到远程网络服务,那么始终连接到实时服务可能不方便,甚至可能成本高昂。在这种情况下,可以使用模拟器来代替服务。其次,模拟器可以被设计成测试所有正面和负面场景,以便将所需的测试包含在自动化测试过程中。
模拟和模拟之间的区别是什么?
模拟器关注的是状态验证,即在模拟器使用后被测试函数(FUT)的输出。另一方面,模拟关注的是行为验证,即模拟函数是如何被 FUT 使用的。一般来说,模拟也像模拟器一样包括状态验证。
组合性意味着什么?
组合性意味着函数可以组合起来创建更大的东西有多容易。可组合函数允许通过重用现有代码来构建应用程序。因为函数在 Julia 中是一等公民,所以只要函数只接受单个参数,它们就可以很容易地组合。
使用功能管道的主要约束是什么?
功能管道的主要约束是管道中参与的功能只能接受单个参数。需要多个参数的函数可以被转换为一个curried函数,这样高阶函数就可以参与管道。
功能管道有什么用途?
功能管道对于数据处理管道非常有用,特别是如果过程本质上是线性的。对于某些人来说,语法易于阅读。
第十章
一级盗版的风险和潜在好处是什么?
一级盗版指的是第三方功能被自定义实现重新定义的情况。风险在于自定义实现可能不符合第三方模块预期的合同。如果代码编写错误,系统可能会变得不稳定并崩溃。
由于二级盗版可能会出现什么问题?
二级盗版指的是在函数参数中不使用自己的类型扩展第三方功能的情况。这可能会出现问题,因为没有保证另一个依赖包也实现了二级盗版,可能与你的盗版函数冲突。结果可能是一个不稳定的系统。
三级盗版是如何引起麻烦的?
类型 III 盗用指的是一种情况,即第三方函数通过您的自定义类型进行了扩展,但目的不同。虽然函数定义使用自定义类型作为参数,但无法保证第三方模块不会因为鸭子类型而最终使用您的函数。因此,您的盗用函数泄漏到第三方模块中,导致意外结果。
在指定函数参数时,我们应该注意哪些问题?
在指定函数参数时,我们应该避免使参数类型过于狭窄。过于狭窄的参数限制了函数的可重用性。
使用抽象函数参数如何影响系统性能?
当使用抽象类型指定函数参数时,系统性能不受影响。Julia 总是根据传递给函数的类型来指定函数。因此,没有运行时开销。
使用抽象字段类型为复合类型时,系统性能如何受到影响?
当在复合类型的字段中使用抽象类型时,系统性能会受到负面影响。Julia 编译器必须在内存中存储这些对象的指针,因为它必须支持与这些字段相关的任何数据类型。因为必须解引用指针才能访问数据,所以系统性能可能会大幅下降。
第十一章
我们可以使用什么技术来实现抽象工厂模式?
为了实现抽象工厂模式,我们可以创建一个抽象类型的层次结构。然后,我们可以实现接受单例类型作为参数的具体函数。通过多分派,我们应该能够调用适合正确平台或环境的正确函数。
如何在多线程应用程序中避免单例被多次初始化?
为了避免单例的多次初始化,我们可以使用可重入锁来同步线程。第一个线程将能够获取锁并初始化单例,而其他线程应在初始化完成后等待。必须在初始化结束时释放锁。
实现观察者模式时,Julia 的哪个特性是必不可少的?
我们可以实现setproperty!函数,以便可以监控对象的字段的所有更新,并触发额外的操作。
我们如何使用模板方法模式来自定义操作?
我们可以设计模板函数,通过关键字参数接受自定义函数。关键字参数可以默认为标准实现,同时调用者也可以传递自定义函数。函数的预期接口应该有明确的文档说明。
我们如何制作适配器以实现目标接口?
我们可以通过创建一个新的类型来包装原始类型来制作一个适配器。然后,我们可以在新类型上实现预期的接口。使用委托模式,新类型可以通过将特定函数转发到原始类型来重用现有功能。
享元模式有哪些好处,我们可以使用什么策略来实现它?
使用享元模式时,由于对象是共享的,我们可以潜在地节省大量内存空间。一般技术是维护一个参考表,该表使用更紧凑的数据元素作为查找键。该键用于查找更占用内存的对象。
我们可以使用 Julia 的哪个特性来实现策略模式?
我们可以使用单例类型作为函数参数来实现策略模式。具有适当算法(策略)的函数在运行时通过多重分派自动选择。
第十二章
实现继承与行为子类型化有何不同?
实现继承允许子类从超类继承字段和方法。行为子类型化允许子类型继承为超类型定义的方法。
与实现继承相关的一些主要问题是什么?
实现继承是有问题的,因为有时,子类可能不想从超类继承字段,即使定义父子关系在逻辑上是有意义的。正如从正方形-矩形问题中所示,子类可能更加限制性,并移除功能,而不是在超类之上添加新功能。其次,实现继承受到脆弱基类问题的困扰,对超类的更改可能无意中修改了子类的行为。
什么是鸭式类型?
鸭式类型是一种动态特性,它允许在不进行强类型检查的情况下分发方法。只要函数遵循预期的接口合约,就可以分发函数。
方法参数的变异性是什么,为什么?
方法参数是协变的,因为它们与 Liskov 替换原则一致,该原则指出,定义为接受类型 S 的函数应该能够与 S 的任何子类型一起工作。
为什么在 Julia 中参数化类型是不变的?
在 Julia 中,参数化类型是不变的,这是一个非常实际的原因。类型参数明确地确定了底层容器的内存布局。当它是不变的,就有机会通过连续压缩存储数据来达到高性能,而不需要解引用指针。
对角线规则何时适用?
当类型变量在协变位置出现多次时,会应用对角线规则。当从不变位置(如参数化类型)明确确定相同的类型变量时,该规则存在例外。


浙公网安备 33010602011771号