作为第二语言的-Julia-指南-全-
作为第二语言的 Julia 指南(全)
原文:Julia as a Second Language
译者:飞龙
前言
前言
我从青少年时期开始编程,从包含魔法师和乌龟漫画的有趣书籍中学习。我阅读了杂志,上面教我如何制作自己的简单游戏或在屏幕上产生愚蠢的效果。我玩得很开心。
但当我上大学时,我的书开始讨论银行账户、余额、销售部门、雇员和雇主。我怀疑我的程序员生活是否会意味着穿上灰色西装,编写处理工资系统的代码。哦,恐怖!
至少有一半的同学对编程充满热情地厌恶。我无法责怪他们。为什么编程书籍一定要如此无聊、功能性和合理?
冒险和乐趣在哪里?乐趣被低估了。如果一本书让你学习并享受学习,谁会在意它是否愚蠢和有愚蠢的笑话?
这是我写这本书的一个原因。我想让读者享受学习编程——不是通过讲笑话,而是通过解决有趣且令人愉快的编程示例。
我向你保证,不会有模拟销售部门的例子。相反,我们将做一些像模拟火箭发射、假装用古老的罗马加密技术给军队指挥官发送秘密信息,以及其他许多事情。
我想要写这本书的第二个重要原因是人们总是问我,“Julia?难道它只是一种只适合科学和科学家的语言吗?” Julia 在这个领域取得了重大成功,这就是为什么今天的 Julia 社区充满了聪明人,他们正在解决难题,比如开发新药、模拟传染病传播、气候变化或经济。
但不是的,你不需要是天才或科学家才能使用 Julia。Julia 是一种神奇的 通用 编程语言,适合每个人!我不是科学家,我已经享受了超过 9 年的使用它。使用 Julia,你会发现你可以比过去更快、更优雅地解决问题。而且作为额外的甜点,计算密集型代码将运行得非常快。
致谢
这本书经历了多次变化。曾经,它是一本自出版的书。后来,偶然的机会让我与 Manning Publications 接触,我们决定合作出版我的书。当时,我没有意识到自己将要投入多少工作。在我心中,我只是对现有的书进行一些小的修订,但根据我收到的所有反馈,我意识到我需要进行很多修订。
有时候我觉得要放弃。然而,尽管困难重重,我相信 Manning 为我们作者建立的大量辅助系统帮助我创作了一本显著更好的书。为此,我必须感谢 Nicole Butterfield,是她让我签约 Manning。我有两位 Manning 编辑:Lesley Trites,在本书早期阶段,以及 Marina Michaels,她凭借丰富的经验和稳健的手法帮助我完成了最后冲刺。我想对 Milan Ćurčić 表示感谢,他是我的技术发展编辑,他在确定材料是否对我的目标受众易懂(或不)方面提供了大量反馈。我的校对员 Christian Berk 作为非母语英语使用者对我非常宝贵,他纠正了我可能写下的任何古怪结构或语法错误。
此外,我想感谢在本书开发过程中不同阶段阅读我的手稿并提供宝贵反馈的审稿人:Alan Lenton、Amanda Debler、Andy Robinson、Chris Bailey、Daniel Kenney、Darrin Bishop、Eli Mayost、Emanuele Piccinelli、Ganesh Swaminathan、Geert Van Laethem、Geoff Barto、Ivo Balbaert、Jeremy Chen、John Zoetebier、Jonathan Owens、Jort Rodenburg、Katia Patkin、Kevin Cheung、Krzysztof Jȩdrzejewski、Louis Luangkesorn、Mark Thomas、Maura Wilder、Mike Baran、Nikos Kanakaris、Ninoslav Čerkez、Orlando Alejo Méndez Morales、Patrick Regan、Paul Silisteanu、Paul Verbeke、Samvid Mistry、Simone Sguazza、Steve Grey-Wilson、Timothy Wolodzko 和 Thomas Heiman。
特别感谢 Maurizio Tomasi,技术校对员,在本书进入生产前,他仔细地再次审查了代码。最后,感谢 Julia 的创造者。你们创造了面向未来的编程语言,我相信它将改变计算机行业。这听起来可能有些夸张,但我确实相信 Julia 是编程语言演变中的一个重要里程碑。
关于本书
《Julia 作为第二语言》是面向软件开发者的 Julia 编程语言入门。它不仅涵盖了语言的语法和语义,还试图通过在基于读取-评估-打印循环(REPL)的环境中进行大量交互式编码,教会读者如何像 Julia 开发者一样思考和操作。
谁应该阅读这本书?
《Julia 作为第二语言》是为对 Julia 编程语言感兴趣但可能没有科学或数学背景的开发者所写。对于想要探索数据科学或科学计算的人来说,本书也是一个很好的起点,因为 Julia 是为这类工作设计得非常好的语言。然而,这并不排除其他用途。任何希望用现代、高性能的语言编程以提高生产力的开发者都将从这本书中受益。
本书是如何组织的
本书分为五个部分,共包含 18 章。
第一部分涵盖了语言的基本知识。
-
第一章解释了 Julia 是什么样的语言,为什么它被创建,以及使用 Julia 编程语言的优势。
-
第二章讨论了在 Julia 中处理数字,展示了如何使用 Julia 的 REPL 环境作为一个非常复杂的计算器。
-
第三章通过实现三角函数和计算斐波那契数来解释控制流语句,如 if 语句、while 循环和 for 循环。
-
第四章解释了如何使用数组类型处理数字集合。读者将通过一个涉及比萨销售数据的示例来学习。
-
第五章是关于处理文本的。本章将指导你如何使用颜色制作漂亮的比萨销售数据展示,以及读取和写入比萨数据到文件。
-
第六章讨论了如何使用字典集合类型实现将罗马数字转换为十进制数字的程序。
第二部分更详细地介绍了 Julia 的类型系统。
-
第七章解释了 Julia 中的类型层次结构以及如何定义自己的复合类型。这是最重要的一章,因为它还解释了多重分派,这是 Julia 中最重要和独特的特点之一。
-
第八章介绍了一个火箭仿真代码示例,我们将在接下来的几章中使用它。本章的重点是定义不同火箭部件的类型。
-
第九章通过构建一个处理不同温度单位的代码示例,深入探讨了 Julia 中的数值转换和提升。本章有助于巩固对 Julia 多重分派系统的理解。
-
第十章解释了如何在 Julia 中表示不存在、缺失或未定义的对象。
第三部分回顾了第一部分中涵盖的集合类型,如数组、字典和字符串,但这次更深入地探讨了细节。
-
第十一章详细介绍了字符串,包括 Unicode 和 UTF-8 在 Julia 中的使用以及它们对字符串使用的影响。
-
第十二章解释了所有 Julia 集合共有的特性,例如遍历元素和构建自己的集合。
-
第十三章通过几个代码示例展示了如何在许多类型的应用程序中使用集合和集合操作来组织和搜索数据。
-
第十四章展示了如何处理和组合不同维度的数组,例如向量和矩阵。
第四部分专注于在多个级别组织代码的方法,包括从函数级别到包、文件和目录的模块化。
-
第十五章深入探讨了在 Julia 中使用函数,重点介绍了函数式编程与面向对象编程的区别。
-
第十六章是关于将代码组织到模块中,使用第三方包以及创建自己的包与他人共享代码的。
第五部分深入探讨了在缺乏前几章作为基础的情况下难以解释的细节。
-
第十七章基于第五章。通过阅读和将火箭引擎写入文件、套接字和管道(CSV 格式),你将深入了解 Julia 的 I/O 系统。
-
第十八章解释了如何定义参数化数据类型以及为什么参数化类型对性能、内存使用和正确性有益。
关于代码
本书包含许多源代码示例,无论是编号列表还是与正常文本并列。在这两种情况下,源代码都使用固定宽度字体进行格式化,如下所示。许多列表旁边都有代码注释,突出显示重要概念。
在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新调整了缩进,以适应书中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续续标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。
你编写的代码大多是在 Julia REPL(读取-评估-打印循环)环境或 Unix shell 中。在这些情况下,你会看到一个提示符,例如 julia>、shell>、help?> 或 $。当你输入时,这些提示符不应该被包含。然而,如果你将代码示例粘贴到终端窗口中,Julia 通常能够过滤掉这些提示符。
旨在写入文件的代码通常不会显示提示符。但是,如果你喜欢,通常可以将此代码粘贴到 Julia REPL 中。
你可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为 livebook.manning.com/book/julia-as-a-second-language。本书中示例的完整代码可以从 Manning 网站 www.manning.com/books/julia-as-a-second-language 下载,也可以从 GitHub github.com/ordovician/code-samples-julia-second-language 下载。
建议使用 Julia 版本 1.7 或更高版本来运行本书中的示例代码。
liveBook 讨论论坛
购买 Julia as a Second Language 包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,你可以对整本书或特定章节或段落附加评论。为自己做笔记、提问和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/julia-as-a-second-language/discussion。你还可以了解更多关于 Manning 论坛和行为准则的信息,网址为 livebook.manning.com/discussion。
曼宁对读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议你尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。
其他在线资源
需要更多帮助?Julia 语言有一个活跃的 Slack 工作空间/社区,拥有超过 10,000 名成员,其中许多成员可以实时与你沟通。有关注册信息,请访问julialang.org/slack。
-
Julia Discourse (
discourse.julialang.org)是关于 Julia 相关问题的首选之地。 -
Julia 社区页面提供了有关 YouTube 频道、即将举行的 Julia 活动、GitHub 和 Twitter 的信息。
-
Julia 语言和标准库的官方文档可以在
docs.julialang.org/en/v1/找到。
关于作者

埃里克·恩海姆(Erik Engheim)是一位作家、会议演讲者、视频课程作者和软件开发者。他在挪威的油气行业中,大部分职业生涯都在开发用于储层建模和模拟的 3D 建模软件。埃里克还担任了多年的 iOS 和 Android 开发者。自 2013 年以来,埃里克一直在使用 Julia 编程,并撰写和制作关于 Julia 的视频。
关于封面插图
《Julia 作为第二语言》封面上的图像是“Paysanne Anglaise”,或“英国农妇”,取自雅克·格拉塞·德·圣索沃尔(Jacques Grasset de Saint-Sauveur)的作品集,该作品集于 1788 年出版。每一幅插图都是手工精细绘制和着色的。
在那些日子里,仅凭人们的着装就可以轻易地识别出他们的居住地以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过如这一系列图片般的生活化重现。
第一部分 基础
这些章节涵盖了 Julia 的基础知识。后续章节将在此基础上扩展讨论。你将学习如何处理数字、数组、if 语句、for 循环、文本字符串、基本输入输出以及从字典中存储和检索数据。文本的后续部分将更深入地讨论这些主题。
1 为什么选择 Julia?
本章涵盖
-
Julia 解决的问题类型
-
快速、动态类型语言的优点
-
Julia 如何提高程序员的生产力
你可以从数百种编程语言中选择——其中许多比 Julia 更受欢迎。那么,为什么选择 Julia 呢?
你希望比以前更快地编写代码吗?用你通常需要的代码行数的一小部分来构建系统怎么样?当然,这样的生产力可能会以糟糕的性能和高内存消耗为代价。不,实际上,Julia 是下一代气候模型的首选语言,这些模型对性能和内存有极端的要求。
我知道这样的赞誉可能听起来像二手车销售员的糟糕推销,但不可否认的是,Julia 在许多方面是一种革命性的编程语言。你可能会问:“如果 Julia 如此出色,那么为什么不是每个人都使用它?为什么还有这么多人还在使用 C 编程语言?”熟悉度、包、库和社区都很重要。在大组织中建立起来的关键任务软件并不是一时兴起就放弃的。
在阅读这本书的许多人可能并不关心拥有更高效、更有生产力的编程语言。相反,你关心的是你可以用它构建什么。简单的答案是:任何东西。Julia 是一种通用编程语言。
这可能不是一个令人满意的答案。原则上,你也可以用 JavaScript 构建任何东西。然而,你知道 JavaScript 主导着前端 Web 开发。你也可以用 Lua 编写任何东西,但它主要被用作计算机游戏的脚本语言。你阅读这本书的主要兴趣可能在于 Julia 能为你带来什么样的工作。
目前,Julia 社区在科学计算、数据科学和机器学习方面最为强大。但学习 Julia 也是一种对未来的赌注。具有如此强大能力的语言不会局限于一个小众领域。如果你继续阅读,将会更清楚地了解 Julia 是什么以及它为什么具有如此大的潜力。我还会涵盖 Julia 不理想的应用领域。
1.1 什么是 Julia?
Julia 是一种通用、多平台的编程语言,它是
-
适合数值分析和计算科学
-
动态类型
-
高性能和即时编译
-
使用自动内存管理(垃圾回收)
-
可组合
这听起来很多,其中一些东西似乎相互矛盾。那么,Julia 如何成为一个通用语言,同时又针对数值编程进行了定制?它之所以是通用的,是因为,像 Python 一样,Julia 可以用于几乎任何东西。它之所以适合数值编程,是因为,像 MATLAB 一样,它非常适合数值编程。但它并不局限于数值编程;它也适合其他用途。通过“可组合”我指的是 Julia 使得表达许多面向对象和函数式编程模式变得容易,从而促进代码重用。
1.1.1 静态类型和动态类型语言的优缺点
让我们关注 Julia 的一个方面:它是动态类型的事实。通常,编程语言被分为两大类:
-
动态类型
-
静态类型
在静态语言中,表达式有类型;在动态语言中,值有类型。
——Julia 创始人 Stefan Karpinski
静态类型语言的例子有 C/C++、C#、Java、Swift、Go、Rust、Pascal 和 Fortran。在静态类型语言中,在程序允许运行之前,对所有的代码执行类型检查。
动态类型语言的例子有 Python、Perl、Ruby、JavaScript、MATLAB 和 LISP。动态类型语言在程序运行时执行类型检查。不幸的是,动态类型语言往往非常慢。
在动态语言中,数值、字符和字符串等值都附有标签,表明它们的类型。这些标签允许使用动态类型语言的程序在运行时检查类型正确性。
Julia 很特别,它既是一种动态类型语言,又具有高性能。对许多人来说,这似乎是一种矛盾。Julia 的这一独特特性得以实现,是因为该语言被明确设计用于即时编译(JIT),并且对所有函数调用使用了一种称为 多分派 的特性。C/C++ 和 Fortran 等语言使用编译前(AOT)编译。编译器在程序运行之前将整个程序翻译成机器代码。其他语言,如 Python、Ruby 和 Basic,使用解释器。在解释性语言中,程序读取每一行源代码并在运行时解释它以执行给定的指令。现在你已经了解了 Julia 是什么类型的语言,我可以开始讨论 Julia 的吸引力了。
语言设计和 JIT 编译
原则上,编程语言与其运行方法解耦。然而,你会发现我谈论 Julia 时将其称为 JIT 编译语言,而将 Fortran 称为 AOT 编译语言。严格来说,这是不准确的。例如,Julia 也可以通过解释器运行。然而,大多数语言都是为特定形式的执行而设计的。Julia 是为 JIT 编译而设计的。
1.2 Julia 结合了优雅、生产力和性能
虽然 performance 是 Julia 的关键卖点之一,但当我 2013 年第一次发现它时,吸引我注意的是它如何考虑周到、功能强大且易于使用。我有一个程序,我用几种不同的语言重写了它,以比较每种语言的表述性、易用性和生产力。使用 Julia,我成功地制作出了这个代码最优雅、紧凑和易于阅读的版本。从那时起,我尝试了许多编程语言,但从未接近我在 Julia 中所取得的成就。以下是一些体现 Julia 表述性的单行代码示例。
列表 1.1 Julia 单行代码示例
filter(!isempty, readlines(filename)) # strip out empty lines
filter(endswith(".png"), readdir()) # get PNG files
findall(==(4), [4, 8, 4, 2, 5, 1]) # find every index of the number 4
自 1990 年代以来,我有过编程已经足够多的时期;Julia 帮助我重新找回了编程的乐趣。部分原因在于,一旦你掌握了 Julia,你会觉得你有一个工具箱中的语言,它作为团队的一员而不是对立面来工作。我认为我们很多人都有过这样的经历:我们有一个很好的解决方案,但我们使用的语言却在阻碍我们。语言的限制迫使我们一个接一个地添加黑客技巧。有了 Julia,我可以按照自己的意愿构建软件,而不会遇到语言的障碍。
另一个有助于提高你的生产力和乐趣的方面是,Julia 附带了一个丰富的标准库。你可以立即上手。你可以在不四处寻找库的情况下完成很多事情。无论你想进行线性代数、统计学、HTTP 还是字符串操作,或者你想处理不同的日期格式,Julia 都能满足你的需求。而且,如果你需要的功能不在标准库中,Julia 有一个紧密集成的包管理器,使得添加第三方库变得轻而易举。用 Julia 编程几乎会让你感到内疚或被宠坏了,因为你可以在不牺牲性能的情况下构建丰富而优雅的抽象。
Julia 的另一个基本优势是它易于学习。这种学习的便利性可以帮助 Julia 随着时间的推移获得更大的社区。要了解为什么 Julia 容易学习,可以考虑用 Julia 编写的著名Hello world程序:
print("Hello world")
当运行此代码时,它会在屏幕上写入文本Hello world。虽然这很简单,但许多语言都需要很多复杂的框架才能完成这样的事情。以下是一个执行相同操作的 Java 程序:
public class Main {
public static void main(String[] args) {
System.out.print("hello world");
}
}
这会让初学者一次性接触到很多概念,可能会感到不知所措。Julia 更容易学习,因为你可以一次专注于学习一个概念。你可以学习编写一个函数,而无需看到类型定义。由于提供了许多开箱即用的功能,你甚至不需要知道如何导入外部库就能编写有用的代码。
1.3 为什么创建 Julia
要真正理解 Julia 能为桌面带来什么,你需要更好地理解它最初为什么被创建。Julia 编程语言的创造者想要解决他们所说的两种语言问题。
这个问题指的是很多软件都是用两种不同的编程语言编写的,每种语言都有不同的特性。在科学领域,机器学习和数据分析动态语言通常更受欢迎。然而,这些语言通常性能不足。因此,解决方案通常需要用性能更高、静态类型化的语言重写。但为什么会有这种偏好呢?为什么不用传统的、高性能的静态类型语言来编写一切呢?
1.3.1 科学家需要动态类型语言提供的交互式编程
科学家开始用 Fortran1 编写软件,包括大型天气模拟,以及用 C 或 C++编写神经网络 2。3 这些语言提供了解决这些大规模问题所需的那种性能。然而,这些语言也有代价。它们往往很僵化、冗长且缺乏表现力——所有这些都降低了程序员的效率。
然而,基本问题是这些语言不适合交互式编程。我指的是什么?交互式编程是编写代码并获得即时反馈的能力。
交互式编程在数据科学和机器学习中非常重要。在典型数据分析过程中,开发者通过将大量数据加载到交互式编程环境中来探索数据。然后开发者对这份数据执行各种分析。这些分析可能包括寻找平均值和最大值或绘制直方图。第一次分析的结果告诉程序员下一步应该做什么。
图 1.1 展示了在动态类型语言中这个过程。你首先运行代码,加载数据,然后你可以观察它。然而,在你更改代码后,你不必再次经历整个过程。你可以更改代码并立即观察变化。你不需要再次加载大量数据。

图 1.1 在动态类型语言中,你可以在编码和观察之间来回切换。大型数据集不需要重新加载到内存中。
让我们对比一下使用静态类型语言(如 Fortran、C/C++或 Java)的经验。4 开发者会编写一些代码来加载数据并探索它,而不知道数据看起来像什么。然后他们必须等待程序执行以下操作:
-
编译
-
启动,然后加载大量数据
此时开发者会看到数据的图表和统计数据,这为他们提供了选择下一步分析所需的信息。但选择下一步分析将需要再次重复整个循环。在每次迭代中都必须重新加载数据的大块。这使得每次迭代都极其缓慢,从而减慢了整个分析过程。这是一种静态的、非交互式的编程方式(图 1.2)。

图 1.2 静态类型语言需要重复整个循环。
1.3.2 其他领域的开发者也需要动态类型语言提供的交互性
这个问题并不仅限于科学家;游戏开发者长期以来一直面临相同的问题。游戏引擎通常是用 C 或 C++这样的语言编写的,可以编译成快速的机器代码。软件的这一部分通常执行众所周知且定义良好的任务,例如在屏幕上绘制对象并检查它们是否相互碰撞。
就像数据分析师一样,游戏开发者有很多代码,需要多次迭代才能满意地工作。具体来说,开发良好的游戏玩法需要大量的实验和迭代。必须调整和修改游戏角色的代码。地图或级别的布局必须反复实验才能正确。因此,几乎所有的游戏引擎都使用第二种语言,允许即时更改代码。通常,这种语言是 Lua、^(5) JavaScript 和 Python.^(6)
使用这些语言,可以更改游戏角色和地图的代码,而无需重新编译和重新加载地图、级别和角色。因此,可以实验游戏玩法,暂停,进行代码更改,并立即继续使用新的更改。
机器学习专业人士面临着类似的挑战。他们构建预测模型,例如神经网络,并将大量数据输入到模型中进行训练。这既是科学也是艺术。正确地完成它需要实验。如果你每次修改模型时都需要重新加载训练数据,这将减慢开发过程。因此,像 Python、R 和 MATLAB 这样的动态类型语言在科学界变得非常流行。
然而,由于这些语言速度不快,它们通常与 Fortran 和 C/C++这样的语言配对以获得良好的性能。用 TensorFlow^(7)或 PyTorch^(8)制作的神经网络由用 C/C++编写的组件组成。Python 用于安排和连接这些组件。因此,在运行时,可以使用 Python 重新排列这些组件,而无需重新加载整个程序。
气候和宏观经济模型可能首先在动态语言中开发,并在开发过程中在小数据集上进行测试。一旦模型完成,许多组织会雇佣 C/C++或 Fortran 开发者将解决方案重写为高性能语言。因此,这增加了一个额外的步骤,使开发过程复杂化并增加了成本。
1.4 Julia 的高性能解决了两种语言的问题
Julia 的创建是为了解决需要使用两种语言的问题。它使得将动态类型语言的灵活性与静态类型语言的性能结合起来成为可能。这就是为什么以下说法已经获得了一些流行度:
Julia 像 Python 一样走路,像 C 一样奔跑。
——Julia 社区流行说法
使用 Julia,许多领域的开发者可以写出与 Python、Ruby、R 和 MATLAB 等语言具有相同生产力的代码。正因为如此,Julia 对行业产生了深远的影响。在 2019 年 7 月的《自然》杂志中,对多位科学家进行了关于他们使用 Julia 的访谈。
例如,墨尔本大学通过将计算模型从 R 迁移到 Julia,实现了 800 倍的性能提升。材料科学加州理工学院(Caltech)的简·赫尔里曼报告说,自从将她的 Python 代码重写为 Julia 后,运行速度提高了十倍。
你可以在一个小时内完成的事情,否则可能需要几周或几个月。
——迈克尔·斯特普夫
在 2019 年国际超级计算会议(SC19)上,Julia 的创造者之一艾伦·埃德尔曼回忆了一个麻省理工学院(MIT)的研究小组如何将他们的一部分 Fortran 气候模型重写为 Julia。他们事先决定可以容忍代码速度降低 3 倍。在他们看来,这是为了获得一个具有更高生产力的高级语言而可以接受的权衡。然而,通过切换到 Julia,他们反而获得了 3 倍的速度提升。
这些只是今天关于 Julia 如何革命性地改变科学计算和通用高性能计算的许多故事中的一部分。通过避免双语言问题,科学家可以比以前工作得更快。
1.5 Julia 适合每个人
这些故事可能会给人一种错误的印象,即 Julia 是一种适合穿白大褂的学霸的语言。但事实并非如此。实际上,使 Julia 成为科学家优秀语言的许多特质,也使其对其他人来说是一个极好的语言。Julia 提供
-
强大的模块化和代码重用功能。
-
严格的类型系统,有助于在代码运行时捕捉到错误。
-
一个用于减少重复样板代码(元编程^(9)的复杂系统。
-
一个丰富且灵活的类型系统,允许你模拟各种各样的问题。
-
一个装备齐全的标准库和各种第三方库,用于处理各种任务。
-
优秀的字符串处理功能。这种能力通常是任何瑞士军刀式编程语言的关键卖点。这也是最初使 Perl、Python 和 Ruby 等语言流行的原因。
-
与各种其他编程语言和工具的简单接口。
虽然 Julia 的最大卖点在于解决了双语言问题,但这并不意味着与现有的 Fortran、C 或 C++代码的接口需求得到了缓解。解决双语言问题的目的是避免每次遇到性能问题时都必须编写 Fortran 或 C 代码。你可以全程使用 Julia。
然而,如果有人已经用另一种语言解决了你遇到的问题,那么你可能没有必要从头开始在 Julia 中重写那个解决方案。Python、R、C、C++ 和 Fortran 拥有经过多年构建的大型包,Julia 社区不可能一夜之间取代它们。为了提高生产力,Julia 开发者需要能够利用现有的软件解决方案。
从长远来看,将遗留软件迁移到 Julia 有一个明显的优势。维护旧的 Fortran 库通常需要比维护 Julia 库更多的开发者工作量。
最大的好处可能是 Julia 提供的组合能力。有一些类型的问题需要构建大型单体库。相比之下,Julia 极其适合构建小型库,这些库可以轻松组合以匹配其他语言中大型单体库提供的功能。让我举一个例子。
机器学习是一个热门话题,它推动了自动驾驶汽车、人脸识别、语音识别和其他许多创新技术。最著名的机器学习包是 PyTorch 和 TensorFlow。这些包是巨大的单体,由大型团队维护。它们之间没有代码共享。Julia 有许多机器学习库,如 Knet、Flux(见 fluxml.ai)和 Mocha(见 mng.bz/epxG)。与它们相比,这些库非常小巧。为什么?因为 PyTorch 和 TensorFlow 的功能可以通过在 Julia 中结合多个小型库来实现。更深入地解释为什么这有效是一个复杂的话题,需要更深入地了解 Julia 以及神经网络库的工作原理。
拥有许多小型库对于通用应用来说是一个优势。任何构建软件的人都将从能够以多种新方式重用现有软件组件的能力中受益,而不是不得不重新发明轮子。在传统的编程语言中,人们经常需要反复实现相同的功能。例如,TensorFlow 和 PyTorch 就有大量的重复功能。Julia 通过在许多机器学习库之间共享库来避免重复,随着你阅读本书的章节,将越来越清楚地了解 Julia 如何实现这一点,以及为什么这种能力在许多其他语言中很难实现。
1.6 我可以用 Julia 做些什么?
在原则上,你可以使用 Julia 来构建任何东西。然而,每种语言都有其生态系统和社区,可能会推动你向某些类型的开发倾斜,而不是其他类型。Julia 也不例外。
1.6.1 Julia 在科学领域
Julia 在科学领域有着强大的影响力。例如,它被用于
-
计算生物学
-
统计学
-
机器学习
-
图像处理
-
计算微积分
-
物理学
但 Julia 涵盖了许多更多领域。例如,它在能源交易中被使用。美国联邦储备银行使用它来构建复杂的宏观经济模型。诺贝尔奖获得者托马斯·J·萨金特创立了 QuantEcon,这是一个利用 Julia 和 Python 推动定量经济学教学的平台。他是 Julia 的强烈支持者,因为用其他编程语言解决宏观经济中的大问题将会很困难。在与卢卡斯·比沃尔德的访谈中,谷歌著名的人工智能(AI)研究员彼得·诺维格表达了他认为机器学习世界将极大地从转向 Julia 中受益的观点。
如果 Julia 成为人工智能的主要语言,我会更高兴。
——彼得·诺维格,《人工智能:一种现代方法》的作者
生命科学是 Julia 另一个明显的应用领域。到 2025 年,每年将收集 2-40 兆字节的人类基因组数据。大多数主流软件都无法处理如此规模的数据。你需要一种高性能的语言,如 Julia,它可以在各种硬件上以最高性能处理各种格式。
在撰写本章时,COVID-19 仍然是世界上的一个重大挑战。Julia 包 Pathogen 用于模拟传染病,并被 COVID-19 研究人员使用。
1.6.2 Julia 的非科学用途
关于它的非科学用途?Julia 也有许多用于其他兴趣的包:
-
Genie——一个全栈 MVC 网络框架
-
Blink——用于在 Julia 中创建 Electron GUI 应用程序
-
GTK——用于使用流行的 Linux GUI 工具包 GTK 制作 Julia GUI 应用程序
-
QML——用于使用 Qt GUI 工具包中使用的 QML 标记语言创建跨平台 GUI
-
GameZero——用于初学者游戏开发
-
Luxor——用于绘制矢量图像
-
Miletus——用于编写金融合约
-
TerminalMenus——用于在终端中允许交互式菜单
-
Gumbo——用于解析 HTML 页面
-
Cascadia——一个用于网络爬取的 CSS 选择器库,用于从网页中提取有用信息
-
QRCode——用于创建流行的广告 QR 码图像,以显示可读 URL
如你所见,Julia 有用于通用编程的包。
1.7 Julia 不太理想的应用场景
原则上,Julia 可以用于几乎任何事情,但作为一个年轻的语言,意味着在各个领域的库选择并不在所有领域都同样全面。例如,网络开发包的选择有限。使用 Julia 构建类似移动应用这样的东西可能不会很好。它也不适合小型、短运行脚本——这类脚本你通常会在 Bash、Python 或 Ruby 中编写。这些限制是由于 Julia 是即时编译的。
这意味着 Julia 程序的启动速度比 Python 或 Bash 程序慢,但一旦 JIT 编译器将代码的关键部分转换为机器代码,它们就会开始运行得更快。Julia 社区正在进行一项持续的努力来减少这个问题,并且有无数种方法可以解决这个问题。解决方案包括更好地缓存之前的 JIT 编译,以及更谨慎地选择何时进行 JIT 编译。
Julia 也不适合实时系统。在实时系统中,软件必须对现实世界发生的事情做出反应。你可以将此与,例如,天气模拟器进行对比。在天气模拟器中,计算机运行模拟时外部世界发生的事情并不重要。
然而,如果你的程序必须以每毫秒处理来自测量仪器的数据,那么你不能有突然的痉挛或延迟。否则,你可能会丢失重要的测量数据。Julia 是一种垃圾回收语言。这意味着程序中不再使用的数據会自动回收用于其他目的。确定要回收的内存的过程往往会引入程序执行中的小随机延迟和痉挛。
这个问题不容忽视。需要实时行为的机器人学正在使用 Julia 进行。麻省理工学院的学者们已经模拟了 Boston Dynamics Atlas 人形机器人平衡在平坦地面上的实时控制,这是为了证明可以通过调整内存的分配和释放来使用 Julia 进行机器人的在线控制。
Julia 不适合内存有限的嵌入式系统。原因是 Julia 通过创建相同代码的高度专业化版本来实现高性能。因此,Julia 中代码本身的内存使用量会比 C、C++ 或 Python 高。
最后,就像 Python、Ruby 和其他动态语言一样,Julia 也不适合典型的系统编程,如数据库系统或操作系统内核的开发。这些任务通常需要详细控制资源使用,而 Julia 并不提供这些功能。Julia 是一种面向易用性的高级语言,这意味着许多关于资源使用的细节都被抽象化了。
1.8 本书你将学到什么
如果你已经在另一种语言中编程,这本书适合你。每种编程语言都有其独特的一套特性、工具和社区。在这本书中,我专注于 Julia 作为语言的独特特性,以及围绕 Julia 建立的工具和编程社区,包括以下关键方面:
-
使用读取-评估-打印循环(REPL)进行交互式编程^(10)
-
以科学和数学为导向的代码示例
-
Julia 独特的 多重分派 功能和类型系统
-
函数式编程及其与面向对象编程的比较
-
以包为导向的开发优于以应用为导向的开发
Julia 的基于 REPL 的开发意味着你可以启动 Julia 命令行工具并开始输入 Julia 表达式,当你按下 Enter 键时,这些表达式将被评估:
julia> reverse("abc")
"cba"
julia> 3+5
8
我在本书的大部分内容中遵循这种方法;对于来自 C/C++、Java 和 C#等语言的读者来说,这可能会感到陌生,但在 Julia 社区中,这种开发风格通常很受欢迎。REPL 环境被用于实验、测试和调试。
由于 Julia 在数据科学、机器学习、数学和科学领域被广泛使用,我在本书中使用了大量以科学和数学为导向的示例,例如计算正弦值或模拟火箭发射,而不是构建网站或库存系统。我在本文中的数学内容保持在高中水平。
在本书中,你将找到对 Julia 的多重分派系统和类型系统的深入探讨。这些系统之所以重要,是因为它们是 Julia 实现如此高性能的关键原因。由于许多 Julia 初学者对这些系统感到困惑,我在这些主题上进行了更详细的阐述。
由于软件行业仍然由面向对象的编程语言主导,跳入 Julia 的更函数式编程风格可能会让人感到困惑。因此,我专门留出空间来展示相同的问题可以用函数式和面向对象的方式解决。本书中使用了许多首选的函数式编程实践。
在阅读本书的过程中,你不会看到很多应用程序的制作——也就是说,点击图标即可启动的那种。你也不会看到在控制台中可以运行的 Julia 制作的命令行工具。对于 Ruby 和 Python 开发者来说,这种选择可能会感到新颖,他们非常习惯于将软件作为命令行工具来构建。
相反,Julia 社区非常注重包。他们鼓励你构建包而不是独立的应用程序,因为这些包更容易与他人共享和重用。这种偏好反映在 Julia 的工具链和包管理器中。Julia 不会阻止你构建应用程序,但本书将帮助你形成以包为先的思维模式。首先构建一个包,然后将其转化为应用程序。
包导向的思维模式在 Julia 的工具交付方式中也很明显。包管理器和调试器是通过将特定的包加载到 Julia 交互式环境中并在此处发出命令来处理的,而不是在 shell 中。这种工作方式可能对 MATLAB 和 R 用户来说很熟悉。在这两种语言中,人们往往更关注包而不是应用程序。
使用 Julia 的典型统计学家、科学家或数据分析师可能会将他们偏好的包加载到他们的 Julia 环境中,并执行 Julia 命令,而不是点击使用 Julia 制作的某些应用程序。Julia 的 REPL 通常是大多数 Julia 工作流程的一个组成部分。
摘要
-
静态类型使构建高性能编程语言和捕捉程序运行前类型错误变得更容易。
-
动态类型使得创建高度交互式的编程语言成为可能。对于需要快速迭代的编程,这是一个优势。
-
科学代码的开发通常需要能够轻松地在大型数据集上进行实验的能力。这需要动态类型语言提供的交互式编程。
-
科学代码通常需要高性能,而动态类型语言通常无法提供。
-
Julia 通过提供高性能、动态类型语言来解决两种语言问题。这种能力推动了 Julia 在性能要求高的领域(如气候建模、天文学和宏观经济模拟)的应用。
-
Julia 不仅限于科学,而且也是一种优秀的通用编程语言。
(1.)Fortran 是一种用于科学计算的古老语言。
(2.)神经网络是一种受人类大脑工作原理启发的算法。
(3.)C 和 C++是系统编程中广泛使用的相关静态类型语言。
(4.)Java 被广泛应用于许多网络服务器软件和 Android 手机。
(5.)Lua 最初被设计为一种配置语言,但如今主要用于编写游戏。
(6.)Python 是目前最受欢迎的数据科学和机器学习语言之一。
(7.)TensorFlow 是一个流行的 Python 机器学习库和平台。
(8.)PyTorch 是一个流行的 Python 机器学习框架。
(9.)元编程是编写代码的代码。这是一个本书未涉及的高级概念。
(10.)REPL 指的是一种编程语言的交互式命令行。
2 Julia 作为计算器
本章涵盖
-
处理整数、浮点数和分数
-
使用变量存储长数字
-
通过定义函数创建可重用的计算
-
Julia 中最基本的类型
即使你最终没有将 Julia 作为你的主要语言,你也可能仍然将其视为桌面计算器的替代品。Julia 甚至可以充当高级高中图形计算器(图 2.1)。作为额外的好处,它是完全免费的。

图 2.1 复古科学计算器。我们能否用 Julia REPL 来替代手持计算器的使用?
记住,你必须先学会走路,然后才能跑步,探索数字是了解 Julia 核心概念的好方法。由于 Julia 不仅仅是一种通用编程语言,而且专门针对数值计算进行了定制,因此在 Julia 中操作数字扮演着独特的角色。
在本章中,你将了解 Julia 的各个方面,让你能够在 Julia 中做与使用计算器相同的事情。当然,你可能反对你并不打算将 Julia 用作计算器,但这仅仅是为了给你打下理解更复杂主题的基础。
2.1 Julia 命令行
如果你已经正确安装并配置了 Julia(见附录 A),你可以在终端提示符中输入 Julia 以启动 Julia REPL。这个交互式命令行程序读取你的输入,就像计算器一样,并在你按下回车键后立即打印出结果。REPL 是一个测试你的代码、查找文档和安装第三方软件的地方。
在本章中,你将专注于在 REPL 中评估数学表达式。下一个代码示例演示了如何从终端(控制台)应用程序启动 Julia 命令行。启动后,输入 2 + 3 并按回车键。Julia 评估这个表达式并打印出 5:
$ julia
_
_ _ _(_)_ | Documentation: https:/ /docs.julialang.org
(_) | (_) (_) |
_ _ _| |_ __ _ | Type "?" for help, "]?" for Pkg help.
| | | | | | |/ _` | |
| | |_| | | | (_| | | Version 1.6.0 (2021-03-24)
_/ |\__'_|_|_|\__'_| | Official https:/ /julialang.org/ release
|__/ |
julia> 2 + 3
5
你可以执行比加两个一位数更复杂的操作。在下一个示例中,你将执行一些非常常见的数学运算,包括求对数、正弦和平方根。rand 是一个数学函数,其结果是在 0 和 1 之间的随机数:
julia> 4 * 5
20
julia> (1.5 + 3.5)*4 ❶
20.0
julia> 2³ ❷
8
julia> log(2.71828⁴) ❸
3.999997309389128
julia> sin(3.1415926/2) ❹
0.9999999999999997
julia> sqrt(9) ❺
3.0
julia> rand() ❻
0.14765146459147327
❶ 复杂表达式可以用括号嵌套。
❷ 指数;求 2 的 3 次方
❸ 2.71 的自然对数,也称为欧拉数 e
❹ 获取π/2 弧度的正弦值。
❺ 9 的平方根
❻ 从 0 到 1 生成一个随机数。
2.2 使用常量和变量
记忆像 3.1415926...(π)或 2.71828...(欧拉数 e)这样的数字的所有位数是枯燥的。实际上,这是不可能的,因为这两个数都是我们所说的无理数,这意味着它们有无限的位数。因此,给每个数字一个名字——或者更准确地说,一个标识符会更好。
重要 变量 和 常量 定义了内存中的区域,其中存储着值(数据)。将内存想象成一个长长的编号信箱列表,每个信箱都持有一个值。为了避免记住包含值的信箱编号,附加一个命名标识符。你可以在创建变量后更改其值,但不能更改常量的值。
标识符可以用来给 Julia 中的常量、变量、类型和函数命名。pi (π)、golden (ϕ) 和欧拉数 e 是用来引用数值 常量 的标识符。常量和变量简化了记忆长而复杂的数字:
julia> using Base.MathConstants ❶
julia> pi
π = 3.1415926535897...
julia> e ❷
ℯ = 2.7182818284590...
julia> golden ❸
ϕ = 1.6180339887498...
julia> catalan ❹
catalan = 0.9159655941772...
❶ 你可以使常见的数学常量在 Julia 中可用。pi 总是可用的,但其他常量则不是。
❷ 欧拉数通常与对数一起使用。
❸ 黄金比例经常用于艺术中的美学原因,并在自然界中存在,例如叶子的螺旋排列。
❹ Catalan 常数
使用这些常量,编写数学表达式变得更加方便。你还会得到更准确的结果,因为无法用足够的数字来表示 e 或 π。这些是无理数,具有无限多位数字。数学家实际上不知道 Catalan 的数是否是无理数,但在 Julia 中它被建模为无理数:
julia> log(e⁴)
4.0
julia> sin(pi/2)
1.0
然而,你并不局限于使用内置常量。你可以在 Julia 中使用 const 关键字定义自己的常量,并在计算中使用它们而不是数字字面量:^(1)
julia> const foo = 3481
3481
julia> const bar = 200
200
julia> foo + bar
3681
julia> const qux = 8
8
julia> qux + 2
10
你可能想知道那些听起来很奇怪的名称 foo、bar 和 qux。这些是常用在代码示例中的无意义词汇,用来告知读者在这种情况下他们可以随意选择 任何 词汇。这些与像 if、while、const 和 function 这样的保留词不同;你不允许将它们用作变量名。
当编写 Julia 标识符时,你可以混合使用大小写。foObAr 和 FOObar 都是有效的。但是 Julia 是区分大小写的,所以它们将被视为 不同的 标识符。
提示:Julia 的标识符是区分大小写的。foo、Foo 和 FOO 不会被 Julia 视为相同的标识符。这是当今大多数现代编程语言的通用做法。
只要数字不是单词的开头,你就可以添加数字。因此 f00bar 是有效的,但 1oobar 是无效的。你应该习惯于来自其他编程语言的类似规则。
Julia 在频繁使用希腊字母方面很独特,例如 π、θ、α 和 Δ。这样做的原因是数学通常使用希腊字母书写。当数学方程在代码中实现时,如果代码看起来与方程相似,那么阅读代码会更容易。
为了适应这一点,Julia 的创建者在 Julia REPL 和 Julia 编辑器插件中内置了特殊功能,以便轻松编写希腊字母和其他 Unicode 字符。例如,在 REPL 环境中,你写一个反斜杠,然后写你想写的字符名,然后按 Tab 键:
julia> \pi
在我按下 Tab 键后,这变成了
julia> π
以下是一些流行的希腊字母和 Unicode 字符的概述,你可能会想在代码中使用,以及一些关于它们通常含义的注释。
| 字符 | 完成提示 | 用法 |
|---|---|---|
| π | \pi | 圆的方程 |
| θ | \theta | 角度 |
| Δ | \Delta | 某物之间的差异或变化 |
| e | \euler | 欧拉数(对对数很重要) |
| √ | \sqrt | 数字的平方根 |
| ϕ | \varphi | 黄金比例 |
变量与常量有什么不同?创建变量的过程非常相似,只是你不需要使用 const 关键字:
julia> y = 7
7
julia> z = 3
3
julia> y + z + 2
12
那么究竟有什么区别呢?这个 REPL 交互演示了变量和常量之间的区别:
julia> const x = 9
9
julia> y = 7
7
julia> x = 12
WARNING: redefinition of constant x. This may fail, cause incorrect answers,
or produce other errors.
12
julia> y = 8
8
在这个例子中,你将 x 设为常量,y 设为变量。注意 Julia 警告你正在尝试更改常量的值。虽然这确实可能工作,但 Julia 不保证它一定会这样做,这就是为什么 Julia 会给你一个警告。永远不要让你的代码依赖于未定义的行为。
有用的快捷键 使用 Ctrl-D 退出 Julia。Ctrl-C 将中断某些卡住的代码的执行。清除终端屏幕的方法因操作系统而异。在 Mac 上,使用 Command-K 清除终端,在 Linux 上使用 Ctrl-L。
重新启动 Julia 以使所有标识符再次可用。
2.2.1 将值赋给变量和绑定
在 Julia 中,等号(=)用于将值赋给变量。使用双等号(==)比较两个表达式是否相等。然而,为了准确起见,Julia 所做的不是赋值,而是绑定。为了更好地理解绑定是如何工作的,我将提供一个代码示例,其中变量 x 首先绑定到值 2。然后它被重新绑定到值 x + 3:
julia> x = 2 ❶
2
julia> x = x + 3 ❷
5
❶ 将 x 的初始值设为 0。
❷ 将 x 增加 2。
如果这个代码示例是一个像 C/C++、Fortran 或 Pascal 这样的语言,那么系统会为 x 变量预留一个内存槽位。每次你给变量 x 赋新值时,这个内存位置中存储的数字就会改变。

图 2.2 将变量 x 绑定到不同的内存位置
使用绑定,它的工作方式不同。你必须将每次计算视为产生一个数字,该数字被放入不同的内存位置。绑定涉及将 x 标签本身移动到新的内存位置。变量移动到结果,而不是结果移动到变量。图 2.2 展示了逐步解释,应该有助于阐明这是如何工作的。
-
数字 2 存储在内存单元格 2 号。你将标签 x 附加到这个值上,这相当于初始赋值语句 x = 2。
-
Julia 在表达式 x = x + 3 中开始评估 x + 3。它将这个计算的结果存储在内存单元 4 号。
-
为了完成对 x = x + 3 语句的评估,Julia 将 x 标签移动到内存单元 4。
但为什么 Julia 和其他动态类型语言执行绑定而不是赋值?在 C/C++ 这样的语言中,你可以编写如下语句。
列表 2.1 C/C++ 中的赋值
int x = 4;
x = 5;
char ch = 'A';
ch = 'B';
这之所以可行,是因为编译器会确保你永远不会尝试将无法放入为变量 x 和变量 ch 预留的内存槽中的值。在动态类型语言中,任何值都可以赋给 x,因此,它不能在内存中预定义的位置有一个预定义的大小。
2.2.2 使用 ans 变量
在 Julia 中有一个特殊的变量,只有在你交互式使用 Julia 时才存在,称为 ans(答案)。Julia REPL 将你评估的最后表达式的值赋给它。正常程序中的表达式不会赋给它。
如果他们使用过高级计算器,许多人会对类似的变量很熟悉。ans 是一个总是持有最后计算结果的变量。这种行为是实用的,因为它允许你轻松地将最后计算的结果用于下一次计算:
julia> 3 + 2
5
julia> ans*4
20
julia> ans
20
julia> ans - 8
12
2.2.3 什么是文字系数?
如果你阅读过数学,你可能已经注意到,像 3 × x + 2 × y 这样的表达式会被写成 3x + 2y。Julia 允许你以相同的方式编写乘法。我们称这些为 文字系数,它是数字文字与常数或变量之间乘法的缩写:
julia> x = 3
3
julia> 2x
6
julia> 2*(3+2)
10
julia> 2(3+2)
10
文字系数仅适用于实际的数字文字。例如,π、e 和 ϕ 不是数字文字。你可以写 2π,但不能写 π2,因为后者会暗示一个标识符。
使用文字系数和执行乘法之间存在细微的差别。看看你是否能理解以下示例:
julia> x = 5
5
julia> 1/2x
0.1
julia> 1/2*x
2.5
这里发生了什么?1/2x 被解释为 1/(2*x)。文字系数的优先级高于除法。
2.3 Julia 中不同的数字类型及其位数
Julia 有多种不同的数字类型,如有符号整数、无符号整数和不同位长的浮点数。如果你对这些概念不熟悉,我建议你阅读附录 B 中关于不同数字类型的内容。
让我们专注于 Julia 的特殊性。在 Julia 中,有符号整数命名为 Int8、Int16、Int32、Int64 和 Int128。数字后缀表示数字的位数。无符号整数类型名称通过在前面加上 U 来形成,这给你提供了 UInt8、UInt16、UInt32、UInt64 和 UInt128。
在运行代码时,了解特定整数类型的最大和最小值通常是实用的。你可以使用 typemin 和 typemax 函数来发现最小和最大值。例如,typemin(Int8)返回-128,因为 8 位整数不能表示小于-128 的值。typemax(Int8)将返回 127:
julia> typemax(Int8)
127
julia> typemin(Int8)
-128
Julia 中数字字面量的默认位长是有符号的 64 位整数。你可以使用 typeof 函数轻松验证这一点,该函数返回输入参数的类型:
julia> typeof(1)
Int64
那么,如何形成其他位长的数字呢?如果你想创建一个有符号的 8 位数字,你写 Int8(x),其中 x 是你想转换成 8 位数的数字。这适用于任何数字类型。自然地,如果你尝试输入一个超出位长的数字,你会得到一个错误信息:
julia> y = Int8(42)
42
julia> typeof(y)
Int8
julia> typeof(Int16(4))
Int16
julia> UInt8(256)
ERROR: InexactError: trunc(UInt8, 256)
你应该知道,与 Python、Ruby 和 R 等其他流行的动态类型语言不同,Julia 不会自动选择一个足够大的数字类型来容纳算术运算的结果。在 Julia 中,如果你将两个 Int8 值相加,结果将始终是 Int8 值。
其他动态语言如果结果太大无法用 8 位整数表示,就会升级到 Int16。在 Julia 中,你将得到一个溢出。如果你不熟悉整数溢出的概念,请阅读附录 B。
有时即使是 Int128 也不足以容纳一个值。在这些情况下,你使用 BigInt,它可以容纳任意大小的整数。这种灵活性是以更高的内存消耗和较低的性能为代价的,所以只有在必要时才使用 BigInt。
2.3.1 使用不同的数字格式编写数字
你如何写一个数字以及这个数字实际上在内存中的存储是两回事。数字 0b1101、0x0d 和 13 在计算机内存中以完全相同的二进制数存储。Julia 默认以十进制格式显示所有有符号数,以十六进制格式显示无符号数,如 UInt8:
julia> Int(0b1101)
13
julia> Int(0x0d)
13
julia> UInt8(13)
0x0d
十六进制数在低级、面向位的编程中很受欢迎。这是因为四个位可以用一个十六进制数字精确表示。八进制数也很受欢迎,因为可以用三个位来精确表示一个八进制数字。
十六进制和八进制数
十进制数是通过组合 0 到 9 的数字来创建的。八进制数是通过组合 0 到 7 的数字来创建的。因此,在八进制数系统中,数字 8 将被写作 10。
使用十六进制数时,存在一个问题,因为十六进制数系统中的数字必须覆盖从 1 到 15 的值;然而,只有 0 到 9 的符号。解决方案是使用字母来表示超过 9 的数字;因此,10 表示为 A,11 表示为 B,以此类推。F 代表 15。一个 8 位无符号整数可以持有的最大值是 0xff,这在十进制中相当于 255。
要编写八进制数,使用 0o 前缀——你不需要完全理解这一点。关键是让你意识到有不同方式表示相同的数字。这是为了避免在处理本章中的无符号整数时产生混淆,因为 Julia 默认以十六进制形式显示它们:
julia> 0o5 == 0b101
true
julia> 0o6 == 0b110
true
julia> 0o7 == 0b111
true
julia> 0o10 == 0b1000
true
julia> Int(0o10)
8
julia> Int(0o23)
19
julia> 2 * 8 + 3
19
2.4 浮点数
与整数类似,浮点数也有不同位长的。在 Julia 中,默认大小是 64 位,这意味着每个浮点数占用 8 字节内存。通过使用更多的位,你不仅可以表示更大的数字,还可以表示更高精度的数字。然而,精度并不总是重要的。对于科学计算,精度很重要,但在计算(例如,用于计算机图形)时,精度的重要性较小。在数百万个像素中,位置或颜色略有错误的像素并不重要。最常见的浮点数类型,Float64 和 Float32,可以写成数字字面量:
julia> 42.98 ❶
42.98
julia> typeof(ans)
Float64
julia> 34.23f0 ❷
34.23f0
julia> typeof(ans)
Float32
❶ 64 位浮点数字面量。你可以使用 typeof 来验证。
❷ 32 位浮点数
注意使用 f0 后缀来表示 32 位浮点数的数字。为什么不像 Java 和 C/C++那样只有一个 f 呢?这是因为字面量系数功能。如果你查看以下 REPL 会话,你可能能够弄清楚发生了什么:
julia> 0.5f
ERROR: UndefVarError: f not defined
julia> f = 2
2
julia> 0.5f
1.0
julia> 2f
4
如果你尝试编写类似于 Java 或 C/C++的 32 位浮点数,Julia 会认为你正在尝试将一个数字与变量 f 相乘。在第一种情况下,这会失败,因为你还没有定义 f 变量。在第二种情况下,它有效,因为 f 已经被定义。
那么,其他浮点值,如 16 位值呢?在这些情况下,你需要执行显式转换:
julia> x = Float16(3.5) ❶
Float16(3.5)
julia> typeof(x)
Float16
julia> z = Float16(4) + 5f0
9.0f0
julia> typeof(z) ❷
Float32
❶ 将 64 位浮点数转换为 16 位浮点数。
❷ 混合不同位长的数字会导致 Julia 选择最大的类型来存储结果。
2.4.1 对整数和浮点数执行操作
虽然你可以在浮点数和整数上执行许多相同的操作,但操作的结果并不总是相同的。而且还有一些操作只适用于某些数字类型。
例如,\除法运算符返回浮点数作为结果。这并不总是你想要的。当与整数一起工作时,你通常想要商和余数。这可以通过 div 和 rem 函数来实现:
julia> 4/2 ❶
2.0
julia> 5/2 ❶
2.5
julia> 5.0/2.0 ❶
2.5
julia> div(5,2) ❷
2
julia> rem(5,2) ❸
1
julia> 5%2 ❸
1
❶ 正规除法运算符给出浮点结果
❷ 整数除法,返回整数结果
❸ 你还会得到一个余数,你可以使用%运算符来获取。
2.5 定义函数
你已经接触到了一些函数,例如sin、cos和√。这些是你可以在普通计算器上找到的函数;它们接受一个数字作为输入,并返回一个数字作为输出。但函数背后的基本理念是什么?其次,数学和 Julia 中的函数是否是同一类东西?细节不同,但从概念上讲,它们是同一类东西。函数有零个或多个称为参数的输入。它们可以被认为是返回一个值或评估到一个值。考虑球体的体积:

你记得这个计算过程有多好?我经常需要查找它。你可以在 Julia 中通过编写以下代码来执行这个计算:
julia> r = 4.5 ❶
4.5
julia> V = 4*pi*r³/3 ❷
381.7035074111598
❶ 球体的半径
❷ 将球体的体积存储在变量 V 中
变量和常量使得记住长而复杂的数字变得容易。在许多方面,你可以将函数视为这个想法的扩展。它们允许你记住复杂的计算。你不需要记住要乘以和除以的数字,只需要记住函数的名称:
julia> sphere_volume(r) = 4*pi*r³/3 ❶
sphere_volume (generic function with 1 method)
julia> sphere_volume(2) ❷
33.510321638291124
julia> sphere_volume(4) ❸
268.082573106329
julia> sphere_volume(1)
4.1887902047863905
❶ 定义名为 sphere_volume 的函数,它接受单个参数 r,指定球体的半径。
❷ 使用先前定义的球体函数来计算半径为 2 的球体的体积。
❸ 半径为 4 的球体的体积
注意,当你定义一个函数时,与变量不同,你指定一个或多个参数。参数是在你的计算中想要改变的变量。例如,在计算球体体积时,你希望π的值每次都相同;因此π不是函数的参数。然而,半径是一个参数,因为你是想计算不同半径的球体的体积:
foo(x, y, z) = 2x + 4y - z
在前面的代码片段中,你可以看到一个简单的函数定义。这是一个名为 foo 的函数,它接受三个不同的参数,分别命名为 x、y 和 z。你可以有少量或许多参数。命名它们的规则与任何 Julia 标识符的规则相同。
2.5.1 在文件中存储函数定义
每次重启 Julia 时都在 Julia REPL 中编写你想要使用的每个函数的定义是不切实际的。相反,你可以在单独的源代码文件中存储函数定义。
代码注释(#)你可以在代码中写注释来提醒自己代码的各个部分做了什么。注释以井号开头:#。井号符号之后的内容会被 Julia 编译器忽略。
此文件可以在需要其中包含的函数时稍后加载到 Julia REPL 中。让我用一个例子来演示。你将创建一个名为 volumes.jl 的文件。在里面,你存储计算球体、圆柱体和圆锥体体积的函数。
列表 2.2 volumes.jl 源代码文件
# Volume calculations
sphere_volume(r) = 4π*r³/3
cylinder_volume(r, h) = π*r²*h
cone_value(r, h) = π*r²*h/3
你可以通过三种不同的方式将此文件中的代码放入 Julia 中。可能最不复杂的方法就是简单地复制粘贴文本到 Julia 命令行。或者,你可以在启动 Julia 时加载该文件:
$ julia -i volumes.jl
_
_ _ _(_)_ | Documentation: https:/ /docs.julialang.org
(_) | (_) (_) |
_ _ _| |_ __ _ | Type "?" for help, "]?" for Pkg help.
| | | | | | |/ _` | |
| | |_| | | | (_| | | Version 1.6.0 (2021-03-24)
_/ |\__'_|_|_|\__'_| | Official https:/ /julialang.org/ release
|__/ |
julia> cone_value(2, 4)
16.755160819145562
然而,更灵活的解决方案是使用 include 函数。这不需要重新启动你的 Julia REPL 会话:
julia> include("volumes.jl") ❶
cone_value (generic function with 1 method)
julia> cylinder_volume(1, 2) ❷
6.283185307179586
❶ 将给定文件中的代码加载到你的当前会话中
❷ 运行当前会话中加载的文件中定义的函数之一
你可以修改此文件中的代码,并使用 include 重新加载它以捕获函数实现的更改。
2.5.2 在 REPL 中处理函数
一旦将复杂计算存储在函数中,你可以轻松地重用这些计算。但是,你如何处理大量函数?Julia REPL 提供了许多帮助的方法。
如果你开始输入函数的第一个字母并按 Tab 键,Julia 将尝试完成函数名。如果你开始输入 sphere 并按 Tab 键,Julia 将将其完成为 sphere_volume。有时可能有多个可能的完成。在这些情况下,你可以按 Tab 键两次以获取可能的完整列表:
julia> find ❶
findall findlast findmax! findmin! findprev
findfirst findmax findmin findnext
❶ 在这里按 Tab 键两次以获取以单词 find 开头的函数的完整列表。
在 Julia 编程语言的网页上,你还可以找到一个手册,其中提供了 Julia 标准库中所有内置函数的完整列表。你可以在以下网页上访问手册:docs.julialang.org/en/v1。
2.5.3 到处都是函数
函数是 Julia 的核心。实际上,甚至常见的数学运算符在 Julia 中也被定义为函数。让我给你举一些例子:
julia> 5 + 3 ❶
8
julia> +(5, 3) ❷
8
julia> 8 - 2 ❶
6
julia> -(8, 2) ❷
6
julia> +(2, 3, 5) ❸
10
❶ 它看起来像你正在使用一个运算符,但实际上是一个以中缀形式编写的函数调用。
❷ 这些是像常规函数一样调用的运算符。这被称为前缀形式。
❸ 使用 + 作为函数的好处是你可以使用超过两个参数(例如,你可以用它来累加多个值)。
要描述标识符的位置,请使用以下术语:前缀、中缀和后缀。因此 +(3, 4) 被认为是 前缀 形式,而 3 + 4 则是等效的 中缀 形式。
你如何知道是否可以在中缀形式中使用一个函数?很简单:函数名需要是一个符号。例如,你不能使用名为 foo 的函数来表示中缀形式。让我们做一些演示:
julia> ×(a, b) = a² + b²
× (generic function with 1 method)
julia> ×(4, 10)
116
julia> 4 × 10
116
julia> 2 × 3
13
在这里,我们创建了一个名为 ×(写 \times)的函数,因为它接受两个参数并且是一个符号,所以我们可以使用它来表示中缀形式。获取符号的最简单方法就是编写类似 LaTeX 的缩写并按 Tab 键(例如,\Delta 表示 Δ)。Julia 支持的类似 LaTeX 的缩写可以在官方 Julia 文档中找到,网址为 docs.julialang.org/en/v1/manual/unicode-input/。
2.5.4 与数字一起工作的函数
你已经看到了一些可以用来操作数字的函数,但 Julia 有一大批这样的函数。我想展示一些最有用的函数,特别是用于处理整数和浮点数。之前,我展示了一些整数和浮点数的操作。然而现在你知道操作实际上只是函数。所有这些变体都是等价的:
julia> 9 % 6
3
julia> %(9, 6)
3
julia> rem(9, 6)
3
Julia 的原则是,你永远不需要使用特殊的 Unicode 符号。整数除法等操作也可以使用简单的函数执行:
julia> 9÷4
2
julia> ÷(9, 4)
2
julia> div(9, 4)
2
事实上,如果你按下?键并写一个整数除法,你会得到内置的帮助系统,显示 div 有两个名称:
help?> div(9, 4)
div(x, y)
÷(x, y)
The quotient from Euclidean division. Computes x/y, truncated to an integer.
了解如何以不同方式四舍五入数字非常有用。Julia 有 floor、ceil 和 round 函数用于此目的:
julia> floor(3.2) ❶
3.0
julia> floor(3.6) ❶
3.0
julia> ceil(3.2) ❷
4.0
julia> ceil(3.8) ❷
4.0
julia> round(3.2) ❸
3.0
julia> round(3.5) ❸
4.0
❶ 总是向下取整
❷ 总是向上取整
❸ 四舍五入到最接近的整数
但如果你要四舍五入到整数,那么你可能想要整数类型:
julia> Int(round(3.5)) ❶
4
julia> round(Int64, 3.5) ❷
4
julia> round(Int8, 3.5)
4
julia> typeof(ans)
Int8
❶ 这是获取整数的原生方法。
❷ 这是预期且最有效的方法:你将作为第一个参数提供你想要的输出类型。
2.6 如何在实际中使用数字
这里涵盖的许多细节不是你在进行常规编码时需要考虑的事情。我不想让你的大脑充斥着太多不必要的细节。关键点在于向你提供对 Julia 中数字工作原理的理解。这不仅仅局限于 Julia,但对于来自其他动态语言(如 Python、R 或 Ruby)的开发者来说可能是不熟悉的。
为了让你更容易操作,以下是一些简单的规则:
-
只使用默认的整数和浮点数大小。只有当性能或问题的性质要求时,才考虑更小或更大的数字。
-
优先使用有符号整数而不是无符号整数。使用无符号数很容易出错。为了让你更容易操作,大多数时候坚持使用有符号数。
摘要
-
Julia 支持不同类型的数字,但最重要的两种类型是整数和浮点数。
-
与数学家使用的数字不同,编程中使用的数字有特定的位数。这决定了你可以存储的数值大小。例如,一个有符号的 8 位整数无法存储小于-128 或大于 127 的数字。
-
变量给数字命名。函数给依赖于零个或多个参数的计算命名。
-
将一个值赋给变量意味着在那个值上贴一个标识符。将另一个值赋给同一个变量意味着将标签移动到新值上。这被称为绑定。
-
在内存中看起来相同的两个数字,因为它们的类型不同,在屏幕上可能看起来不同。
-
你可以使用不同的格式输入数字:二进制、十进制和十六进制。除非你进行系统编程,否则通常首选十进制格式。
(1.)一个数字字面量由 0 到 9 的数字组成,而不是用命名的变量来表示。
3 控制流
本章涵盖
-
在条件中使用布尔值
-
使用 while 和 for 循环多次运行相同的代码
-
使用 if 语句决定运行什么代码
-
遍历数字范围
-
定义跨越多行代码的函数
-
使用递归或迭代实现控制流
控制流是区分计算机和普通计算器的地方。计算器用于计算单个表达式。另一方面,计算机具有在无需人工干预的情况下多次使用不同输入重复相同计算的能力。计算机可以根据条件是否为真来选择执行一个计算而不是另一个计算。
在本章中,您将探索围绕生成数学表的代码示例。您将了解三角函数表,因为这些表广为人知,并且具有历史重要性。稍后,您将探索条件执行,以帮助跟踪使用 800 年前意大利数学家斐波那契开发的方法繁殖的兔子的增长。
3.1 航海与三角学
在帆船时代,数学表的使用变得更加普遍,并且需要开发自动化计算这些表的方法(航海基于计算角度和三角形的边;图 3.1)。这意味着计算正弦、余弦和正切等三角函数。

图 3.1 一艘船上的船长可以使用六分仪来测量地平线和灯塔顶部之间的角度θ。
海图包含不同灯塔的高度。因此,如果您想在靠近海岸的海域知道自己的位置,您可以测量地平线和已知高度的灯塔顶部之间的角度。这将给出您到该灯塔的距离。然而,所有这些计算都需要计算正弦和余弦等三角函数,而 18 世纪的船长们没有携带计算器(表 3.1)。
表 3.1 一个简单的三角函数表
| 角度 | 0° | 30° | 45° | 60° | 90° |
|---|---|---|---|---|---|
| sin | 0.000 | 0.500 | 0.707 | 0.866 | 1.000 |
他们使用的是数学表。当时,大型、印刷的表格,详细列出不同角度的正弦、余弦和正切值,是航海家们常用的工具。
如果您有一段时间没有使用三角函数,让我来帮您回顾一下高中数学。观察图 3.2 中的三角形,正弦、余弦和正切的定义如下:


图 3.2 一条边长为 a、b 和 h 的直角三角形。最长的边 h 称为斜边,有一个角度θ。
例如,一个角的正弦值等于直角三角形中对立边的长度除以最长边(斜边)的长度(一个角是 90°)。今天你使用计算器来计算这些三角函数。但如果你生活在 1972 年之前^(1),你会如何手动进行这些计算?实际上,没有计算正弦和余弦的标准方法。相反,有各种近似方法。计算正弦的一个流行方法是称为 泰勒级数:

你可以将其写成更紧凑和通用的形式为

但数学表不仅限于三角函数。表格对于许多其他函数都很有用,可以减少所需的手动计算量。
这促使查尔斯·巴贝奇在 1819 年开始构建一台名为差分机的巨大机械计算器(图 3.3)。它可以通过重复相同的计算多次来计算表格中的多个值。用现代编程术语来说,它是在循环中创建表格。循环基于评估(运行)相同的代码,只要给定条件保持为真。在所有编程语言中,布尔表达式都用于定义条件。你将跟随巴贝奇的脚步,在 Julia 中创建这样的循环。

图 3.3 查尔斯·巴贝奇的差分机的一部分,它是分析机——第一台机械通用计算机的先驱
你的目标是使用泰勒级数在 Julia 中实现三角函数。在此之前,你需要理解布尔表达式,这些表达式使得理解 while 循环、for 循环和 if 语句成为可能。你将通过一系列较小的代码示例来发展这种理解,打印数字、加数字和将角度转换为弧度。
3.2 布尔表达式
在小学阶段,你首先学到的是布尔表达式。讽刺的是,大多数学生从未练习过它们。没有人告诉你它们是任何编程语言最重要的部分之一。你已经看过数值表达式,例如 3 + 5;这些表达式评估为数字。相比之下,布尔表达式评估为真或假。通过一些实际例子,它们更容易理解:
julia> 3 > 5 ❶
false
julia> 8 > 3 ❷
true
julia> 8 == 5 + 3 ❸
true
julia> 3 == 5 ❸
false
julia> 3 ≤ 3 ❹
true
julia> 3 <= 3 ❹
true
❶ 3 是否大于 5?
❷ 8 是否大于 3?
❸ 检查值是否相等。这不是一个赋值操作符。
❹ 小于等于
在这个例子中,你使用了 小于等于 操作符的 Unicode 版本。有几个布尔操作符有 Unicode 变体。以下表格显示了如何在 Julia 的 REPL 中编写它们:
| 字符 | Tab 完成提示 | 描述 |
|---|---|---|
| ≤ | \leq | 小于等于 <= |
| ≥ | \geq | 大于等于 >= |
| ≠ | \ne | 不等于 != |
| ≈ | \approx | isapprox(x, y) |
布尔表达式返回布尔值,其中只有两个:true 和 false。记得我说过在计算机内部一切都是数字吗?布尔值也不例外:
julia> typeof(7 > 3) ❶
Bool
julia> typeof(false)
Bool
julia> reinterpret(UInt8, false) ❷
0x00
julia> reinterpret(UInt8, true) ❸
0x01
❶ 布尔表达式给出类型为 Bool 的值。
❷ 假值存储为 0。
❸ true 存储为 1。
与许多其他编程语言不同,Julia 实际上允许你对布尔值执行算术运算。在算术中,布尔值被视为 0 或 1:
julia> true + true
2
julia> 3true + true
4
julia> true + false + true
2
julia> false + false
0
为了清晰起见,最好避免将布尔值用作数字。然而,在某些情况下,这非常有用。Julia 开发者经常用它来计算有多少个东西是真的。在第四章中,你将看到这个例子。
3.2.1 复合语句
布尔表达式可以用 || 和 && 运算符组合。这些执行所谓的逻辑 OR 和逻辑 AND 操作。因此,给定一个变量 x,例如,我可以询问它是否小于 4 或大于 10:
julia> x = 3
3
julia> x < 4 || x > 10
true
julia> x = 5
5
julia> x < 4 || x > 10
false
或者,你可以询问 x 是否大于 4 并且小于 10:
julia> x > 4 && x < 10
true
julia> x = 12
12
julia> x > 4 && x < 10
false
接下来,你将使用布尔表达式来定义重复执行相同代码的条件。
3.3 循环
任何编程语言中最简单的循环结构是 while 循环。它允许你在布尔条件为真时重复执行相同的代码,如下面的列表所示。
列表 3.1 一个简单的 while 循环
i = 0
while i < 5
i = i + 1
end
在关键字 while 和 end 之间的所有代码只要条件 i < 5 为真就会重复执行。你可以将这段代码复制粘贴到你的 Julia REPL 中,但你不会看到任何输出。为什么?因为 while end 是一个求值为空的表达式。这可能听起来有点抽象,所以让我举一个例子:3 + 4 是一个求值为 7 的表达式。你可以将表达式的值存储在变量中,如下所示:
julia> x = 3 + 4
7
julia> y = while false end ❶
julia> typeof(y) ❷
Nothing
❶ 无意义的 while 循环,立即终止
❷ Julia REPL 不打印 nothing 值。
while 循环的例子说明了几个不同的事情。循环本身求出一个值,就像 3 + 4 一样。你将这个值存储在变量 y 中。然而,你无法在 REPL 中看到这个值,因为它属于类型 Nothing。
还要注意,将 while 循环放在单行上是完全可能的。在 Julia 中,空白不像在 Python 中那样重要;在 Python 中,你必须记住缩进属于循环的语句。但在 Julia 中,空白确实扮演着角色。考虑以下三个赋值:
x = 4
y = 8
z = 3
如果你想要它们在单行上,你需要用分号来分隔它们:
x = 4; y = 8; z = 3
你可能会想知道为什么我将 while 表达式的值存储在一个变量中。我这样做纯粹是为了教学目的,让你意识到在 Julia 中几乎所有东西都是一个表达式,它评估为值。甚至赋值也会评估为值(参见列表 3.2)。虽然这可能听起来像是一个对你没有兴趣的理论上的好奇心,但它确实有许多实际的影响。它使得 REPL 显示你在赋值语句中给变量赋予的值。你也会在讨论 if 语句时看到将一切视为表达式的优点。
列表 3.2 赋值的结果是一个值
julia> z = 3 + 2
5
注意,将赋值称为语句在技术上是不正确的,因为在 Julia 中一切都是表达式。然而,我将在本书中关于许多表达式中使用“语句”这个词。原因是这有助于区分赋值和控制流,例如 if 语句和 while 循环,以及更多的数学表达式。
REPL 总是显示外部表达式的值,而不是内部表达式的值。例如,如果你评估 1 + (3+2),你永远不会看到 5 打印出来,因为那是子表达式 3+2 的值。同样,你也不会在循环中看到 i = i + 1。要看到每次迭代的 i 的值,你需要明确告诉 Julia 将值打印到控制台。这是通过 print 和 println 函数完成的:
julia> i = 0
0
julia> while i < 5
i = i + 1
print(i)
end
12345
julia> i = 0
0
julia> while i < 5
i = i + 1
println(i)
end
1
2
3
4
5
从这些例子中,你可能可以分辨出差异。println 是 print line 的缩写。它在单独的一行上打印一个变量。你可以使用 print 和 println 在循环之外显式地打印值,但这很少需要:
julia> print(3 + 4)
7
julia> print(i)
5
3.3.1 流程图
文本不能很好地可视化程序的控制流。你必须知道语义。正因为如此,使用方框和箭头描绘程序流程的流程图曾经非常流行(图 3.4)。

图 3.4 标准流程图框
在过去,学生会学习将程序设计成流程图,然后编写代码。面向对象编程的流行导致流程图不再使用,因为它们无法模拟对象关系。然而,流程图在教授程序中的控制流方面仍然非常有用。如果你不熟悉循环,流程图可以帮助你发展对它们如何工作的直觉(图 3.5)。

图 3.5 while 循环中的控制流可视化
方形框表示执行的操作,而菱形框表示决策,控制流会分支到不同的方向。如果条件 i < 5 ? 为真,则流程将遵循标记为 yes 的箭头。否则,控制流将遵循 no 箭头。
3.3.2 为正弦函数制作数学表
现在,你已经拥有了所有基本构建块来重复查尔斯·巴贝奇差分机的功能:计算数学表。为了保持简单,让我们先打印出角度,如下所示。
列表 3.3 以 15 为增量打印角度的循环
angle = 0
while angle <= 90
println(angle)
angle = angle + 15
end
你可以将此代码复制并粘贴到你的 Julia REPL 中,然后你会得到以下打印结果:
0
15
30
45
60
75
90
在计算这些角度的正弦之前,你需要将它们转换为弧度,因为正弦、余弦和正切函数通常不使用 0° 到 360° 的度数,而是使用 0 到 2π 的弧度。图 3.6 中的插图显示了 1 弧度的定义。如果你有一个半径为 r 的圆,并在圆周上画一个长度为 r 的弧 s,那么这个饼形部分的角度等于 1 弧度。

图 3.6 弧度与圆半径的关系
圆的周长 C 和弧长 s 定义如下:

从这里你可以推导出一个函数 deg2rad,用于将度转换为弧度。
列表 3.4 将度转换为弧度
deg2rad(θ) = (θ/360)*2π
实际上,你不必编写这个函数,因为 Julia 已经在其标准库中提供了它。使用这个函数,你可以修改列表 3.3 中的代码,创建一个生成正弦值表的程序,如下所示。
列表 3.5 循环打印正弦表
angle = 0
while angle <= 90
rad = deg2rad(angle)
x = sin(rad)
println(x)
angle = angle + 15
end
当你运行这个程序时,你会得到以下输出:
0.0
0.25881904510252074
0.49999999999999994
0.7071067811865475
0.8660254037844386
0.9659258262890683
1.0
3.3.3 范围对象
当阅读正常的 Julia 代码时,你会发现基于条件的循环实际上不是正常的方法。相反,循环往往使用范围对象来定义;范围是用 : 运算符构建的。你可以对范围做很多事情,比如检查特定值是否在给定范围内。在这个例子中,你将获取范围的第一部分和最后一部分,在查询特定数字是否在给定范围内之前:
julia> r = 2:4 ❶
2:4
julia> first(r) ❷
2
julia> last(r) ❸
4
julia> in(1, r) ❹
false
julia> in(3, r) ❺
true
julia> 3 in r ❻
true
❶ 构建一个范围对象,并将其存储在变量 r 中。或者更准确地说,将标签 r 绑定到范围对象上。
❷ 获取范围的起始点。
❸ 获取范围的末尾。
❹ 检查 1 是否在 2 到 4 的范围内。由于 1 不在范围内,这个表达式将评估为假。
❺ 检查 3 是否在范围内。是的。
❻ 通常,你只能使用中缀形式的 Unicode 符号,但 in 是一个例外。r 中的 3 等同于 in(3, r)。
3.3.4 For 循环
范围对象在 for 循环中常用,但在展示 for 循环示例之前,让我先展示你如何在 while 循环中使用它。当 i 在 0:4 条件保持为真时,循环会重复,如下所示。
列表 3.6 使用范围对象的 while 循环
i = 0
while i in 0:4
println(i)
i = i + 1
end
这是一个如此常见且有用的习语,以至于 for 循环被用来移除很多样板代码。以下代码在行为上是等效的。
列表 3.7 对范围进行 for 循环
for i in 0:4
println(i)
end
当你不想在每次迭代中递增 1 时怎么办?当你计算角度时,你是以 15 度的增量进行的。这是否可以用 for 循环做到?没问题;范围允许你定义步长,如下面的列表所示。
列表 3.8 带步长范围的 for 循环
for angle in 0:15:90
println(angle)
end
当你运行此代码时,你会得到以下输出:
0
15
30
45
60
75
90
你可以在 for 循环中使用的对象,如 range 对象,被称为可迭代对象。Julia 中有许多不同的可迭代对象,我们将在后面的章节中探讨。
3.4 多行函数
你到目前为止所使用的函数都是定义在一行上的。这相当有限。更复杂的问题需要多行代码。你该如何做到这一点?你需要一种方式来标记应该包含在函数中的代码的开始和结束。for 循环和 while 循环可能已经给你一些提示,告诉你如何做到这一点。一个多行函数以关键字 function 开始,以关键字 end 结束。在下面的示例代码中,你使用循环来打印出从 0 到 max_angle 的所有角度的正弦值,角度递增。
列表 3.9 创建存储在函数中的正弦表的代码
function print_sin_table(increment, max_angle)
angle = 0
while angle <= max_angle
rad = deg2rad(angle)
x = sin(rad)
println(x)
angle = angle + increment
end
end
注意列表 3.9 是如何修改之前的代码列表 3.5,使用函数参数 increment 和 max_angle 而不是硬编码 15 度和 90 度角度。因此,用户可以轻松地更改他们生成的表格。例如,用户可以使用 print_sin_table(1, 90)生成以 1 度递增的正弦值。
那么,这与查尔斯·巴贝奇制造的差分机有什么关系?巴贝奇的 println 等价物不会在计算机屏幕上写数字,而是写入一种打印机。差分机旨在连接到一种机器,该机器会在金属板上印制数字。然后,这些数字可以用于在书中打印数字表。你还可以将你生成的数字发送到其他设备,但这将在后面的章节中介绍。
3.4.1 实现正弦三角函数
现在你已经学会了使用循环和多行函数,实际上你已经拥有了构建自己的 sin 函数所需的所有构建块,这意味着你可以复制计算器的功能。回顾用于计算sin(x)函数的泰勒级数:

这个函数可以写成求和的形式:

我不会从数学上证明如何得到这个定义;你在这里的兴趣是展示计算机如何被用来解决这样的问题。在 19 世纪,随着数学和科学的重要性增加,手工计算成为一个真正的问题。
如果你对数学符号不熟悉,让我用代码演示Σ运算符的工作原理。让我们从一个简单的例子开始:

Σ 符号的底部和上半部分基本上定义了一个范围。您正在声明您将迭代变量 x 从 1 到 n。您可以将 Σ 运算符视为执行循环;它遍历一个范围,并累加它遍历的表达式的值。您可以使用如下 for 循环来模拟这种行为。
列表 3.10 如何工作求和运算符
function f(n)
total = 0 ❶
for x in 1:n
total += 2x + 1 ❷
end
total ❸
end
❶ 用于存储总和
❷ total = total + 2x + 1 的简写
❸ 返回值
函数的值等于其最后一个表达式的值。在许多其他语言中,这被称为 返回值,最后一个表达式将被写成
return total
这在 Julia 中也有效,但只有在您需要提前退出函数时才会使用。否则,在 Julia 函数中通常省略 return。以下列表应有助于您了解如何使用泰勒级数来实现正弦函数。
列表 3.11 使用泰勒级数实现的正弦函数
function sine(x)
n = 5
total = 0
for i in 0:n
total += (-1)^i*x^(2i+1)/factorial(2i + 1)
end
total
end
将此函数放在单独的文件中(例如,trig.jl),其中包含您实现的其他三角函数,对读者来说是一个很好的练习。也实现余弦和正切函数;您可以通过网络搜索找到它们的泰勒级数定义。然后您可以将此文件加载到 Julia 中,并与内置的 sin 函数进行比较:
julia> sine(0.5)
0.4794255386041834
julia> sin(0.5)
0.479425538604203
julia> sine(1.0)
0.841470984648068
julia> sin(1.0)
0.8414709848078965
您可以看到,尽管您只迭代到 n = 5,但结果相当相似。准确定义意味着 n = ∞,这在代码中实现是不切实际的。尝试使用不同的 n 值,看看您是否能得到与内置 sin 函数一样准确的结果。
3.5 实现阶乘
您自定义的正弦函数使用了内置的阶乘函数。一个数字 n 的阶乘意味着将 1 到 n 之间的每个数字相乘。所以五的阶乘将是 5 × 4 × 3 × 2 × 1。您会如何自己实现它呢?有好多方法。在本节中,我们将探讨其中的一些:
-
使用内置的 prod 函数。
-
使用 while 循环执行多次乘法。
-
通过结合递归和 if 语句重复乘法。
prod 函数能够乘以一个范围内的所有数字:
julia> fac(n) = prod(1:n) ❶
julia> fac(5) ❷
120
julia> factorial(5) ❷
120
❶ 定义自己的名为 fac 的阶乘函数,使用 prod 实现。
❷ 检查 fac 和 factorial 是否给出相同的结果。
尝试使用这两个函数,以确保您得到相同的结果。您可以通过使用循环来实现这一点。
列表 3.12 使用循环实现的阶乘
function fac(n)
prod = 1
while n >= 1
prod *= n
n -= 1
end
prod
end
在循环的每次迭代中,您将 n 的值减去 1,直到条件 n >= 1 不再成立,然后退出,并带上 n 到 1 范围内所有数字的乘积^(2)。
3.6 递归阶乘
另一种不使用 while 和 for 循环实现循环的方法称为 递归。查看以下代码。
列表 3.13 使用递归实现的错误阶乘函数
fac(n) = n*fac(n-1)
尝试运行此代码。它并不完全工作。您会得到以下错误信息:
ERROR: StackOverflowError:
Stacktrace:
[1] fac(n::Int64) (repeats 79984 times)
这是因为 fac 函数不断地调用 fac,直到无限循环。或者更具体地说,在我的例子中,它调用了 79,984 次 fac,直到因为内存耗尽而崩溃。这产生了 stack overflow 错误信息。这是因为你甚至在 n 参数小于 1 时仍然调用 fac。某种方式你需要检查 n 是否已经小于 1 并退出。你很幸运,因为 Julia 的 if 语句可以帮助你做到这一点。
3.7 If 语句
现在用 if 语句重写你的递归 fac 函数。以下代码是第一次尝试。你将在此基础上扩展代码,直到阶乘函数处理所有边缘情况,例如 fac(0)。
列表 3.14 几乎可以工作的使用递归实现的阶乘函数
function fac(n)
if n <= 2
return n ❶
end
n*fac(n-1) ❷
end
❶ 如果 n 参数小于或等于 2,你将使用 return 语句退出 fac 函数。你在这里显式调用 return 以进行早期返回,而不是等待到达函数中的最后一个表达式。
❷ 再次调用 fac,但这次是 n-1,并将返回的结果与 n 相乘。记住 Julia 函数中的最后一个表达式会进行隐式返回。
3.7.1 If-else 语句
而不是使用 return 语句提前退出函数,你可以通过添加一个 else 子句来选择执行两个不同的代码块之一,如下面的列表所示。
列表 3.15 If-else 语句
function fac(n)
if n <= 2
n
else
n*fac(n-1)
end
end
如果 n <= 2 的条件不成立,你将评估 else-end 块之间的代码。整个 if-else 语句,就像 Julia 中的所有其他语句一样,是一个返回值的表达式。该语句返回被评估的代码块中的值。你可以在 Julia REPL 中亲自尝试。通过改变 x 的不同值,看看 y 的值是如何变化的:
julia> x = 4
4
julia> y = if x > 3
6
else
4
end
6
julia> y
6
然而,你的 fac 函数实际上还没有正确工作:
julia> factorial(0)
1
julia> factorial(-1)
ERROR: DomainError with -1:
`n` must not be negative.
julia> fac(0)
0
julia> fac(-1)
-1
fac(0) 返回 0,但它应该返回 1。此外,n < 0 的 fac(n) 甚至不应该被允许。因此,你需要以不同的方式处理 n == 0 和 n < 0 的情况。
3.7.2 ElseIf 子句
在这种情况下,elseif 子句起到了救星的作用。你可以在任何 if 语句中添加多个这样的子句。你已经在下面的列表中这样做,以处理所有独特的情况。现在请在 REPL 中测试 fac(0) 和 fac(-1) 是否能正确地处理这个更新。
列表 3.16 If-else 语句
function fac(n)
if n > 2
n*fac(n-1)
elseif n > 0
n
elseif n == 0 ❶
1
else
err = DomainError(n, "`n` must not be negative.") ❷
throw(err) ❸
end
end
❶ 如果 n 是零,则返回 1。
❷ 创建一个异常对象。这些用于存储关于发生的错误的信息。
❸ 报告一个错误,指出 n 不允许为负数。
每个 elseif 子句都会添加另一个条件检查。首先检查 n > 2,然后检查它是否是 n > 0。继续执行每个 elseif 检查,直到遇到一个评估为真的条件。如果没有条件为真,则评估 else 子句,该子句报告错误(图 3.7)。

图 3.7 包含 elseif 和 else 的 if 语句
在进一步讨论错误处理之前,我将通过澄清编写 if 语句的规则来结束讨论:
-
必须恰好使用一个 if 关键字,并且它必须位于开头。
-
else 是可选的,但它只能使用一次,并且只能放在非常末尾。
-
你可以写任意数量的 elseif 子句,但它们必须跟在 if 子句之后。
3.8 抛出异常以处理错误
在编程术语中,函数返回值但抛出异常。在 Julia 中,这用于处理程序员的错误。作为一个程序员,你不应该向 fac 提供负数。然而,错误是会发生的,必须得到处理。想法是尽可能早地报告问题——一旦你发现了它。这使你在开发和测试软件时诊断问题变得更加容易。
抛出异常与返回值有什么不同?让我用一个例子(图 3.8)来解释。

图 3.8 正常返回与抛出异常的区别
如果函数 alpha 调用 beta,而 beta 又调用 gamma,那么你得到的就是所谓的 调用栈。调用栈是内存中存储函数调用位置的地方。这是必要的,因为当你的 CPU 完成对 gamma 中指令的处理后,它需要回到 beta 中 gamma 被最初调用的位置。这个位置被存储在内存中。你称之为 返回地址。同样,你需要记住如何从 beta 返回到 alpha。这些嵌套的函数调用创建了一个返回地址的栈。这就是 调用栈。
如图 3.8 所示,return 会带你回到来的地方。throw 是不同的;它允许你跳过调用栈中的许多步骤。throw 跳过所有被调用的函数,直到它到达一个有 catch 定义的点:
function alpha()
try ❶
beta()
catch e ❷
# handle exception
end
end
❶ 定义一个代码块,其中在调用栈的某个地方可能会发生异常
❷ 如果发生异常,它将被捕获,并且此代码块旨在清理或处理异常。
错误信息存储在异常对象中,该对象被传递给了 throw 函数。变量 e 被设置为这个对象;因此 catch 块能够访问到发生的错误信息。在此阶段,我们无法详细讨论异常,因为这需要更深入地理解 Julia 的类型系统。然而,你可以在 REPL 中自己尝试这个操作,以了解异常如何打断正常的控制流:
julia> y = try
fac(3)
catch e
42
end
6
julia> y = try
fac(-3)
catch e
42
end
42
julia> fac(-3)
ERROR: DomainError with -3:
`n` must not be negative.
记住,在 Julia 中几乎一切都是表达式,包括 try-catch 块。
3.9 控制流与数据流的比较
现在你已经了解了不同的控制流形式,我们将更深入地讨论 控制流 的含义。将控制流与数据流进行比较可能有助于你更好地掌握这个概念。考虑以下简单的代码片段:
alice = encrypt(bob)
在查看此代码时,有两种不同的视角:存储在 bob 中的消息流向 encrypt 函数,而密文对象从函数中流出(图 3.9)。

图 3.9 对比数据流和控制流
在数据流中,数据沿着盒子之间的箭头流动。在图 3.9 中,亮色的盒子是源和汇,而深色的盒子是一个过滤器。它将传入的数据转换成另一种类型的数据。
在控制流图中(例如,流程图),箭头不表示数据的移动,而是控制的转换。控制流是关于控制如何从一个盒子流向另一个盒子,以及这种流动如何被改变和重新导向:
y = gamma(beta(alpha(x)))
在这个例子中,你可以思考控制是如何从 alpha 函数传递到 beta 函数,最终传递到 gamma 函数的:存在控制流。从数据流的角度来看,我们认为数据流入 alpha,流出它,然后流入 beta。
当分析复杂的代码时,绘制数据流图可能会有所帮助。通过在箭头上标注函数(过滤器)输入和输出的数据类型,你可以更好地了解代码中复杂的数据流。
3.10 计算兔子数量
在许多编程书籍中,你会找到 fib 函数的实现,这个名字是斐波那契的缩写。为什么这个函数在编程中如此流行?你为什么应该关心?考虑以下原因:
-
这是一种简单的方法,可以展示数学定义如何转化为代码。
-
实现它允许你对比通过递归和迭代(循环)解决问题。
-
斐波那契数出现在各种现实生活中的情况中:在花瓣的数量、向日葵或乌贼壳上的螺旋,以及出现在叶序中的分数。
-
这是一个简单演示如何构建现实世界现象的模型。
这就是数字序列的看起来。序列一直延伸到无穷大:

这个序列中的每一个数都被称为斐波那契数。数学家们喜欢用字母F来指代这些数中的每一个。序列中的第一个数是F[0],第二个是F[1],以此类推。换句话说,索引从 0 开始。从数学的角度来看,斐波那契数被定义为如下:

这可能看起来就像维基百科页面一样启发人心(即,不是非常),所以我会尝试用一个具体的例子来提供这个数学定义背后的直觉:兔子的种群增长。事实上,这就是斐波那契数是如何被发现的。比萨的莱昂纳多,也就是斐波那契,大约 800 年前在思考兔子种群每个月会如何增长。他提出了以下假设性问题:
如果我们在年初有一对兔子,那么一年结束时会有多少只?
为了回答这个问题,你需要构建现实的模型。在构建模型时,尝试提取你试图模拟的具体特征的最重要的特征。例如,你不需要关心兔子如何扩散,它们看起来像什么,它们吃什么,或者它们如何获取食物。你的模型只关心它们的种群增长。所有模型都是为了特定目的而构建的;如果你想检查一部新手机是否适合某人的口袋,那么模型不需要比一块木头更复杂。你需要模拟的只是手机的物理尺寸,而不是外部的颜色或屏幕的清晰度。
因此,模型总是涉及对现实的重大 简化。在斐波那契的兔子增长模型中,你处理的是不朽的兔子。它们实际上从未死去。它们出生,出生后一个月就开始繁殖。你总是将它们建模为对。一对兔子在达到繁殖年龄后,每个月都会产生另一对兔子(如图 3.10)。

图 3.10 Mathigon 展示的每月兔子种群增长
Mathigon (mathigon.org/course/sequences/fibonacci) 是一个优秀的在线资源,通过交互式演示如何根据斐波那契数列来展示兔子种群的增长。截图中的六边形显示了给定月份的兔子对数。在第一个月你只有一对兔子,而在第六个月你则有 8 对。当你实现 fib 函数(列表 3.17)来计算斐波那契数时,它的工作方式是这样的:fib(1) 与 F[1] 相同;fib(n) 对应于 F[n]。
列表 3.17 计算斐波那契数
function fib(n)
if n == 0 ❶
0
elseif n == 1 ❶
1
else
fib(n-1) + fib(n-2) ❷
end
end
❶ 数学定义说 F[0] = 0,F[1] = 1,这在这里得到了表达。
❷ 对于所有其他 n 的值,它们等于前两个斐波那契数。这表达了递归。fib 函数用另一个参数调用自己。
让我们尝试分析这个函数在实际中的工作方式。当你尝试评估 fib(3) 时会发生什么?这设置了 n = 3。每当 n > 1 时,以下行会被评估:
fib(n-1) + fib(n-2)
这将会被反复评估,但每次参数 n 都会减少 1 和 2,这意味着迟早 fib 函数的第一个条件会成立。然后结果会冒泡回传,完成之前的请求。因此,在这种情况下,你有一种双重递归。这些 REPL 示例提供了斐波那契函数工作方式的感觉:
julia> fib(3) == fib(2) + fib(1)
true
julia> fib(4) == fib(3) + fib(2)
true
julia> n = 5
5
julia> fib(n) == fib(n-1) + fib(n-2)
true
3.10.1 基础情况
为了避免递归运行到消耗完所有栈内存,你需要定义 基础情况。这是让你在某个点上退出递归的 if 语句。
列表 3.18 计算斐波那契数
function fib(n)
if 0 <= n <= 1 ❶
n
else
fib(n-1) + fib(n-2)
end
end
❶ 检查 n 是否在 0 到 1 的范围内。它是否等于或大于零,同时也小于或等于一?
0 <= n <= 1 条件定义了基本条件或存在点。每个递归函数都需要类似的东西。递归函数是调用自身的函数。
3.10.2 迭代与递归
之前你演示了递归只是解决问题多种方式中的一种;它从来不是必需的。递归总是可以被迭代替换。通过迭代,我指的是循环(例如,使用 for 循环或 while 循环)。例如,使用以下代码,你正在 迭代 从 0 到 4 的范围:
for i in 0:4
println(i)
end
如果你可以用迭代解决相同的问题,那么为什么还要使用递归?让我们看看迭代解决方案来讨论其优缺点。
列表 3.19 使用迭代计算斐波那契数
function fibi(n)
if 0 <= n <= 1
return n ❶
end
prev = 0 ❷
x = 1 ❸
for i in 2:n
tmp = x
x += prev ❹
prev = tmp
end
x
end
❶ 提前退出;为了避免深层嵌套,你使用 return 关键字立即以值 n 退出函数。
❷ prev 用于表示 fib(n-2)。
❸ 要保存最终结果 fib(n)
❹ 这是对 x = x + prev 的简写,它等同于 fib(n-1) + fib(n-2) 的计算。
虽然这段代码在概念上可能更容易理解,但你可能会注意到迭代使一切变得混乱。你需要做更多的记录,这意味着我们有更多的变量需要正确维护和更新。我花费了明显更长的时间来创建这个示例代码,并且在初始版本中犯了几次错误。相比之下,递归版本我在第一次尝试时就写对了。
因此,虽然递归可能需要一些时间来习惯,但它通常会使得你的代码更加简单。一个缺点是递归通常较慢。因此,通常你首先通过递归实现你的解决方案,如果它变得太慢,你将重新编写它使用迭代。
3.10.3 返回或不返回
最后一个例子使用了返回语句来从函数中提前退出。这是可选的,但正如你所见,不写返回语句可能会使代码更难阅读。这是因为你可能会遇到控制流语句的深层嵌套,如下面的列表所示。
列表 3.20 不使用早期返回计算斐波那契数
function fibi(n)
if 0 <= n <= 1
n
else
prev = 0
x = 1
for i in 2:n
tmp = x
x += prev
prev = tmp
end
x
end
end
这里没有硬性规则。你只需使用常识并依靠你自己的审美观。作为一个经验法则,我尽量不嵌套超过三个层次;然而,避免制定严格的规则。对刻在石头上的规则的执着一直是软件行业的困扰。更灵活地使用常识会更好。Julia 本身就是一种试图实用的语言。
概述
-
控制流语句使用由布尔表达式组成的条件。循环会一直重复,直到条件保持为真。
-
即使控制流语句在 Julia 中也是表达式,意味着它们会返回一个值。在 Julia 中,甚至 nothing 也是一个值。
-
计算机能够重复进行大量类似的计算,这使得它们适合于手工难以完成的计算,例如计算三角函数。
-
递归函数是调用自身的函数。递归函数必须有一个基准情况,否则它们将永远不会终止执行。
-
递归和迭代可以解决相同的问题。递归通常使代码更容易编写,而迭代通常提供更好的性能。
(1.)1972 年,惠普公司发布了 HP-35,这是第一个具有正弦和余弦函数的计算器。
(2.)乘积是乘法的结果,与加法的结果之和相对。
4 Julia 作为电子表格
本章涵盖了
-
使用 Array 和 Tuple 类型处理数字集合
-
可用于集合的有用类型,如数字、字符和文本字符串
-
进行统计分析
-
使用 map 函数转换数字列表
-
使用过滤器函数的谓词
在第二章中,我们讨论了如何将 Julia 作为计算器使用。然而,今天使用数字的人通常不会使用台式计算器;他们使用电子表格工具,如 Microsoft Excel 或 Apple Numbers(图 4.1)。

图 4.1 Apple Numbers 是一个用于处理数字行和列的电子表格应用程序。
在这些应用程序中,数字存储在表格中。所有严肃的科学工作都涉及处理大量数据表格,包括整个数字列。科学家和数据分析师获取他们想要分析的调查数据或测量数据。Julia 对于这类工作非常出色。你实际上并不是在处理图形电子表格工具,但你是在表格形式中操作数据,就像现代电子表格应用程序一样。
你在本章中只会触及到可能性的表面。相反,主要目的是介绍 Array 和 Tuple 数据类型。因为这些是其他值的容器,你还将接触到 Char(字符)和 String(文本字符串)类型,以便在数组中放入一些有趣的内容。实际上,你将把数字、字符、文本字符串和布尔值放入这两种集合类型中。
4.1 分析披萨销售
为了更好地理解不同 Array 操作的目的,我将使用披萨销售的例子。表 4.1 显示了不同类型的披萨以不同的数量和价格销售。你将探索如何使用 Julia 代码来回答以下问题:
-
总共卖出了多少披萨?
-
你从披萨销售中总共获得了多少收入?
-
卖出的披萨的平均价格是多少?
-
每个类别中卖出的披萨平均数量是多少?
表 4.1 披萨销售数据,其中每一行说明每种类型的披萨销售数量
| 披萨 | 数量 | 价格 |
|---|---|---|
| 辣味香肠 | 4 | 15.0 |
| 玛格丽塔 | 1 | 11.5 |
| BBQ 鸡肉 | 5 | 13.0 |
| 夏威夷 | 3 | 12.75 |
| 芝士火腿 | 2 | 14.25 |
4.2 不同类型的数组
Julia 中的数组可以表示数字的行、列或表格。实际上,数组可以包含任何类型的元素,而不仅仅是数字。你可以有布尔值、字符或文本字符串的数组(例如,数组中的元素是有序的)。你可以要求特定位置的元素,例如,“给我数组 A 中的第三个元素。”
让我们创建一个包含卖出披萨数量的数字列。注意数字是如何垂直列出的。这是 Julia 告诉你你刚刚创建了一个 列向量。在创建列向量时,用逗号分隔每个元素:
julia> amounts = [4, 1, 5, 3, 2]
5-element Vector{Int64}:
4
1
5
3
2
也可以通过用空格分隔每个元素来创建一个行向量:
julia> row = [4 1 5 3 2]
1×5 Matrix{Int64}:
4 1 5 3 2
向量中的每个值都有一个相关的元素索引,如图 4.2 所示。第一个元素的索引是 1。

图 4.2 行向量中元素组织的示意图
术语向量通常用来指代一维数组,而二维数组被称为矩阵。你可以将矩阵想象成电子表格应用中的表格。在 Julia 中,你可以通过将行向量堆叠在一起来构建表格。
注意到每一行之间用分号分隔。这里你有一个包含数量和价格列的表格:
julia> pizzas = [4 15.0;
1 11.5;
5 13.0;
3 12.75;
2 14.25]
5×2 Matrix{Float64}:
4.0 15.0
1.0 11.5
5.0 13.0
3.0 12.75
2.0 14.25
新行不是必需的;它们只是使代码更容易阅读。你通过写下以下内容会得到完全相同的矩阵:
pizzas = [4 15.0; 1 11.5; 5 13.0; 3 12.75; 2 14.25]
为了理解列和矩阵是如何组织的,你可以查看以下插图(图 4.3)。对于一维向量,我们通常谈论元素索引,但对于矩阵,行和列都有编号。

图 4.3 列向量和矩阵的比较
然而,在本章中,你将主要关注列向量。它们在其他语言中被称为数组的内容最为接近。多维数组在 Julia 中不是核心特性。
4.3 在数组上执行操作
数字列表并不很有趣,除非它们允许你做些有用的事情。幸运的是,许多函数可以操作数组。例如,Julia 的 sum 函数可以用来计算数组中所有元素的总和。这里你计算出售的披萨总数:
julia> no_pizzas_sold = sum(amounts)
15
如果你想知道数量中有多少个元素,你可以使用 length。这也允许你计算每种类型售出的披萨的平均数量:
julia> length(amounts)
5
julia> avg = sum(amounts) / length(amounts)
3.0
让我们把价格放入一个变量中,以便进行实验:
julia> prices = [15.0, 11.5, 13.0, 12.75, 14.25]
5-element Vector{Float64}:
15.0
11.5
13.0
12.75
14.25
为了更容易地了解你拥有的不同价格,你可以对它们进行排序:
julia> sorted = sort(prices) ❶
5-element Vector{Float64}:
11.5
12.75
13.0
14.25
15.0
julia> prices ❷
5-element Vector{Float64}:
15.0
11.5
13.0
12.75
14.25
❶ 排序后的价格存储在 sorted 中
❷ sort 没有修改价格。
当你调用 sort 时,你会创建一个新的向量。价格向量不会被修改。按照惯例,Julia 函数永远不会修改它们的任何输入。有时修改函数的输入是必要的。Julia 开发者已经建立了在函数名称后附加感叹号(!)的惯例,以表示任何修改其输入的函数。因此,许多不修改其输入的 Julia 函数都有修改输入的兄弟函数。例如,sort!函数将对其输入向量进行排序,而不是返回一个新的排序版本:
julia> sort!(prices)
5-element Vector{Float64}:
11.5
12.75
13.0
14.25
15.0
julia> prices ❶
5-element Vector{Float64}:
11.5
12.75
13.0
14.25
15.0
❶ 价格经过排序修改!
如果你生活在一个有增值税的国家呢?为了计算出你披萨的标价,你需要加上销售税。如果你住在挪威,增值税是 25%。让我们计算带税的新价格:
julia> prices = [15.0, 11.5, 13.0, 12.75, 14.25];
julia> prices_with_tax = prices * 1.25
5-element Vector{Float64}:
18.75
14.375
16.25
15.9375
17.8125
但如果你想知道你每种披萨赚了多少钱?你可以尝试将金额与价格相乘:
julia> amounts * prices
ERROR: MethodError: no method matching *(::Vector{Int64}, ::Vector{Float64})
不要担心错误信息。我将在后面的章节中解释你需要掌握的概念来阅读它。
目前,你需要知道的是,两个数字列之间的乘法没有明显的定义。可以想象出许多解释方式。因此,你必须明确告诉 Julia 你想要逐元素操作。你可以通过在数学运算符上添加一个点来实现这一点。+、-、* 和 / 用于对单个数字(标量)进行算术运算。要对数字数组执行逐元素运算,你需要使用 .+、.-、.* 和 ./ 运算符:
julia> amounts .* prices
5-element Vector{Float64}:
60.0
11.5
65.0
38.25
28.5
你可以将这个结果传递给求和函数,以计算你通过卖披萨获得的总利润:
julia> sum(amounts .* prices)
203.25
4.4 使用统计模块
做统计和数据分析的专业人士通常与数据表打交道;你可以轻松实现自己的函数来对数据的单个列进行统计分析。以下是一个计算平均值的简单示例。
列表 4.1 计算算术平均值
average(A) = sum(A) / length(A)
而不是重新发明轮子,你可以使用现成的统计函数。这些函数包含在 Julia 中,但位于 Statistics 模块中(见 docs.julialang.org/en/v1/stdlib/Statistics/)。模块将在稍后更详细地介绍,但你可以把它们看作是预先准备的功能包,你可以在程序中使用它们。要使用模块,请编写
using Statistics
这将导致模块中定义的函数、类型和常量被加载并可供你使用。它还将使模块的文档可用。记住,你可以通过在行首输入一个问号(?)来进入 Julia 的文档模式:
julia> using Statistics
help?> Statistics
search: Statistics
Statistics
Standard library module for basic statistics functionality.
help?> middle(3, 4)
middle(x, y)
Compute the middle of two numbers x and y, which is equivalent in both
value and type to computing their mean ((x + y) / 2).
要了解模块中存在哪些类型和函数,请编写模块名称和一个点,然后按两次 Tab 键。这将显示所有可能的完成项(为了清晰起见,我已经编辑掉了一些结果):
julia> Statistics.
corm mean realXcY
corzm mean! sqrt!
cov median std
cov2cor! median! stdm
covm middle unscaled_covzm
covzm quantile var
eval quantile! varm
include range_varm varm!
让我们探索一些在 REPL 中的统计函数:
julia> mean(amounts) ❶
3.0
julia> mean(prices)
13.3
julia> median(amounts) ❷
3.0
julia> median(prices)
13.0
julia> std(amounts) ❸
1.5811388300841898
julia> std([3, 3, 3])
0.0
❶ 计算金额的算术平均值。
❷ 当值排序时的中间值
❸ 标准差
平均值和中位数都用于计算平均值,但工作方式略有不同。使用平均值时,你将所有值加起来,然后除以值的数量。如果有几个极端值,平均值可能会严重偏斜。因此,例如,在计算家庭平均收入时,你通常使用中位数。中位数收入是通过对所有家庭收入进行排序,然后选择排序列表中间的收入来计算的。这样,几个非常富裕的家庭就不会使结果偏斜。
使用 std 函数,您可以在值集合中找到标准差。标准差是衡量值差异程度的度量。如果每个元素都相同,则标准差将为零。到目前为止,您已经看到了如何处理整个数组,但为了能够构建自己的处理数组的功能,您需要知道如何访问数组中的单个元素。
4.5 访问元素
Julia 数组中的每个元素都是从 1 开始编号的。这称为基于 1 的索引,在数值和数学相关的语言中非常常见。然而,主流语言,如 Python、C、C++和 Java,使用基于 0 的索引。
基于 1 的索引与基于 0 的索引
索引数组最好的方式是开发者喜欢争论的话题。在数学中,使用基于 1 的索引对元素、行和列进行编号是一种常见约定。当讨论硬件细节,如计算机内存地址时,更常见的是使用基于 0 的索引。因此,具有数值关注点的语言倾向于使用基于 1 的索引,而更接近硬件的语言,如 C,则使用基于 0 的索引。
使用方括号来定义数组字面量,以及通过索引访问单个元素:
julia> amounts = [4, 1, 5, 3, 2]
5-element Vector{Int64}:
4
1
5
3
2
julia> amounts[1] ❶
4
julia> amounts[2]
1
julia> amounts[5] ❷
2
❶ 访问金额数组中的第一个元素。
❷ 获取金额数组中的第五(最后一个)元素。
使用方括号既可定义数组字面量,也可访问单个元素。当然,您也希望能够更改单个元素。这可以通过相同的方式进行:
julia> xs = [2, 3]
2-element Vector{Int64}:
2
3
julia> xs[1] = 42
42
julia> xs
2-element Vector{Int64}:
42
3
julia> xs[2] = 12
12
julia> xs
2-element Vector{Int64}:
42
12
每次更改元素时,您都可以打印它以显示数组当前的形状。所有这些示例都很整洁。如果您尝试通过无效索引访问元素会发生什么?
julia> xs[3] = 5 ❶
ERROR: BoundsError: attempt to access 2-element Vector{Int64} at index [3]
julia> xs[0] ❷
ERROR: BoundsError: attempt to access 2-element Vector{Int64} at index [0]
❶ 数组 xs 只有两个值,因此您不能尝试设置第三个元素。Julia 会检查您是否使用了有效的索引。
❷ 元素从索引 1 开始。索引 0 处没有值。
您在这里看到的行为在大多数主流语言中都很常见。然而,一些较老的流行语言允许您在任意索引处设置元素,无论您事先将数组设置得多大。
在这些示例中访问元素的方式存在一些挑战:
-
您并不总是知道最后一个元素的索引,因为数组可以有不同的大小,并且可以增长。
-
虽然基于 1 的索引是标准,但在 Julia 中可以构建基于 0 的数组。
为了处理您无法始终知道数组开始或结束位置的事实,请使用 begin 和 end 关键字分别访问第一个和最后一个元素:
julia> amounts[1] ❶
4
julia> amounts[begin] ❶
4
julia> amounts[5] ❷
2
julia> amounts[end] ❷
2
julia> amounts[4] ❸
3
julia> amounts[end-1] ❸
3
❶ 访问第一个元素。[1]和[begin]是相同的。
❷ 访问最后一个元素。[5]和[end]是相同的。
❸ 通过减法,您可以执行诸如访问倒数第二个元素之类的操作。
4.6 创建数组
到目前为止,你已经使用数组字面量创建了数组。数组字面量意味着你实际上列出了数组由哪些元素组成。例如,[4, 8, 1] 和 [false, false, true] 都是数组字面量的例子。变量 xs 可能指向一个数组,但它不是一个数组字面量。然而,数组字面量在创建大型数组时并不十分有效。你有一系列函数,如 zeros、ones、fill 和 rand,这使得快速创建包含特定值的数组变得容易。
例如,如果你想要一个包含 50 个元素且所有元素值都为 0 的数组,你可以使用 zeros 函数:
julia> xs = zeros(50)
50-element Vector{Float64}:
0.0
0.0
0.0
⋮
0.0
0.0
0.0
0.0
julia> length(xs)
50
初始化向量的元素为 1 是如此常见,以至于有一个专门的函数 ones 来执行此操作。该函数创建一个指定长度的数组,每个元素都设置为值 1:
julia> ones(5)
5-element Vector{Float64}:
1.0
1.0
1.0
1.0
1.0
但是,使用 fill 函数可以填充一个大型数组中的任何值。在这里,你创建了一个包含六个元素且每个元素都设置为值 42 的数组:
julia> fill(42, 6)
6-element Vector{Int64}:
42
42
42
42
42
42
在许多情况下,你需要具有大量随机值的数组。rand(n) 创建一个包含 n 个介于 0 和 1 之间的随机数的向量:
julia> rand(3)
3-element Vector{Float64}:
0.5862914538673218
0.8917281248249265
0.37928032685681234
当你创建数组时,在 Julia REPL 中创建的数组描述将看起来像这样:
5-element Vector{Float64}
这表示你创建的向量包含五个元素,并且每个元素都是 Float64 类型。但是,如果你想有不同的元素类型呢?比如说,你想要 8 位有符号整数。你该如何做?ones、zeros 和 rand 函数允许你指定元素类型。以下是一些示例:
julia> ones(Int8, 5) ❶
5-element Vector{Int8}:
1
1
1
1
1
julia> zeros(UInt8, 4) ❷
4-element Vector{UInt8}:
0x00
0x00
0x00
0x00
julia> rand(Int8, 3) ❸
3-element Vector{Int8}:
-50
125
58
❶ 创建一个包含有符号 8 位整数且值为 1 的数组。注意向量的描述说 Vector{Int8}。
❷ 创建四个无符号 8 位的零。注意零是如何以十六进制形式编写的,因为在 Julia 中这是格式化无符号整数的默认方式。
❸ 创建三个随机的 8 位有符号整数值。这些值将从完整的范围中随机选择:-128 到 127。
即使数组字面量也允许你指定元素类型。因此,你可以表明你想要一个 8 位有符号整数的向量:
julia> xs = Int8[72, 69, 76, 76, 79]
5-element Vector{Int8}:
72
69
76
76
79
数组字面量前面加上你想要为每个元素指定的类型——在这个例子中是 Int8。如果你没有指定类型,Julia 将推断元素类型。关于它是如何工作的细节,我将在第七章讨论类型时说明。如果你想检查数组中每个元素的类型,你可以使用 eltype(元素类型的简称)函数:
julia> eltype(xs)
Int8
julia> eltype([3, 4, 5])
Int64
julia> eltype([true, false])
Bool
4.7 在数组中映射值
你可以做的不仅仅是简单地对值进行加法和乘法。在所有支持函数式编程风格的编程语言中,你都会找到一个由 map、reduce 和 filter 组成的三联函数。让我们首先通过回顾你之前的正弦表计算来探索 map 函数。你还记得这个函数吗?
列表 4.2 存储在函数中的创建正弦表代码
function print_sin_table(increment, max_angle)
angle = 0
while angle <= max_angle
rad = deg2rad(angle)
x = sin(rad)
println(x)
angle = angle + increment
end
end
然而,您不必打印出正弦值的表格,您可以创建一个包含所有正弦值的数组。为此,您可以使用 map 函数,该函数旨在将值集合转换为另一个值数组。这里使用 map 将度数数组转换为弧度数组。map 将 deg2rad 函数应用于输入数组中的每个元素:
julia> degs = [0, 15, 30, 45, 60];
julia> rads = map(deg2rad, degs)
5-element Vector{Float64}:
0.0
0.2617993877991494
0.5235987755982988
0.7853981633974483
1.0471975511965976
map 被称为高阶函数。这些函数接受其他函数作为参数,并且/或者返回函数。这与您迄今为止看到的函数不同,后者仅接受数字作为参数。map 的基本形式将函数 f 作为第一个参数,并将该函数应用于集合 xs 中的每个元素,生成一个新的集合 ys 作为输出:
ys = map(f, xs)
第二个参数,表示一个集合,不需要是一个实际的数组。它可以是一切可以迭代并获取多个元素的东西;因此,您也可以使用范围对象。这里您使用从 0 到 90 的范围,以 15 度为步长值:
julia> map(deg2rad, 0:15:90)
7-element Vector{Float64}:
0.0
0.2617993877991494
0.5235987755982988
0.7853981633974483
1.0471975511965976
1.3089969389957472
1.5707963267948966
您可以将这些组合起来创建一个正弦表:
julia> map(sin, map(deg2rad, 0:15:90))
7-element Vector{Float64}:
0.0
0.25881904510252074
0.49999999999999994
0.7071067811865475
0.8660254037844386
0.9659258262890683
1.0
然而,这通常不是您这样做的方式。相反,您可以将所有想要进行的转换组合成一个单独的函数。这更节省内存,因为每次调用 map 都会产生一个新的数组。另一个解决方案是预先分配一个与输出大小相同的数组,并在重复映射中使用该数组。mutating map! 函数允许您做到这一点。
它将输出直接写入作为第二个参数给出的数组。第三个参数是输入,它不会被 map! 函数修改!
然而,如果输入与所需的输出类型和长度相同,则可以重用输入参数作为输出参数:
result = zeros(Float64, length(0:15:90)) ❶
map!(deg2rad, result, 0:15:90) ❷
map!(sin, result, result) ❷
❶ 分配数组来存储结果。
❷ 输入和目标数组必须具有相同的长度。
然而,这不是编写代码的优雅方式,并且最好避免使用可变函数调用,因为它们使数据分析变得更加困难。因此,您将所有转换收集到一个函数中,这减少了代码必须进行的内存分配次数,并且通常更容易阅读:
degsin(deg) = sin(deg2rad(deg))
map(degsin, 0:15:90)
第一行只是一个单行函数定义。您也可以使用多行定义,但这会占用更多空间:
function degsin(deg)
sin(deg2rad(deg))
end
map(degsin, 0:15:90)
理解内置函数的一个好方法是自己实现它们。为了更好地理解 map,创建一个名为 transform 的自己的 map 函数。它包含新的概念,我们将在更详细地讨论。
列表 4.3 内置 map 函数的简化版本
function transform(fun, xs)
ys = [] ❶
for x in xs
push!(ys, fun(x)) ❷
end
ys ❸
end
❶ 创建一个空数组来存储最终结果
❷ 将转换后的元素添加到结果数组 ys 中。
❸ 返回最终结果。
transform 接受两个参数,fun 和 xs,其中前者是函数,后者是数组或其他可迭代的集合对象。函数可以存储在变量中并使用。以下是一个简单的演示:
julia> sin(1.0)
0.8414709848078965
julia> g = sin
sin (generic function with 13 methods)
julia> g(1.0) ❶
0.8414709848078965
julia> add = + ❷
+ (generic function with 190 methods)
julia> add(2, 3)
5
❶ 调用 sin 函数
❷ 记住 Julia 中的加号运算符是一个函数。
这就是为什么你可以将 fun 用作函数并调用它,尽管它是对 transform 函数的参数。需要进一步解释的下一部分是
push!(xs, x)
这个函数将元素 x 添加到数组 xs 中。记住,感叹号警告你 push!函数可能会更改其输入。在这种情况下,xs 参数被修改。
你必须添加感叹号来调用正确的函数;感叹号是函数名的一部分,所以 push 和 push!会被视为两个不同的函数名。在 Julia 中,没有名为 push 的函数。如果它存在,你可以想象它会返回一个包含额外元素的新数组。
在你定义的函数中添加感叹号不是必需的,但你应该养成这种习惯,以帮助阅读你代码的其他开发者。这样,就可以很容易地看到变量可能被修改(突变)的地方。
小贴士 对于初学者来说,突变函数可能看起来不是什么大问题。然而,当你编写更大的程序时,你将开始注意到,突变输入的函数往往会使程序更难阅读和跟踪。感叹号有助于在阅读源代码时减轻心理负担。没有它,每个函数调用都可能潜在地修改其输入,使代码分析变得更加困难。
以下是一个简单的演示,说明 push!是如何工作的。你创建一个空数组 ys 并向其中添加数字。每次你都可以看到数组如何变大:
julia> ys = [] ❶
Any[]
julia> push!(ys, 3) ❷
1-element Vector{Any}:
3
julia> push!(ys, 8)
2-element Vector{Any}:
3
8
julia> push!(ys, 2)
3-element Vector{Any}:
3
8
2
❶ 创建一个空数组。
❷ 将数字 3 添加到数组中。
4.8 字符和字符串的介绍
到目前为止,你几乎只与数字打交道,但你的披萨表包含的不仅仅是数字。表中还包含文本,例如披萨的名称。你在 Julia 中如何处理文本?
让我们从最基本的构建块开始。文本由字符组成,单个字符在 Julia 中由 Char 类型表示。在计算机内存中,一切都是数字,包括字符。这里有一个小挑战:看看下面的例子,看看你是否能理解它:
julia> x = Int8(65) ❶
65
julia> ch = Char(65) ❷
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
julia> ch = Char(66)
'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
julia> 'A' ❸
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
julia> Int8('A') ❹
65
❶ 从 64 位数值为 65 的数值中创建一个 8 位有符号整数
❷ 从数字 65 创建一个字符
❸ 字符字面量
❹ 从字符字面量创建一个 8 位数字
你用单引号包围单个字符来创建字符字面量.^(1) 'A'和'Y'都是字符字面量。
这个代码示例表明,Julia 中的字符只是不同类型的数字。记得一个 UInt8 和一个 Int8 消耗相同数量的位,可以存储相同的数据,但解释方式不同吗?字符也是如此。虽然在内存中它们看起来相同,但类型决定了你可以对它们做什么。例如,你不能将两个字符相加,但你可以将一个数字加到一个字符上:
julia> 'A' + 'B'
ERROR: MethodError: no method matching +(::Char, ::Char)
julia> 'A' + 3
'D': ASCII/Unicode U+0044 (category Lu: Letter, uppercase)
你可以创建字符数组,就像你可以创建数字或布尔值的数组一样:
julia> chars = ['H', 'E', 'L', 'L', 'O']
5-element Vector{Char}:
'H': ASCII/Unicode U+0048
'E': ASCII/Unicode U+0045
'L': ASCII/Unicode U+004C
'L': ASCII/Unicode U+004C
'O': ASCII/Unicode U+004F
注意:为了提高可读性和清晰度,我偶尔会编辑 REPL 输出。例如,我会移除附加在字符上的(类别 Lu:大写字母)描述,因为它会产生大量的视觉噪音。
文本字符串只是字符的组合。注意,从输出中可以看出,文本字符串用双引号标识,而单个字符用单引号标识:
julia> join(chars)
"HELLO"
join 可以接受任何可迭代的对象(你可以在 for 循环中使用的对象)作为输入。因此,你可以提供一个字符范围:
julia> join('A':'G')
"ABCDEFG"
使用范围对象中的步长为‘2’跳过每个其他字符:
julia> join('A':2:'G')
"ACEG"
你可以收集文本字符串中的单个字符,因此你可以得到一个字符数组:
julia> collect("HELLO")
5-element Vector{Char}:
'H': ASCII/Unicode U+0048
'E': ASCII/Unicode U+0045
'L': ASCII/Unicode U+004C
'L': ASCII/Unicode U+004C
'O': ASCII/Unicode U+004F
collect 是一个多功能的函数;它可以将任何允许迭代多个值的对象转换为数组。因此,你可以收集范围:
julia> collect(2:5)
4-element Vector{Int64}:
2
3
4
5
julia> collect('B':'D')
3-element Vector{Char}:
'B': ASCII/Unicode U+0042
'C': ASCII/Unicode U+0043
'D': ASCII/Unicode U+0044
字符串和字符对于表示披萨数据非常有用。让我们看看你如何将每个披萨的信息捆绑在一起。
4.9 在元组中存储披萨数据
要做到这一点,你将使用与数组紧密相关的元组。要编写它们,将方括号[]替换为圆括号()。以下是一个描述胡椒披萨销售信息的元组示例。它表示一个小(S)夏威夷披萨以 10.50 美元的价格售出:
pizza_tuple = ("hawaiian", 'S', 10.5)
由于许多流行的语言,如 Python 和 JavaScript,都使用单引号和双引号来表示字符串,因此值得提醒读者,在这个例子中 'S' 表示一个字符,而不是字符串。在 Julia 中,你不能使用单引号来编写字符串:
julia> 'hawaiian'
ERROR: syntax: character literal contains multiple characters
因此,'S' 和 "S" 之间有一个重要的区别。后者是一个字符串,你必须将其视为字符的集合。这种区别类似于数字 42 和数组[42]之间的区别。与其编写一个元组来包含披萨数据,你还可以使用一个数组:
pizza_array = ["hawaiian", 'S', 10.5]
那么究竟有什么区别呢?数组是用来存储同质数据的。每个元素都必须是同一类型。但显然,在这个例子中,它们不是同一类型的。结果是数组的元素类型变成了 Any 类型:
julia> pizza = ["hawaiian", 'S', 10.5]
3-element Vector{Any}:
"hawaiian"
'S': ASCII/Unicode U+0053
10.5
julia> eltype(pizza)
Any
你将在稍后更详细地探索 Julia 的类型系统。现在你可以将 Any 理解为“任何都可以”。你可以将任何类型的值放入数组中。如果元素类型更具体,例如 Int64,这将不起作用:
julia> xs = [4, 5, 3]
3-element Vector{Int64}: ❶
4
5
3
julia> xs[1] = "hi"
ERROR: MethodError: Cannot `convert` an object of type String to an object
of type Int64 ❷
❶ Julia 推断每个元素都是 Int64 类型。
❷ 朱莉亚不知道如何将字符串转换为数字。
与此相反,披萨数组是完全无差别的,这意味着它们不关心对象类型。你可以将任何东西分配给单个元素,因为披萨数组的元素类型是 Any:
julia> pizza[3] = true
true
julia> pizza[1] = 42
42
julia> pizza
3-element Vector{Any}:
42
'S': ASCII/Unicode U+0053
true
另一方面,元组要严格得多。元组跟踪每个元素的类型;如果你对元组执行 typeof 操作,你可以看到这一点:
julia> pza = ("hawaiian", 'S', 10.5)
("hawaiian", 'S', 10.5)
julia> typeof(pza)
Tuple{String, Char, Float64}
其次,元组是不可变的,这意味着它们不能被更改。你只能从它们中读取值;你不能更改这些值。
julia> pza[1]
"hawaiian"
julia> pza[1] = "pepperoni"
ERROR: MethodError: no method matching
setindex!(::Tuple{String, Char, Float64}, ::String, ::Int64)
在其他方面,元组与数组非常相似。你可以像数组或范围一样遍历元组:
julia> for item in pza
println(item)
end
hawaiian
S
10.5
你可以将它们传递给 sum、median 和 mean 等函数,前提是它们实际上包含数字:
julia> nums = (3, 4, 1)
(3, 4, 1)
julia> sum(nums)
8
julia> median(nums)
3.0
你可以通过结合元组和数组来查看如何创建披萨销售数据列表(表 4.2),这是你希望在 Julia 中存储的数据。
表 4.2 披萨销售数据,其中每一行代表一个已售披萨
| 披萨 | 尺寸 | 价格 |
|---|---|---|
| 夏威夷披萨 | S | 10.5 |
| 西西里披萨 | S | 12.25 |
| 夏威夷披萨 | L | 16.5 |
| 烤鸡披萨 | L | 20.75 |
| 烤鸡披萨 | M | 16.75 |
你希望能够处理这些数据并找出诸如你总共赚了多少钱或你卖了多少个大披萨等信息。你可以在 Julia 中用以下方式表示:
julia> sales = [
("hawaiian", 'S', 10.5),
("sicilian", 'S', 12.25),
("hawaiian", 'L', 16.5),
("bbq chicken", 'L', 20.75),
("bbq chicken", 'M', 16.75)
]
你正在使用浮点数来表示货币数据,这是一个不好的选择。如果你为处理货币数据的客户构建软件,你应该始终使用定点数,^(2) 但为了教育目的,我保持事情简单。
为了更容易处理披萨数据,你将为不同的属性定义 访问器 函数^(3)。你将调用披萨尺寸部分的访问器,因为 Julia 已经在标准库中有一个名为 size 的函数:
name(pizza) = pizza[1]
portion(pizza) = pizza[2]
price(pizza) = pizza[3]
这些只是普通函数。在这里,我使用了 Julia 的单行语法来定义函数,但我也可以使用多行定义:
function price(pizza)
pizza[3]
end
记住,Julia 函数中的最后一个表达式是返回值。你不需要写 return pizza[3]。
这些披萨参数的类型是什么?是元组还是数组?实际上,它们是什么并不重要,因为索引访问在两者上以相同的方式工作。这些访问器函数与 map 一起使用很有用,因为它们允许你执行诸如获取所有披萨名称的操作:
julia> map(name, sales)
5-element Vector{String}:
"hawaiian"
"sicilian"
"hawaiian"
"bbq chicken"
"bbq chicken"
上述代码片段只是将名称函数应用于销售数组中的每个元素,并将所有结果值收集到一个新的数组中。
4.10 基于谓词过滤披萨
在有了有用的数据来操作后,我可以向你介绍下一个高级函数:
ys = filter(p, xs)
filter 函数接受一个值集合 xs,并返回这些值的子集 ys。xs 中包含在结果 ys 中的特定值由谓词 p 决定。你问什么是谓词?
定义 A 谓词 是一个函数,它接受一些值并始终返回一个布尔值,例如 true 或 false。
Julia 的标准库中包含了许多谓词函数。以下是一些示例:
julia> iseven(3) ❶
false
julia> iseven(2) ❶
true
julia> isodd(3) ❷
true
julia> isodd(4) ❷
false
❶ 检查数字是否为偶数(能被二整除)。
❷ 检查数字是否为奇数(不能被二整除)。
谓词不仅限于数字。还有针对字符的谓词:
julia> isuppercase('A') ❶
true
julia> isuppercase('a') ❶
false
julia> isspace(' ') ❷
true
julia> isspace('X') ❷
false
❶ 提供的字符是否为大写字母?
❷ 字符是否为空格?例如,x 是一个字母,不是一个空白空间。
谓词与 filter 函数结合使用非常有效。以下是从一个范围中获取偶数的示例:
julia> filter(iseven, 1:10)
5-element Vector{Int64}:
2
4
6
8
10
但要处理披萨数据,你需要定义自己的谓词,这允许我们检索特定尺寸或类型的披萨的销售情况:
issmall(pizza) = portion(pizza) == 'S'
islarge(pizza) = portion(pizza) == 'L'
isbbq(pizza) = name(pizza) == "bbq chicken"
4.10.1 组合高阶函数
你可以使用以下方法结合 map、访问器、过滤器和谓词来找出你卖大披萨或烧烤鸡肉披萨等赚了多少钱。首先,找到大披萨:
julia> filter(islarge, sales)
2-element Vector{Tuple{String, Char, Float64}}:
("hawaiian", 'L', 16.5)
("bbq chicken", 'L', 20.75)
接下来,你得到大披萨的价格:
julia> map(price, filter(islarge, sales))
2-element Vector{Float64}:
16.5
20.75
使用 sum,你可以计算出你卖大披萨赚了多少钱:
julia> sum(map(price, filter(islarge, sales)))
37.25
在最后的例子中,你确定卖烧烤鸡肉披萨赚了多少钱:
julia> bbq_sales = filter(isbbq, sales)
2-element Vector{Tuple{String, Char, Float64}}:
("bbq chicken", 'L', 20.75)
("bbq chicken", 'M', 16.75)
julia> sum(map(price, bbq_sales))
37.5
结果表明,在编程中,将许多值映射到另一组值,然后将所有这些值归一化到 1 是一种如此常见的做法,以至于它有自己专门的名称:mapreduce。在上一个例子中,你将烧烤鸡肉的销售项目映射到销售价格,然后将它们加起来。在函数式编程中,将所有数字加起来被称为归约。
4.11 映射和归约数组
使用 mapreduce 函数,你可以将最后一部分写成一个单独的函数调用:
julia> mapreduce(price, +, bbq_sales)
37.5
mapreduce 由两个高阶函数组成:map 和 reduce。为了演示它是如何工作的,创建你自己的 mapreduce 变体,称为 mapcompress,以避免命名冲突:
mapcompress(f, g, xs) = reduce(g, map(f, xs))
让我澄清 reduce 是如何工作的:它接受一个二元函数 g 作为第一个参数,然后使用这个函数将提供的第二个参数集合中的元素组合起来。
g(x, y) = ...
y = reduce(g, xs)
与 map 不同,reduce 需要一个接受两个参数的输入函数。这就是为什么它被称为二元函数。在 Julia 中,像+、-和*这样的常规数学运算符是二元函数。因此,你可以使用它们与 reduce 一起执行求和和阶乘的等效操作:
julia> sum(2:4)
9
julia> reduce(+, 2:4)
9
julia> factorial(4)
24
julia> reduce(*, 1:4)
24
注意:许多开发者发现 reduce 函数的命名不够直观。可能更好的名称是accumulate、aggregate或compress。在某些语言中,它被称为 inject。
4.11.1 使用 map 和 reduce 的正弦表
正弦函数本身实际上是一个映射和归约的经典案例。对于 sin(x)的每个参数,你得到一个无限序列的数字,通过加法将它们归约为一个值。这就是你实现自己的正弦函数(称为 sine)而不与内置的 sin 函数冲突的方式。
列表 4.4 使用泰勒级数实现的正弦函数
function sine(x)
n = 5
total = 0
for i in 0:n
total += (-1)^i*x^(2i+1)/factorial(2i + 1)
end
total
end
你可以使用 mapreduce 高阶函数更优雅地表达这个计算。
列表 4.5 通过泰勒级数上的 mapreduce 实现的正弦函数
function sinus(x)
n = 5
taylor(i) = (-1)^i*x^(2i+1)/factorial(2i + 1)
mapreduce(taylor, +, 0:n)
end
在这里,你正在做一件新的事情:在正弦函数内部,你定义了一个名为 taylor 的新函数,它接受单个参数 i。该函数用于计算泰勒级数中的一个项,即泰勒级数中要加上的一个数字。在函数内部定义函数是完全可能的。
但为什么不在外部定义这个函数呢?这是因为它使用了 x 变量,而 x 的值在正弦函数定义之外是未知的。如果你感到困惑,不要担心。这些概念将在本书后面的许多地方被重新审视,并且在那个时刻可能会更有意义。
mapreduce(taylor, +, 0:n)将首先将taylor函数应用于 0 到 n 范围内的每个值。这将产生一个值数组,然后使用加法运算符(+,mapreduce函数的第二个参数)将它们组合起来。
4.12 使用布尔数组计数匹配项
在第三章中,我提到将布尔值视为整数 0 或 1 可能是有用的。现在你已经接触到了数组,我们将探讨一个具体的例子。
列表 4.6 计数匹配谓词的披萨
julia> matches = map(islarge, sales)
5-element Vector{Bool}:
0
0
1
1
0
julia> sum(matches)
2
由于你正在组合一个映射和求和的高阶函数,你可以用单个mapreduce调用替换它。然而,在映射后添加元素是非常常见的,因此 Julia 的sum函数允许映射和加法。这为你提供了一种优雅的方式来计算大型披萨的数量、有多少是烤鸡披萨等等:
julia> sum(islarge, sales)
2
julia> sum(isbbq, sales)
2
sum函数将通过应用第一个参数作为谓词将所有的披萨销售额转换为布尔值。这将产生一个由零和一组成的数组,这些值将由sum函数相加。
在阅读完本章后,你已经成功学习了编程中最基本的概念。没有控制流和数据集合,编程将不会非常有用。能够轻松地处理多个数据元素的能力使得计算机如此灵活和强大。
摘要
-
Julia 支持许多不同类型的数组。向量是一维数组,而矩阵是二维数组。
-
在数学和 Julia 中,人们区分列向量和行向量。列向量最接近其他编程语言中所谓的数组。
-
Julia 将行向量表示为只有一行的矩阵,这就是为什么行向量与其他语言中的一维数组非常不同。
-
Julia 中的数组默认采用基于 1 的索引。这意味着第一个元素从索引 1 开始。
-
可以对整个数组中的每个元素执行数学运算。为此,在正常数学运算符前加上一个点:.+, .-, .*, 和 ./。
-
数组上的操作可以描述为映射、过滤或归约(例如,sum 和 mean 执行归约,因为多个值被归约为一个)。
-
zeros、ones、fill和rand函数使得创建具有大量元素的数组变得容易。 -
字符对象数组并不完全等同于字符串。字符必须组合在一起才能形成一个字符串。然而,字符串的行为与数组相似。
-
元组的行为与数组类似,但它们是不可变的,这意味着你不能改变它们。
^(1.)字符字面意思是 A 到 Z 之间的字符,而不是例如包含字符的变量或常量。
(2.)查阅 FixedPointDecimals.jl 或 CurrenciesBase.jl 以处理货币数据。
(3.)用于在更复杂的数据结构中设置和获取值的函数被称为访问器函数,或简称访问器。
5 处理文本
本章节涵盖
-
使用 String 类型表示文本
-
使用 lpad 和 rpad 格式化文本
-
从键盘或文件中读取文本
-
将文本写入屏幕或文件
-
创建一个简单的交互式程序
本章将重点介绍在 Julia 中处理文本的实际方面,例如如何在屏幕上显示文本以及如何读取或写入文件。你还将查看一个简单的交互式应用程序,其中用户对问题进行响应。
然而,首先我将关注在屏幕上显示文本的不同方式,重新审视你的比萨饼销售和正弦表示例。你之前创建的表格不太易读。如何创建一个像图 5.1 中那样的整洁显示?

图 5.1 使用 Unix 终端窗口中的对齐和颜色格式化显示余弦和正弦表
在这里,正弦和余弦值被整洁地排列在单独的列中。同样,将比萨饼销售信息整洁地组织到清晰分隔的列中,如图 5.2 所示会更好吗?

图 5.2 使用对齐和颜色在 Unix 终端窗口中格式化显示比萨饼销售情况
你将使用 printstyled 函数来着色文本,使用 rpad 和 lpad 函数来格式化输出。你将使用^运算符来重复字符。接下来,你将使用 open 函数来允许你使用 print 和 println 将文本输出写入文件。为了读取和处理输入,你将使用 readline、split 和 parse 函数。
5.1 制作一个漂亮的比萨饼销售表
你将从查看最终结果,即你想要编写的代码开始,然后逆向工作来解释你是如何到达那里的。代码不应该完全陌生,但我会解释一些新概念。
列表 5.1 创建一个漂亮的比萨饼销售表
function print_pizzatable(pizzas)
print("│ ")
printstyled(rpad("name", 12), color=:cyan)
print(" │ ")
printstyled("size", color=:cyan)
print(" │ ")
printstyled(rpad("price", 5), color=:cyan)
println(" │")
for pz in pizzas
print("│ ", rpad(name(pz), 12))
print(" │ ", rpad(portion(pz), 4), " │ ")
println(lpad(price(pz), 5), " │")
end
end
你有新的函数——printstyled、rpad 和 lpad,需要进一步探索和解释。为了让 print_pizzatable 函数正常工作,你还需要在第四章中定义的访问器函数。所有这些函数都接受一个比萨饼元组,例如("bbq chicken", 'L', 20.75),作为参数,并返回元组中的一个元素。
列表 5.2 比萨饼元组访问器函数
name(pizza) = pizza[1]
portion(pizza) = pizza[2]
price(pizza) = pizza[3]
我将更详细地介绍 print_pizzatable 中使用的每个函数,并附上一些简单的示例。我在过去的章节中只是简要介绍了 print 和 println,所以让我们来详细讲解。
5.1.1 打印、println 和 printstyled
这些是多功能函数,可以用来将文本写入你的屏幕或文件,甚至网络连接(详见第十八章以获取更多细节)。让我们看看一些简单的示例来展示这些函数是如何工作的:
julia> println("hello world")
hello world
julia> print("hello world")
hello world
嗯?它们不是在做完全相同的事情吗?不,但在这个例子中并不容易看出。相反,你将使用分号 ; 来分隔单行上的语句。这将有助于使差异更清晰:
julia> println("hello"); println("world")
hello
world
julia> print("hello"); print("world")
helloworld
julia> print("hello\n"); print("world\n")
hello
world
这段代码显示 println 只是一个简写,它将一个换行字符 \n 添加到末尾。Julia,像许多其他语言一样,允许你通过使用带有不同字母组合的反斜杠来表示不可见的控制字符,如换行。表 5.1 展示了你在 Julia 中可以使用的、用于影响文本写入 Unix 终端窗口的一些更常见的控制字符。
表 5.1 Unix 终端中使用的常见控制字符的转义序列
| 转义序列 | 十六进制值 | 效果 |
|---|---|---|
| \n | 0x0A | 换行 |
| \t | 0x09 | 水平制表符 |
| \v | 0x0B | 垂直制表符 |
| \r | 0x0D | 光标返回 |
| \b | 0x08 | 退格 |
| \ | 0x5C | 反斜杠 |
| " | 0x22 | 双引号 |
双引号不是一个控制字符,但既然你用它来标记字符串的开始和结束,你需要使用转义序列来表示它。但知道十六进制值有什么用?你可以直接使用它来创建字符。在这里,使用 0x0a 十六进制值创建了一个新行:
julia> newln = Char(0x0a)
'\n': ASCII/Unicode U+000A
julia> print("hi"); print(newln); print("world")
hi
world
让我们看看使用这些不同的转义序列与常规文本结合使用的一些更多示例效果:
julia> println("hello \v world") ❶
hello
world
julia> println("hello \n world")
hello
world
julia> println("hello \r world") ❷
world
julia> println("ABC\n\tABC\n\tABC")
ABC
ABC
ABC
❶ 这使用的是垂直制表符 \v,而不是更广为人知的水平制表符 \t。
❷ 光标返回将光标移动到行的开头。因此,单词 "world" 覆盖了最初写入的 "hello"。
这需要一些上下文。为什么你的文本字符串中的字符会导致光标移动?这与你今天使用的文本打印系统的历史有关。当 Unix 操作系统最初开发时,没有像你现在使用的这样的电子显示器。相反,计算机用户使用了一种称为 电传打字机 的电磁机械设备(图 5.3)。电传打字机在操作上与老式的打字机非常相似。

图 5.3 西门子 Teletype Type 68D(挪威技术博物馆)
这些设备也充当了你的屏幕。如果计算机想要给你一些信息,它必须将字符发送到你的打字机,这将导致它将发送的字符打印到纸上。这产生了对控制字符的需求,即控制你的电传打字机的字符,告诉它创建新行或将光标向下或向后移动。
你今天使用的终端应用程序是这些旧式电传打字机的模拟器。这样,为电传打字机编写的程序仍然可以工作。Unix 命令如 ls、cp、cat 和 echo 对它们运行在现代计算机上的电子显示器一无所知。就它们而言,它们正在与一台好用的旧式电传打字机交互。
最终,这些基于纸张的终端被电子终端所取代。在此阶段,通过添加新的控制字符来扩展控制字符以表示颜色。例如,当电子终端接收到转义序列\u001b[33m 时,它会切换到以黄色字母书写。如果接收到\u001b[31m,它会以红色字母书写。因此,要使用黄色字母打印 hello world,您可以编写以下代码:
julia> print("\u001b[33m hello world")
hello world
然而,记住这些不同颜色的转义序列是繁琐的。因此,Julia 提供了 printstyled 函数,允许您通过名称指定要使用的颜色。颜色通过关键字参数 color 指定:
julia> printstyled("hello world", color = :cyan)
hello world
此语句以青色打印出“hello world”。您可以通过查看 printstyled 的帮助来了解可以使用哪些颜色。只需将文本光标移至行首(Ctrl-A)并按?键进入帮助模式:
help?> printstyled("hello world", color = :cyan)
printstyled([io], xs...; bold=false, color=:normal)
Print xs in a color specified as a symbol or integer,
optionally in bold.
color may take any of the values :normal, :default,
:bold, :black, :blink, :blue, :cyan, :green, :hidden,
:light_black, :light_blue, :light_cyan, :light_green,
:light_magenta, :light_red, :light_yellow, :magenta,
:nothing, :red, :reverse, :underline, :white, or :yellow
or an integer between 0 and 255 inclusive. Note that not
all terminals support 256 colors. If the keyword bold is
given as true, the result will be printed in bold
所有颜色都表示为符号。符号与文本字符串非常相似。它通常用于文本字符串,这些字符串对程序员很重要,但对程序的用户来说并不重要。您可以通过编程方式创建符号对象:
julia> sym = Symbol("hello")
:hello
julia> sym == :hello
true
5.1.2 打印多个元素
所有打印函数在允许您打印的内容和可以打印的元素数量方面都非常灵活:
julia> print("abc", 42, true, "xyz")
abc42truexyz
当将数字和布尔值作为参数传递给各种打印函数时,它们会被转换为文本字符串。了解字符串函数以完全相同的方式工作,除了它返回一个字符串而不是打印到屏幕上:
julia> string("abc", 42, true, "xyz")
"abc42truexyz"
这允许您使用单个 println 语句显示有关一个披萨的信息。注意使用之前定义的访问器函数:
julia> pizza = ("hawaiian", 'S', 10.5)
("hawaiian", 'S', 10.5)
julia> println(name(pizza), " ", portion(pizza), " ", price(pizza))
hawaiian S 10.5
5.1.3 打印多个披萨
您可以使用此功能编写有关披萨销售的简单表格。
列表 5.3 基本披萨表格打印
pizzas = [
("hawaiian", 'S', 10.5),
("mexicana", 'S', 13.0),
("hawaiian", 'L', 16.5),
("bbq chicken", 'L', 20.75),
("sicilian", 'S', 12.25),
("bbq chicken", 'M', 16.75),
("mexicana", 'M', 16.0),
("thai chicken", 'L', 20.75),
]
for pz in pizzas
println(name(pz), " ", portion(pz), " ", price(pz))
end
此代码的问题在于名称、尺寸和价格列没有对齐,最终变成这样:
hawaiian S 10.5
mexicana S 13.0
hawaiian L 16.5
bbq chicken L 20.75
sicilian S 12.25
bbq chicken M 16.75
mexicana M 16.0
thai chicken L 20.75
要执行对齐,您需要使用 lpad 和 rpad 函数添加左侧填充和右侧填充。
5.1.4 使用 lpad 和 rpad 对齐
使用 Julia 的填充函数,您可以指定文本字符串应始终具有给定的长度。如果您提供的文本较小,它将使用所选字符进行填充。如果没有指定字符,填充字符默认为空格。
julia> lpad("ABC", 6, '-') ❶
"---ABC"
julia> rpad("ABC", 6, '-') ❷
"ABC---"
❶ 左侧填充
❷ 右侧填充
首先,您使用-字符在左侧填充,直到整个字符串长度为 6 个字符。第二个示例与第一个示例相同,只是您使用-字符在右侧填充。
使用 lpad 和 rpad,您可以定义表格中每列的宽度,并在提供的文本字符串较短的地方添加填充,例如空格。在这个例子中,您将保持简单,并检查每列中最宽的字符串的宽度:
julia> length("thai chicken") ❶
12
julia> length("size") ❷
4
julia> max(length("16.75"), length("price")) ❸
5
❶ 披萨名称列的宽度
❷ 尺寸列的宽度
❸ 价格列的宽度
让我们修改初始代码以使用填充。你可以直接将此代码粘贴到你的终端中,或者将其存储在一个文件中,然后用例如 include 命令将其加载到终端中。注意在代码中你没有指定填充字符。如果你没有指定,它将默认为空格。
列表 5.4 简单对齐披萨表
function simple_pizzatable(pizzas)
pname = rpad("name", 12)
psize = rpad("size", 4)
pprice = rpad("price", 5)
printstyled(pname, " ",
psize, " ",
pprice,
color=:cyan) ❶
println()
for pz in pizzas
pname = rpad(name(pz), 12)
psize = rpad(portion(pz), 4)
pprice = lpad(price(pz), 5) ❷
println(pname, " ", psize, " ", pprice)
end
end
❶ 你使用青色来描述每一列标题的颜色。
❷ 数字在左侧填充,因此小数对齐。
你可以在终端中测试这个:
julia> simple_pizzatable(pizzas)
name size price
hawaiian S 10.5
mexicana S 13.0
hawaiian L 16.5
bbq chicken L 20.75
sicilian S 12.25
bbq chicken M 16.75
mexicana M 16.0
thai chicken L 20.75
注意小数点没有对齐。有许多方法可以解决这个问题,但在下一个关于三角函数表的例子中,你将确保每个数字后面都有相同数量的小数。
5.1.5 添加线条
添加分隔线实际上非常简单。你只需使用长垂直线的符号:'│':
julia> '│'
'│': Unicode U+2502 (category So: Symbol, other)
这是 Julia 告诉你这个字符由十六进制值 0x2502 表示的方式。因此,你可以通过两种不同的方式得到这个字符:
julia> Char(0x2502)
'│': Unicode U+2502
julia> '\U2502'
'│': Unicode U+2502
事实上,有许多有用的字符可以用来制作表格:
julia> collect("─├┼┤")
3-element Vector{Char}:
'├': Unicode U+251C
'─': Unicode U+2500
'┼': Unicode U+253C
'┤': Unicode U+2524
要绘制线条,了解如何轻松重复字符是有用的。在 Julia 中,指数运算符 ^ 用于重复字符:
julia> "A2"³
"A2A2A2"
julia> "-"⁴
"----"
julia> "─"²
"──"
5.2 打印三角函数表
你可以用你学到的知识来创建一个三角函数表。此代码使用了你之前已经看到的一些相同函数:print、printstyled、lpad 和 rpad。不要尝试运行此函数;我只是在给你一个概述。
列表 5.5 创建三角函数表
function print_trigtable(inc, maxangle)
print("│ ") ❶
printstyled("θ ", color=:cyan) ❶
print(" │ ") ❶
printstyled(rpad("cos", n), ❶
color=:cyan) ❶
print(" │ ") ❶
printstyled(rpad("sin", n), ❶
color=:cyan) ❶
println(" │") ❶
angle = 0
while angle <= maxangle ❷
rad = deg2rad(angle) ❷
cosx = format(cos(rad)) ❷
sinx = format(sin(rad)) ❷
print("│ ") ❷
print(lpad(angle, 3), " │ ", ❷
lpad(cosx, 6), " │ ", ❷
lpad(sinx, 6)) ❷
println(" │") ❷
angle += inc ❷
end ❷
end
❶ 为数字的每一列写出标题。
❷ 为每个角度写出三角值的一行。
这段代码中并没有太多新的想法;我在第三章中已经介绍了核心逻辑。然而,数字需要特殊处理以对齐小数点。你需要每个数字后面都有相同数量的数字,而且你不想数字太长。如果你直接使用这些结果,你会得到太多的数字:
julia> rads = map(deg2rad, 0:15:90);
julia> map(sin, rads)
7-element Vector{Float64}:
0.0
0.25881904510252074
0.49999999999999994
0.7071067811865475
0.8660254037844386
0.9659258262890683
1.0
而你想要的是这样的:
julia> print_trigtable(15, 180)
│ θ │ cos │ sin │
│ 0 │ 1.000 │ 0.000 │
│ 15 │ 0.966 │ 0.259 │
│ 30 │ 0.866 │ 0.500 │
│ 45 │ 0.707 │ 0.707 │
│ 60 │ 0.500 │ 0.866 │
│ 75 │ 0.259 │ 0.966 │
│ 90 │ 0.000 │ 1.000 │
│ 105 │ -0.259 │ 0.966 │
│ 120 │ -0.500 │ 0.866 │
│ 135 │ -0.707 │ 0.707 │
│ 150 │ -0.866 │ 0.500 │
│ 165 │ -0.966 │ 0.259 │
│ 180 │ -1.000 │ 0.000 │
为了实现这一点,你有一个辅助函数,你可以在主代码列表中看到它:format。
列表 5.6 格式化数字的辅助函数
n = length("-0.966")
function format(x)
x = round(x, digits=3) ❶
if x < 0
rpad(x, n, '0') ❷
else
rpad(x, n-1, '0')
end
end
❶ 四舍五入到三位小数。
❷ 如果你有一个负数,你必须为负号留出额外的字符。
n 存储数字的最大字符宽度。我基本上使用了一个最坏的情况,比如负数,以获取数字字符串所需的最多字符:
julia> format(3.1)
"3.100"
julia> format(-3.1)
"-3.100"
你可以在前面的例子中看到,当你使用负数时,你必须允许更多的字符。稍后你将使用 lpad 和空格打印,这意味着无论数字是正数还是负数,总宽度都不会改变:
julia> lpad(format(4.2), 6)
" 4.200"
julia> lpad(format(-4.2), 6)
"-4.200"
5.3 读取和写入披萨销售到 CSV 文件
你迄今为止处理的数据是以表格格式存在的,这正是电子表格应用程序设计来处理的内容。在各类电子表格应用程序和科学应用程序之间交换数据的一种非常常见的文件格式被称为 CSV,它是“逗号分隔值”的缩写。你将实现一个 store_pizzatable 函数,用于将比萨饼数据以 CSV 格式写入文件,以及一个 load_pizzatable 函数用于读取相同的 CSV 文件。以下是一个示例,展示了这两个函数都将处理的比萨饼数据 CSV 文件格式:
name,size,price
hawaiian,S,10.5
mexicana,S,13.0
hawaiian,L,16.5
bbq chicken,L,20.75
sicilian,S,12.25
bbq chicken,M,16.75
mexicana,M,16.0
thai chicken,L,20.75
第一行被称为“标题”。它为文件中的每一列命名。对于每一行,你使用逗号分隔每个值。图 5.4 展示了将此类 CSV 文件加载到 Apple Numbers 中的示例。

图 5.4 将比萨饼销售数据加载到 Apple Numbers 电子表格应用程序中
假设你产生了大量想要分享、在表格中检查或图形化表示的有用计算;导出为 CSV 格式有助于此。Julia 已经内置了用于此目的的函数,以及非常好的外部库,如 CSV.jl 在 csv.juliadata.org。然而,你的重点将放在学习文件读取和写入的基本知识上;因此,你将不会使用外部包或函数。
5.3.1 将比萨饼销售数据写入文件
你将定义一个简单的函数,名为 store_pizzatable,该函数以逗号分隔值的形式输出比萨饼销售数据。
列表 5.7 导出比萨饼销售数据为逗号分隔值的函数
function store_pizzatable(io, pizzas)
println(io, "name,size,price") ❶
for pz in pizzas ❷
println(io, name(pz), ",",
portion(pz), ",",
price(pz))
end
end
❶ 写出 CSV 标题。
❷ 每售出一款比萨饼就写一行
这个函数对你来说应该很熟悉。新的地方在于 println 函数接受一个新的第一个参数,名为 io。这带来了一些常见的陷阱,所以让我首先错误地使用这个函数:
julia> store_pizzatable("-->", pizzas[1:3])
-->name,size,price
-->hawaiian,S,10.5
-->mexicana,S,13.0
-->hawaiian,L,16.5
这是可预测的。它只是将 io 对象作为常规文本字符串写出来。但如果第一个参数是特殊类型——不是字符串,不是数字,也不是布尔值,而是一个 IO 对象,那么你将改变 print 和 println 函数输出结果的位置。
println("hello") 实际上是 println(stdout, "hello") 的简写。什么是 stdout?它是“标准输出”的缩写,代表了打印的目的地。stdout 代表了打印任何内容的默认目的地;默认情况下是你的终端窗口;然而,目的地可以是文件,甚至是网络连接。你可以尝试使用 stdout 而不是字符串"--->",尽管结果可能相当无聊:
julia> store_pizzatable(stdout, pizzas[1:3])
name,size,price
hawaiian,S,10.5
mexicana,S,13.0
hawaiian,L,16.5
当你提供一个文件作为目的地时,事情会变得更有趣。为了做到这一点,你需要创建一个表示文件的 IO 对象。
julia> io = open("pizza-sales.csv", "w") ❶
IOStream(<file pizza-sales.csv>)
julia> store_pizzatable(io, pizzas) ❷
julia> close(io) ❸
❶ 打开文件 pizza-sales.csv。第二个参数 w 告诉 Julia 你想要以写入模式打开它。因此,如果文件不存在,它将被创建。
❷ 使用 io 对象将比萨饼销售数据写入打开的文件。
❸ 当你完成时,必须关闭与文件的连接。读写文件可以是缓冲的。因此,除非你关闭,否则不一定所有数据都已经写入。
你可以通过在行首写分号;进入 shell 模式。无论何时你想回到 Julia 模式,你都可以在行首按 Backspace 键。进入 shell 模式,并使用 Unix cat 命令查看你创建的文件:
shell> cat pizza-sales.csv
name,size,price
hawaiian,S,10.5
mexicana,S,13.0
hawaiian,L,16.5
bbq chicken,L,20.75
sicilian,S,12.25
bbq chicken,M,16.75
mexicana,M,16.0
thai chicken,L,20.75
5.3.2 从文件中读取披萨销售数据
你可能经常从互联网上下载 CSV 文件,你想要从中读取。从学校成绩到失业率到人均 GDP 的统计数据都可以下载为 CSV 文件。
你可以打开 pizza-sales.csv 文件并尝试从中读取。有许多巧妙的方法可以做到这一点,我将在第十七章中更详细地介绍。这个示例使用 readline 函数保持简单;它一次读取一行:
julia> io = open("pizza-sales.csv")
IOStream(<file pizza-sales.csv>)
julia> line = readline(io)
"name,size,price"
julia> line = readline(io)
"hawaiian,S,10.5"
读取数据的来源不一定是文件。正如我们之前讨论的,你的终端窗口被视为一个名为 stdout 的 IO 对象。还有一个代表你的键盘的 IO 对象,称为 stdin。这为你提供了一种读取键盘输入的方法:
julia> s = readline(stdin)
hello ❶
"hello" ❷
julia> print(s)
hello
❶ 你输入的文本,被 readline 捕获
❷ 存储在 s 中的值。注意,这里使用引号来表示这个值是一个字符串。
为了更好地理解其工作原理,最好是尝试另一个示例。你将很快制作一个简单应用程序,利用这个功能。
总之,让我们回到披萨的话题。你如何将逗号分隔的字符串转换成披萨元组或数组?为此,你可以使用 split 函数。这个函数允许你将字符串分割成多个部分,并将这些部分收集到一个数组中:
julia> pizza = split(line, ',')
3-element Vector{SubString{String}}:
"hawaiian"
"S"
"10.5"
然而,将它们视为披萨存在一些问题。比如说,你想添加 25%的增值税。这是行不通的:
julia> p = price(pizza)
"10.5"
julia> p*1.25
ERROR: MethodError: no method matching *(::SubString{String}, ::Float64)
这个问题存在是因为价格 p 实际上不是一个数字,而是一个字符串(或者更具体地说,是一个子字符串,但让我们不要陷入细节):
julia> typeof(p)
SubString{String}
这适用于从文件中读取的任何内容;Julia 会将它视为文本。它无法知道你可能希望文件的一部分以数字、布尔值或其他形式表示。然而,Julia 有一个名为 parse 的函数,它允许你将文本字符串转换为其他任何形式。在下面的代码片段中,你可以看到将文本转换为数字的示例:
julia> parse(Int, "42")
42
julia> parse(Float64, "42")
42.0
julia> parse(Bool, "true")
true
julia> parse(Bool, "1")
true
在这里,数字 42 被解析了两次。在第一种情况下,它被转换成整数,而在第二种情况下,它被转换成浮点数。因此,相同的文本可以以许多不同的方式被解释。如何解释它取决于你。
还有一些完全不同的文本字符串可以被解释为相同的对象。例如,“true”和“1”都可以被解析为布尔值 true。有了这些构建块,你可以构建一个披萨加载函数。
列表 5.8 加载披萨销售数据
function load_pizzatable(io)
pizzas = [] ❶
readline(io) ❷
while !eof(io) ❸
pz = split(readline(io), ',')
pr = parse(Float64, price(pz)) ❹
sz = portion(pz)
push!(pizzas, (name(pz), sz[1], pr)) ❺
end
pizzas
end
❶ 存储披萨列表的地方
❷ 跳过标题名称、大小、价格。
❸ eof 是文件结束的缩写。当没有更多内容可读时,此函数将为真。
❹ 使用 parse 将价格文本字符串转换为实际数字。
❺ 在您的披萨列表中添加一个条目。注意 sz[1];这是您将字符串“M”转换为字符'M'的方法。
如果您在 REPL 中尝试此函数,您应该得到一个类似于以下的结果:
julia> io = open("pizza-sales.csv");
julia> pizzas = load_pizzatable(io)
8-element Vector{Any}:
("hawaiian", 'S', 10.5)
("mexicana", 'S', 13.0)
("hawaiian", 'L', 16.5)
("bbq chicken", 'L', 20.75)
("sicilian", 'S', 12.25)
("bbq chicken", 'M', 16.75)
("mexicana", 'M', 16.0)
("thai chicken", 'L', 20.75)
julia> close(io)
5.4 与用户交互
让我们创建一个交互式应用程序,演示通过 stdin 读取用户输入的实用性。这个应用程序是受一个简单应用程序的启发,该应用程序帮助我的孩子们练习乘法表(图 5.5)。

图 5.5 运行乘法测试应用程序
该应用程序会反复要求用户乘以两个数字并检查答案。最后,您会得到一个关于您正确回答次数的总结。让我们看看练习函数的实现。
列表 5.9 练习乘法:要求用户写出 n 个答案
function practice(n)
correct = 0 ❶
for i in 1:n
x = rand(2:9) ❷
y = rand(2:9)
print(x, " * ", y, " = ")
answer = readline(stdin)
z = parse(Int, answer) ❸
if z == x*y ❹
correct += 1
else
printstyled("Wrong, it is ", x*y, color = :red)
println()
end
end
println("Correct: ", correct, " of ", n)
end
❶ 跟踪用户正确回答的次数。
❷ 范围在 2 到 9 之间的随机数
❸ 将用户输入的数字转换为整数。
❹ 检查用户是否回答正确。
您可以通过从 REPL 调用练习函数来启动此程序。假设您想练习八种不同的乘法。您会写下以下内容:
julia> practice(8)
当您处理更复杂的函数并试图理解它们时,您可以通过简单地复制和粘贴像这样的代码行来探索它们的工作原理:
julia> x = rand(2:9)
3
julia> y = rand(2:9)
6
julia> print(x, " * ", y, " = ")
3 * 6 =
julia> answer = readline(stdin)
18
"18"
优点是您可以看到每个表达式的值。这允许您看到值是如何在多个步骤中转换的。例如,您可以探索为什么直接比较答案不起作用:
julia> z = parse(Int, answer)
18
julia> answer == x*y
false
julia> z == x*y
true
为了巩固您的理解,您可以尝试改进这个程序。以下是一些想法:计时。记录您回答问题所花费的时间。您可以使用 time()函数来完成此目的。记录提问前后的时间,看看差异。您可能希望使用 round()函数将时间四舍五入到最接近的秒。使用 Julia 的帮助系统查看如何最好地使用这些函数。
您还可能希望将 rand 使用的范围作为参数传递给练习函数。您可能想要练习特定范围的数字。尝试使用──、├、┤和┼符号创建漂亮的表格也可能很有趣。
摘要
-
print、println 和 printstyled 都可以用来将文本发送到目的地,如终端窗口或文件。
-
在终端中,可以使用特殊的控制字符序列来编写彩色文本。printstyled 简化了这个任务,因此您只需记住不同颜色的名称。
-
要写入文件或从文件中读取,您需要打开它们。当您完成时,您需要关闭它们。
-
在读取文件时,您可以检查是否完成 eof。
-
lpad 和 rpad 函数可以帮助您在列中对齐文本。这是通过在左侧或右侧填充所选字符来完成的,直到达到所需的字符串宽度。
-
文本可以写入或从 IO 对象中读取。IO 对象是真实物理事物的占位符,例如硬盘上的文件、键盘、网络连接或终端窗口——甚至电传打字机。
-
使用 parse 函数可以将文本字符串转换为各种对象。
6 在字典中存储数据
本章涵盖
-
在字典中通过键存储值
-
与成对对象一起工作
-
使用元组创建字典
-
比较字典和数组
-
比较命名元组和字典
本章介绍了一种新的数据类型,称为字典。在其他一些语言中,这种数据类型也被称为映射。在字典中,通过键查找值,而不是像数组那样仅使用整数索引进行查找。代码示例说明了这种差异。每一行执行以下操作:
-
在数组 xs 中查找第 42 个值 x。数组中的值是有序的。然而,xs 也可以是一个字典,因为字典的键可以是任何东西,包括整数。
-
在字典 ys 中通过键"foo"查找值 y。
-
在字典 zs 中使用字符'D'而不是字符串作为键来查找值 z。
x = xs[42]
y = ys["foo"]
z = zs['D']
通过一个涉及将罗马数字转换为十进制值以及反向转换的代码示例,你会发现字典的实用性。字典将用于跟踪字母 I、V 或 X 在十进制系统中的对应值。
6.1 解析罗马数字
虽然罗马数字在当今不太实用,但了解它们对于理解数字系统是有用的。特别是,在编程时,你会遇到各种数字系统。
罗马数字和二进制——计算机使用的系统——可能看起来非常繁琐。然而,它们往往看起来是这样,因为你没有按照它们本来的意图使用这些数字。
与阿拉伯数字(通常用于数字)相比,使用罗马数字用笔和纸进行计算比较困难。然而,罗马人并不是用笔和纸进行计算的。相反,他们使用罗马算盘(图 6.1)进行计算。

图 6.1 一个带有代表不同值的鹅卵石的罗马算盘。列决定了每个鹅卵石赋予的价值。
它被分为多个列。从右到左,你可以看到标记为 I、X 和 C 的鹅卵石列;它们各自包含四个鹅卵石。每个鹅卵石代表不同的值,具体取决于它们所在的列:
-
在 I 列中,每个鹅卵石代表 1。
-
在 X 列中,每个鹅卵石代表 10。
-
在 C 列中,每个鹅卵石代表 100。
这些列中每个都包含一个鹅卵石。它们被称为 V、L 和 D,分别代表 5、50 和 500。(在罗马算盘上实际上看不到 VLD 字母。)
注意:罗马数字系统的美在于你可以快速写下算盘上鹅卵石所表示的确切数值。同样,将鹅卵石排列在罗马算盘上以匹配你读到的罗马数字也很迅速。因此,在欧洲,罗马数字一直被使用到 1500 年,尽管阿拉伯数字已经引入。
让我们看看如何使用这些知识来解析罗马数字并将它们转换为阿拉伯数字。将以下代码放入文本文件中,并保存。不用担心新的语法;我们将在后面介绍。
列表 6.1 解析并将罗马数字转换为十进制数字
roman_numerals =
Dict('I' => 1, 'X' => 10, 'C' => 100,
'V' => 5, 'L' => 50, 'D' => 500,
'M' => 1000)
function parse_roman(s)
s = reverse(uppercase(s))
vals = [roman_numerals[ch] for ch in s]
result = 0
for (i, val) in enumerate(vals)
if i > 1 && val < vals[i - 1]
result -= val
else
result += val
end
end
result
end
将此文件加载到 Julia REPL 环境中以进行测试。这是使用不同罗马数字作为输入的 parse_roman 的一个示例:
julia> parse_roman("II")
2
julia> parse_roman("IV")
4
julia> parse_roman("VI")
6
julia> parse_roman("IX")
9
julia> parse_roman("XI")
11
让我们看看代码是如何工作的。
6.2 使用 Dict 类型
您使用所谓的 字典 将罗马字母 I、V、X 等映射或转换为数字。字典由多个对组成;对是通过箭头操作符 => 构建的。您不能使用等号操作符 =,因为它用于赋值。x = y 将 y 的值赋给变量 x,而 x => y 则在 x 和 y 中创建一个值对:
julia> 'X' => 10 ❶
'X' => 10
julia> pair = 'X' => 10 ❷
'X' => 10
julia> dump(pair) ❸
Pair{Char,Int64}
first: Char 'X'
second: Int64 10
julia> pair.first ❹
'X': ASCII/Unicode U+0058 (category Lu: Letter, uppercase)
julia> pair.second
10
❶ 字母 X 和数字 10 的一个对
❷ 对可以存储在变量中并在以后进行检查。
❸ dump 允许您查看任何值的字段。
❹ 提取对中的第一个值
对是复合对象,具有 first 和 second 字段。这些字段允许您访问在构建对时给出的两个值。然而,您应该将其视为实现细节,并使用 first 和 last 访问器函数访问对的字段。这种行为使对与第三章中介绍的 range 对象和第四章中介绍的元组非常相似:
julia> range = 2:4
2:4
julia> pair = 8=>9
8 => 9
julia> tuple = (3, 'B')
(3, 'B')
julia> first(range), first(pair), first(tuple)
(2, 8, 3)
julia> last(range), last(pair), last(tuple)
(4, 9, 'B')
在此代码示例中,我通过在行中用逗号分隔来访问多个值。这产生了一个包含三个值的元组。
看起来可能有些困惑,因为对中的第二个字段是通过函数 last 访问的。原因是数组也有最后一个元素。因此,last 在多个集合类型中具有更好的泛化能力。
注意 出于好奇,您可能会尝试在字典对象上使用 dump 函数。它具有 slots、idxfloor、maxprobe 等字段,这些字段可能不会很有意义。这是因为 dump 揭示了实现细节。作为数据类型的使用者,您不需要知道它有哪些字段,只需要知道您可以使用哪些函数来操作它。
您提供这些对的一个列表来创建一个字典。以下代码显示了如何创建一个字典,将罗马数字使用的字母映射到它们对应的十进制值。
julia> roman_numerals =
Dict('I' => 1, 'X' => 10, 'C' => 100,
'V' => 5, 'L' => 50, 'D' => 500,
'M' => 1000)
Dict{Char,Int64} with 7 entries:
'M' => 1000
'D' => 500
'I' => 1
'L' => 50
'V' => 5
'X' => 10
'C' => 100
在字典中使用时,您将每个对中的第一个值称为字典中的 键。每个对中的第二个值形成字典的 值。因此,I、X 和 C 是键,而 1、10 和 100 是值。
您可以向字典询问与键对应的值。这需要一个罗马字母并返回相应的值:
julia> roman_numerals['C']
100
julia> roman_numerals['M']
1000
6.3 遍历字符
您可以使用这个字典来帮助您将罗马字母转换为相应的数值。在parse_roman函数的第 8 行,您使用所谓的数组推导式进行这种转换。您遍历字符串s中的每个字符ch。在每次迭代中,您评估roman_numerals[ch],所有这些值都被收集到一个数组中:
vals = [roman_numerals[ch] for ch in s]
推导式就像一个 for 循环,其中每个迭代都会评估一个值并将其添加到集合中。您可以为任何集合创建推导式,包括字典:
julia> Dict('A'+i=>i for i in 1:4)
Dict{Char, Int64} with 4 entries:
'C' => 2
'D' => 3
'E' => 4
'B' => 1
但在罗马数字代码中,推导式循环用于构建一个数组。为了更好地理解数组推导式的工作原理,让我们看看一个执行完全相同任务的常规 for 循环。在这个例子中,您从罗马数字“XIV”开始,您想要将其转换:
julia> s = "XIV"
"XIV"
julia> vals = Int8[]
Int8[]
julia> for ch in s
push!(vals, roman_numerals[ch])
end
julia> vals
3-element Vector{Int8}:
10
1
5
“XIV”被转换成值数组[10, 1, 5],命名为vals。然而,工作还没有完成。稍后,您需要将这些值组合成一个数字。
在转换输入字符串之前,代码将每个字母都转换为大写。例如,“xiv”将无法正确处理,因为字典的所有键都是大写的。
我将向您介绍这个过程的工作原理,并将解释为什么执行这些步骤的原因留到最后。反转字母的顺序,这样您就可以在循环中从右到左方便地处理数字:
julia> s = "xiv"
"xiv"
julia> s = reverse(uppercase(s))
"VIX"
6.4 枚举值和索引
在循环中处理值val时,您希望能够与前面的值进行比较。您可以使用一个变量,比如prev,来存储前一次迭代的值。相反,您将使用enumerate函数来获取每个值val处理的索引i。那么,val前面的值就是vals[i-1]:
for (i, val) in enumerate(vals)
if i > 1 && val < vals[i - 1]
result -= val
else
result += val
end
end
为了更好地理解enumerate的工作原理,让我们使用一些专注于enumerate的示例:
julia> enumerate([4, 6, 8])
enumerate([4, 6, 8])
那个输出毫无用处。原因是enumerate是惰性的。您不会得到任何值,因为此表达式实际上不需要评估任何值。但您可以使用collect函数收集enumerate本应产生的所有值到一个数组中。以下是一个收集范围的简单示例:
julia> collect(2:3:11)
4-element Vector{Int64}:
2
5
8
11
更有趣的是如何从枚举中收集值:
julia> collect(enumerate(2:3:11))
4-element Vector{Tuple{Int64, Int64}}:
(1, 2)
(2, 5)
(3, 8)
(4, 11)
julia> collect(enumerate([4, 6, 8]))
3-element Vector{Tuple{Int64, Int64}}:
(1, 4)
(2, 6)
(3, 8)
collect函数将模拟循环操作,就像 for 循环一样,但它会将遇到的全部值收集到一个数组中,然后返回。因此,您可以看到,使用enumerate,您在每次迭代时都会得到一对值:一个整数索引和该索引处的值。
6.5 解释转换过程
您不能简单地将转换成相应数值的罗马字母相加。以罗马数字 XVI 为例。它变成了[10, 5, 1]。您可以相加这些元素并得到正确的结果:16。然而,XIV 应该表示 14,因为在IV这样的情况下,较小的罗马数字位于较大的数字之前,您需要从较大的数字中减去较小的值。
你不能只是将相应的数组 [10, 1, 5] 相加。相反,你需要反转它,以便通过值向后工作。在每一个索引处,你都会询问当前值是否低于前一个值。如果是,则从结果中减去;否则,加上下一个值:
if i > 1 && val < vals[i - 1]
result -= val
else
result += val
end
这就是 val < vals[i - 1] 的作用。它将当前值 val 与前一个值 vals[i - 1] 进行比较。result 用于累积所有单个罗马字母的值。
6.6 使用字典
现在你已经查看了一个使用 Julia 中的字典类型 Dict 的实际代码示例,让我们探索更多与字典交互的方法。
6.6.1 创建字典
创建字典有多种方法。在本节中,我将讨论一些示例,从多个参数开始,其中每个参数都是一个对对象:
julia> Dict("two" => 2, "four" => 4)
Dict{String,Int64} with 2 entries:
"two" => 2
"four" => 4
将一个对数组传递给字典构造函数(一个与它创建的实例类型同名的函数):
julia> pairs = ["two" => 2, "four" => 4]
2-element Vector{Pair{String, Int64}}:
"two" => 2
"four" => 4
julia> Dict(pairs)
Dict{String,Int64} with 2 entries:
"two" => 2
"four" => 4
将一个元组数组传递给字典构造函数。与对相比,元组可以包含超过两个值。对于字典,它们必须只包含一个键和一个值:
julia> tuples = [("two", 2), ("four", 4)]
2-element Vector{Tuple{String, Int64}}:
("two", 2)
("four", 4)
julia> Dict(tuples)
Dict{String,Int64} with 2 entries:
"two" => 2
"four" => 4
你如何知道使用哪个变体?这取决于你试图解决的问题。例如,当你阅读第五章中的披萨数据时,你得到了一个元组的数组:
pizzas = [
("mexicana", 13.0),
("hawaiian", 16.5),
("bbq chicken", 20.75),
("sicilian", 12.25),
]
你可能想将此数据放入字典中,以便快速查找给定披萨的价格:
julia> pizza_dict = Dict(pizzas)
Dict{String, Float64} with 4 entries:
"sicilian" => 12.25
"bbq chicken" => 20.75
"mexicana" => 13.0
"hawaiian" => 16.5
julia> pizza_dict["mexicana"]
13.0
然而,如果你不介意保持披萨数据的顺序,你可以直接定义这个字典:
Dict(
"sicilian" => 12.25,
"bbq chicken" => 20.75,
"mexicana" => 13.0,
"hawaiian" => 16.5)
有时你需要一个空字典,稍后将其填充。一个例子就是从文件直接加载到字典中。你不必将值追加到数组的末尾,而是可以将它们插入到字典中:
julia> d = Dict()
Dict{Any, Any}()
注意到 {Any, Any} 部分。这描述了 Julia 推断的字典中键和值的类型。然而,当你创建你的披萨字典时,你会注意到 Julia 将其描述为 Dict{String, Float64} 类型。String 指的是字典中键的类型,而 Float64 指的是值的类型。然而,你仍然可以为空字典指定键和值的类型:
julia> d = Dict{String, Float64}()
Dict{String,Int64} with 0 entries
julia> d["hawaiian"] = 16.5
16.5
指定键和值的类型的好处是,在运行时更容易捕捉到字典的错误使用。如果你尝试为键和值使用错误类型的值,Julia 将抛出一个异常来指示错误(第六章将更深入地介绍不同类型)。在这种情况下,你试图使用一个整数 5 作为键,而预期的是一个文本字符串键:
julia> d[5] = "five"
ERROR: MethodError: Cannot `convert` an object of type Int64
to an object of type String
有时你会得到单独的键和值数组。然而,你仍然可以使用 zip 函数将它们组合成对来创建字典:
julia> words = ["one", "two"]
2-element Vector{String}:
"one"
"two"
julia> nums = [1, 2]
2-element Vector{Int64}:
1
2
julia> collect(zip(words, nums))
2-element Vector{Tuple{String,Int64}}:
("one", 1)
("two", 2)
julia> Dict(zip(words, nums))
Dict{String,Int64} with 2 entries:
"two" => 2
"one" => 1
6.6.2 元素访问
你已经看到了一种获取和设置字典元素的方法。但是,如果你尝试检索一个不存在的键的值,比如 "seven",会发生什么?
julia> d["hawaiian"]
16.5
julia> d["seven"]
ERROR: KeyError: key "seven" not found
你会得到一个错误。当然,你可以简单地添加它:
julia> d["seven"] = 7;
julia> d["seven"]
7.0
但当你不确定键是否存在时,如何避免产生错误?一种解决方案是使用 get()函数。如果键不存在,则返回一个哨兵值。哨兵可以是任何值。
注意:在计算机编程中,哨兵值(也称为标志值、触发值、异常值、信号值或占位数据)是在使用其存在作为终止条件的情况下使用的特殊值,通常在循环或递归算法中使用。
这是在许多编程语言中处理字典时使用的一种策略。以下示例使用-1 作为哨兵值:
julia> get(d, "eight", -1)
-1
或者,你可以简单地询问字典是否具有该键:
julia> haskey(d, "eight")
false
julia> d["eight"] = 8
8
julia> haskey(d, "eight")
true
6.7 为什么使用字典?
原则上,你可以使用数组将罗马数字转换为十进制数字。以下是如何做到这一点的示例。
列表 6.2 在键值对数组中通过键查找值
function lookup(key, table)
for (k, v) in table ❶
if key == k
return v ❷
end
end
throw(KeyError(key)) ❸
end
❶ 从数组中的每个键值对中提取键 k 和值 v。
❷ 找到了匹配的键,因此返回相应的值。
❸ 如果遍历所有键值对没有找到匹配的键,那么你无法返回任何内容,必须抛出异常。在 Julia 中,当键缺失时,使用 KeyError 异常是惯例。
你可以将查找表定义为键值对的数组,而不是字典:
numerals = ['I' => 1, 'X' => 10, 'C' => 100,
'V' => 5, 'L' => 50, 'D' => 500,
'M' => 1000]
这样,你可以根据键进行查找,类似于字典。
julia> lookup('X', roman_numerals)
10
julia> lookup('D', roman_numerals)
500
julia> lookup('S', roman_numerals) ❶
ERROR: KeyError: key 'S' not found
❶ 查找不存在的键,产生异常的演示
在进行基于键的查找时避免使用数组,因为查找所需的时间会随着数组大小的线性增长而增长。在 30 个条目中查找一个元素,平均需要的时间是 10 个元素中查找一个条目的三倍。不难看出,这种方法在大数组中扩展性不好。在 100 万个元素中查找一个元素所需的时间将是 1000 个元素中查找该元素所需时间的 1000 倍。
相反,字典被设计成查找时间与字典包含的元素数量无关。在 100 个元素中查找 1 个元素与在 100 万个元素中查找相似。
为什么字典查找如此快速?
为什么字典允许根据键快速查找值,这超出了本书的范围。关于数据结构和算法的书籍通常会详细讨论这个主题,而字典更多地指的是数据结构的接口,而不是用于实现快速查找的实际数据结构。在 Julia 中,使用哈希表来实现快速查找,但也可以使用二叉搜索树数据结构来实现字典。
但不要低估数组。短数组搜索非常快——比同等大小的字典快。因此,当元素数量少于 100 时,数组仍然是一个可行的选择。实际上,罗马数字代码示例使用了字典,因为当处理基于键的查找时,字典使用起来很方便,而且你永远不必担心因为添加了太多元素而导致性能大幅下降。
然而,在某些特殊情况下,使用数组可以非常有效(例如,如果你永远不会修改数组)。如果元素永远不会被添加或删除,你可以简单地保持数组排序。使用 Julia 的 searchsortedfirst 函数可以非常快速地搜索排序后的数组。实际上,罗马数字代码示例非常适合这种方法,因为数字和十进制值之间的映射是固定的。你可以通过保持键和值分别排序的数组来实现这一点。
列表 6.3 带有匹配值数组的排序键数组
keys = ['C', 'D', 'I', 'L', 'M', 'V', 'X']
vals = [100, 500, 1, 50, 1000, 5, 10]
使用 searchsortedfirst,你可以找到特定键的索引。
julia> i = searchsortedfirst(keys, 'I')
3
确保键 I 的值位于 vals 数组中的相同索引 i:
julia> vals[i]
1
这里还有一个例子:
julia> j = searchsortedfirst(keys, 'V')
6
julia> vals[j]
5
6.8 使用命名元组作为字典
在结束本章之前,我想向你展示另一个很酷的技巧,它允许你用更好的性能编写更易读的代码。你已经看到了可以通过索引访问元素的元组。但你还没有看到通过键访问元组值,就像字典一样。
记住你创建了一个像这样的 pizza 元组:("hawaiian", 'S', 10.5)。你可以给每个值命名;你给出的名称将不是文本字符串,而是 Julia 符号(内置 Julia 类型,用于表示标识符)。在第五章中,你使用了 :cyan、:green 和 :red 这样的符号来指定打印文本的颜色。同样,你可以使用符号如 :name 和 :price 访问 pizza 元组中的单个值:
julia> pizza = (name = "hawaiian", size = 'S', price = 10.5)
(name = "hawaiian", size = 'S', price = 10.5)
julia> pizza[:name]
"hawaiian"
julia> pizza[:price]
10.5
julia> pizza.name
"hawaiian"
julia> pizza.size
'S': ASCII/Unicode U+0053
注意你在最后两个表达式中使用的快捷方式;pizza[:price] 等同于写作 pizza.price。这种方式处理数据的方式对于 JavaScript 开发者来说很熟悉。
请记住,符号在功能上比字符串有限得多。在大多数情况下,它们被视为原子值。你不能访问符号中的单个字符,也不能像字符串那样组合和操作它们。幸运的是,很容易在键和字符串之间进行转换:
julia> s = "price"; t = :name;
julia> Symbol(s) ❶
:price
julia> string(t) ❷
"name"
❶ 从字符串创建一个符号。
❷ 从符号创建一个字符串。
使用这些知识,你可以将列表 6.1 中的 parse_roman 函数重写为使用命名元组而不是字典。观察你会发现,你必须将查找 roman_numerals[ch] 改为 roman_numerals[Symbol(ch)],因为 roman_numerals 的键不再是字符,而是符号。
列表 6.4 使用命名元组解析罗马数字
roman_numerals = ❶
(I = 1, X = 10, C = 100,
V = 5, L = 50, D = 500,
M = 1000)
function parse_roman(s)
s = reverse(uppercase(s))
vals = [roman_numerals[Symbol(ch)] for ch in s] ❷
result = 0
for (i, val) in enumerate(vals)
if i > 1 && val < vals[i - 1]
result -= val
else
result += val
end
end
result
end
❶ 从字典更改为命名元组
❷ 使用 Symbol(ch) 而不是 ch 进行查找
6.8.1 何时使用命名元组?
命名元组看起来与字典非常相似,那么拥有它们的目的是什么呢?所有类型的元组都是不可变的,这意味着你不能改变它们。一旦创建了元组,就不能向其中添加值,也不能修改现有值。相比之下,数组和字典都允许你添加值。字典为你提供了更广泛的类型选择,可以用作键。命名元组只允许你使用符号作为键。
任何元组类型相对于数组或字典的优势在于,Julia JIT 编译器将确切知道在任何给定时间元组中会有哪些元素,这允许进行更激进的优化。因此,你可以假设元组通常会比数组或字典提供相等或更好的性能。
虽然只使用符号作为键是一种限制,但它也允许命名元组提供更方便的语法来访问值。例如,pizza.name 比 pizza[:name] 更容易编写和阅读。
6.8.2 将所有内容结合起来
本章涵盖了任何程序员都应该了解的所有关键类型。有了数字、范围、字符串、数组、元组和字典,你可以做几乎所有的事情。然而,我还没有详细说明类型实际上是什么,或者你如何创建自己的自定义类型。这对于促进构建更大、功能更丰富的应用程序至关重要。这将是下一章的重点。
摘要
-
字典持有键值对,其中键必须是唯一的。
-
在字典中,键值对可以快速查找、添加或删除。这与可能需要耗时搜索的大数组不同。
-
当元素数量较少或你可以进行基于索引而不是基于键的元素访问时,数组提供了更好的性能。
-
在 Julia 中,键和值是有类型的。因此,Julia 能够捕获错误类型键的使用,以及尝试插入错误类型值的尝试。
-
命名元组类似于不可变版本的字典。你可以查找值,但不能修改它们或添加新条目。
第二部分 类型
在第一部分,基础部分,你以表面的方式审视了类型。第二部分通过构建各种示例,如反复出现的火箭示例,更详细地讨论了 Julia 类型系统,这些示例展示了类型系统是如何工作的以及它提供了哪些好处。
本部分介绍了使 Julia 特殊的地方。本部分对类型系统的更深入覆盖也使我能够介绍函数和方法在 Julia 中的工作方式。特别是,它允许对多重分派进行适当的解释,这是 Julia 的杀手特性。多重分派是使 Julia 成为一个如此表达性和高性能的语言的核心,尽管它是动态类型的。
7 理解类型
本章涵盖了
-
理解类型层次结构
-
抽象类型和具体类型之间的区别
-
将原始类型组合成复合类型
-
利用多重分派的力量优雅地解决复杂任务
-
多重分派与面向对象语言中的单分派有何不同^(1)
Julia 中的所有对象都属于特定类型。记住,你可以使用 typeof 来发现任何对象的类型:
julia> typeof(42)
Int64
julia> typeof('A')
Char
julia> typeof("hello")
String
类型决定了你可以对对象做什么。例如,字典允许你通过键查找值,而数组按顺序存储元素。评估为 Bool 值的表达式,如 true 或 false,可以在 if 语句和 while 循环中使用,而评估为浮点值的表达式则不能:
julia> if 2.5
print("this should not be possible")
end
ERROR: TypeError: non-boolean (Float64) used in boolean context
因此,如果你想创建具有不同行为和特征的对象,你需要定义新的类型。在编程中,我们经常试图模仿现实世界:
-
银行应用程序有代表银行账户、客户和交易的类型。
-
视频游戏有代表怪物、英雄、武器、宇宙飞船、陷阱等等的对象。
-
图形用户界面有代表按钮、菜单项、弹出菜单和单选按钮的对象。
-
绘图应用程序有代表不同形状、笔触、颜色和绘图工具的对象。
因此,无论你想制作哪种类型的应用程序,你都需要知道如何创建与该应用程序相关的类型。本章和下一章将定义与视频游戏和火箭模拟器中的模型行为相关的类型。
7.1 从原始类型创建复合类型
让我们从基础知识开始:整数、字符和浮点数都是原始类型的例子。你不能将它们进一步分解成更小的部分。在某些语言中,如 LISP,这些被恰当地命名为原子。使用 isprimitivetype 你可以检查一个类型是否是原始的:
julia> isprimitivetype(Int8)
true
julia> isprimitivetype(Char)
true
julia> isprimitivetype(String)
false
你可以将原始类型组合成复合类型。复合类型甚至可以由其他复合类型组成。例如,字符串是由多个字符组成的复合类型,而字符是原始类型。让我们通过定义一个在视频游戏中表示弓箭手向对手射箭的有用复合类型来具体演示这一点。
列表 7.1 复合类型的定义
struct Archer
name::String ❶
health::Int ❷
arrows::Int ❸
end
❶ 弓箭手的名字——比如说罗宾汉
❷ 剩余的生命值
❸ 箭袋中的箭
将类型视为模板或模具,你使用它们来制作多个对象。从类型中制作的对象称为实例。
警告 Julia 的复合类型可能看起来与 Java、C++或 Python 中的类非常相似,但它们并不是同一回事。它们不支持实现继承,也没有附加的方法。
以下代码片段显示了 Archer 类型的实例创建。你也可能听到人们使用诸如“实例化一个 Archer 对象”之类的短语。
julia> robin = Archer("Robin Hood", 30, 24)
Archer("Robin Hood", 30, 24)
julia> william = Archer("William Tell", 28, 1)
Archer("William Tell", 28, 1)
julia> robin.name ❶
"Robin Hood"
julia> robin.arrows ❷
24
❶ 访问 robin 对象的名称字段。
❷ 访问 robin 对象的 arrows 字段。
组合类型的定义与使用字典有一些相似之处。例如,你定义字段以存储可以通过其字段名访问的值。然而,与字典不同,你可以使用 类型注解 为每个字段指定不同的类型。
重要提示:在 Julia 中,:: 用于注释变量和表达式及其类型。x::T 表示变量 x 应该具有类型 T。这有助于 Julia 确定需要多少字节来存储结构体中的所有字段。
为了阐明这一点,定义一个字典来存储关于弓箭手的资料。
列表 7.2 使用字典存储关于弓箭手的资料
julia> robin = Dict("name" => "Robin Hood",
"health" => 30,
"arrows" => 24)
Dict{String, Any} with 3 entries: ❶
"name" => "Robin Hood"
"health" => 30
"arrows" => 24
julia> robin["name"] ❷
"Robin Hood"
julia> robin["arrows"]
24
❶ 字典具有 String 键,其中值是 Any 类型
❷ 访问存储在 name 键中的值
使用字典的一个问题是它要求每个值都具有相同的类型。但是等等,name 和 arrows 完全是不同类型吗?
简短的回答是,字典中的值是 Any 类型。这意味着你可以存储任何类型的值。键更加限制性,因为它们被定义为 String 类型。但要真正理解这是如何工作的,你需要探索 Julia 类型层次结构。
7.2 探索类型层次结构
如果你熟悉面向对象的语言,那么你应该熟悉类继承层次结构。在 Julia 中,你也有类型层次结构,但一个显著的区别是这些层次结构也存在于原始类型中。例如,在 Java 或 C++ 这样的语言中,整数或浮点数只是一个具体的类型。然而,在 Julia 中,甚至数字、集合和字符串都是更深层次类型层次结构的一部分(图 7.1)。

图 7.1 数字类型层次结构,显示抽象和具体类型在深浅阴影的框中
你可以使用超类型和子类型函数来探索这些层次结构。你可以通过从类型层次结构的顶部开始,向下使用子类型函数找到子类型,然后进一步探索来重新创建图 7.1 中数字的类型层次结构:
julia> subtypes(Number) ❶
2-element Vector{Any}:
Complex
Real
julia> subtypes(Real) ❷
4-element Vector{Any}:
AbstractFloat
AbstractIrrational
Integer
Rational
julia> subtypes(Integer) ❸
3-element Vector{Any}:
Bool
Signed
Unsigned
❶ 查找 Number 类型的直接子类型。
❷ 发现实数的子类型。
❸ 整数可以是带符号的或无符号的。
但你怎么知道数字层次结构的根是 Number 类型呢?你可以从你已知的数字类型向上工作:
julia> T = typeof(42) ❶
Int64
julia> T = supertype(T) ❷
Signed
julia> T = supertype(T)
Integer
julia> T = supertype(T)
Real
julia> T = supertype(T)
Number
❶ 将 42 的类型存储在变量 T 中。
❷ 查找 Int64 的超类型,并将其存储在 T 中。
你甚至可以继续传递数字层次结构的根,直到你到达整个 Julia 类型层次结构的根。一旦你到达 Any,你就知道你已经到达了类型层次结构的顶端,因为 Any 的超类型也是 Any:
julia> T = supertype(T)
Any
julia> T = supertype(T)
Any
重要的是要认识到 Julia 的类型是一等对象,你可以将它们作为参数传递或存储在变量中。例如,这里你将整数 42 的类型存储在一个名为 T 的变量中。在许多语言中,使用 T 作为任意类型的名称是一种约定。让我们通过一些简单的函数来探索类型层次。
列表 7.3 查找类型层次的最顶层
function findroot(T)
T2 = supertype(T)
println(T)
if T2 != T ❶
findroot(T2)
end
end
❶ 检查 T 的超类型是否与 T 相同。
这是一个递归函数,你可以用它来找到类型层次的最顶层:
julia> findroot(typeof(42))
Int64
Signed
Integer
Real
Number
Any
julia> supertype(Any)
Any
你可以看到类型层次在 Any 处停止,因为 Any 的超类型是 Any。那么这些类型层次有什么意义呢?它们作为程序员如何帮助你?让我给你一个 REPL 中的例子来给你一些提示:
julia> anything = Any[42, 8] ❶
2-element Vector{Any}:
42
8
julia> integers = Integer[42, 8] ❷
2-element Vector{Integer}:
42
8
julia> anything[2] = "hello" ❸
"hello"
julia> integers[2] = "hello" ❹
ERROR: MethodError: Cannot `convert` an object
of type String to an object of type Integer
❶ 定义一个可以存储 Any 值的数组。
❷ 定义一个可以存储 Integer 值的数组。
❸ 将字符串放入任何数组中都可以正常工作。
❹ 整数数组不接受字符串。
由于 Julia 中的每个对象都符合 Any 类型,因此你可以将任何对象放入你指定每个元素必须为 Any 类型的数组中。然而,并非 Julia 中的每个对象都是 Integer 类型。因此,将文本字符串“hello”放入必须为 Integer 类型的数组中是不行的。
你如何知道哪些类型是兼容的?你尝试存储的值必须是一个允许的元素类型的子类型。实际上,你可以使用<:运算符来程序化地检查这一点。
列表 7.4 检查哪些类型是彼此的子类型
julia> String <: Any
true
julia> String <: Integer ❶
false
julia> Int8 <: Integer
true
julia> Float64 <: Integer ❷
false
❶ 字符串不是整数的一种。
❷ Float64 是一个数字,但不是一个整数。
你可以从这个例子中看到,类型不能仅仅是有些相关(例如,浮点数和整数)。例如,4.5 是一个浮点数,但不是一个整数。然而,Int8(4)和 Int32(5)都是整数;它们是 Integer 的子类型。
这应该给你一些关于定义复合类型来存储相关数据的优势,而不是使用字典的优势的提示。每个字段可以有不同的类型。这提供了更好的运行时类型检查。
7.3 创建战斗模拟器
为了进一步探索这些概念,你将开发一个简单的不同战士之间战斗的模拟器,这在桌面游戏、卡牌游戏和视频游戏中很常见。
许多电脑游戏基于石头剪刀布的原则。让我澄清一下:在你的游戏中,有弓箭手、骑士和长矛兵,你会设置它们,使得
-
弓箭手打败了长矛兵,
-
骑士打败了弓箭手,并且
-
长矛兵打败了骑士。
这些单位在历史上的工作方式大致如此。弓箭手会向缓慢移动的长矛兵射箭,并在他们接近到足以攻击弓箭手之前击败他们。然而,如果骑士在射出大量箭矢并砍倒他们之前冲到弓箭手身边,这种策略就会失败。但是,骑士不能使用这种策略对付长矛兵,因为一堵长矛墙会阻止骑士冲锋,以免被刺穿。
你将在代码中实现以下内容:
-
一个适用于所有战士类型的抽象类型 Warrior
-
具体的战士类型弓箭手、长矛兵和骑士
-
具体类型和抽象类型之间关系的解释
-
通过定义如 shoot!和 mount!等函数来定义每种战士类型的行为
-
一个攻击!函数来模拟一个战士攻击另一个战士
-
一个战斗!函数来模拟两个战士反复攻击对方,直到一方获胜或双方都死亡
7.3.1 定义战士类型
创建一个名为 warriors.jl 的文件来存储你将开发的代码。从定义你将使用的类型开始。
列表 7.5 战斗模拟器中类型的定义
abstract type Warrior end ❶
mutable struct Archer <: Warrior ❷
name::String
health::Int
arrows::Int
end
mutable struct Pikeman <: Warrior
name::String
health::Int
end
mutable struct Knight <: Warrior
name::String
health::Int
mounted::Bool ❸
end
❶ 定义一个抽象类型 Warrior
❷ 将弓箭手定义为可变子类型 Warrior
❸ 骑士可以骑马或步行。
列表 7.5 中的代码正在创建以下所示的类型层次结构。在这些层次结构中,你区分了抽象类型和具体类型。弓箭手、长矛兵和骑士是具体类型的例子,而战士是抽象类型的例子。你可以创建具体类型的对象,但不能创建抽象类型的对象:
julia> robin = Archer("Robin Hood", 34, 24)
Archer("Robin Hood", 34, 24)
julia> Warrior()
ERROR: MethodError: no constructors have been defined for Warrior
抽象类型的目的在于促进类型层次结构的构建。
在图 7.2 中,我已将名称和健康添加到战士类型框中。然而,这只是为了说明所有子类型都必须有这些字段。Julia 没有提供语法来强制执行此操作。相反,这是通过惯例来做的。

图 7.2 战士类型层次结构。深色框是抽象类型,浅色框是具体类型。
在 Julia 中,如果你定义一个类型为抽象类型,它不能有任何字段。只有具体类型才能有字段或值。一个复合类型是一个具有字段的具体系列类型,而一个原始类型是一个具有单个值的具体系列类型。
子类型运算符<:不仅用于检查一个类型是否是另一个类型的子类型,还用于定义一个类型为子类型:
struct B <: A
...
end
这段代码片段将类型 B 定义为类型 A 的子类型。在 Julia 中,你不能将具体类型作为子类型。如果你使用过流行的面向对象语言,如 Java、C++、C#、Python 或 Ruby,这可能会让你感到惊讶。如果你考虑我们刚刚覆盖的数字层次结构,这就有意义了。你知道 Int32 或 Float64 需要多少空间,但你需要多少字节来存储整数或实数?你不知道。这就是为什么大多数数字类型都是抽象的。
7.3.2 为战士添加行为
只包含数据的战士并不令人兴奋。因此,你将通过定义几个带有相应方法的函数来为它们添加行为。将这些添加到 warrior.jl 源代码文件(列表 7.6)中。
所有这些函数的名字中都有一个感叹号,因为它们修改了一个字段(记住,这只是一个约定)。这就是为什么组合类型在其定义中添加了可变关键字。如果一个结构类型没有被定义为可变,它将不支持修改字段的函数。如果没有可变关键字,组合类型将默认为不可变。
列表 7.6 为战士类型添加行为
function shoot!(archer::Archer)
if archer.arrows > 0
archer.arrows -= 1
end
end
function resupply!(archer::Archer)
archer.arrows = 24
end
function mount!(knight::Knight)
knight.mounted = true
end
function dismount!(k::Knight)
knight.mounted = false
end
这里是每个函数的简要描述:
-
shoot!—弓箭手射出一支箭。箭矢数量减少一支。
-
resupply!—模拟弓箭手获得 24 支箭矢的补给。
-
mount!—改变骑士的状态,使其骑在马上。
-
dismount!—让骑士下马,为步兵战斗做准备。
可变与不可变类型
以下是在函数式编程社区内发展的重要见解:如果对象不能被修改,你的程序出现 bug 的可能性会更小。不能被修改的对象被称为不可变。如果它们可以被修改,则被称为可变。
在较老的语言中,对象默认是可变的。Julia 遵循一个现代趋势:除非明确标记为可变,否则对象是不可变的。
使用 shoot!函数可以模拟弓箭手在战斗中如何消耗箭矢。通常,中世纪的弓箭手在箭袋里有 24 支箭。当这些箭矢用完时,弓箭手需要重新补给:
julia> robin = Archer("Robin Hood", 34, 24) ❶
Archer("Robin Hood", 34, 24)
julia> shoot!(robin)
23 ❷
julia> shoot!(robin)
22 ❷
julia> robin
Archer("Robin Hood", 34, 22) ❸
❶ 创建带有 24 支箭的弓箭手
❷ 剩余箭矢数量
❸ 剩余 22 支箭矢
你可以通过一个技巧来改进 shoot!函数,这个技巧我在开发 Julia 软件时经常使用:我返回在 REPL 中运行函数时最有用的对象(参见列表 7.7)。在调用修改对象的函数时,看到修改后的对象的样子非常有用。因此,在修改函数中返回修改后的对象是一个好习惯。
列表 7.7 将修改函数修改为 REPL 友好型
function shoot!(archer::Archer)
if archer.arrows > 0
archer.arrows -= 1
end
archer ❶
end
function resupply!(archer::Archer)
archer.arrows = 24
archer ❶
end
❶ 返回修改后的弓箭手对象
这使得测试你正在开发的函数,并检查它们是否执行正确的操作变得简单得多:
julia> robin = Archer("Robin Hood", 34, 24)
Archer("Robin Hood", 34, 24)
julia> shoot!(robin)
Archer("Robin Hood", 34, 23) ❶
julia> shoot!(robin)
Archer("Robin Hood", 34, 22) ❶
julia> shoot!(robin)
Archer("Robin Hood", 34, 21) ❶
julia> resupply!(robin)
Archer("Robin Hood", 34, 24) ❷
❶ 展示箭矢数量的减少。
❷ 箭矢数量增加到 24。
你可以使用这些函数来构建新的函数,以模拟战士攻击另一个战士。再次,将此代码添加到 warriors.jl 文件中。看起来你定义了两次 attack!。这是怎么做到的?
列表 7.8 模拟弓箭手与骑士之间战斗的两种方法
function attack!(a::Archer, b::Archer) ❶
if a.arrows > 0 ❷
shoot!(a)
damage = 6 + rand(1:6) ❸
b.health = max(b.health - damage, 0) ❹
end
a.health, b.health
end
function attack!(a::Archer, b::Knight) ❺
if a.arrows > 0 ❷
shoot!(a)
damage = rand(1:6) ❻
if b.mounted
damage += 3
end
b.health = max(b.health - damage, 0) ❹
end
a.health, b.health
end
❶ 模拟弓箭手攻击另一个弓箭手。
❷ 如果箭矢用尽,则无法攻击
❸ 投掷一个六面骰子(d6)来计算箭矢伤害。
❹ 使用 max 避免健康值变为负数。
❺ 模拟弓箭手攻击骑士。
❻ 投掷一个六面骰子(d6)来计算箭矢伤害。
如果这是一个常规的动态语言,例如 JavaScript、Python、Ruby 或 Lua,那么 attack! 的最后一个定义将覆盖第一个定义。如果这是一个静态类型语言,例如 Java、C# 或 C++,你将创建一个称为 函数重载 的东西 ^(4)。但在 Julia 中,发生的事情完全不同。
7.3.3 使用多重分派调用方法
在 Julia 中,你实际上并没有定义两个函数,而是定义了两个附加到 attack! 函数的 方法。我知道这听起来很令人困惑,所以让我更详细地解释一下。在 Julia 中,你实际上定义函数的方式如下所示。
列表 7.9 Julia 中没有方法的函数定义
function shoot! end
function resupply! end
function attack! end
函数只是名称。除非你将方法附加到它们,否则它们什么也不能做。启动一个新的 Julia REPL,并将以下函数定义以及战士、弓箭手和骑士类型的定义(参见列表 7.5)粘贴进去,然后创建一些对象来使用:
julia> robin = Archer("Robin Hood", 34, 24)
Archer("Robin Hood", 34, 24)
julia> white = Knight("Lancelot", 34, true)
Knight("Lancelot", 34, true)
现在,你可以尝试使用这些对象做一些事情,看看会发生什么:
julia> attack!(robin, white) ❶
ERROR: MethodError: no method matching attack!(::Archer, ::Knight)
julia> shoot!(robin) ❶
ERROR: MethodError: no method matching shoot!(::Archer)
julia> mount!(white) ❷
ERROR: UndefVarError: mount! not defined
❶ 尝试调用没有定义方法的函数
❷ 尝试调用未定义的函数
你可以从这些错误中看出,Julia 区分了你完全没有定义的函数,例如 mount!,以及定义了但没有方法的函数,例如 shoot! 和 attack!。但你怎么知道它们没有任何方法呢?Julia 有一个名为 methods 的函数,它允许你检查附加到函数的方法数量:
julia> methods(attack!) ❶
# 0 methods for generic function "attack!":
julia> methods(mount!) ❷
ERROR: UndefVarError: mount! not defined
❶ 证明 attack! 是一个没有方法的函数。
❷ Julia 找不到 mount!。
你可以查看 Julia 报告说 attack! 没有任何方法。让我们将这个结果与将 warriors.jl 文件加载到 REPL 中进行比较。
julia> include("warriors.jl") ❶
julia> methods(shoot!)
# 1 method for generic function "shoot!":
[1] shoot!(archer::Archer)
julia> methods(attack!)
# 2 methods for generic function "attack!":
[1] attack!(a::Archer, b::Archer)
[2] attack!(a::Archer, b::Knight)
julia> methods(mount!)
# 1 method for generic function "mount!":
[1] mount!(knight::Knight)
❶ 将代码加载到 Julia REPL 中
图 7.3 展示了你在 REPL 中看到的内容。在内部,Julia 有一个函数列表。每个函数都进入另一个列表,包含对应函数条目的方法。方法可以跨越不同的类型,因为它们不是附加到类型上,而是附加到函数上。没有任何东西阻止你添加一个 shoot! 方法,该方法操作字典或数组类型。

图 7.3 Julia 如何将方法附加到函数。每个方法处理一组独特的参数。
让我们创建一些对象,让你可以玩一玩:
julia> robin = Archer("Robin Hood", 34, 24)
Archer("Robin Hood", 34, 24)
julia> tell = Archer("William Tell", 30, 20)
Archer("William Tell", 30, 20)
julia> white = Knight("Lancelot", 34, true)
Knight("Lancelot", 34, true)
julia> black = Knight("Morien", 35, true)
Knight("Morien", 35, true)
对于某些对象,你可以在调用 attack! 函数时使用不同类型的对象进行实验:
julia> attack!(robin, white) ❶
(34, 30) ❷
julia> attack!(robin, white) ❶
(34, 26) ❷
julia> attack!(tell, robin) ❸
(30, 22) ❷
julia> attack!(black, white) ❹
ERROR: MethodError: no method matching attack!(::Knight, ::Knight)
Closest candidates are:
attack!(::Archer, ::Knight) ❺
❶ 用弓箭手攻击骑士。
❷ 攻击者和防御者剩余的生命值
❸ 让一个弓箭手攻击另一个弓箭手。
❹ 让一个骑士攻击另一个骑士。
❺ 与尝试调用最接近匹配的方法
我建议你自己尝试一下。你可以查看不同攻击下生命值是如何减少的。为了更容易地跟踪生命值的变化,每个方法都设置为在战斗结束时返回一个元组,包含攻击者和防御者的生命值。
这里有趣的一点是最后的部分,当你尝试让两个骑士进行战斗时。你可能已经注意到,我们还没有添加处理两个骑士之间战斗的方法。我们将在下面的列表中添加一个。
列表 7.10 模拟骑士之间攻击的方法
function attack!(a::Knight, b::Knight)
a.health = max(a.health - rand(1:6), 0)
b.health = max(b.health - rand(1:6), 0)
a.health, b.health
end
你可以将此方法添加到 warriors.jl 文件中并重新加载它。你不必重新加载所有内容,只需将定义粘贴到 REPL 中即可。之后,你会注意到黑骑士莫里恩爵士攻击白骑士兰斯洛特爵士是可行的:
julia> attack!(black, white)
(33, 22)
你还会注意到 Julia 报告说 attack! 函数现在有三个方法:
julia> methods(attack!)
# 3 methods for generic function "attack!":
[1] attack!(a::Archer, b::Archer)
[2] attack!(a::Archer, b::Knight)
[3] attack!(a::Knight, b::Knight)
让我们添加另一个 attack! 方法,允许弓箭手攻击长矛兵。然后你可以亲自看到方法数量是如何变化的。
列表 7.11 箭兵攻击长矛兵
function attack!(a::Archer, b::Pikeman)
if a.arrows > 0 ❶
shoot!(a)
damage = 4 + rand(1:6)
b.health = max(b.health - damage, 0)
end
a.health, b.health
end
❶ 仅当弓箭手剩余箭矢大于零时才允许攻击。
7.4 Julia 如何选择调用的方法
当你调用攻击!(a, b)时,Julia 将找到每个参数的类型以找到所有参数类型的元组:
argtypes = (typeof(a), typeof(b))
Julia 将使用这个参数类型的元组来遍历所有方法列表以找到匹配的方法。记住,在 Julia 中,函数没有代码;方法是代码。如果一个函数没有任何方法,你不能运行该函数。这个过程在图 7.4 中得到了说明。

图 7.4 使用多分派调用方法
在这个例子中,我们假设一个弓箭手正在攻击一个长矛兵,所以 a 是一个弓箭手,b 是一个长矛兵。让我们一步一步地看看会发生什么:
-
Julia 尝试评估(执行)你程序中的 attack!(a, b) 表达式。
-
它取函数名 attack! 并查找所有函数的表,直到找到 attack! 的条目。
-
Julia 做的是 (typeof(a), typeof(b)) 的等价操作,得到元组 (弓箭手, 长矛兵)。Julia 从上到下扫描存储在攻击!函数上的方法列表,直到在第 4 个条目处找到匹配项。
-
Julia 定位到该方法。该方法被编码为抽象语法树(AST)。这是动态语言中用于在运行时表示函数和方法的常见数据结构^(5)。
-
Julia JIT 编译器将 AST 转换为机器代码,^(7) 然后执行。编译后的机器代码被存储在方法表中,所以下次查找 attack(Archer, Pikeman) 时,它可以直接执行缓存的机器代码。
要完全理解这一点,需要对编译器和解释器理论进行深入研究,这超出了本书的范围。因此,你最好的思考方式是你在第 4 步就完成了。你以某种方式找到了可以运行的方法表示。最后几步主要对那些对理解为什么 Julia 与其他语言相比有如此高性能感兴趣的人有用。
ASTs for the curious
这本书不是关于编译器概念,如 AST 的书。但我将提供一些关于它们的信息,以帮助您理解 Julia。考虑以下表达式
y = 4*(2 + x)
当编译器或解释器读取此类代码时,它通常会将其转换为称为 AST 的树结构,如下面的图所示:

表达式 y = 4*(2 + x) 的 AST
在 Julia 中,每个方法都会转换为这样的树结构。每个函数的方法表会跟踪这些树结构中的每一个。Julia 编译器使用这些结构来创建计算机能理解的实际机器代码。
7.4.1 对比 Julia 的多重分派与面向对象语言
多重分派对于有面向对象编程语言背景的开发者来说通常很令人困惑。因此,我将尝试将 Julia 方法与面向对象语言的工作方式进行比较。在面向对象语言中,方法的实现是基于单个参数类型来选择的。这就是为什么我们称这种方法为 单分派。在面向对象语言中,你不会编写 attack!(archer, knight),而是编写以下列表中的代码。
列表 7.12 如果 Julia 是面向对象语言,其语法
archer.attack!(knight)
archer.shoot!()
knight.mount!()
虽然你不能像那样编写 Julia 代码,但你可以在 Julia 中模拟这种行为。
列表 7.13 Julia 中的单分派
function attack(archer::Archer, opponent) ❶
if typeof(opponent) == Archer
...
elseif typeof(opponent) == Knight
...
elseif typeof(opponent) == Pikeman
end
end
function attack(knight::Knight, opponent) ❷
if typeof(opponent) == Archer
...
elseif typeof(opponent) == Knight
...
elseif typeof(opponent) == Pikeman
end
end
❶ 处理攻击者类型为 Archer 的所有情况。
❷ 处理攻击者类型为 Knight 的所有情况。
这说明了单分派的局限性。因为攻击!方法只能根据第一个参数类型进行选择,所以你需要一个长的 if-else 语句列表来处理不同类型的对手。让我通过逐步解释(图 7.5)来澄清单分派是如何工作的。
-
当执行 a.attack!(b) 时,查找由 a 指向的对象。
-
在这个弓箭手对象上,有一个隐藏的字段,isa,它指向弓箭手对象的类型。
-
类型 Archer 是一个具有各种字段的实体。它为每个方法(如 shoot!、attack! 等)都有字段。它就像一个字典,你可以使用函数名 attack! 来查找正确的方法。
-
该方法是 AST,你可以对其进行评估。

图 7.5 使用单分派调用方法的方式
因此,与 Julia 相关的关键区别在于,在大多数主流语言中,方法存储在对象的类型上,而在 Julia 中,方法存储在函数上。
7.4.2 多重分派与函数重载有何不同?
静态类型语言,如 Java、C# 和 C++,有一种称为函数重载的功能,其外观与多重分派相似。关键区别在于,使用函数重载时,正确的方法在编译时就已经确定,这意味着在静态类型语言中不可能有如下列表所示的方法。
列表 7.14 编写两位战士之间战斗的结果
function battle!(a::Warrior, b::Warrior) ❶
attack!(a, b)
if a.health == 0 && b.health == 0
println(a.name, " and ", b.name, " destroyed each other")
elseif a.health == 0
println(b.name, " defeated ", a.name)
elseif b.health == 0
println(a.name, " defeated ", b.name)
else
println(b.name, " survived attack from ", a.name)
end
end
❶ a 和 b 必须是战士类型的子类型。
将此方法添加到你的 warriors.jl 源代码文件中。重新加载一切,并重新创建白罗宾等常用角色,以在 REPL 中测试 battle!。罗宾汉多次攻击兰斯洛特爵士,直到兰斯洛特的生命值足够低,以至于 battle! 打印出他已经被打败:
julia> battle!(robin, white)
Lancelot survived attack from Robin Hood
julia> battle!(robin, white)
Lancelot survived attack from Robin Hood
julia> battle!(robin, white)
Robin Hood defeated Lancelot
当你调用 battle!(robin, white) 时,Julia 会寻找具有签名 battle!(a::Archer, b::Knight) 的方法,但找不到。然而 battle!(a::Warrior, b::Warrior) 是一个有效的匹配,因为骑士和弓箭手都是战士的子类型。
当 Julia 编译器编译 battle! 方法时,它无法知道 a 和 b 将具有什么具体类型参数。它只知道它们是战士的某个子类型。因此,编译器无法选择正确的 attack! 方法来调用。这个决定只能在运行时做出。这就是它与函数重载不同的地方。函数重载,如 Java 和 C++ 中所见,依赖于编译器能够选择正确的方法。
摘要
-
Julia 中的数字是复杂类型层次结构的一部分。
-
在类型层次结构中,只有叶节点可以是具体类型。所有其他类型都是抽象的。
-
typeof、supertype 和 subtypes 函数可以用来探索类型层次结构。
-
函数只是一个名称。没有附加的方法,它们什么也不能做。代码始终存储在方法中。参数的类型决定了在运行时哪个方法将被执行。
-
面向对象的语言使用单重分派,这意味着只有第一个函数参数的类型决定选择哪个方法。Julia 是多重分派,这意味着所有参数都会影响选择哪个方法。
-
组合类型与原始类型不同,由零个或多个字段组成。使用 struct 关键字来定义组合类型。
-
通过在结构定义中添加 mutable 关键字,你可以允许在运行时修改组合类型中的单个字段。
^(1.)今天的大多数主流语言都是面向对象的。它们被设计为将行为与类型耦合,并通过我们所说的继承来重用功能。
^(2.)面向对象编程中的类是一种可以成为类型层次结构一部分的类型,并且具有称为方法的关联函数。
^(3.)递归函数是一种函数,它调用自身而不是使用循环。
^(4.)函数重载是许多静态类型语言的一个特性。它允许定义具有不同类型参数的相同函数多次。当代码被编译时,编译器会选择正确的函数。
^(5.)数据结构是计算机程序中组织数据的一种特定方式。数组、字符串、二叉树、链表和哈希表是数据结构的例子。但几乎任何组合类型都可以被认为是定义数据结构。
(6.)在动态语言中,在程序被允许运行之前,没有编译器分析类型正确性。Julia 有编译器,但它是在运行时调用的。
(7.)微处理器不理解像 Julia 或 Java 这样的编程语言。它只理解机器码。
8 构建火箭
本章涵盖
-
构建由许多不同类型的对象组成的复杂数据结构
-
抽象掉不同但相关的类型之间的差异
在上一章中,你创建了一些简单的复合类型来表示不同类型的战士。然而,在更现实的应用中,你必须将许多不同类型的对象组合成更复杂的数据结构。
为了探索这个主题,你将通过代码构建一枚火箭。为什么是火箭?因为火箭由许多不同的部分组成。这为你提供了从其他复合类型中构建复合类型的机会,并展示了在 Julia 中使用抽象类型以促进复杂数据结构构建的不同方式。这个火箭示例将在本书的后面部分用于探索许多其他主题,例如 Julia 如何表示对象的集合。
代码示例将从定义一个简单的 Rocket 类型的火箭开始,它由一个有效载荷、一个油箱和一个引擎对象组成。稍后你将修改简单的类型定义以创建一个由多个 StagedRocket 对象组成的更复杂的多级火箭。接下来,你将进一步修改代码以添加一个表示火箭引擎集群的类型 Cluster,它可以连接到任何火箭阶段。最后,你将定义一个名为 launch!的函数来模拟多级火箭的发射。
8.1 构建简单火箭
让我们从在代码中模拟一个简单的太空火箭开始。这是一枚单级火箭,从底部到顶部由以下部分组成(见图 8.1):
-
火箭引擎—提供推进力
-
推进剂油箱—包含由引擎排出的物质
-
有效载荷—例如舱段或卫星

图 8.1 火箭的组成部分
有效载荷是你想在太空中移动的有用物品。它可能是一艘宇航员乘员舱或带有仪器以探索其他行星的探测器。
这样一枚火箭可以通过一个复合类型(列表 8.1)来定义。但先不要把它打出来;这只是让你思考你需要定义的类型。你将为坦克和引擎实现不同的类型。然后你将添加不同的属性和行为,例如重新填充油箱和消耗推进剂。
列表 8.1 简单火箭的第一个定义
struct Rocket
payload::Payload
tank::Tank
engine::Engine
end
相反,你将专注于推进剂油箱。推进剂是火箭引擎为了前进而排出的物质。在其最简单的形式中,它是一种被释放的压缩气体。然而,在现实空间火箭中,它是由像煤油或氢这样的燃料和像液氧(LOX)这样的氧化剂组成的混合物。然而,这些细节你不需要包含在你的模型中。相反,考虑以下内容:
-
干重—空油箱的质量
-
总质量—满油箱的质量
-
推进剂质量—油箱中目前剩余的推进剂
-
质量—干重加上目前剩余的推进剂
我将展示一些在 Julia 中建模的不同方法,并讨论这些不同方法的优缺点。
为了使你在这章中编写的代码更容易组织,你可以将其分散到多个文件中,然后有一个文件(例如,Rockets.jl),它包含所有这些文件。这样,你只需将此文件加载到你的 REPL 中即可导入所有代码。列表 8.2 假设你已经创建了 tanks.jl、engines.jl 和 payloads.jl 文件,并希望一次性加载它们。
列表 8.2 Rockets.jl
include("tanks.jl")
include("engines.jl")
include("payloads.jl")
这只是一个建议。如果你发现将所有代码放入一个文件更实用,你可以这样做。
重要:当你在源代码中更改类型的定义时,你需要完全重新启动你的 Julia REPL 并重新加载你的代码。然而,更改函数只需要将新代码粘贴到 REPL 中即可生效。
为了允许火箭包含许多不同类型的油箱,将 Tank 制作为一个抽象类型。由于以下列表中定义的中型和大型油箱是 Tank 的子类型,它们可以插入到任何期望 Tank 对象的字段中。
列表 8.3 定义具有固定容量的不同推进剂油箱
abstract type Tank end ❶
mutable struct SmallTank <: Tank ❷
propellant::Float64
end
mutable struct MediumTank <: Tank ❷
propellant::Float64
end
mutable struct LargeTank <: Tank ❷
propellant::Float64
end
# Accessor functions (getters)
drymass(::SmallTank) = 40.0 ❸
drymass(::MediumTank) = 250.0 ❸
drymass(::LargeTank) = 950.0 ❸
totalmass(::SmallTank) = 410.0 ❸
totalmass(::MediumTank) = 2300.0 ❸
totalmass(::LargeTank) = 10200.0 ❸
❶ 将 Tank 制作为一个抽象类型。
❷ 可变,允许推进剂质量改变
❸ 干重和总重不存储。
这些油箱的干重和总重与其类型相关联。然而,你也可以制作一个灵活的油箱,你可以设置干重和总重为你想要的任何值,如下所示。
列表 8.4 具有灵活容量的推进剂油箱
mutable struct FlexiTank <: Tank
drymass::Float64
totalmass::Float64
propellant::Float64
end
# Accessors (getters)
drymass(tank::FlexiTank) = tank.drymass
totalmass(tank::FlexiTank) = tank.totalmass
目前,你的油箱只是信息化的哑容器。它们没有做任何有用的事情,所以让我们添加有用的行为。
列表 8.5 添加推进剂油箱功能和行为
# Accessors (setters and getters)
propellant(tank::Tank) = tank.propellant ❶
function propellant!(tank::Tank, amount::Real) ❶
tank.propellant = amount
end
isempty(tank::Tank) = tank.propellant <= 0 ❷
mass(tank::Tank) = drymass(tank) + propellant(tank) ❸
# Actions
function refill!(tank::Tank) ❹
propellant!(tank, totalmass(tank) - drymass(tank))
tank ❺
end
function consume!(tank::Tank, amount::Real) ❻
remaining = max(propellant(tank) - amount, 0)
propellant!(tank, remaining)
remaining
end
❶ 接口用于推进剂字段
❷ 检查油箱是否为空。
❸ 计算油箱当前的总质量。
❹ 用推进剂重新填充油箱。
❺ 将更改后的油箱状态提供给 REPL。
❻ 消耗推进剂。
让我们创建一些油箱来演示这些函数的行为:
julia> small = SmallTank(50) ❶
SmallTank(50.0)
julia> consume!(small, 10) ❷
40.0
julia> consume!(small, 10) ❷
30.0
julia> small ❸
SmallTank(30.0)
julia> refill!(small)
SmallTank(370.0)
julia> flexi = FlexiTank(5, 50, 0) ❹
FlexiTank(5.0, 50.0, 0.0)
julia> refill!(flexi)
FlexiTank(5.0, 50.0, 45.0)
❶ 制作一个装有 50 kg 推进剂的微型油箱。
❷ 消耗 10 kg 推进剂。
❸ 检查剩余多少推进剂。
❹ 5 kg 干重,50 kg 总重,和 0 kg 推进剂
在代码示例中,你使用了两个不同的油箱:一个小型油箱和一个灵活油箱。尽管 consume! 和 refill! 只是为了处理 Tank 类型而编写的,但你仍然可以使用这些函数为 SmallTank 和 FlexiTank,因为你已经为 Tank 的所有具体子类型实现了 drymass 和 totalmass。
推进剂和推进剂! 是基于所有 Tank 子类型都有推进剂字段的假设实现的。但这并不总是情况。然而,这并不是问题。如果你为具体的 Tank 子类型定义了推进剂访问器函数,这些函数将始终具有优先权。当 Julia 搜索函数的方法列表时,它总是寻找具有最具体参数类型的函数。
注意 在面向对象的语言中,访问器函数,也称为设置器和获取器,非常重要。例如,在 Java 中,如果一个类型有燃料字段,你可能会编写 GetPropellant 和 SetPropellant 方法。在 Julia 中,等效的是 propellant 用于获取值和 propellant!用于设置值。然而,Julia 不是面向对象的语言,所以避免过度使用这种模式。
你可以使用 refill!方法在构造时自动将油箱填充到最大容量。
列表 8.6 额外的构造函数,便于创建满油箱
function SmallTank()
refill!(SmallTank(0))
end
function MediumTank()
refill!(MediumTank(0))
end
function LargeTank()
refill!(LargeTank(0))
end
代码示例已经展示了建模燃料油箱的不同方法。你如何在自己的代码中决定使用哪种方法?对于每个容量都有特定类型的第一种方法,例如拥有 SmallTank、MediumTank 和 LargeTank,如果你创建了很多这样的对象,可能会节省内存。一个对象在内存中存储所需的字节数取决于它拥有的字段数量以及每个字段的大小。Int8 字段仅消耗一个字节,但 Int64 字段将消耗八个字节。每个 FlexiTank 对象在内存中占用的空间是固定油箱对象的 3 倍。
然而,在你所编写的代码中,这并不重要。你并没有很多油箱,即使你有成千上万的油箱,这也不会有什么影响。FlexiTank 将是一个更好的选择,因为它提供了更多的使用灵活性。那么,为什么这本书包含了固定油箱的定义呢?
通过对比这些不同的建模油箱的方法,你可以更好地了解 Julia 的类型系统可以实现什么。有些情况下,这种权衡是值得的。例如,想象模拟一个拥有数百万人口的城市。每个人可能都有位置、饥饿、疲劳、口袋里的钱、衣服、鞋子等属性。当处理这么多对象时,你可能需要更深入地考虑减少对象的大小。
8.2 在代码中维护不变量
在编写代码时,了解一个重要概念是不变量。不变量是在你的整个程序执行期间或其某个部分执行期间必须始终为真的东西。这可能会听起来非常抽象,所以让我通过实现一个函数 propellant!来设置油箱中的燃料量来阐述表达不变量的必要性。
julia> tank = FlexiTank(5, 50, 10) ❶
FlexiTank(5.0, 50.0, 10.0)
julia> propellant!(tank, 100) ❷
100
julia> totalmass(tank)
50.0
julia> mass(tank)
105.0
❶ 干质量 5 公斤,总质量 50 公斤,和 10 公斤的燃料
❷ 将燃料质量设置为 100 公斤。
这里有什么问题?你将燃料的质量设置得大于油箱的最大总质量。这不应该可能。在任何时候,以下油箱的不变量应该是真实的:
0 <= propellant(t) + drymass(t) <= totalmass(t)
确保这一点始终成立的一种方法是将燃料设置器方法修改为,如果输入错误则抛出异常。
列表 8.7 燃料设置器维护油箱的不变量
function propellant!(tank::Tank, amount::Real)
if 0 <= amount + drymass(tank) <= totalmass(tank) ❶
tank.propellant = amount
else
msg = "Propellant mass plus dry mass must be less than total mass"
throw(DomainError(amount, msg)) ❷
end
end
❶ 检查新的燃料量是否破坏了油箱的不变量。
❷ 通过抛出异常来发出领域错误信号。
DomainError 是 Julia 标准库中定义的一种异常类型。Domain 指的是函数参数的合法输入值集合。因此,提供大于总质量的数值是一个域错误。
然而,这并不是你唯一可能破坏你的坦克不变量的方法。这里你正在制作一个含有 90 公斤推进剂的坦克,而总质量只能为 50 公斤:
julia> t = FlexiTank(5, 50, 90)
FlexiTank(5.0, 50.0, 90.0)
julia> mass(t), totalmass(t)
(95.0, 50.0)
处理这些问题迫使你了解 Julia 中复合对象是如何创建的。
8.3 使用构造函数函数创建对象
当你定义一个复合类型时,Julia 会创建一个与你的类型同名的一个特殊函数,称为构造函数。构造函数负责创建与其关联的类型的一个实例(对象)。Julia 向构造函数函数添加两个方法,这些方法的参数数量与你拥有的字段数量相同。一个方法使用类型注解为其参数,正如在结构中为每个字段指定的那样。另一个接受 Any 类型的参数。
然而,你可以像添加任何其他函数的方法一样,向这个构造函数函数添加方法。如果你未指定推进剂数量,你可以添加创建满油箱的方法。
列表 8.8 当未指定推进剂数量时创建满油箱
function FlexiTank(drymass::Number, totalmass::Number)
FlexiTank(drymass, totalmass, totalmass - drymass)
end
MediumTank() = refill!(MediumTank(0))
LargeTank() = refill!(LargeTank(0))
如果你使用方法,你可以看到 FlexiTank 函数已添加了第三个方法:
julia> methods(FlexiTank)
# 3 methods for type constructor:
[1] FlexiTank(drymass::Float64, totalmass::Float64, propellant::Float64) ❶
[2] FlexiTank(drymass::Number, totalmass::Number) ❷
[3] FlexiTank(drymass, totalmass, propellant) ❶
❶ Julia 定义的现有方法
❷ 你添加的新方法
以下是一个使用这些新方法创建满油箱的示例:
julia> FlexiTank(5, 50)
FlexiTank(5.0, 50.0, 45.0) ❶
julia> MediumTank()
MediumTank(2050.0) ❷
julia> LargeTank()
LargeTank(9250.0)
❶ 自动填充了 45 公斤的推进剂
❷ 填充了 2,050 公斤的推进剂
但如果你不想让你的类型的用户独立设置推进剂质量怎么办?也许你想要减少破坏之前讨论的重要坦克不变量的可能性。也许有一种方法可以防止 Julia 创建自己的构造函数方法?
8.4 外部构造函数与内部构造函数之间的差异
你刚刚所涉及的是 Julia 术语中的外部构造函数。构造函数是在复合类型定义外部定义的。外部构造函数向内置构造函数添加方法。如果你不想用你自己的方法替换 Julia 的构造函数方法,你需要在结构定义内部定义构造函数函数,如下所示。
列表 8.9 为 FlexiTank 定义内部构造函数
mutable struct FlexiTank <: Tank
drymass::Float64
totalmass::Float64
propellant::Float64
function FlexiTank(drymass::Number, totalmass::Number)
new(drymass, totalmass, totalmass - drymass) ❶
end
end
❶ 注意新版本替换了 FlexiTank
内部构造函数向你介绍了一个名为 new 的特殊函数。它仅在内部构造函数内部可用——其他任何地方都不可用。你需要它,因为创建内部构造函数会移除 Julia 创建的所有构造函数方法。换句话说,你不能再调用它们中的任何一个。
new 与 Julia 提供的默认构造函数方法非常相似,但有一些重要差异:你可以向 new 提供零个或多个参数,但参数数量不能超过你的复合类型中的字段数量。对于未提供值的字段会发生什么?它们会得到一个随机值。
你如何知道你的内部构造器替换了所有 Julia 提供的构造函数方法?你可以重新加载你的 REPL 环境并测试:
julia> t = FlexiTank(5, 50)
FlexiTank(5.0, 50.0, 45.0)
julia> t = FlexiTank(5, 50, 150) ❶
ERROR: MethodError: no method matching FlexiTank(::Int64, ::Int64, ::Int64)
Closest candidates are:
FlexiTank(::Number, ::Number)
julia> methods(FlexiTank) ❷
# 1 method for type constructor:
[1] FlexiTank(drymass::Number, totalmass::Number)
❶ Julia 无法再找到接受第三个参数的方法。
❷ 检查附加到构造函数的方法数量。
现在,你可以看到方法只报告 FlexiTank 构造函数的单个方法。
8.5 建模火箭发动机和有效载荷
让我们转换话题,谈谈你想要送入太空的有效载荷以及提供火箭推进力的火箭发动机。有效载荷可能是一个探测器;卫星;乘员舱;或者,如果你是埃隆·马斯克,可能是一辆特斯拉 Roadster。
列表 8.10 定义火箭有效载荷
struct Payload
mass::Float64
end
这可能看起来很简单,但请记住你正在创建模型。模型只包含回答感兴趣问题所需的属性。例如,智能手机的初始模型可能只是一个木头块,没有任何按钮、屏幕或配色方案。为什么?因为,最初,你想回答的问题是,“这个形状和大小是否适合放入我的口袋里?你有多少空间可以用来创建屏幕和内部电子设备?”
同样,设计和建造火箭也是如此。最初,你只对质量预算感兴趣。你想知道以下内容:
-
我需要多少推进剂?
-
我能将多大的有效载荷送入轨道?
-
给定的火箭能飞多远?
要回答这样的问题,你不需要在你的模型中包含空间探测器上存在的仪器类型或它所拥有的电池或太阳能电池的类型。火箭发动机的重要属性是质量、推力和 Isp(比冲)。你可以把推力看作是发动机的强大程度,把 Isp 看作是它的燃油效率。
列表 8.11 定义自定义火箭发动机
abstract type Engine end
struct CustomEngine <: Engine
mass::Float64 ❶
thrust::Float64 ❷
Isp::Float64 ❸
end
mass(engine::CustomEngine) = engine.mass
thrust(engine::CustomEngine) = engine.thrust
Isp(engine::CustomEngine) = engine.Isp
❶ 火箭发动机质量(千克)
❷ 火箭发动机产生的推力(牛顿)
❸ 比冲(推进剂效率)
你也可以为具有已知属性的具体发动机定义类型,例如在 Electron 火箭中使用的 Rutherford 发动机和在 Falcon 9 火箭中使用的 Merlin 发动机。
列表 8.12 定义 Rutherford 和 Merlin 火箭发动机
struct Rutherford <: Engine end ❶
struct Merlin <: Engine end ❶
mass(::Rutherford) = 35.0 ❷
thrust(::Rutherford) = 25000.0 ❷
Isp(::Rutherford) = 311.0 ❷
mass(::Merlin) = 470.0
thrust(::Merlin) = 845e3
Isp(::Merlin) = 282.0
❶ 发动机是空的 struct。
❷ 使用访问器函数获取发动机属性。
推力是火箭发动机产生的力。如果你知道火箭的总质量,你可以计算出火箭发动机启动后整个火箭的加速度是多少。这是通过牛顿第二定律得到的,该定律表明力 F 与质量 m 乘以加速度 a 成正比。

然而,要知道你在任何给定时间推动的质量,你需要知道发动机每秒消耗多少推进剂。仅仅推力不能告诉你这一点;你需要比冲(Isp)。高比冲发动机更高效,意味着它将在相同的推力下消耗更少的推进剂。
注意:在物理学中,你通常用I表示冲量。因此Isp明确表示你指的是比冲。
Isp类似于汽车的油耗。然而,与在路上的汽车不同,在太空中的火箭即使在没有推力的情况下也会继续移动,因此你不能通过一公斤推进剂能走多远来衡量燃料效率(或推进剂效率)。相反,你通过衡量一单位推进剂能维持 1 G(地球上的重力)的力多少秒来衡量它。这允许你计算质量流量(每秒推进剂的消耗量):
g = 9.80665 ❶
function mass_flow(thrust::Number, Isp::Number) ❷
thrust / (Isp * g)
end
❶ 地球上的重力加速度为 m/s²
❷ 从发动机排出的质量以 kg/s 来衡量。
例如,你可以使用这个来计算 Falcon 9 火箭每秒消耗的推进剂量。它有九个 Merlin 1D 发动机,每个发动机的比冲为 282 s,推力为 845 kN:
julia> engine_thrust = 845e3
845000.0
julia> isp = 282
282
julia> thrust = engine_thrust * 9
7.605e6
julia> flow = mass_flow(thrust, isp)
2749.979361594732
因此,你得到 Falcon 9 火箭每秒消耗大约 2.7 吨推进剂的估计值。
8.6 组装一个简单的火箭
现在你已经拥有了组装火箭的所有部件。那么,让我们开始火箭的建造吧!
列表 8.13 带有载荷、储罐和发动机的火箭
struct Rocket
payload::Payload
tank::Tank
engine::Engine
end
你将制作一个类似 Rocket Lab 制造的 Electron 火箭的火箭。^([1)] 它可以将重量为 300 千克的载荷送入近地轨道。你将用一个小储罐和一个 Rutherford 发动机制作这个火箭的一个阶段。Rutherford 发动机是一种小型火箭发动机,用于 Electron 火箭的第一和第二阶段。
为了紧凑性,我指示 REPL 不要打印前三个赋值的值,通过附加分号;。你可以移除分号来查看差异:
julia> payload = Payload(300);
julia> tank = SmallTank();
julia> engine = Rutherford();
julia> rocket = Rocket(payload, tank, engine)
Rocket(Payload(300.0), SmallTank(370.0), Rutherford())
如果你略知火箭学,你可能意识到之前的火箭配置存在多个问题:
-
现实中的 Electron 火箭有九个 Rutherford 发动机,而不仅仅是一个。
-
太空火箭有多个阶段,随着火箭飞得更高,这些阶段会分离;你的火箭只有一个阶段。
8.7 创建多阶段和发动机的火箭
让我们解决这些问题。一个重要的洞察是意识到多阶段火箭有点像俄罗斯套娃.^(2) 你可以将火箭的载荷做成另一个火箭。下一个火箭的载荷可以是另一个火箭,以此类推。图 8.2 说明了阶段火箭是如何由多个嵌套火箭组成的。

图 8.2 火箭阶段
它展示了一个多阶段火箭,你不断地移除顶部以露出火箭的载荷。让我来解释每个编号的阶段:
-
整个多阶段火箭及其所有部件统称为航天器。第一阶段被称为助推器。
-
打开航天器,露出第二阶段火箭。
-
第二阶段的有效载荷是第三阶段。
-
第三阶段由一个整流罩保护,这是用于保护有效载荷(你的模型将忽略整流罩)的防护壳。
-
当发射完成时,最终的有效载荷被送入太空。这个有效载荷将是一艘航天器,例如卫星、月球着陆器或舱。
要使火箭内部可以放置火箭,你需要将 Rocket 改为抽象类型,并定义新的具体子类型,如下所示。
列表 8.14 允许火箭成为另一枚火箭的有效载荷
abstract type Rocket end ❶
struct Payload <: Rocket ❷
mass::Float64
end
struct StagedRocket <: Rocket
nextstage::Rocket ❸
tank::Tank
engine::Engine
end
function Rocket(payload::Rocket, tank::Tank,
➥ engine::Engine) ❹
StagedRocket(payload, tank, engine)
end
thrust(r::Payload) = 0.0 # no engines
thrust(r::StagedRocket) = thrust(r.engine)
❶ 将 Rocket 定义为抽象类型。
❷ 将有效载荷转换为 Rocket 的子类型。
❸ 为了清晰起见,将有效载荷重命名为下一阶段。
❹ 允许使用旧的构造函数。
但在你建造火箭之前,你需要做一些更多的调整。现实生活中的 Electron 火箭在第一阶段有九个 Rutherford 发动机——我们称之为助推器。你目前无法添加超过一个发动机。为了解决这个问题,你将定义一个新的发动机子类型,称为 Cluster。这种新类型旨在模拟许多相同发动机的集群。
图 8.3 没有显示每个类型。例如,我只能在抽象类型 Tank 下获得 MediumTank 和 FlexiTank 的空间。

图 8.3 火箭不同部分之间的关系 UML 图
使用空心箭头,该图显示了 StagedRocket 和 Payload 是抽象类型 Rocket 的子类型。实心箭头显示 StagedRocket 有一个字段,nextstage,它指向另一个 Rocket 对象。
让我们看看如何实现 Cluster 类型(列表 8.15)。UML 图说明它既是 Engine 的子类型,又通过 engine 字段指向另一个发动机。
列表 8.15 定义火箭发动机集群
struct Cluster <: Engine
engine::Engine
count::Int ❶
end
Isp(cl::Cluster) = Isp(cl.engine)
mass(cl::Cluster) = mass(cl.engine) * cl.count
thrust(cl::Cluster) = thrust(cl.engine) * cl.count
❶ 集群中相同发动机的数量
你会注意到比冲没有改变。燃料效率不会因为添加更多发动机而改变。然而,添加更多发动机会增加集群的质量以及总推力。
那么一个异构的发动机集群呢?你能制作由不同类型的发动机组成的集群吗?挑战在于决定如何计算每个发动机具有不同比冲的集群的 Isp。然而,你将在第十二章中制作一个异构的发动机集群。
你可以使用这些抽象来定义一个函数,update!,它负责在模拟火箭飞行时消耗推进剂。你通过执行小的时间步Δt 来进行模拟。
这是在编写实时系统模拟时常用的一种策略。当模拟具有许多部件的复杂事物时,通过解一个单独的数学方程来进行分析解法会变得过于复杂。视频游戏也是这样制作的。游戏中移动的每个对象都将有一个更新!函数,类似于以下列表中所示。
列表 8.16 在经过Δt 时间后更新推进剂质量
function update!(r::StagedRocket, t::Number, Δt::Number)
mflow = mass_flow(thrust(r), Isp(r.engine))
consume!(r.tank, mflow * Δt)
end
# Payload has no tanks with propellant to consume
update!(r::Payload, t::Number, Δt::Number) = nothing
假设您想制作一个三阶段的电子火箭。第三阶段非常小,因此只需要一个非常小的发动机。开发电子火箭的公司正在为此目的制造一个名为 Curie 的小型发动机。这个发动机的完整规格尚不清楚,因此您将基于一些猜测来定义这个发动机。
列表 8.17 定义第三阶段的小型发动机
struct Curie <: Engine end
mass(::Curie) = 8.0 ❶
thrust(::Curie) = 120.0 ❷
Isp(::Curie) = 317.0 ❶
❶ 质量(Mass)和比冲(Isp)需要猜测。
❷ 唯一已知的规格
您现在已经有足够的功能来定义一个由多个阶段组成的电子火箭:
julia> payload = Payload(300)
Payload(300.0)
julia> thirdstage = Rocket(payload, SmallTank(), Curie())
StagedRocket(Payload(300.0), SmallTank(370.0), Curie())
julia> secondstage = Rocket(thirdstage, MediumTank(), Rutherford())
StagedRocket(StagedRocket(Payload(300.0),
SmallTank(370.0),
Curie()),
MediumTank(2050.0),
Rutherford())
julia> booster = Rocket(secondstage, LargeTank(), Cluster(Rutherford(), 9))
StagedRocket(StagedRocket(StagedRocket(
Payload(300.0),
SmallTank(370.0),
Curie()),
MediumTank(2050.0),
Rutherford()),
LargeTank(9250.0),
Cluster(Rutherford(), 9))
在进行物理计算时,抽象出火箭或火箭的一部分属性(如质量)是如何确定的,这很有帮助。
列表 8.18 计算分阶段火箭的总质量
mass(payload::Payload) = payload.mass
function mass(r::StagedRocket)
mass(r.nextstage) + mass(r.tank) + mass(r.engine)
end
您可以看到在如何定义 mass(r::StagedRocket)时质量抽象化的好处。在实现这个函数之后,您不必关心有关有效载荷(nextstage)的细节。它可能是一个有效载荷或另一个有 20 个阶段的分阶段火箭。您不必知道;这些差异被抽象化了。
同样,您也不必关心您得到的是单个发动机的质量还是发动机集群的质量。想象一下,在创建 Cluster 类型之前您已经实现了这个函数。您不需要更改这个实现,因为只要 Cluster 是 Engine 类型并且实现了 mass,一切都会正常工作。
8.8 将火箭发射到太空
update!函数允许您跟踪推进剂的消耗。当调用时,它给您剩余的推进剂量。当这个数量达到零时,您知道您的火箭不能再飞得更高,因为速度将稳步下降,直到变为负值。您将实现一个新的函数 launch!,该函数确定火箭在没有推进剂之前达到的高度,以及那时经过的时间。
火箭被发动机的推力 T 产生的某个力推上去。然而,这个力必须与重力作用力相抗衡。重力对火箭施加的力与火箭的质量 m 成正比,与地球上的重力加速度 g 成正比:

从这里您可以确定火箭发射时的加速度:

在讨论 update!时,我注意到您正在将发射模拟为一系列小的时间增量Δt。对于一个小的时间增量,您可以找到在这个增量中速度的变化量(图 8.4)。

图 8.4 火箭发射后的速度。x 轴表示发射后的秒数。
这些观察结果是实现 launch! 函数的基础。你将速度的变化 Δv 在许多小的时间增量中累加起来。对于每一个增量,你假设速度是恒定的,并使用它来计算行驶的距离 Δh = v × Δt*。将所有小增量中行驶的距离累加起来,得到总行驶距离 h。
这个计算可以用图表来可视化。每个条形的高度等于 Δh,因为每个条形的宽度是 Δt,高度是 v。因此,将所有条形的面积累加起来等于行驶的距离。你使增量 Δt 越小,计算就越准确:

用数学术语来说,你正在对速度的积分进行近似。列表 8.19 中的代码是实现这些想法的一个实现。
注意:代码使用 1,000 秒的截止点。如果你使用大油箱,火箭可能不会在那么长时间内消耗完所有燃料。你可以使用一个 while 循环,但你会面临无限循环的风险。
列表 8.19 模拟火箭发射并确定火箭的最大高度
function launch!(rocket::Rocket, Δt::Real)
g = 9.80665 # acceleration caused by gravity
v = 0.0 # velocity
h = 0.0 # altitude (height)
for t in 0:Δt:1000 ❶
m = mass(rocket) ❷
F = thrust(rocket) - m*g
remaining = update!(rocket, t, Δt)
# Any propellant and thrust left?
if remaining == 0 || F <= 0
return (t, h) ❸
end
h += v*Δt ❹
a = F/m
v += a*Δt
end
end
❶ 在 1,000 秒时停止模拟。
❷ 质量变化,因为推进剂被消耗了。
❸ 返回花费的时间和行驶的距离
❹ 使用前一个时间增量中的速度。
我将 launch! 函数放入了一个名为 simulate.jl 的文件中,但你也可以将其粘贴到 REPL 中。然后你可以构建一个火箭并发射它:
julia> engine = Rutherford();
julia> tank = SmallTank();
julia> payload = Payload(300);
julia> rocket = Rocket(payload, tank, engine)
julia> launch!(rocket, 0.5)
(45.0, 31117.8036364191)
从输出中,你可以看到你的火箭花费了 45 秒才达到大约 31 公里的高度。对于真正的火箭来说,这将是不同的,因为它们必须处理空气阻力。你基本上是在一个没有大气层的地球上发射了这枚火箭。你可以看到油箱中的所有燃料都已经用完了:
julia> tank
SmallTank(0.0)
作为练习,你可以尝试用不同的载荷和油箱大小来发射火箭。你注意到更大的油箱并不总是能让你飞得更远吗?这是因为它们会增加你的火箭所受的引力。因此,重力可能最终会大于火箭发动机的推力。
除了不考虑空气阻力之外,这个模拟还有许多限制。该模拟也无法处理分级火箭。
摘要
-
你可以定义自己的自定义构造函数,以确保对象用有效的值初始化。
-
内部构造函数替换了 Julia 提供的默认构造函数。外部构造函数只是在外部复合类型定义中定义的方便构造函数。
-
抽象类型是用例如抽象类型 Payload end 来定义的。抽象类型不能有字段,并且你不能创建它们的对象(你不能实例化一个抽象类型)。
-
抽象类型和具体类型都可以是另一个抽象类型的子类型。然而,没有具体类型可以是另一个具体类型的子类型。
<:是子类型运算符。 -
将抽象类型与多态结合使用,可以使你抽象掉相关类型之间的差异,因此它们可以互换使用。
(1.) Rocket Lab 是一家起源于新西兰的太空公司,该公司将重量为几百公斤的小型卫星送入轨道。
(2.) 俄罗斯套娃是一种俄罗斯嵌套娃娃。每个娃娃内部都嵌套着一个小木偶。
9 转换和提升
本章涵盖
-
一种相关类型到另一种类型的转换
-
表达式中相关类型如何找到最小公倍数
-
使用 @edit 宏探索 Julia 标准库
Julia 和其他主流编程语言处理涉及不同数字类型的算术运算如此轻松,以至于我们大多数人可能不太关注这个事实:
julia> 3 + 4.2 ❶
7.2
julia> UInt8(5) + Int128(3e6) ❷
3000005
julia> 1//4 + 0.25
0.5
❶ 表达式中的数字转换为浮点数
❷ 所有整数转换为 Int128
事实上,这样做涉及相当多的复杂性。在底层,大多数编程语言都定义了一套 提升规则,这些规则说明了如果你组合不同类型的数字应该做什么。提升规则确保所有数字都转换为可以用于最终计算的合理公共数字类型。不要将数字转换与解析文本字符串以生成数字混淆。
你可能会想知道为什么你应该关心这些概念。掌握 Julia 的提升和转换系统将打开深入了解 Julia 中数字工作方式的门。这将使你能够执行各种任务,例如以下内容:
-
定义自定义数字类型
-
定义物理单位系统并在不同单位之间进行转换
本章的主要编程示例将做到这一点:为不同类型的角度定义一个单位系统,例如度数和弧度。然后我将演示 Julia 的提升和转换系统如何将不同的角度类型组合在同一表达式中。
但为什么要用带有单位的数字呢?为什么不让数字仅仅是数字呢?因为软件开发中经常出现由于英尺、米和其他单位混淆而导致的错误。通过使用带有单位的数字,你可以减少软件中的错误数量。
在 Julia 的许多情况下,参数的顺序并不重要。例如,如果你想检查两个几何对象是否重叠,那么 overlap(circle, triangle) 应该给出与 overlap(triangle, circle) 相同的结果。你可以想象在视频游戏中模拟不同类型战士之间的战斗时会出现类似的情况。Julia 的提升系统提供了一种优雅的技术来处理这类情况,而无需两次实现相同的算法。
9.1 探索 Julia 的数字提升系统
在微处理器内部,数学运算总是在相同类型的数字之间进行。微处理器没有将整数加到浮点数上的指令。微处理器总是执行相同数字类型之间的算术运算。
因此,当处理由不同数字类型组成的表达式时,所有高级编程语言都必须将表达式中的所有参数转换为相同的数字类型。但这个公共数字类型应该是什么?确定这个公共类型就是提升的全部内容。
我们将这表达为将数学运算符的参数提升到公共类型。在大多数主流语言中,控制数字提升的机制和规则是硬编码到语言中,并在语言的规范中详细说明。
注意:你会在其他编程语言中看到术语“类型强制转换”。强制转换是由编译器执行的隐式转换。Julia 的编译器不执行此操作,因此 Julia 中没有“强制转换”。
Julia 采取了截然不同的方法。在 Julia 中,数字是一等对象。它们不是具有独特硬编码规则的特殊类型。Julia 的提升规则定义在标准库中,而不是 Julia JIT 编译器的内部。这为你作为开发者提供了扩展转换和提升系统的能力。你可以添加新的数字类型以及新的规则。
但这不会增加开发者搞乱数字类型系统的风险吗?不,因为你是扩展现有系统;你并没有修改它。
提升规则由普通的 Julia 函数处理。挂钩到现有系统只是简单地添加你自己的方法到现有函数。你可以使用@edit 宏探索 Julia 源代码中的提升。
Julia 环境变量设置
为了使@edit 宏工作,你需要设置 JULIA_EDITOR 环境变量。这取决于你的操作系统。例如,我使用 fish shell。它需要修改启动配置,$HOME/.config/fish/config.fish,并添加以下行:
set -x JULIA_EDITOR mate
如果你使用 bash shell,你将修改$HOME/.profile 文件:
export JULIA_EDITOR=mate
这两个示例在 macOS 和 Linux 上都能工作。Windows 用户将使用 GUI 对话框来修改 JULIA_EDITOR 环境变量。或者,Windows 用户可以安装 Unix shell。
在下面的代码片段中,你正在添加一个整数和一个浮点数。通过使用@edit 宏前缀,Julia 会跳转到被调用的函数的定义,以便你查看源代码。
julia> @edit 2 + 3.5
一切都是函数!
值得注意的是,在 Julia 中几乎一切都是函数调用。当你写下 3 + 5 时,这实际上是调用名为+的函数的语法糖,如下所示:+(3, 5)。每个使用+、-、*等符号的函数都支持以前缀形式使用。
下面的代码展示了在 Julia 中,对某个 Number 进行的每个算术操作首先调用 promote,然后再执行实际的算术操作。
列表 9.1 Julia 标准库中数字算术操作的定义
+(x::Number, y::Number) = +(promote(x,y)...)
*(x::Number, y::Number) = *(promote(x,y)...)
-(x::Number, y::Number) = -(promote(x,y)...)
/(x::Number, y::Number) = /(promote(x,y)...)
... 被称为 splat 操作符,你可以使用它将数组或元组转换为函数参数。这意味着 foo([4, 5, 8]...) 与 foo(4, 5, 8) 相同。你还可以使用它将 promote 返回的元组转换为各种算术函数的参数,包括 +、-、* 等等。你可以在 Julia REPL 中进行一些实验,以更好地理解 promote 函数的工作原理:
julia> promote(2, 3.5)
(2.0, 3.5)
julia> typeof(1//2), typeof(42.5), typeof(false), typeof(0x5)
(Rational{Int64}, Float64, Bool, UInt8)
julia> values = promote(1//2, 42.5, false, 0x5)
(0.5, 42.5, 0.0, 5.0)
julia> map(typeof, values)
(Float64, Float64, Float64, Float64)
这表明 promote 返回一个数字元组,这些数字被转换为最合适的公共类型。然而,对于日常编程,你可以使用 typeof 来代替,以确定表达式将被提升为哪种类型。
9.2 理解数字转换
数字转换意味着将一种数字类型转换为另一种类型。这不应与解析混淆。例如,文本字符串可以被解析为数字,但字符串和数字不是相关类型;因此你不应该称之为转换。
在 Julia 中进行数字转换的推荐且最简单的方法是使用你想要转换到的类型的构造函数。所以如果你有一个值 x,并且你想将其转换为类型 T,那么只需写 T(x)。我将提供一些例子:
julia> x = Int8(32)
32
julia> typeof(x)
Int8
julia> Int8(4.0)
4
julia> Float64(1//2)
0.5
julia> Float32(24)
24.0f0
请记住,转换并不总是可以执行的:
julia> Int8(1000)
ERROR: InexactError: trunc(Int8, 1000)
julia> Int64(4.5)
ERROR: InexactError: Int64(4.5)
8 位数字无法表示大于 255(2⁸ - 1)的值,整数无法表示小数。
在许多情况下,转换是由 Julia 隐式完成的。Julia 使用 convert 函数来实现这一点,而不是构造函数。然而,你可以自由地显式调用 convert。以下是一个演示:
julia> convert(Int64, 5.0) ❶
5
julia> convert(Float64, 5) ❷
5.0
julia> convert(UInt8, 4.0)
0x04
julia> 1//4 + 1//4
1//2
julia> convert(Float32, 1//4 + 1//4)
0.5f0
❶ 将浮点数 5.0 转换为整数 5。
❷ 将整数 5 转换为浮点数 5.0。
注意这些函数调用的第一个参数 Int64、Float64 等。这些都是类型对象。类型是 Julia 中的第一类对象,这意味着它们可以像任何其他对象一样被处理。你可以传递它们、存储它们,并为它们定义方法。类型对象甚至有自己的类型。Int64 的类型是 Type{Int64},对于 Float64 是 Type{Float64}:
julia> 3 isa Int64 ❶
true
julia> Int64 isa Type{Int64} ❷
true
julia> "hi" isa String
true
julia> String isa Type{String}
true
julia> Int64 isa Type{String} ❸
false
❶ 数字 3 是 Int64 类型。
❷ 类型 Int64 是 Type{Int64} 类型。
❸ 类型 Int64 不是 Type{String} 类型。
你几乎可以将 Type 视为一个特殊类型的函数。向这个“函数”提供一个参数 T,将返回 T 的类型。正式来说,Type 是一个参数化类型,可以用类型参数 T 来参数化,以产生一个具体类型。如果你觉得这不太明白,不要担心;这是一个复杂的话题,我将在下一章更详细地解释这个话题。
在执行各种类型的赋值操作时,包括以下情况,会隐式调用 convert 函数:
-
对数组元素进行赋值
-
设置复合类型字段的值
-
使用类型注解对局部变量进行赋值
-
使用类型注解从函数返回
让我们看看一些演示隐式转换的例子:
julia> values = Int8[3, 5]
2-element Vector{Int8}:
3
5
julia> typeof(values[2])
Int8
julia> x = 42
42
julia> typeof(x)
Int64
julia> values[2] = x ❶
42
❶ 将 Int64 值赋给定义为 Int8 的数组元素,这会导致调用 convert(Int8, x)
在下一个示例中,你创建了一个复合类型 Point,具有字段 x 和 y。接下来,你创建了一个 Point 的实例 p,并将其 x 字段赋值为一个 8 位整数。由于字段类型为 Float64,会发生隐式数字转换。
julia> mutable struct Point
x::Float64
y::Float64
end
julia> p = Point(3.5, 6.8)
Point(3.5, 6.8)
julia> p.x = Int8(10) ❶
10
❶ 导致调用 convert(Float64, Int8(10))
在这里,向函数添加了一个类型注解,以确保返回值是某种类型。如果不是,将尝试使用 convert 进行转换:
julia> foo(x::Int64) :: UInt8 = 2x
foo (generic function with 1 method)
julia> y = foo(42)
0x54
julia> typeof(y)
UInt8
接下来,我们将通过一个更大的代码示例来详细说明如何进行转换和提升。
9.3 定义角度的自定义单位
通常,如果混合单位,科学计算很容易出错。例如,在石油工业中,混合英尺和米很容易,因为油井的坐标通常以米给出,而井深以英尺给出。
一个著名的例子是火星气候轨道器 (mng.bz/m2l8),这是由 NASA 发射的机器人太空探测器,由于 NASA 和洛克希德使用不同的度量单位而丢失。NASA 使用公制单位,而洛克希德使用美国习惯单位,如英尺和磅。因此,在设计代码时,避免意外混合单位是有优势的。
在此示例中,我们将展示如何处理角度的不同单位。在数学中,角度通常以弧度给出,而使用地图导航的人通常会使用度。当使用度时,你将圆分成 360 度。因此,1 度是那个圆周长度的 1/360。
与弧度相比,我们处理的是圆周上半径重复多少次以得到该角度(图 9.1)。因此,1 弧度是当你沿着圆周标记出等于圆半径的距离时得到的角。

图 9.1 弧度的定义
与此相反,度与导航(特别是天体导航)的联系更为紧密。地球每天大约移动 1 度绕太阳转,因为一年由 365 天组成。角度进一步分为 60 分,而一分分为 60 秒。
实际上,你可以同时处理公制度和度、分、秒(DMS),但在这里你使用 DMS 以保持事情有趣。

图 9.2 度、分和秒的细分
在此代码示例中,你将实现以下功能:
-
弧度和 DMS 类型用于表示不同类型的角度单位
-
构造函数,以便在给定度、分和秒的情况下更容易构建角度对象
-
角度类型上的操作,如加法和减法
-
访问器以提取度、分和秒
-
扩展
show函数以创建不同角度单位的漂亮显示 -
扩展
convert函数以支持从一个角度单位转换到另一个单位 -
覆盖 sin 和 cos 函数以仅与角度单位一起工作
-
结合一些巧妙的技巧来制作角度单位的漂亮数字字面量
-
扩展 promotion_rule 函数,以便不同的角度单位可以在同一表达式中使用
让我们先实现 Radian 和 DMS 角度类型。
列表 9.2 定义弧度和度作为抽象 Angle 类型的子类型
abstract type Angle end
struct Radian <: Angle
radians::Float64
end
# Degrees, Minutes, Seconds (DMS)
struct DMS <: Angle
seconds::Int
end
如同火箭示例中一样,您定义了一个抽象类型 Angle,所有具体的角度单位都是其子类型。其好处将在以后变得明显。
9.3.1 定义角度构造函数
应将 DMS 存储为秒视为实现细节,不应暴露给用户。因此,用户不应直接使用该构造函数。相反,您将定义更自然的构造函数,如下所示。
列表 9.3 弧度构造函数:度、分和秒
Degree(degrees::Integer) = Minute(degrees * 60)
Degree(deg::Integer, min::Integer) = Degree(deg) + Minute(min)
function Degree(deg::Integer, min::Integer, secs::Integer)
Degree(deg, min) + Second(secs)
end
function Minute(minutes::Integer)
DMS(minutes * 60)
end
function Second(seconds::Integer)
DMS(seconds)
end
9.3.2 定义角度上的算术运算
要能够实际运行这些构造函数,您需要能够将 DMS 数字相加。代码片段 Degree(deg) + Minute(min) 实际上执行的是 DMS(deg, 0, 0) + DMS(0, min, 0)。然而,+ 运算符尚未为 DMS 类型定义。您也没有为弧度定义它们,所以让我们在下面的列表中同时进行这两项定义。
列表 9.4 DMS 和弧度角度的算术运算
import Base: -, +
+(Θ::DMS, α::DMS) = DMS(Θ.seconds + α.seconds)
-(Θ::DMS, α::DMS) = DMS(Θ.seconds - α.seconds)
+(Θ::Radian, α::Radian) = Radian(Θ.radians + α.radians)
-(Θ::Radian, α::Radian) = Radian(Θ.radians - α.radians)
我将阐明这是如何工作的。如第 7.3.3 节所述,在 Julia 中定义一个方法会自动创建一个函数,如果还没有相应的函数存在。例如,如果没有导入 + 函数,当您定义 +methods 时,Julia 不会知道它已经存在。因此,Julia 将创建一个全新的 +function 并将您的角度特定方法附加到它上。
主要和基础模块 所有 Julia 类型和方法都属于一个模块。您可以将模块视为一个命名空间或库。Julia 随带的大部分功能都在名为 Base 的模块中。之前,您已经使用了 Statistics 模块。您没有明确将其包含在命名模块中的函数和类型将成为 Main 模块的一部分。在 Julia REPL 中创建的每个函数和类型都是 Main 模块的一部分。
如果您尝试计算 3 + 4,Julia 将尝试查找这个新定义的 + 函数上的匹配方法。但它没有处理常规数字的方法,只有角度的方法。因此,如果您忘记编写 import Base: +,您将收到以下错误信息:
julia> 3 + 4
ERROR: MethodError: no method matching +(::Int64, ::Int64)
You may have intended to import Base.+
实际上,您最终会覆盖在 Base 中定义的 + 函数及其附加的方法。通过导入,您实际上是在告诉 Julia 您想要向现有模块中定义的函数添加方法,例如 Base。如果您不这样做,您新定义的 + 函数将成为 Main 模块的一部分。在 REPL 中定义的、未从其他地方导入的一切都将成为 Main 模块的一部分。
图 9.3 是阴影问题的说明。函数列表 A 是在你向这些函数添加方法之前没有从 Base 中导入 + 和 - 时得到的。结果是每个运算符有两个单独的方法表:一个是 Base 的,另一个是 Main 的。在 B 示例中,导入了 + 和 -;因此,方法被添加到由 Base 定义的方法表中,而不是在 Main 模块中创建一个新的方法表。

图 9.3 在扩展函数之前未导入函数时发生的函数阴影问题
9.3.3 定义访问器以提取度、分和秒
给定一个 DMS(度、分、秒)角度,让我们在下面的列表中探讨其度、分和秒的部分。
列表 9.5 DMS 对象的度和分访问器
function degrees(dms::DMS)
minutes = dms.seconds ÷ 60
minutes ÷ 60
end
function minutes(dms::DMS)
minutes = dms.seconds ÷ 60
minutes % 60
end
seconds(dms::DMS) = dms.seconds % 60
你可以使用这些函数在 Julia REPL 中提供这些角度的定制显示。要表示角度为 90 度、30 分和 45 秒,你会使用表示法 90° 30' 45''。
9.3.4 显示 DMS 角度
如果你现在使用构造函数,你得到的显示效果并不是很好。它暴露了 DMS 度的内部表示是由弧秒组成的:
julia> α = Degree(90, 30, 45)
DMS(325845)
julia> degrees(α)
90
julia> minutes(α)
30
julia> seconds(α)
45
julia> β = Degree(90, 30) + Degree(90, 30)
DMS(651600)
julia> degrees(β)
181
julia> minutes(β)
0
你可以通过向 Julia 的 show 函数添加一个方法来定义一个替代视图。Julia 的交互式环境(REPL)使用 show(io::IO, data) 来向用户显示某些特定类型的数据。记住,在 Julia 中,你可以定义方法来处理通用抽象类型;然而,你也可以添加处理更具体类型的方法,这正是你在这个情况下想要做的。
列表 9.6 定义弧度和 DMS 对象的字符串表示
import Base: show
function show(io::IO, dms::DMS)
print(io, degrees(dms), "° ", minutes(dms), "' ", seconds(dms), "''")
end
function show(io::IO, rad::Radian)
print(io, rad.radians, "rad")
end
你将在第十一章中了解更多关于 show 和 IO 对象的内容。但就目前而言,这为你提供了一个查看 DMS 角度的不错方式:
julia> α = Degree(90, 30, 45)
90° 30' 45''
julia> β = Degree(90, 30) + Degree(90, 30)
181° 0' 0''
9.3.5 定义类型转换
现在你已经有了基础知识,你想要能够对这些角度做一些有用的事情。你可能想要使用它们与 sin 和 cos 等函数一起,但这些函数只接受纯数字,即弧度。你需要定义转换,以便 DMS 角度可以转换为弧度。
列表 9.7 定义直接和间接类型转换的方法
import Base: convert
Radian(dms::DMS) = Radian(deg2rad(dms.seconds/3600))
Degree(rad::Radian) = DMS(floor(Int, rad2deg(rad.radians) * 3600))
convert(::Type{Radian}, dms::DMS) = Radian(dms) ❶
convert(::Type{DMS}, rad::Radian) = DMS(rad) ❷
❶ 将 DMS 值转换为弧度值。
❷ 将弧度值转换为 DMS 值。
这包含了一些我将更详细讨论的新内容。注意,转换方法定义没有指定参数的名称,只指定了其类型。这与火箭示例类似,在那里你将 Rutherford 发动机的质量定义为
mass(::Rutherford) = 35
你可以写成 engine::Rutherford,但这有什么意义呢?Rutherford 组合类型没有任何可以访问的字段。同样,Type{Radian} 和 Type{DMS} 没有任何你在 convert 定义中感兴趣的字段。有了这些转换,你可以实现接受具有单位数字作为参数的 sin 和 cos 的版本。
列表 9.8 覆盖标准 sin 和 cos 函数以使用 DMS 和弧度
sin(rad::Radian) = Base.sin(rad.radians)
cos(rad::Radian) = Base.cos(rad.radians)
sin(dms::DMS) = sin(Radian(dms))
cos(dms::DMS) = cos(Radian(dms))
在这种情况下,您在创建方法之前没有导入 sin 和 cos。这是因为您实际上想覆盖 真实 的 sin 和 cos 函数,因为您不希望人们意外地使用纯数字调用这些函数。您希望他们明确使用弧度或度:
julia> sin(π/2)
ERROR: MethodError: no method matching sin(::Float64)
You may have intended to import Base.sin
julia> sin(90)
ERROR: MethodError: no method matching sin(::Int64)
You may have intended to import Base.sin
julia> sin(Degree(90))
1.0
julia> sin(Radian(π/2))
1.0
现在您不能在没有指定角度是以弧度还是度为单位给出时,意外地将角度用作三角函数的输入。
9.3.6 制作漂亮的字面量
这很好,但如果您能写 sin(90°) 而不是 sin(Degree(90)),以及 sin(1.5rad) 而不是 sin(Radian(1.5)),那就更好了。
实际上,您可以实现这一点。观察 Julia 将 1.5rad 解释为 1.5rad。因此,通过定义常规标量与度或弧度单位的乘法,您可以神奇地*解决这个问题。
列表 9.9 允许漂亮角度字面量的运算和常数
import Base: *, /
*(coeff::Number, dms::DMS) = DMS(coeff * dms.seconds)
*(dms::DMS, coeff::Number) = coeff * dms
/(dms::DMS, denom::Number) = DMS(dms.seconds/denom)
*(coeff::Number, rad::Radian) = Radian(coeff * rad.radians)
*(rad::Radian, coeff::Number) = coeff * rad
/(rad::Radian, denom::Number) = Radian(rad.radians/denom)
const ° = Degree(1)
const rad = Radian(1)
最后两行展示了秘诀。它们意味着 Julia 将 90° 读取为 90 * Degree(1),当计算时,将得到 Degree(90):
julia> sin(90°)
1.0
julia> sin(1.5rad)
0.9974949866040544
julia> cos(30°)
0.8660254037844387
julia> cos(90°/3)
0.8660254037844387
julia> sin(3rad/2)
0.9974949866040544
9.3.7 类型提升
添加对不同角度单位进行算术运算的支持的简单但劳动密集型方法意味着定义大量具有所有可能组合的函数。想象一下,如果您还有一个角度类型:MetricDegree。它将迅速导致组合爆炸,如下面的列表所示。
列表 9.10 算术运算的组合爆炸
+(α::DMS, β::Radian) = Radian(α) + β
+(α::MetricDegree, β::DMS) = α + MetricDegree(β)
+(α::Radian, β::MetricDegree) = α + Radian(β)
+(α::Radian, β::DMS) = α + Radian(β)
我甚至没有展示所有组合。我想要表达的观点是,您会得到许多难以管理的组合。相反,更好的解决方案是定义针对不同单位的通用函数,如下面的列表所示。
列表 9.11 通过利用提升简化算术运算
+(Θ::Angle, α::Angle) = +(promote(Θ, α)...)
-(Θ::Angle, α::Angle) = -(promote(Θ, α)...)
剩下的唯一问题是您没有告诉 promote 如何提升角度类型。它只知道 Number 类型。一个关于如何添加温度类型的初步猜测是添加另一个 promote 方法,但这不是它的工作方式。相反,promote 通过调用一个名为 promote_rule 的函数来完成其工作。您需要通过为 您的 类型定义 promote_rule 方法来注册您的类型。
列表 9.12 定义弧度和 DMS 的类型提升
import Base: promote_rule
promote_rule(::Type{Radian}, ::Type{DMS}) = Radian
这些方法是不寻常的,因为所有参数都是类型对象。此外,您没有为任何参数命名,因为类型对象不用于任何目的,只是用于通过多态选择 promote_rule 函数的正确方法。
promote_rule 函数接受两个类型对象作为参数,并返回另一个类型对象:
julia> promote_rule(Int16, UInt8)
Int16
julia> promote_rule(Float64, UInt8)
Float64
julia> promote_rule(Radian, DMS)
Radian
您可以将提升规则表述为以下问题:给定两种不同的类型,它们应该提升为哪种类型?现在您已经将所有部件放在一起。您通过实现 convert 和 promote_rule 函数的方法,将它们连接到 Julia 的转换和提升机制中:
julia> sin(90° + 3.14rad/2)
0.0007963267107331024
julia> cos(90° + 3.14rad/2)
-0.9999996829318346
julia> 45° + 45°
90° 0' 0''
julia> Radian(45° + 45°)
1.5707963267948966rad
julia> 45° + 3.14rad/4
1.5703981633974484rad
这个例子给你一个关于使用多分派语言(如 Julia)的优点提示。使用面向对象编程来实现这种行为会更难,并且随着你添加更多类型而变得越来越困难。如果你将每个角度定义为类,你将需要为每个操作定义几个方法——每个类型一个。
并且面向对象方法还有更多实际问题。如果你需要添加另一个角度单位,它将需要以下步骤:
-
为每个运算符添加一个包含四个方法的新类
-
通过添加处理新角度单位版本的每个运算符,修改其他每个角度类,包括基类 Angle
-
在每个类中添加另一个构造函数来处理新的角度单位(以允许转换)
这显然无法扩展,并且违反了面向对象编程中的开放-封闭原则^(1)。开放-封闭原则可以用以下说法来概括:对扩展开放,对修改封闭。
如果角度单位作为库提供,则无法在不修改库本身的情况下扩展它。这显然是不切实际的。
Julia 通过 不 将函数作为类型的一部分来优雅地解决这个问题。因此,你可以在不修改类型定义本身的情况下向类型添加新的构造函数。添加 convert 和 promote_rule 函数不需要修改提供你试图定义提升规则和转换的类型的库。
摘要
-
Julia 通过定义提升规则来处理类型提升。这是通过向 promote_rule 函数添加方法来完成的。
-
将值 x 转换为类型 T 的操作通过两种不同的方法完成:T(x) 和 convert(T, x)。后者用于处理隐式转换。
-
对象 x 可以具有类型 T。Type{T} 是类型对象 T 的类型。这一知识有助于你正确地向 convert 函数添加方法。
-
通过定义自己的提升规则和转换函数,你可以将新的数字类型添加到 Julia 中,或者将度、米、英尺、摄氏度或华氏度等单位添加到 Julia 数字中。单位有助于使数值代码更加健壮。新的数字类型可以帮助提高计算的准确性,减少内存需求,并提高性能。
-
要向在另一个模块中定义的函数添加方法,你需要显式地从该模块导入这些函数。如果不这样做,你将最终覆盖这些函数,这通常不会提供你期望的结果。
[1]面向对象软件构造,伯特兰·梅耶,普伦蒂斯·霍尔,1988 年,第 23 页。
10 表示未知值
本章涵盖
-
理解如何使用未定义的值
-
使用 Nothing 类型表示值的缺失
-
使用 Missing 类型处理存在但未知的值
在任何编程语言中处理的一个重要问题是表示值的缺失。长期以来,大多数主流编程语言,如 C/C++、Java、C#、Python 和 Ruby,都有一个名为 null 或 nil 的值,这是变量在没有值时可能包含的内容。更准确地说:null 或 nil 表示变量没有绑定到具体对象。
这在什么情况下会有用?让我们以 Julia 的 findfirst 函数为例。它定位子串的第一个出现:
julia> findfirst("hello", "hello world") ❶
1:5
julia> findfirst("foo", "hello world") ❷
❶ 找到子串 hello。
❷ 未找到子串 foo。
但如何表示找不到子串呢?像 Java、C#和 Python 这样的语言会使用 null 或 nil 关键字来表示这一点。然而,它的发明者,英国计算机科学家 Tony Hoare,称 null 指针是他的十亿美元错误,并非没有原因。
这使得编写安全代码变得困难,因为任何变量在任何给定时间都可能为 null。在支持 null 的语言编写的程序中,你需要大量的样板代码来执行 null 检查。这是因为对 null 对象执行操作是不安全的。
因此,现代语言倾向于避免有 null 对象或指针。Julia 没有通用的 null 对象或指针。相反,它有各种类型来表示未知或缺失的值。本章将向您介绍这些不同类型,如何使用它们以及何时使用它们。
10.1 nothing 对象
Julia 中最接近 null 的对象是 Nothing 类型的 nothing 对象。它是在 Julia 中定义的一个简单具体类型,如下所示。
列表 10.1 Julia 定义的 Nothing 类型和 nothing 常量
struct Nothing
# look, no fields
end
const nothing = Nothing()
nothing 对象是类型 Nothing 的一个实例。然而,Nothing 的每个实例都是同一个对象。您可以在 REPL 中自行测试:
julia> none = Nothing()
julia> none == nothing
true
julia> Nothing() == Nothing()
true
然而,这里并没有什么神奇的事情发生。当你用零字段调用复合类型的构造函数时,你总是得到相同的对象返回。用更正式的话来说:对于没有字段的类型 T,类型 T 的每个实例 t 都是同一个对象。以下示例应该有助于澄清:
julia> struct Empty end
julia> empty = Empty()
Empty()
julia> none = Empty()
Empty()
julia> empty == none
true
julia> Empty() == Empty()
true
然而,不同空复合类型的实例被认为是不同的。因此,Empty()返回的对象与 Nothing()不同:
julia> Empty() == Nothing()
false
julia> empty = Empty()
Empty()
julia> empty == nothing
false
空复合类型使得在 Julia 中创建具有特殊意义的专用对象变得容易。按照惯例,Julia 使用 nothing 来表示某物找不到或不存在:
julia> findfirst("four", "one two three four")
15:18
julia> findfirst("four", "one two three")
julia> typeof(ans)
Nothing
julia> findfirst("four", "one two three") == nothing
true
10.2 在数据结构中使用 nothing
多级火箭类似于一个更通用的数据结构,称为 链表。就像火箭的例子一样,将多个对象链接起来通常很有用。例如,你可以使用它来表示由多个车厢组成的火车,这些车厢承载着一些货物。以下定义将不会工作。你能确定为什么吗?
列表 10.2 定义一个无限火车
struct Wagon
cargo::Int ❶
next::Wagon ❷
end
cargo(w::Wagon) = w.cargo + cargo(w.next) ❸
❶ 火车车厢中有大量的货物
❷ 链接到这个车厢的下一节车厢
❸ 计算所有车厢中的总货物量。
使用我们给出的定义,无法构建由这些车厢组成的火车。我将通过一个例子来澄清:I’ll clarify with an example:
train = Wagon(3, Wagon(4, Wagon(1, Wagon(2, ....))))
无法结束这列车厢的链条。每个 Wagon 构造函数都需要一个车厢对象作为其第二个参数。为了说明无限车厢链条,我在代码示例中插入了 ....。下一个字段始终必须是一个 Wagon。但如果你将 Wagon 设为一个抽象类型呢?这是可能的解决方案之一,这已经在多级火箭的例子中得到了应用。
记住,并非每个 Rocket 子类型都有一个下一阶段字段。然而,在本章中,我将介绍一个更通用的解决方案来解决这个问题,利用 参数化类型。这只是为了介绍基础知识,因为第十八章完全致力于参数化类型。
重要参数化类型可能看起来只是对高级 Julia 程序员有吸引力的附加功能。然而,我故意在代码示例中尽量减少参数化类型的使用。现实世界的 Julia 代码广泛使用参数化类型。参数化类型对于类型正确性、性能和减少代码重复至关重要。
10.2.1 参数化类型是什么?
当我定义范围和配对时,你已经接触到了参数化类型。如果 P{T} 是一个参数化类型 P,那么 T 就是类型参数。我知道这听起来非常抽象,但通过一些例子,它将变得非常清晰:
julia> ['A', 'B', 'D']
3-element Vector{Char}: ❶
'A'
'B'
'D'
julia> typeof(3:4)
UnitRange{Int64} ❷
julia> typeof(0x3//0x4)
Rational{UInt8} ❸
❶ 带有 Char 类型参数的参数化类型 Vector
❷ 带有 Int64 类型参数的参数化类型 UnitRange
❸ 带有 UInt8 类型参数的参数化类型 Rational
你可以将 Vector 视为一个模板来创建一个实际类型。要创建一个具体的向量,你需要知道向量中元素的类型。在第一个例子中,类型参数是 Char,因为每个元素都是一个字符。对于 UnitRange,类型参数表示范围的起始和结束的类型。对于 Rational,类型参数指定分数中的分子和分母的类型。
类型参数对于参数化类型,就像值对于函数。你将一个值输入到函数中,得到一个值输出:
y = f(x)
你将 x 输入到函数 f 中,得到值 y。对于参数化类型,可以做出相同的类比:
S = P{T}
你将类型 T 输入到 P 中,得到类型 S。你可以通过一些实际的 Julia 类型来演示这一点:
julia> IntRange = UnitRange{Int} ❶
UnitRange{Int64}
julia> FloatRange = UnitRange{Float64} ❷
UnitRange{Float64}
julia> IntRange(3, 5)
3:5
julia> FloatRange(3, 5) ❸
3.0:5.0
julia> NumPair = Pair{Int, Float32}
Pair{Int64, Float32}
julia> NumPair(3, 5)
3 => 5.0f0
julia> 3 => 5
3 => 5
❶ 创建一个名为 IntRange 的范围类型。
❷ 基于浮点数创建一个范围类型。
❸ 使用自定义范围类型构建一个范围。
在这个例子中,你可以看到类型可以被处理得像对象一样。你创建新的类型对象,并将它们绑定到变量 IntRange、FloatRange 和 NumPair。然后使用这些自定义类型来实例化不同类型的对象。
10.2.2 使用联合类型结束车厢列车
联合是一种参数化类型。你可以提供多个类型参数来构建新的类型。联合类型是特殊的,因为它们可以作为任何列出的类型参数的占位符。或者,你可以将联合类型视为将两种或更多类型组合成一种类型的方法。
假设你有名为 T1、T2 和 T3 的类型。你可以通过编写 Union{T1, T2, T3}来创建这些类型的联合。这创建了一个新类型,它可以作为任何这些类型的占位符。这意味着如果你编写了一个具有签名的 f(x::Union{T1, T2, T3})的方法,那么每当 x 是 T1、T2 或 T3 类型时,这个特定的方法就会被调用。让我们看一个具体的例子:
julia> f(x::Union{Int, String}) = x³
f (generic function with 1 method)
julia> f(3)
27
julia> f(" hello ")
" hello hello hello "
julia> f(0.42)
ERROR: MethodError: no method matching f(::Float64)
Closest candidates are:
f(!Matched::Union{Int64, String}) at none:1
最后一个例子失败是因为 x 是一个浮点数,而我们只定义了一个接受 Int 和 String 联合的函数 f 的方法。Float64 没有被包括在内。
在类型联合中包含的每个类型都将被计为该联合的子类型。你使用<:运算符来定义子类型或测试类型是否为子类型:
julia> String <: Union{Int64, String}
true
julia> Int64 <: Union{Int64, String}
true
julia> Float64 <: Union{Int64, String}
false
julia> Union{Int64, String} == Union{String, Int64} ❶
true
❶ 类型参数顺序无关紧要。
联合定义中类型参数的顺序无关紧要,如最后评估的表达式所示。有了联合类型,你可以用无限列车解决问题。
列表 10.3 定义有限列车
struct Wagon
cargo::Int
next::Union{Wagon, Nothing} ❶
end
cargo(w::Wagon) = w.cargo + cargo(w.next)
cargo(w) = 0 ❷
❶ 下一个链接的车厢可以是空值。
❷ 非车厢的值没有货物。
重新加载你的 Julia REPL,并粘贴新的类型定义。此代码将允许你创建有限的车厢链。注意,已经定义了两种货物方法。你有两种不同的情况要处理,因为 next 可以是 Wagon 或无值:
julia> train = Wagon(3, Wagon(4, Wagon(1, nothing)))
Wagon(3, Wagon(4, Wagon(1, nothing)))
julia> cargo(train)
8
julia> train = Wagon(3, Wagon(4, Wagon(1, 42))) ❶
ERROR: MethodError: Cannot `convert` an object of type Int64
to an object of type Wagon
❶ 尝试将 42 用作下一个车厢。
上一个例子包括是为了展示由于联合定义,next 只能是 Wagon 或 Nothing 对象。因此,将 next 设置为整数,如 42,是不合法的。这会导致 Julia 类型系统大声抗议,通过抛出异常。
10.3 缺失值
缺失值在 Julia 中用缺失对象表示,其类型为 Missing。这看起来与 nothing 非常相似,那么为什么你需要它呢?
这是因为 Julia 旨在成为科学计算、统计学、大数据等学术领域的良好语言。在统计学中,缺失数据是一个重要概念。它经常发生,因为在几乎任何用于统计的数据收集中都会有缺失数据。例如,你可能遇到参与者填写表格的情况,其中一些人未能填写所有字段。
一些参与者可能在研究完成前离开,留下那些进行实验的人拥有不完整的数据。缺失数据也可能由于数据录入错误而存在。所以,与“无”的概念不同,缺失数据实际上存在于现实世界中。我们只是不知道它是什么。
专门为统计学家设计的软件,如 R(见www.r-project.org)和 SAS(见www.sas.com),长期以来一直认为缺失数据应该传播而不是抛出异常。这意味着如果在大计算中的任何部分引入了缺失值,整个计算将评估为缺失。Julia 也选择了遵循这一惯例。让我们看看这在实践中意味着什么。
列表 10.4 比较数学表达式中缺失值和“无”的行为
julia> missing < 10
missing
julia> nothing < 10
ERROR: MethodError: no method matching isless(::Nothing, ::Int64)
julia> 10 + missing
missing
julia> 10 + nothing
ERROR: MethodError: no method matching +(::Int64, ::Nothing)
你可以在列表 10.4 中看到,涉及缺失值的每个数学运算都会评估为缺失,与“无”不同,它会导致抛出异常。这样做的原因是,过去在统计工作中已经犯了许多严重的错误,这些错误源于没有注意到存在缺失值。由于缺失值在 Julia 中像病毒一样传播,未处理的缺失值会迅速被发现。
缺失值可以显式处理。例如,如果你想计算可能包含缺失值的数组的总和或平均值,你可以使用 skipmissing 函数来避免尝试将缺失值包含在结果中:
julia> using Statistics
julia> xs = [2, missing, 4, 8];
julia> sum(xs) ❶
missing
julia> sum(skipmissing(xs)) ❷
14
julia> median(skipmissing(xs))
4.0
julia> mean(skipmissing(xs))
4.666666666666667
❶ 缺失值的出现会污染整个计算,导致结果缺失。
❷ 跳过缺失值,因此你可以将非缺失值相加。
10.4 非数值
与缺失值多少有些相关的是浮点数 NaN(非数值)。当操作的结果未定义时,你会得到 NaN。这通常在除以零时成为一个问题:
julia> 0/0
NaN
julia> 1/0
Inf
julia> -1/0
-Inf
在这种情况下,Inf 代表无穷大,是除以非零数除以零时得到的结果。这有些道理。当除数接近零时,结果往往会变得更大。
很容易将 NaN 视为与缺失值相似,并且它们可以互换。毕竟,NaN 也会在所有计算中传播。
列表 10.5 数学运算中 NaN 的传播
julia> NaN + 10
NaN
julia> NaN/4
NaN
julia> NaN < 10
false
julia> NaN > 10
false
然而,NaN 的比较结果为假。以下是不应使用 NaN 作为缺失值的原因:如果你在算法中犯了一个错误导致 0/0 发生,你将得到 NaN。这将与输入缺失值无法区分。
列表 10.6 无法区分导致 NaN 的计算或输入为 NaN
julia> calc(x) = 3x/x;
julia> calc(0)
NaN
julia> calc(NaN)
NaN
你可能会错误地认为你的算法正在工作,因为它在计算中移除了缺失值,从而掩盖了一个缺陷。例如,在列表 10.6 中,你在除法之前没有检查输入 x 是否为零。因此,当 x 为 0 时,你得到一个 NaN 作为结果。如果你将 NaN 作为 calc 函数的输入以指示缺失值,那么你无法区分程序员错误和缺失值。
10.5 未定义数据
在 Julia 中,你很少会遇到未定义的数据,但了解这一点是值得的。未定义数据发生在变量或结构体的字段未设置时。通常,Julia 会尽量聪明地处理这种情况;如果你定义了一个具有数字字段的 struct,Julia 会自动将它们初始化为零,除非你做了其他操作。然而,如果你定义了一个没有告诉 Julia 字段类型的 struct,Julia 就没有方法猜测字段应该初始化为什么。
列表 10.7 定义一个复合类型,使用未定义的值实例化
julia> struct Person
firstname
lastname
Person() = new()
end
julia> friend = Person()
Person(#undef, #undef)
julia> friend.firstname
ERROR: UndefRefError: access to undefined reference
Julia 允许构建具有未初始化字段的复合对象。然而,如果你尝试访问一个未初始化的字段,它将抛出一个异常。未初始化的值没有好处,但它们有助于捕捉程序员的错误。
10.6 将所有内容组合在一起
区分这些 nothing 概念中的每一个可能有点令人畏惧,所以我将简要总结它们之间的区别:nothing 是程序员的 null 类型。当某物不存在时,程序员想要的就是这个。missing 是统计员的 null 类型。当他们的输入数据中缺少值时,他们想要的就是这个。NaN 表示在代码中某个地方发生了非法的数学操作。换句话说,这与计算有关,而不是与数据的统计收集有关。未定义 是指程序员忘记初始化所有使用的数据。这很可能是程序中的错误。
作为最后的提醒:Julia 在常规意义上没有 null,因为你需要使用类型联合显式允许 nothing 值。否则,函数参数不会意外地传递一个 nothing 值。
摘要
-
Julia 中的未知值由 nothing、missing、NaN 和 undefined 表示。
-
nothing 是类型 Nothing,表示不存在的东西。当查找函数失败或在数据结构中(例如,用于终止链表)时,将其用作返回值。
-
missing 是存在但缺失的数据,例如在调查中。它是类型 Missing。当实现从文件中读取统计数据的代码时,使用 missing 作为缺失数据的占位符。
-
NaN 是非法数学操作的结果。如果你的函数返回 NaN,你应该调查你是否犯了一个编程错误。例如,你确保你的代码中 0/0 永远不会发生吗?
-
未定义是指变量未初始化为已知值。
-
在该语言中,既没有“无”也没有“缺失”作为内置值,但它们被定义为没有任何字段的复合类型。
-
联合参数化类型与“无”和“缺失”类型一起使用非常实用。例如,如果一个字段可以是字符串或无,则将类型定义为 Union{Nothing, String}。
第三部分 集合
集合是存储和组织其他对象的实体。在本部分中,您将了解集合的独特特性和功能,例如数组、集合和字符串。然而,您还将深入了解所有集合的共同点,例如在 for 循环中遍历所有元素以及高阶函数,如 map、filter 和 reduce。我在第一部分中简要介绍了集合,但在这里我将更深入地探讨每个主题。
11 处理字符串
本章涵盖
-
理解 Unicode、码点和 UTF-8 编码之间的关系
-
比较字符串,将它们转换为小写,以及执行其他字符串操作
-
何时以及如何使用原始字符串
-
了解不同类型的字符串字面量,包括正则表达式、MIME 类型以及 BigInt 字面量
你已经在前面章节中有了处理字符串的一些实际经验;然而,我将在本章中涵盖更多细节,这将帮助你正确使用文本字符串。在本章中,你将更仔细地检查这些细节。只要你在处理 A-Z 的字母,事情就会很简单。然而,世界上有无数种语言,它们都有自己的独特字符集,而 Julia 需要能够处理这些字符集。
这意味着有效使用 Julia 字符串所需的最小知识包括一些关于 Unicode 的知识。Unicode 是将数字(码点)映射到字符的国际标准。
Julia 还支持特殊字符串字面量,以帮助执行各种任务。例如,有一些称为 正则表达式 的特殊字符串,允许你检查另一个字符串是否与特定模式匹配,例如电子邮件地址、IP 地址或邮政编码。
11.1 UTF-8 和 Unicode
Julia 中的文本字符串是 Unicode,以 UTF-8 格式编码。但这意味着什么,为什么你应该关心?我将通过一个简单的例子来引导你,说明更好地理解 Unicode 的重要性。
Æser 是挪威语中 norse gods 的复数形式。它是一个四个字母的单词,正如长度函数所确认的那样:
julia> length("Æser")
4
但是,当尝试访问单词中的单个字符时,你会注意到一些奇怪的事情:
julia> "Æser"[1] ❶
'Æ': Unicode U+00C6 (category Lu: Letter, uppercase)
julia> "Æser"[2] ❷
ERROR: StringIndexError("Æser", 2)
julia> "Æser"[3] ❸
's': ASCII/Unicode U+0073 (category Ll: Letter, lowercase)
julia> "Æser"[4]
'e': ASCII/Unicode U+0065 (category Ll: Letter, lowercase)
❶ 正如预期的那样工作
❷ 你会得到一个异常。尝试在索引 2 处访问字符 s 明显不起作用。
❸ 相反,第二个字符位于索引 3。这看起来奇怪吗?
再来一个单词?Þrúðvangr 是北欧神托尔领域的名字:
julia> length("Þrúðvangr")
9
julia> "Þrúðvangr"[9]
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
这是一个九个字符的单词,但索引 9 的字符是第六个字符,'a'。这是怎么回事?为了理解这一点,你需要了解 Unicode 以及 Julia 字符串如何通过 UTF-8 编码来支持它:
julia> sizeof("Æser") ❶
5
julia> sizeof("Þrúðvangr") ❷
12
❶ Æser 使用 5 个字节进行编码。
❷ Þrúðvangr 使用 12 个字节进行编码。
在 UTF-8 中,每个字符被编码为 1 到 4 个字节。正常的字母,如 A、B 和 C,只需 1 个字节,而像 Æ、Þ 和 ð 这样的字母,这些字母在英语中不常用,通常需要超过 1 个字节来编码。然而,在我进一步探讨 Julia 如何处理这个问题之前,了解一些 Unicode 的关键概念是有用的,这些概念并不特定于 Julia 编程语言。
11.1.1 理解码点和码单元之间的关系
Unicode 和字符编码是许多开发者可能会在某一点上遇到复杂话题。了解 Unicode 和 UTF-8 存在的历史原因将为理解提供重要背景。
两种标准都源自较老的 ASCII 标准,该标准将每个字符编码为 8 位。数字 65 到 90 将编码字母 A-Z,而数字 97 到 122 将编码小写字母 a-z。您可以使用 Char 类型的构造函数在 Julia REPL 中探索 ASCII 码与其对应字符之间的关系:
julia> Char(65)
'A': ASCII/Unicode U+0041
julia> Char(90)
'Z': ASCII/Unicode U+005A
为了处理不同的语言,人们需要操作从 1 到 255 的不同数字解释。然而,这很快变得不切实际。例如,您不能在同一页面上混合使用不同字母表编写的文本。解决方案是 Unicode,它旨在为世界上每个字符赋予一个唯一的数字——不仅限于拉丁字母,还包括西里尔字母、中文、泰语以及所有日文字符集。
分配给每个字符的数字在 Unicode 术语中称为码点(www.unicode.org/glossary/#code_point)。最初,人们认为 16 位足以存储每个 Unicode 码点。16 位提供了 216 - 1 = 65,535 个唯一的数字。因此,Unicode 的第一个编码之一,UCS,使用 16 位(2 字节)来编码每个 Unicode 码点(www.unicode.org/glossary/#UCS)).
后来人们确定这还不够,需要 4 个字节(32 位)来编码每个可能的 Unicode 字符。在这个时候,UCS 方法开始显得有缺陷,原因如下:
-
UCS 已经与 8 位 ASCII 码不兼容。
-
每个字符总共需要 4 个字节将消耗大量空间。
UTF-8 编码通过每个字符使用可变数量的字节解决了这些问题(www.cl.cam.ac.uk/~mgk25/ucs/utf-8-history.txt)。这样,常用字符可以用单个字节编码,节省空间。1 字节字符有意与 ASCII 向后兼容。
在可变长度字符编码中,需要区分字符的码点和编码该字符所需的码单元。每个 Unicode 字符都有一个数字,即码点,用于标识它。码单元用于在内存中存储这些码点。UTF-8 需要可变数量的码单元来完成这项工作(图 11.1)。

图 11.1 Unicode 码点编码为 UTF-8 码单元(并非每个索引都显示)
相比之下,UCS 具有固定大小的代码单元。每个 UCS 代码单元是 16 位。图 11.1 说明了字符、码点和代码单元之间的关系。每个灰色的码点块代表 4 个字节。每个字符需要多个代码单元。因此,它们被堆叠起来以显示哪些字节与同一字符相关。黑色的小球给出了组成字符的一些代码单元的字节索引。为了帮助阐明这些概念,你将在 Julia REPL 中进行一些关于 Unicode 字符的动手实验:
julia> codepoint('A') ❶
0x00000041
julia> Int(codepoint('A')) ❷
65
julia> ncodeunits('A') ❸
1
julia> isascii('A') ❹
true
❶ 以十六进制形式获取字母 A 的码点。
❷ A 的十进制码点
❸ 编码字母 A 的码点所需的代码单元数量
❹ 这个字母是否是原始 ASCII 标准的一部分?
让我们探索不是原始 ASCII 标准一部分的字符。它们应该有多个代码单元,并且在调用 isascii()时不会返回 true:
julia> codepoint('Æ')
0x000000c6
julia> ncodeunits('Æ') ❶
2
julia> isascii('Æ') ❷
false
julia> codepoint('😏') ❸
0x0001f60f
julia> ncodeunits('😏') ❹
4
julia> isascii('😏')
false
❶ 在 Julia 使用的 UTF-8 标准中,编码Æ字符需要 2 个字节。
❷ Æ不是原始 ASCII 标准的一部分。
❸ 表情的码点
❹ 一个表情符号 emoji 需要 4 个字节来编码。
没有 isUnicode 函数,因为 Julia 的每个字符都是 Unicode 字符。isascii 只是检查给定 Unicode 字符是否也是原始 ASCII 标准的一部分的一种方式。
在 REPL 中直接输入字母也会在字符字面量被评估时提供有用的信息:
julia> 'A'
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
julia> 'Æ'
'Æ': Unicode U+00C6 (category Lu: Letter, uppercase)
julia> '😏'
'😏': Unicode U+1F60F (category So: Symbol, other)
注意这告诉你 Unicode 码点的数字是多少。
小贴士:你可以使用反斜杠 \ 和 Tab 键轻松地写入键盘上没有的不寻常字符。例如,要在 Julia REPL 中写入 😏,请输入 😏 并按 Tab 键,以获取完成。
你甚至可以在写下 : 之后按 Tab 键,以获取可能的全部表情符号列表。例如,挪威字母如 ÆØÅ,我在示例中偶尔使用,可以在 Mac 上通过简单地按住选项键并输入字符 O、A 或'(英文键盘布局)来轻松输入。对于其他操作系统,切换到挪威键盘布局或复制字母。
在 Julia 中,Unicode 码点可以用多种方式显式表示:
julia> '\U41'
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
julia> Char(0x41)
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
julia> '\U00c6'
'Æ': Unicode U+00C6 (category Lu: Letter, uppercase)
julia> Char(0xc6)
'Æ': Unicode U+00C6 (category Lu: Letter, uppercase)
julia> '\U01f60f'
'😏': Unicode U+1F60F (category So: Symbol, other)
julia> Char(0x01f60f)
'😏': Unicode U+1F60F (category So: Symbol, other)
你可以将这些与 map 结合使用来创建各种范围。例如,范围不必仅仅用数字来表示。例如,'A':'F'是一个完全有效的范围:
julia> map(lowercase, 'A':'F')
6-element Vector{Char}:
'a'
'b'
'c'
'd'
'e'
'f'
julia> map(codepoint, 'A':'F')
6-element Vector{UInt32}:
0x00000041
0x00000042
0x00000043
0x00000044
0x00000045
0x00000046
当然,你也可以选择相反的方向:
julia> map(Char, 65:70)
6-element Vector{Char}:
'A'
'B'
'C'
'D'
'E'
'F'
由于字符的数量和它的索引并不完全相关,所以在处理字符串和索引时必须小心。你应该使用 lastindex 和 nextind 函数,这些函数也适用于非字符串,如下面的示例所示:
julia> xs = [4, 5, 3]
julia> i = firstindex(xs) ❶
1
julia> while i <= lastindex(xs) ❷
println((i,xs[i]))
i = nextind(xs, i) ❸
end
(1, 4)
(2, 5)
(3, 3)
❶ 获取第一个元素的索引。因此,你不必假设第一个元素在索引 1。你可以创建具有不同起始索引的 Julia 数组。
❷ 检查你是否已经到达最后一个索引。
❸ 在数组 xs 中找到索引 i 之后的元素的索引
在下面的列表中,你可以看到如果你用字符串这样做,逻辑是完全相同的。
列表 11.1 使用 while 循环遍历字符串中的字符
julia> s = "Þrúðvangar"
julia> i = firstindex(s)
1
julia> while i <= lastindex(s)
println((i, s[i]))
i = nextind(s, i)
end
(1, 'Þ')
(3, 'r') ❶
(4, 'ú')
(6, 'ð') ❷
(8, 'v')
(9, 'a')
(10, 'n')
(11, 'g')
(12, 'a')
(13, 'r')
❶ 注意你从索引 1 跳到 3,从 Þ 到 r。
❷ 从 ð 跳过索引 7 到 v。
通过使用这些函数,你可以抽象出字符串和常规数组之间的差异。例如,你可以看到在标准库中 nextind 是如何实现的。对于数组来说,它只是一个简单的递增:
nextind(::AbstractArray, i::Integer) = Int(i)+1
对于字符串来说,这是一个更复杂的操作。我将只展示实现的部分,以编辑清晰度。
列表 11.2 字符串 nextind 基本实现的摘录
function nextind(s::String, i::Int)
i == 0 && return 1
n = ncodeunits(s)
between(i, 1, n) || throw(BoundsError(s, i))
l = codeunit(s, i)
(l < 0x80) | (0xf8 =< l) && return i+1
if l < 0xc0
i' = thisind(s, i)
return i' < i ? nextind(s, i') : i+1
end
...
end
对于常规代码,你不需要处理 nextind 和 lastindex。相反,你使用 for 循环,因为它们将在每次迭代时自动获取整个字符:
julia> for ch in "Þrúðvangar"
print(ch, " ")
end
Þ r ú ð v a n g a r
如果你需要每个字符的索引,请使用 eachindex 函数:
julia> for i in eachindex(s)
print(i, s[i], " ")
end
1Þ 3r 4ú 6ð 8v 9a 10n 11g 12a 13r
11.2 字符串操作
处理文本是一件非常常见的事情,因此了解语言中存在的可能性是值得的。我的意图不是展示每个存在的字符串操作,而是给出一个可能性的概念。
当我与其他编程语言一起工作时,我经常使用 Julia 作为助手。我使用 Julia 以不同的方式转换代码。我将通过一个例子来引导你。在许多编程语言中,标识符的文本格式变化是常见的:
-
FooBar—帕斯卡式(大驼峰式);这是一种常用于类型或类的风格。有时也用于常量。
-
foo_bar—蛇形案例;它通常用于变量、方法和函数的名称。
-
fooBar—驼峰式(小驼峰式);它通常用于方法和变量名。
-
FOO_BAR—大蛇形案例;它通常用于常量和枚举值。
-
foo-bar—短横线案例;你可以在 LISP 程序和配置文件中找到它。
我将演示几种在这些风格之间转换的方法,以及如何将它们变成有用的实用函数,以帮助你的编程。
以下是我开发一个简单函数的典型过程,用于执行某些操作:由于我对一个不熟悉的函数的工作方式不确定,我首先尝试它。然后我逐渐将其与更多的函数调用结合起来,以获得我想要的结果。最终,我有了足够的代码来实现我的函数。
列表 11.3 骆驼峰式函数的迭代开发
julia> s = "foo_bar"
"foo_bar"
julia> split(s, '_')
2-element Vector{SubString{String}}:
"foo"
"bar"
julia> uppercasefirst("foo")
"Foo"
julia> map(uppercasefirst, split(s, '_'))
2-element Vector{String}:
"Foo"
"Bar"
julia> join(["Foo", "Bar"])
"FooBar"
julia> join(map(uppercasefirst, split(s, '_')))
"FooBar"
julia> camel_case(s::AbstractString) = join(map(uppercasefirst,
split(s, '_')))
camel_case (generic function with 1 method)
现在你有一个可以进行转换的函数,但通常你希望能够快速完成这个操作。在你的代码编辑器中选择一些蛇形案例的文本,并将其粘贴回驼峰式。
这就是 Julia 的 clipboard() 函数派上用场的地方。它可以读取和写入剪贴板。剪贴板是所有你复制的内容所在的地方。
警告 在 Linux 上,除非你安装了 xsel 或 xclip 命令,否则剪贴板函数将不起作用。在 Debian 或 Ubuntu 上,你可以使用以下命令安装这些命令:
$ sudo apt-get install xsel
在 Red Hat 或 Fedora Linux 上,你可以使用以下命令安装:
$ sudo yum install xsel
在列表 11.4 中,向 camel_case 函数添加了一个方法,它不接受任何字符串参数,而是读取剪贴板。在运行 clipboard() 之前,我标记了这段文字的第一部分。
列表 11.4 将剪贴板中的文本转换为驼峰式
julia> s = clipboard()
"HiHowAreYou"
julia> function camel_case()
s = camel_case(clipboard())
clipboard(s)
s
end
clipboard() 获取剪贴板的内容,而 clipboard(s) 将 s 的内容存储在剪贴板上。每次你编码并想要将蛇形文本转换为驼峰式时,你可以按照以下步骤操作:
-
复制文本。
-
切换到你的 Julia REPL。
-
开始输入 came...,然后按上箭头键。如果之前调用过它,应该会完成到 camel_case()。或者按 Tab 键。
-
返回到编辑器,并粘贴结果。
更高效地使用 Julia REPL
要快速使用 Julia,重要的是要习惯所有快捷键。上箭头键用于快速搜索你的历史记录。如果你开始输入几个字母,它将过滤历史记录,只匹配以那些字母开头的历史记录。
Tab 键用于完成 Julia 已知的函数匹配。这可能是一个内置函数或你自己定义的函数。
Ctrl-A 和 Ctrl-E 用于在 Julia REPL 中跳转到行的开始和结束。假设你刚刚写了
map(uppercasefirst, split(s, '_'))
你可能想要修改为
join(map(uppercasefirst, split(s, '_')))
按上箭头键返回你刚刚写的行。按 Ctrl-A 跳到行的开头。写 join(。最后,按 Ctrl-E 跳到行的末尾,写)。
11.2.1 从驼峰式转换为蛇形
让我们看看如何反向操作的代码。在这种情况下,使用 split 函数将不起作用,但为什么?在这种情况下,你无法在特定字符上分割;然而,split 可以接受函数而不是字符来决定分割的位置。要按空白分割,使用 split(s, isspace),因此你可以尝试使用 isuppercase 函数。它检查一个字符是否为大写。这很有用,因为你在字符大写的地方分割:
julia> isuppercase('a')
false
julia> isuppercase('A')
true
julia> s = "oneTwoThreeFour"
"oneTwoThreeFour"
julia> split(s, isuppercase)
4-element Vector{SubString{String}}:
"one"
"wo"
"hree"
"our"
如你所见,这种方法不起作用,因为 split 会移除你用于分割的字符。相反,你将使用 Julia 的许多 find 函数之一。如果你在 REPL 中写入 find 并按 Tab 键,你会看到许多可能的选择:
julia> find
findall findfirst findlast
findmax findmax! findmin
findmin! findnext findprev
findfirst 查找匹配项的第一个出现,而 findall 查找所有出现。让我们通过一个示例来澄清:
julia> s = "The quick brown fox";
julia> findfirst(isspace, s)
4
julia> indices = findall(isspace, s)
3-element Vector{Int64}:
4
10
16
julia> s[indices]
" "
你可以遍历所有大写字母的索引,并使用范围捕获子字符串。
列表 11.5 打印出每个大写单词
function snake_case(s::AbstractString)
i = 1
for j in findall(isuppercase, s)
println(s[i:j-1])
i = j
end
println(s[i:end])
end
列表 11.5 只是一个演示,说明如何逐步开发函数。在这种情况下 println 将确保正确的输出。在这里,范围 i:j-1 将提取子字符串:
julia> snake_case("oneTwoThreeFour")
one
Two
Three
Four
以下列表展示了一个完整的示例。你已经移除了 println 并添加了一个名为 words 的字符串数组来存储每个单独的大写单词。
列表 11.6 将驼峰式字符串转换为蛇形字符串
function snake_case(s::AbstractString)
words = String[]
i = 1
for j in findall(isuppercase, s)
push!(words, lowercase(s[i:j-1]))
i = j
end
push!(words, lowercase(s[i:end]))
join(words, '_')
end
一旦你收集了数组中的单词,使用 join(words, '')将它们连接成一个字符串。第二个参数''导致每个单词以'_'作为分隔符连接。
11.2.2 介于数字和字符串之间的转换
第五章介绍了从用户读取输入。无论输入来自键盘还是文件,它通常以文本字符串的形式出现;然而,你可能需要输入数字。在第五章中,你研究了 parse 函数来处理这个问题;让我们更详细地再次看看它。将类型对象作为第一个参数提供,以指定你想要解析到的数字类型。这可以是任何从不同类型的整数到浮点数:
julia> parse(UInt8, "42")
0x2a ❶
julia> parse(Int16, "42")
42
julia> parse(Float64, "0.42")
0.42
❶ 十六进制形式的 42(见第二章)
你甚至可以指定基数。Julia 在解析数字时默认假设基数为 10,这指的是从 0 到 9 的数字。然而,如果你想将数字解析为二进制,基数 2,你可以这样做。这假设你只有 0 和 1 这两个数字来形成数字:
julia> parse(Int, "101") ❶
101
julia> parse(Int, "101", base=2) ❷
5
julia> parse(Int, "101", base=16) ❸
257
❶ 将字符串 101 解析为十进制数。
❷ 作为二进制数(基数 2)
❸ 将 101 解析为一个十六进制数。
这些转换也可以反向进行。你可以取一个数字,并决定在转换为文本字符串时使用什么基数:
julia> string(5, base=2) ❶
"101"
julia> string(17, base=16) ❷
"11"
julia> string(17, base=10) ❸
"17"
❶ 使用十进制数 5 的二进制数位创建一个文本字符串。
❷ 将十进制数 17 转换为十六进制字符串。
❸ 使用十进制数系统转换为字符串。
从之前的字符串章节中,你可能还记得 color=:green 这样的命名参数。这里你还有一个命名参数 base=2。这是一个典型的命名参数使用案例,因为你指定的是偶尔需要指定的事情。继续火箭主题,我现在将介绍一些涉及由 Energomash 制造的 RD-180 火箭发动机的字符串操作(见图 11.2)。

图 11.2 Energomash RD-180 火箭发动机
11.2.3 字符串插值和连接
在 Julia 中,字符串可以通过多种方式组合;我将比较实现它的不同方法。通常,你会有一些对象,比如数字,你想将其转换为文本字符串。以下代码定义了一些不同类型的变量,用于字符串示例:
julia> engine = "RD-180"
"RD-180"
julia> company = "Energomash"
"Energomash"
julia> thrust = 3830
3830
julia> string("The ", engine,
" rocket engine, produced by ",
company,
" produces ", thrust,
" kN of thrust")
"The RD-180 rocket engine, produced
by Energomash produces 3830 kN of thrust"
上述代码使用了 string()函数来执行字符串连接,并将非字符串转换为字符串。或者,你也可以使用字符串连接运算符*。如果你来自其他语言,你可能更熟悉使用+运算符进行字符串连接。Julia 选择了在数学符号中通常用来表示连接的运算符:
julia> "The " * engine *
" rocket engine, produced by " *
company *
" produces " *
string(thrust) *
" kN of thrust"
"The RD-180 rocket engine, produced by Energomash
produces 3830 kN of thrust"
当处理大量变量时,通常最好使用字符串插值。字符串插值使用$符号进行:
julia> "The $engine rocket engine, produced by $company produces
$thrust kN of thrust"
"The RD-180 rocket engine, produced by Energomash produces 3830 kN
of thrust"
注意,当没有空白可以清楚地区分变量名和周围文本时,你通常需要使用 $(variable) 而不是 $variable。如果你试图插值一个表达式而不是变量,这也适用。例如,考虑以下情况,当你想写出 3830kN 而不带空格时:
julia> "produces $thrustkN of thrust" ❶
ERROR: UndefVarError: thrustkN not defined
julia> "produces $(thrust)kN of thrust" ❷
"produces 3830kN of thrust"
julia> "two engines produces $(2 * thrust) kN of thrust" ❸
"two engines produces 7660 kN of thrust"
❶ 你不能这样插值推力变量。
❷ 当没有周围空白时进行字符串插值的正确方法
❸ 表达式的字符串插值
11.2.4 sprintf 格式化
如果你熟悉 C 编程,你可能熟悉 printf 和 sprintf 函数。Julia 有名为 @printf 和 @sprintf 的宏,这些宏模仿了这些函数。与字符串插值不同,这些宏允许你更详细地指定变量应该如何显示。
注意,宏与 Julia 函数通过 @ 前缀区分。宏类似于代码生成器;宏的调用位置将被其他代码替换。宏允许进行高级元编程,本书将不会涉及。
例如,你可以指定打印十进制数时应使用的数字位数。@printf 将结果输出到控制台,但 @sprintf 和 @printf 不在始终加载的 Julia 基础模块中。因此,要使用这些宏,你需要包含 Printf 模块,这解释了第一行:
julia> using Printf
julia> @printf("π = %0.1f", pi)
π = 3.1
julia> @printf("π = %0.2f", pi)
π = 3.14
julia> @printf("π = %0.5f", pi)
π = 3.14159
以下是一些常见格式化选项的简要概述:
-
%d—整数
-
%f—浮点数
-
%x—以十六进制表示法显示的整数
-
%s—以字符串形式显示参数
使用这些格式化选项中的每一个,你可以指定诸如数字位数、小数位数或填充等。首先,让我们回顾一些基本格式化选项的示例:
julia> @sprintf("|%d|", 29)
"|29|"
julia> @sprintf("|%f|", 29)
"|29.000000|"
julia> @sprintf("|%x|", 29)
"|1d|"
我在数字前后添加了横线,因此以下填充示例更容易阅读:
julia> @sprintf("|%2d|", 42)
"|42|"
julia> @sprintf("|%4d|", 42)
"| 42|"
julia> @sprintf("|%-2d|", 42)
"|42|"
julia> @sprintf("|%-4d|", 42)
"|42 |"
注意,填充可以应用于右侧或左侧。通过添加连字符实现右填充。如果你想要显示对齐的数字列,填充很有用。你可以通过在填充数字前加 0 来使用零而不是空格作为填充:
julia> @sprintf("|%02d|", 42)
"|42|"
julia> @sprintf("|%04d|", 42)
"|0042|"
填充并不指定要添加多少个空格或零,而是指定数字应该填充的总字符数。如果填充为两个且数字为两位数,则不会发生任何变化。然而,如果填充为四个,则添加两个空格,总共为四个字符。
11.3 使用字符串插值生成代码
你可以使用你刚刚学到的知识创建小的实用函数。这个例子将涵盖生成 C++ 代码。Julia 可能不是你的主要工作语言;相反,你可能在工作中使用更冗长的语言,如 C++ 或 Java,但 Julia 可以作为辅助工具使你的工作更轻松。接下来,你将回顾一个 C++ 开发者如何通过利用 Julia 编程语言来简化其工作的示例。
可视化工具包(VTK;vtk.org)是一个用于可视化科学数据的惊人的 C++ 库。你可以用它来创建如图 11.3 所示的视觉表示。

图 11.3 VTK 中的可视化
很遗憾,由于 C++ 中需要所有典型的样板代码,编写 VTK C++ 代码很繁琐。以下是一些在 VTK 中用于定义几何线的 C++ 代码示例。了解列表中的代码做什么并不重要;它已被编辑以从示例代码中删除不必要的细节。
列表 11.7 VTK 中的 Line 类
#ifndef vtkLine_h
#define vtkLine_h
class VTKCOMMONDATAMODEL_EXPORT vtkLine : public vtkCell
{
public:
static vtkLine *New();
vtkTypeMacro(vtkLine,vtkCell);
void PrintSelf(ostream& os, vtkIndent indent) override;
int GetCellType() override {return VTK_LINE;};
protected:
vtkLine();
~vtkLine() override {}
private:
vtkLine(const vtkLine&) = delete;
void operator=(const vtkLine&) = delete;
};
将此代码与列表 11.8 中定义多边形的下一块代码进行比较;你会注意到有很多重复。这适用于所有用于定义几何原型的 VTK 代码。
列表 11.8 VTK 中的 Polygon 类
#ifndef vtkPolygon_h
#define vtkPolygon_h
class VTKCOMMONDATAMODEL_EXPORT vtkPolygon : public vtkCell
{
public:
static vtkPolygon *New();
vtkTypeMacro(vtkPolygon,vtkCell);
void PrintSelf(ostream& os, vtkIndent indent) override;
int GetCellType() override {return VTK_POLYGON;};
protected:
vtkPolygon();
~vtkPolygon() override;
private:
vtkPolygon(const vtkPolygon&) = delete;
void operator=(const vtkPolygon&) = delete;
};
#endif
想象你经常为不同的几何类型编写新的 C++ 类(类型),如下所示;重复所有这些样板代码会很繁琐。幸运的是,你可以创建小的 Julia 工具函数来帮助(列表 11.9)。
当生成由多行组成的文本时,使用三引号:""" 是实用的。这允许你跨多行编写字符串。
列表 11.9 Julia 代码中的 VTK C++ 代码生成器
function create_class(class::AbstractString)
s = """
#ifndef vtk$(class)_h
#define vtk$(class)_h
class VTKCOMMONDATAMODEL_EXPORT vtk$class : public vtkCell
{
public:
static vtk$class *New();
vtkTypeMacro(vtk$class,vtkCell);
void PrintSelf(ostream& os, vtkIndent indent) override;
int GetCellType() override {return VTK_$(uppercase(class));};
protected:
vtk$class();
~vtk$class() override;
private:
vtk$class(const vtk$class&) = delete;
void operator=(const vtk$class&) = delete;
};
#endif
"""
clipboard(s) ❶
println(s)
end
❶ 使将生成的类源代码粘贴到代码编辑器中变得容易。
以下是一个使用此实用函数创建 Hexagon 类的示例。注意在前两行中,生成的代码也存储在剪贴板上。
列表 11.10 使用 Julia 生成 C++ Hexagon 类
julia> create_class("Hexagon")
#ifndef vtkHexagon_h
#define vtkHexagon_h
class VTKCOMMONDATAMODEL_EXPORT vtkHexagon : public vtkCell
{
public:
static vtkHexagon *New();
vtkTypeMacro(vtkHexagon,vtkCell);
void PrintSelf(ostream& os, vtkIndent indent) override;
int GetCellType() override {return VTK_HEXAGON;};
protected:
vtkHexagon();
~vtkHexagon() override;
private:
vtkHexagon(const vtkHexagon&) = delete;
void operator=(const vtkHexagon&) = delete;
};
#endif
11.4 使用非标准字符串字面量
在许多编程语言中,有一些有用的对象,例如日期、正则表达式、MIME 类型以及数字,它们的生命周期始于字符串。例如,在 Julia 中,你不能将非常大的数字作为数字字面量表达,所以你必须将它们作为字符串来解析。例如,浮点数字面量在 Julia 中是 64 位,这不足以容纳像 1.4e600 这样的数字。Julia 中有 BigInt 和 BigFloat 这样的类型,可以存储任意大的数字。但是,当数字字面量限制为 64 位浮点值时,如何创建这样的数字呢?解决方案是解析包含数字定义的字符串:
julia> 1.4e600
ERROR: syntax: overflow in numeric constant "1.4e600"
julia> x = parse(BigFloat, "1.4e600")
1.3999...9994e+600
julia> typeof(x)
BigFloat
当处理日期时,还可以展示另一个示例。假设你正在从文件中读取多个日期,并希望解析它们。为此,指定一个日期格式,例如 yyyy-mm-dd。这种日期格式表示年份在前,日期在后,并且每个组件由连字符分隔:-。以下是将一种日期格式转换为另一种格式的示例(稍后将会介绍日期格式化选项)。
列表 11.11 将一种数据格式转换为另一种格式
using Dates
dates = ["21/7", "8/12", "28/2"];
for s in dates
date = Date(s, DateFormat("dd/mm"))
dstr = Dates.format(date, DateFormat("E-u"))
println(dstr)
end
如果你运行此代码,你将得到以下输出:
Saturday-Jul
Saturday-Dec
Wednesday-Feb
这段日期格式化代码说明了所有从字符串派生的对象存在的问题。如果你以自然的方式编写代码,你将反复解析相同的字符串。在每次循环迭代中,你都会解析字符串 "dd/mm" 和 "E-u",但这并不是必要的。这些字符串在每次迭代中都是相同的;只有日期字符串本身会改变。为了避免解析字符串来创建 BigFloat 和 DateFormat 等对象,Julia 提供了特殊的字符串字面量,如 big"1.4e600" 和 dateformat"dd/mm"。
当 Julia 解析你的程序代码并遇到这些字符串时,它不会创建 String 对象,而是创建 BigInt、BigFloat 或 DateFormat 对象。这种方法的优点是对象是在代码解析时创建的,而不是在运行时创建的。
这可能听起来像是一个微不足道的细节,然而,这确实有显著的区别。Julia 将只解析程序中 for 循环的代码一次。但它可能会在循环内部执行代码多次。因此,通过在解析时而不是在运行时创建如 DateFormat 这样的对象,你可以提高性能。
我将依次介绍每个非标准字符串字面量,并在单独的章节中进行说明。通过阅读这些章节,将变得清楚这种策略的含义。
11.4.1 DateFormat 字符串
让我们回顾一下 DateFormat 的例子。在其他编程语言中,如果你想避免解析 DateFormat 字符串多次的性能开销,你可能需要重构代码,如下所示。
列表 11.12 优化但可读性较差的日期格式化代码
using Dates
informat = DateFormat("dd/mm")
outformat = DateFormat("E-u")
dates = ["21/7", "8/12", "28/2"]
for s in dates
date = Date(s, informat)
dstr = Dates.format(date, outformat)
println(dstr)
end
从性能的角度来看,这是可行的。问题是代码的可读性降低了。浏览解析和格式化日期的行,你无法立即看到使用了哪种格式。为了可读性,最好能够将日期格式定义放在使用的地方。使用 dateformat 字符串字面量,你可以做到这一点,如下所示。
列表 11.13 优化且可读的日期格式化代码
using Dates
dates = ["21/7", "8/12", "28/2"]
for s in dates
date = Date(s, dateformat"dd/mm")
dstr = Dates.format(date, dateformat"E-u")
println(dstr)
end
我还没有详细说明如何指定日期格式字符串。幸运的是,Julia 在线帮助系统提供了一个很好的概述;只需进入帮助模式 (?), 输入 DateFormat,它将为你提供一个所有可用字母及其含义的列表。基本上,你使用字母如 y、m 和 d 来表示年、月和日。如果你想将年份写成四位数字,你可以指定为 yyyy。在 REPL 中的一些代码示例应该能让你了解这是如何工作的:
julia> d = Date(2022, 8, 28)
2022-08-28
julia> Dates.format(d, dateformat"dd-mm-yyyy")
"28-08-2022"
julia> Dates.format(d, dateformat"mm-yy")
"08-22"
julia> Dates.format(d, dateformat"yy/m")
"22/8"
并非所有格式都处理数字。u 和 U 提供月份的名称,例如一月和二月。e 和 E 提供星期的名称,例如星期一和星期二:
julia> Dates.format(d, dateformat"e u yyyy")
"Sun Aug 2022"
julia> Dates.format(d, dateformat"E U yyyy")
"Sunday August 2022"
11.4.2 原始字符串
正则表达式字符串的一个问题是,像\(和\n 这样的字符具有特殊含义。对于某些类型的文本,这可能会很麻烦。你可以通过使用转义字符\\来解决它;因此,\)将被写作$,\n 将被写作\n。然而,如果你不想这样做,也不需要字符串插值,你可以使用原始字符串:
julia> thrust = 3830
3830
julia> raw"produces $thrust kN of thrust" # Don't work
"produces \$thrust kN of thrust"
在这种情况下,非标准字符串字面量不会创建新的对象类型。相反,它在构建字符串对象时以不同的方式解释字符串字面量的内容。
11.4.3 使用正则表达式匹配文本
正则表达式是一种你可以用来指定要匹配的文本的微型语言。正则表达式在 Unix 文本处理工具和许多编码编辑器中广泛使用。例如,你可以使用正则表达式在代码中搜索特定的文本字符串。
在这个例子中,你有一些存储在变量 s 中的 Julia 源代码。你已经决定你想将 Rocket 类型的名称更改为 SpaceCraft。你可以使用 replace 函数定位要替换的文本:
julia> s = """
struct RocketEngine
thrust::Float64
Isp::Float64
end
mutable struct Rocket
tank::Tank
engine::RocketEngine
end
"""; ❶
julia> result = replace(s, "Rocket"=>"SpaceCraft"); ❷
julia> println(result)
struct SpaceCraftEngine
thrust::Float64
Isp::Float64
end
mutable struct SpaceCraft
tank::Tank
engine::SpaceCraftEngine
end
❶ 在这段源代码文本中,你想象需要进行字符串替换
❷ 将 Rocket 的出现替换为 SpaceCraft。
如你从第六章所记得的,你使用=>运算符来创建一个对;这被用来创建键值对以存储在字典中。在这种情况下,这个对代表要查找的文本和替换文本。所以“Rocket"=>"SpaceCraft”意味着定位“Rocket”,并将其替换为“SpaceCraft”。
然而,正如你从例子中看到的,这并没有完全达到你的预期。“RocketEngine”也被替换为“SpaceCraftEngine”。然而,在这种情况下,你只想更改 Rocket 类型。使用正则表达式可以更容易地更具体地说明你要找的是什么。
在正则表达式中,"."表示任何字符;“[A-D]”表示从 A 到 D 的任何字符;而写作“[^A-D]”表示不在 A 到 D 范围内的任何字符。因此,“Rocket[^A-Za-z]”意味着找到单词“Rocket”,并且第一个后续字符不是字母,如下所示。
列表 11.14 使用正则表达式进行文本替换
julia> result = replace(s, r"Rocket[^A-Za-z]"=>"SpaceCraft");
julia> println(result)
struct RocketEngine
thrust::Float64
Isp::Float64
end
mutable struct SpaceCraft
tank::Tank
engine::RocketEngine
end
在这个例子中,你通过在前面加上 r 将你要搜索的字符串转换为正则表达式。这意味着它将不是一个字符串对象。这可以在 REPL 中演示:
julia> rx = r"Rocket[^A-Za-z]"
r"Rocket[^A-Za-z]"
julia> typeof(rx)
Regex
这个正则表达式对象是在解析时创建的,而不是在运行时。因此,就像 DateFormat 一样,你避免了在运行时多次解析相同的正则表达式。
关于正则表达式语法的良好文档有很多,所以我将只概述正则表达式中使用的基本字符。以下是一个所谓的字符类的列表:
| 字符 | 含义 | 示例 |
|---|---|---|
| \d | 匹配任何数字 | "387543" |
| \w | 匹配任何包含下划线的字母数字词 | "foo_bar_42" |
| \s | 匹配任何空白字符、制表符、换行符、空格 | " " |
| . | 匹配任何字符 | "aA ;%4t" |
你还有特殊字符,它们会影响字符类匹配的方式;这些被称为量词。它们可以显示字符类应该重复多少次:
| 字符 | 含义 |
|---|---|
| * | 重复字符 0 次或多次 |
| + | 重复一次或多次 |
| ? | 零次或一次 |
与 Julia 正则表达式系统的更复杂交互将涉及使用 RegexMatch 对象。在这个例子中,你想要挑选出一个多位数,\d+,和一个由多个字母组成的单词,\w+。你可以使用 match 函数来完成,它将返回一个包含所有匹配的 RegexMatch 对象:
julia> rx = r"\w+ (\d+) (\w+) \d+" ❶
julia> m = match(rx, "foo 123 bar 42") ❷
RegexMatch("foo 123 bar 42", 1="123", 2="bar")
julia> m[1] ❸
"123"
julia> m[2] ❸
"bar"
❶ 定义一个正则表达式。
❷ 将正则表达式与字符串匹配。
❸ 访问第一个和第二个匹配。
注意正则表达式中一些部分包含括号;这些括号捕获了字符串的这部分。你已经设置了你的正则表达式对象 rx 来捕获一个数字和一个单词。你可以通过整数索引访问这些捕获,例如 m[1] 和 m[2]。
对于更复杂的正则表达式,很难跟踪每个捕获的位置。幸运的是,正则表达式允许你命名你的捕获。比如说,你想从字符串 11:30 中捕获小时和分钟。你可以使用正则表达式 r"(\d+)😦\d+)",但你可以使用 ? 命名每个匹配,其中 s 是捕获的名称:
julia> rx = r"(?<hour>\d+):(?<minute>\d+)"
r"(?<hour>\d+):(?<minute>\d+)"
julia> m = match(rx, "11:30 in the morning")
RegexMatch("11:30", hour="11", minute="30")
julia> m["minute"]
"30"
julia> m["hour"]
"11"
RegexMatch 对象在很多方面都类似于 Julia 集合,因此你可以使用 for 循环迭代 RegexMatch 对象。当命名你的正则表达式捕获时,RegexMatch 对象与字典中可用的许多相同函数一起工作:
julia> keys(m)
2-element Vector{String}:
"hour"
"minute"
julia> haskey(m, "hour")
true
julia> haskey(m, "foo")
false
虽然正则表达式非常强大且灵活,但过度使用它们很容易。Go、Plan 9、UTF-8 以及许多其他系统编程中流行的技术的创造者之一 Rob Pike 一直警告过度使用正则表达式。随着新需求的提出,它们可能会变得复杂且难以修改。
个人来说,我很少使用它们。在 Julia 中,你可以通过基本的字符串和字符函数(如 split、endswith、startswith、isdigit、isletter 和 isuppercase)走得很远。
11.4.4 使用 BigInt 创建大整数
大多数数字类型都存在字面量语法,如下例所示:
julia> typeof(42)
Int64
julia> typeof(0x42)
UInt8
julia> typeof(0x42000000)
UInt32
julia> typeof(0.42)
Float64
julia> typeof(0.42f0)
Float32
julia> typeof(3//4)
Rational{Int64}
在不存在的情况下,你可以进行如下转换:Int8(42),它将一个 64 位有符号整数转换为 8 位有符号整数。当编写任意精度的整数(任意数量的数字)时,你也可以这样做,通过编写 BigInt(42);然而,这可能会导致一些低效。在遇到这种情况时,必须将整数转换为大整数。相反,如果你写 big"42",大整数将在程序解析时创建——而不是每次运行时都创建。
这不是语言内置的。任何人都可以定义一个数字字面量。以下是一个示例,添加对写入 int8"42" 以在解析时创建 42 作为有符号 8 位整数的支持。你可以用这个例子来展示宏与函数不同,宏只调用一次。
列表 11.15 定义 8 位有符号整数的字符串字面量
macro int8_str(s) ❶
println("hello") ❷
parse(Int8, s) ❸
end
❶ 对于具有前缀 foo 的字符串字面量,例如 foo"42",写入 foo_str。
❷ 通过在每次宏调用时输出一条消息,你可以看到它被调用的频率。
❸ 解析数字字符串并返回 8 位数字
现在,你可以在循环中尝试它。如果宏像函数一样工作,那么你应该在循环中每次增加总数时都得到一个函数调用:
julia> total = 0
0
julia> for _ in 1:4
total += int8"10"
end
hello
julia> total
40
然而,你只看到 hello 写了一次,而不是四次。这就是我要说的关于宏的所有内容,因为这个话题太大,不适合在入门级教科书中涵盖。然而,了解 Julia 中存在的一些更强大的功能是有用的,即使你不太可能在你最初的 Julia 程序中需要它们。
11.4.5 MIME 类型
不同的操作系统有不同的系统来跟踪其文件类型。例如,Windows 闻名地使用三个字母的文件扩展名来指示文件类型。原始的 macOS 将文件类型存储在特殊属性中。
然而,为了在互联网上的计算机之间发送不同类型的文件,需要一个共同的标准来识别文件类型;这就是 MIME 类型的用途。它们通常被描述为用斜杠分隔的类型和子类型。HTML 页面表示为 text/html,而 JPEG 图像表示为 image/jpeg。PNG 文件类型将写作 image/png 等等。你可以在 Julia 中使用以下方式创建 MIME 类型对象:
julia> MIME("text/html")
MIME type text/html
julia> typeof(ans)
MIME{Symbol("text/html")}
因此,MIME 类型对象 MIME("foo/bar") 将具有类型 MIME{Symbol{"foo/bar"}}。这将在我介绍第十八章的参数化类型之前看起来有些晦涩。MIME{Symbol{"foo/bar"}} 写起来既长又繁琐,这就是为什么 Julia 提供了 MIME"foo/bar" 的快捷方式。
这很容易混淆。MIME("foo/bar") 和 MIME"foo/bar" 并不相同。前者是一个对象,而后者是对象类型。以下是一个简单示例,说明你可以如何使用它为不同的 MIME 类型创建提供不同输出结果的方法:
say_hello(::MIME"text/plain") = "hello world"
say_hello(::MIME"text/html") = "<h1>hello world</h1>"
这很有用,因为它允许你在 Julia 中定义函数,为不同的上下文提供不同格式的文本输出:
julia> say_hello(MIME("text/plain"))
"hello world"
julia> say_hello(MIME("text/html"))
"<h1>hello world</h1>"
在图形笔记本风格的环境中执行 Julia 代码,例如 Jupyter (jupyter.org),会传递一个 HTML MIME 类型,因此图表和表格可以以 HTML 格式渲染。
摘要
-
Julia 字符串以 UTF-8 编码,这意味着每个码点被编码为一个可变数量的码单元。
-
parse 用于将字符串转换为其他类型,例如数字。
-
string 可以用来将数字转换为字符串。
-
Julia 字符串可以通过使用 $ 符号进行字符串插值或使用具有可变数量参数的字符串函数与其他对象类型结合。
-
字符串可以使用乘法运算符 * 进行连接。
-
使用 @printf 宏可以在标准输出(stdout)上实现格式化输出。使用 @sprintf 获取返回的字符串值。这两个都在 Printf 模块中。
-
Julia 中的字符串是可扩展的,但它自带了一些内置类型:原始字符串、大整数和正则表达式。
12 理解 Julia 集合
本章涵盖
-
理解根据它们支持的运算类型对集合进行分类的方法
-
将分阶段火箭转换为可迭代集合
-
使用各种集合类型支持的操作
你已经查看过数组、字典等集合,但还有许多其他类型的集合,包括集合、链表、堆、栈和二叉树。在本章中,我将介绍不同类型集合之间的共性。每个集合都组织和存储多个元素,每种集合类型都提供访问这些元素的独特方式。例如,使用字典,你可以通过提供键来访问元素,而数组则需要索引。
然而,集合也有所有集合都必须支持的核心功能,例如可迭代性。如果某物是可迭代的,你可以在 for 循环中访问其单个元素或使用高阶函数,如 map 或 filter。
什么使某物成为集合?不同集合类型之间的差异和相似之处是什么?你如何创建自己的集合?你将通过扩展第八章中的多级火箭示例来探索这些问题。因为火箭由许多不同的部分组成,所以它可以被转换成 Julia 能识别为集合的东西。
在本章中,你将为 Tank 抽象类型添加代码,以展示接口是如何定义的。你将修改 engine Cluster 类型以支持遍历引擎。在最后的示例中,你将修改 StagedRocket 类型以支持多级火箭的遍历。
12.1 定义接口
接口究竟是什么?它有助于将接口与实现进行对比。当你与计算机交互时,你使用鼠标和键盘;这就是你的计算机接口——你不需要知道你使用的特定计算机是如何构建的(图 12.1)。你可以使用相同的鼠标和键盘与许多不同方式构建的计算机一起使用。无论你的计算机有多少内存或什么微处理器,你都可以通过点击相同的图标和移动相同的窗口与之交互。换句话说,许多计算机模型之间存在一个共享的接口,这使你免受每个计算机特定硬件实现的干扰。

图 12.1 计算机不需要知道输入设备的工作原理。
通过清晰定义的接口分离组件,允许你构建大型、复杂的结构。组成你系统的各个部分不需要了解实现细节,只要每个部分使用一个定义良好的接口即可。让我们将这一点与使用 Julia 进行编程联系起来。数组和范围都是 AbstractArray 的子类型,如图 12.2 所示。

图 12.2 数组和范围的类型层次结构
因此,如果你已经定义了一个在抽象数组上操作的功能,你就不必处理数组和范围之间的区别。你可以创建一个名为 addup(见列表 12.1)的功能,它无论你传递的是数组还是范围作为参数都能正常工作。
列表 12.1 在抽象数组中累加元素的功能
function addup(xs::AbstractArray)
total = 0 ❶
for x in xs
total += x
end
total ❷
end
❶ 存储总和。
❷ 返回总和。
让我们用不同的参数调用这个函数。注意它在功能上与 sum 函数非常相似,只是它不允许你添加值元组。为什么是这样呢?
julia> addup(3:5) ❶
12
julia> addup([3, 4, 5]) ❷
12
julia> addup((3, 4, 5)) ❸
ERROR: MethodError: no method matching addup(::Tuple{Int64, Int64, Int64})
julia> sum((3, 4, 5))
12
❶ 添加一系列值
❷ 添加一个值数组
❸ 尝试添加一个值元组
Tuple 类型在 AbstractArray 类型层次结构中无处可寻,因此 Tuple 类型的值不是 addup 的有效参数。另一个常见接口的例子是,对于范围和数组,都可以通过索引访问元素。请记住,Julia 数组的第一个元素位于索引 1:
julia> r = 3:5 ❶
3:5
julia> r[2] ❷
4
julia> sum(r) ❸
12
julia> a = [3, 4, 5] ❶
3-element Vector{Int64}:
3
4
5
julia> a[2] ❷
4
julia> sum(a) ❸
12
❶ 定义一个看起来相似的范围和数组。
❷ 访问第二个元素看起来是一样的。
❸ 对范围和数组求和的效果相同。
范围没有元素;元素是隐式存在的。然而,你可以通过给范围和数组一个相似的接口来抽象出这种差异。这允许我们定义像 sum 这样的函数,使其对这两种类型都有效,而不需要创建两个不同的方法。
在面向对象的语言中,例如 Java、C++或 C#,AbstractArray 的接口是明确定义的。这些语言中的类型定义包括子类型必须实现的方法列表。未能这样做会产生编译器错误。
然而,在 Julia 中,接口是非正式定义的。因此,没有编译器会告诉你你错误地实现了接口。
12.2 推进剂罐接口示例
为了阐明如何在 Julia 中定义和使用接口,我们将查看第八章中的推进剂罐示例(见列表 12.2)。假设你正在提供一个推进剂罐接口,其他开发人员可以使用它,并且你希望他们能够创建自己的 Tank 子类型,以便在组装火箭时使用。
列表 12.2 定义一个抽象推进剂罐
abstract type Tank end
propellant(tank::Tank) = tank.propellant
function refill!(tank::Tank) ❶
propellant!(tank, totalmass(tank) - drymass(tank))
tank
end
❶ 将推进剂罐装满至最大
现在想象另一个开发人员试图创建一个具体的 Tank 子类型,用于火箭模拟。开发人员编写了以下代码。
列表 12.3 定义推进剂罐子类型
mutable struct FlexiTank <: Tank
drymass::Float64
totalmass::Float64
propellant::Float64
end
开发人员想要尝试他们的新罐子,并在 Julia REPL 中编写以下代码:
julia> tank = FlexiTank(10, 100, 0)
FlexiTank(10.0, 100.0, 0.0)
julia> refill!(tank)
ERROR: UndefVarError: totalmass not defined ❶
❶ Julia 不知道 totalmass 是什么。
这个错误信息使得试图实现 Tank 接口的人难以知道他们应该做什么。Julia 解决这个问题的惯例是在接口中定义函数并对其进行文档化。
当查看列表 12.4 中的代码时,你可能会有以下疑问:为什么这个例子专注于文档化代码?接口在哪里定义?定义接口的语法是什么?答案是,没有。这就是为什么我说 Julia 中的接口是非正式定义的。因此,文档是定义 Julia 接口的关键部分。
记住第七章中提到的,在 Julia 中,方法附加到函数上,而不是类型上。你不能将函数与任何特定类型关联。totalmass、drymass 和 propellant 属于 Tank 接口的原因仅仅是因为我们在文档中这样说了。这完全是虚构的。
列表 12.4 定义一个良好文档化的推进剂罐接口
"Stores propellant for a rocket"
abstract type Tank end
"""
totalmass(t::Tank) -> Float64
Mass of propellant tank `t` when it is full.
"""
function totalmass end
"""
drymass(t::Tank) -> Float64
Mass of propellant tank `t` when it is empty.
"""
function drymass end
"""
propellant(t::Tank) -> Float64
Get remaining propellant in tank. Propellant is fuel plus oxidizer
"""
propellant(tank::Tank) = tank.propellant
"""
refill!(tank::Tank) -> Tank
Fill propellant tankt to the max. Returns full tank
"""
function refill!(tank::Tank)
propellant!(tank, totalmass(tank) - drymass(tank))
tank
end
Julia 的文档系统通过在函数或类型定义前加上一个常规的 Julia 文本字符串来工作。在这个文本字符串内部,你使用 markdown^(1) 语法来文档化你的函数或类型。在 markdown 中,你缩进你想格式化为源代码的行。为了突出显示单个单词作为代码,你使用反引号 `。
小贴士:有时你希望在 Julia REPL 中直接编写函数定义。然而,当你按下 Enter 键结束文档字符串后,它在你能够编写函数定义之前就会被评估。如何解决这个问题?如果你在按下 Enter 键时按住 Alt 或 Option 键,Julia 将允许你继续编写代码。
要给你的函数添加文档,你可以使用双引号或三引号字符串 (" 或 """)。请记住,这与使用井号 # 符号添加注释是不同的。注释不会存储在 Julia 帮助系统中。
三引号和双引号的工作方式略有不同。例如,如果你想在使用双引号的双引号文本中使用双引号,你需要使用反斜杠转义引号。对于三引号来说这不是必要的:
julia> print("file \"foo.txt\" not found")
file "foo.txt" not found
julia> print("""file "foo.txt" not found""")
file "foo.txt" not found
你的文档不需要匹配 Julia 语法。例如,你已经在文档中使用箭头来告知读者函数返回的对象类型:
"drymass(t::Tank) -> Float64"
将这个新的 Tank 定义与 FlexiTank 一起放入文件中,并用它重新加载你的 Julia REPL。你可以以几乎任何你喜欢的组织方式来做这件事。我使用一个名为 tank-interface.jl 的文件,如下所示(为了简洁,我已删除文档字符串):
abstract type Tank end
function totalmass end
function drymass end
propellant(tank::Tank) = tank.propellant
function refill!(tank::Tank)
propellant!(tank, totalmass(tank) - drymass(tank))
tank
end
mutable struct FlexiTank <: Tank
drymass::Float64
totalmass::Float64
propellant::Float64
end
让我们探索在尝试重新填充柔性罐时出现的错误信息:
julia> t = FlexiTank(10, 100, 0)
FlexiTank(10.0, 100.0, 0.0)
julia> refill!(t)
ERROR: MethodError: no method matching totalmass(::FlexiTank)
在这种情况下,你会得到一个更好的错误信息。Julia 通知我们,totalmass 确实是一个函数,但它缺少 FlexiTank 类型的方法。通过检查存在哪些方法,你可以推断出需要一个处理 FlexiTank 类型的方法:
julia> methods(totalmass)
# 0 methods for generic function "totalmass":
要进入 Julia 帮助系统,按 ? 键,如第二章所述:
help?> totalmass
search: totalmass
totalmass(t::Tank) -> Float64
Mass of propellant tank t when it is full.
通常,你会提供一个库指南来解释开发者应该如何使用它。这个指南解释了存在哪些接口以及如何实现这些接口。
在静态类型语言,例如 Java 中,编译器和复杂的 IDE^(2)可以通知开发者需要实现的方法及其参数。由于 Julia 是动态类型语言,您没有这样的便利。您必须充分记录您的函数,以便其他开发者知道预期的参数以及函数应该返回什么。以下示例显示,在完成函数调用之前,您可以按 Tab 键以获取与您所写内容匹配的方法及其参数列表:
refill!(tank::Tank)
julia> refill!( ❶
❶ 按 Tab 键,可用的方法会弹出。
然而,这种策略对 totalmass 和 drymass 没有用,因为这些函数没有任何附加的方法。这就是为什么记录这些函数的必需参数至关重要。
12.3 习惯用法接口
Julia 中的接口并不都与特定的抽象类型相关联,如前例所示。例如,有一个迭代接口。如果您实现此接口,您将能够使用 for 循环遍历您的集合。这将使您能够使用 map、reduce 和 filter 等函数,这些函数在可迭代集合上操作。
迭代接口不是由任何特定的抽象类型表示,而是非正式地描述的。您至少需要扩展您的集合类型的 iterate 函数,以下方法:
| 必需方法 | 目的 |
|---|---|
| iterate(iter) | 第一个项和初始状态 |
| iterate(iter, state) | 当前项和下一个状态 |
有几种这样的方法,这些方法在官方 Julia 文档中得到了详细记录。以下是最有用的几个:
| 可选方法 | 目的 |
|---|---|
| IteratorSize(IterType) | 指示集合是否有已知长度 |
| eltype(IterType) | 每个元素的类型 |
| length(iter) | 集合中的项目数量 |
我将涵盖两个不同的与火箭相关的示例,在这些示例中,您将实现其中的一些方法。在第一个示例中,您将遍历集群中的发动机。在第二个示例中,您将遍历多级火箭的阶段。
12.4 实现发动机集群迭代
在第八章中,我们定义了一个类似以下的发动机集群。
列表 12.5 火箭发动机集群的旧定义
struct Cluster <: Engine
engine::Engine
count::Int
end
根据这个定义,集群中的所有发动机都必须是同一类型。但如果你想要不同类型发动机的混合呢?一些火箭实际上确实有发动机的混合,而您不能使用给定的 Cluster 类型定义来模拟这样的火箭。为了解决这个问题,您将 Cluster 转换为抽象类型。这个抽象类型将有两个具体的子类型:
-
一个 UniformCluster,表示相同的发动机
-
一个 MixedCluster,表示不同发动机的混合
但为什么引入第二层抽象?为什么 UniformCluster 和 MixedCluster 不能直接成为 Engine 的子类型?随着您代码的发展,这一层抽象的好处将变得明显。打开第八章中 Cluster 类型的源代码,并使用以下代码进行修改。
列表 12.6 重新设计的集群类型层次结构
abstract type Cluster <: Engine end
struct UniformCluster <: Cluster
engine::Engine
count::Int
end
struct MixedCluster <: Cluster
engines::Vector{Engine} ❶
end
function Cluster(engine::Engine, count::Integer)
UniformCluster(engine, count)
end
function Cluster(engine::Engine, engines::Engine...)
sametype(e) = (typeof(engine) == typeof(e)) ❷
if all(sametype, engines) ❸
UniformCluster(engine, length(engines) + 1) ❹
else
MixedCluster([engine, engines...]) ❺
end
end
❶ 一个元素为 Engine 子类型的向量
❷ 定义一个检查引擎 e 是否与第一个引擎相同的函数。
❸ 检查所有引擎是否为同一类型。
❹ 如果所有引擎类型相同,则返回 UniformCluster。
❺ 如果引擎类型不同,则返回 MixedCluster。
您添加了 Cluster 方法,这些方法会查看传递给参数的引擎类型,以确定是否创建均匀或混合集群。您在这里使用了一些新技巧。
sametype 函数是在 Cluster 构造函数内部定义的。这意味着它有权访问引擎参数,而无需将其作为参数传递。这很有益,因为 all 是一个接受单个参数并返回 true 或 false 的高阶函数。以下是一些示例,以给您一个想法。
列表 12.7 all 函数的使用演示
julia> iseven(3)
false
julia> iseven(4)
true
julia> all(iseven, [4, 8, 10]) ❶
true
julia> all(iseven, [3, 8, 10]) ❷
false
❶ 在这种情况下,每个数字都是偶数。
❷ 数字 3 不是偶数。
通过隐藏表示集群使用的类型,您可以产生只有一种 Cluster 类型的错觉。您内部使用两种不同的类型成为实现细节。让我们通过 Julia REPL 演示它是如何工作的:
julia> Cluster(Rutherford(), Rutherford())
UniformCluster(Rutherford(), 2) ❶
julia> Cluster(Rutherford(), Merlin())
MixedCluster(Engine[Rutherford(), Merlin()]) ❷
❶ 由于所有参数都是同一类型,因此您得到一个 UniformCluster。
❷ 您需要一个混合集群来容纳 Merlin 和 Rutherford 引擎。
您需要根据这些更改重新定义您的 Isp、质量和推力方法。请记住,在第八章中,这些函数被定义为
Isp(cl::Cluster) = Isp(cl.engine)
mass(cl::Cluster) = mass(cl.engine) * cl.count
thrust(cl::Cluster) = thrust(cl.engine) * cl.count
在这些集群类型上实现一个可迭代接口,以便您只需编写一个质量推力和实现,它适用于两种集群类型。
12.4.1 使集群可迭代
您可以尝试迭代一个集群,如目前定义的那样,但不会工作:
julia> cl = Cluster(Rutherford(), 3)
UniformCluster(Rutherford(), 3)
julia> for engine in cl
println(mass(engine))
end
ERROR: MethodError: no method matching iterate(UniformCluster)
Julia JIT 编译器会将这个 for 循环转换为更低级的 while 循环,其代码如下所示。
列表 12.8 Julia 中的 for 循环实现
cluster = Cluster(Rutherford(), 3)
next = iterate(cluster) ❶
while next != nothing ❷
(engine, i) = next ❸
println(mass(engine))
next = iterate(cluster, i) ❹
end
❶ 开始迭代。
❷ 检查是否到达迭代的末尾。
❸ 从下一个元组中提取值。
❹ 在集合中推进到下一个元素。
因此,您的 for 循环不起作用,因为您尚未实现所需的 iterate 方法。以下列表显示了如何添加这些方法,以便可以对混合集群的引擎进行迭代。
列表 12.9 为 MixedCluster 实现迭代接口
import Base: iterate ❶
function iterate(cluster::MixedCluster) ❷
cluster.engines[1], 2
end
function iterate(cluster::MixedCluster, i::Integer) ❸
if i > length(cluster.engines)
nothing ❹
else
cluster.engines[i], i+1 ❺
end
end
❶ 向 Base 模块中定义的 iterate 函数添加方法。
❷ 用于启动迭代
❸ 将集合中的下一个元素向前推进。
❹ 返回 nothing 以指示您已到达末尾。
❺ 当前元素和下一个元素的索引
从 Base 导入 iterate 函数很重要,因为 for 循环是为了使用 Base 中的 iterate 而设计的,而不是在其他模块中定义的同名 iterate 函数。当你开始迭代时,你需要返回第一个元素和下一个元素的索引。因此,当你开始迭代时,你必须返回第二个元素的索引。这就是为什么你返回 cluster.engines[1], 2。你可以手动调用 iterate 来了解它是如何工作的:
cluster = Cluster(Rutherford(), Merlin());
julia> next = iterate(cluster) ❶
(Rutherford(), 2)
julia> (engine, i) = next ❷
(Rutherford(), 2)
julia> next = iterate(cluster, i) ❸
(Merlin(), 3)
julia> (engine, i) = next ❷
(Merlin(), 3)
julia> next = iterate(cluster, i) ❸
❹
❶ 获取初始状态。
❷ 从下一个元组中提取发动机和下一个索引。
❸ 获取下一个发动机。
❹ 已经到达末尾,所以 next 等于 nothing。
现在以下列表中关于 UniformCluster 迭代的实现应该更加清晰。
列表 12.10 实现 UniformCluster 的迭代
import Base: iterate
function iterate(cluster::UniformCluster)
cluster.engine, 2
end
function iterate(cluster::UniformCluster, i::Integer)
if i > cluster.count
nothing
else
cluster.engine, i+1
end
end
你可以看到,这种实现更简单,因为你总是返回相同的发动机。i 索引仅用于跟踪你是否已经到达迭代的末尾。因为现在 Cluster 类型都支持迭代,你可以通过迭代来实现质量和推力,如下面的列表所示。
列表 12.11 为集群定义质量和推力
mass(cluster::Cluster) = sum(mass, cluster)
thrust(cluster::Cluster) = sum(thrust, cluster)
这是如何工作的?sum 函数遍历作为第二个参数提供的集合。sum 将提供的第一个参数函数应用于它遍历的每个元素。sum(thrust, cluster)等价于编写 sum(map(thrust, cluster))。这两个调用在实现列表 12.12(为集群实现长度)之前都不会工作;否则,Julia 无法在开始迭代之前确定结果向量的长度。
列表 12.12 为 Julia 提供一种确定集群中发动机数量的方法
import Base: length
length(cluster::UniformCluster) = cluster.count ❶
length(cluster::MixedCluster) = length(cluster.engines) ❶
❶ 将长度函数扩展以支持集群类型。
记住,有一些求和函数可以接受一个或两个参数。对于比冲(Isp),你不能直接求和值;相反,你需要找到一个平均值,如下所示。
列表 12.13 计算发动机集群的比冲
Isp(cl::Cluster) = sum(Isp, cl)/length(cl)
此代码还让集合支持长度,这对于大多数集合来说是有意义的。自然地,开发者希望能够检查集合中包含了多少个元素。
通过这些更改,应该更明显地看出你为什么将 Cluster 定义为抽象类型。它允许你在多个集群类型之间共享质量、比冲和推力的实现。使用抽象类型是实现代码重用的一种好方法。
接下来,你将探索遍历火箭阶段。这会有点不同,因为你不能通过索引访问火箭阶段。
12.5 实现火箭阶段迭代
以下列表显示了第八章中使用的火箭阶段的定义。
列表 12.14 火箭阶段的定义
struct StagedRocket <: Rocket
nextstage::Rocket
tank::Tank
engine::Engine
end
注意,你没有可以从其中提取单个阶段的向量。因此,从 iterate 返回的元组中的元素不会是整数索引。
列表 12.15 启动分阶段火箭的迭代
import Base: iterate
iterate(r::StagedRocket) = (r, r.nextstage)
iterate(r::Rocket) = nothing
列表 12.15 中的代码处理了两种不同的情况:
-
一个实际有有效载荷的分级火箭
-
所有其他未分级且因此没有下一个元素的火箭
这意味着你不必为 Rocket 的每个可能的子类型添加 iterate。相反,你使 Rocket 类型默认不支持迭代。你还需要支持通过阶段集合的推进,这正是以下列表中的迭代方法将要做的。
列表 12.16 前进到火箭的下一阶段
function iterate(first::StagedRocket,
➥ current::StagedRocket) ❶
current, current.nextstage
end
function iterate(first::StagedRocket, current::Rocket) ❷
nothing
end
❶ 当下一阶段也是分级火箭时被调用
❷ 默认处理迭代到下一阶段
你已经定义了这些新方法,以便默认结束迭代。这是通过指定 current 的类型为 Rocket 来实现的。然后你对 current 是 StagedRocket 类型的情况进行例外处理。在这种情况下,你知道有一个 nextstage 字段,你可以访问它来前进到集合中的下一个元素。
因此,虽然 Cluster 类型的第一个例子使 current 看起来像是一个整数索引,但这并不完全正确。iterate 的第二个参数不需要是整数。它可以是你迭代集合中当前位置的任何数据。你可以通过将以下列表中的代码放入 REPL 或加载到 REPL 中的文件中来测试迭代。
列表 12.17 遍历火箭阶段
payload = Payload(300)
thirdstage = Rocket(payload, SmallTank(), Curie())
secondstage = Rocket(thirdstage, MediumTank(), Rutherford())
booster = Rocket(secondstage, LargeTank(), Cluster(Rutherford(), 9))
for rocket in booster
println("Mass of rocket: ", mass(rocket))
println("Thrust of rocket: ", thrust(rocket))
println()
end
确保你首先将你的火箭代码加载到你的 REPL 中。当你运行这个程序时,你应该得到以下输出:
Mass of rocket: 13568.0
Thrust of rocket: 225000
Mass of rocket: 3053.0
Thrust of rocket: 25000
Mass of rocket: 718.0
Thrust of rocket: 120
这表明在 for 循环中迭代是可行的。然而,你不能与 sum、map 和 collect 等函数一起使用。以下 REPL 会话显示了在助推阶段使用 map 和 collect 函数的失败尝试。
julia> map(mass, booster)
ERROR: MethodError: no method matching length(::StagedRocket)
julia> collect(booster)
ERROR: MethodError: no method matching length(::StagedRocket)
使其工作将是下一步。
12.5.1 添加对 map 和 collect 的支持
map 和 collect 失败是因为你缺少 Rocket 类型的 length 方法的实现。为了理解这个问题,我将首先展示一个原始解决方案,如下所示。
列表 12.18 计算分级火箭的阶段数量
import Base: length
length(::Rocket) = 0
length(r::StagedRocket) = 1 + length(r.nextstage)
虽然这可行,但其性能特性较差。计算分级火箭长度的所需时间与其长度成正比。这类算法在大 O 表示法中被称为线性或 O(n)。
大 O 表示法
在计算机科学中,我们经常讨论数据结构和算法的内存需求和处理能力,这被称为大 O 表示法。如果一个算法查找一个项目所需的时间是线性的(例如,它依赖于集合中的元素数量),那么我们将其写为 O(n)。这里的 n 指的是你整个集合中的 n 个元素。因此,如果 n 加倍,那么 O(n)算法完成所需的时间也将加倍。使用 for 循环查看每个元素的算法被称为 O(n)算法,而具有恒定时间的算法则写为 O(1)。
Julia 给我们一种方法来告诉它的迭代机制,没有有效的方法来确定你迭代的集合的长度。你可以通过实现一个 IteratorSize 方法来实现这一点,如下所示。
列表 12.19 将 SizeUnknown 特性添加到 Rocket 子类型
import Base: iterate, IteratorSize
IteratorSize(::Type{<:Rocket}) = Base.SizeUnknown()
这个概念一开始并不容易理解。我们称之为“神圣特性模式”。编程中的模式指的是在许多不同情境中重复使用的问题解决特定方式。在 Julia 中,你使用神圣特性模式向类型添加特性。一个“特性”就像是一种能力或特征(图 12.3)。例如,一个弓箭手可能有 CanShoot 特性;一个骑士可能有 CanRide 特性;而像蒙古战士这样的弓箭手,则可能同时拥有 CanShoot 和 CanRide 特性。

图 12.3 IteratorSize 特性和其子类型
可迭代的 Julia 集合可以有不同的特性,其中之一就是 IteratorSize。这究竟是如何工作的呢?当你调用 collect(rocket) 时,这会被转换成以下代码。
列表 12.20 collect 函数的实现
_collect(rocket, IteratorEltype(rocket), IteratorSize(rocket)).
_collect 函数附带了处理具有不同特性的集合的几种不同方法。你可以看到,collect 的行为取决于两个不同的特性:IteratorEltype 和 IteratorSize。你不必总是为你的集合注册特性,因为默认值就很好。以下列表显示了 IteratorSize 的默认值是如何定义的。
列表 12.21 Julia 集合的默认 IteratorSize
IteratorSize(x) = IteratorSize(typeof(x))
IteratorSize(::Type) = HasLength()
当 IteratorSize 特性被定义为 HasLength 时,Julia 将调用 length 来确定从 collect 生成的结果数组的长度。当你将此特性定义为 SizeUnknown 时,Julia 将使用一个空数组作为输出,该数组根据需要增长。
在 Julia 中,特性被定义为抽象类型。特性可以具有的值由具体子类型确定。HasLength 和 SizeUnknown 都是 IteratorSize 的子类型。你可能从第九章中认出了类似的模式:convert 和 promote_rule 函数都接受类型作为参数;然而在这种情况下,你添加了一个小的变化,即描述参数类型为 Type{<:Rocket}。
子类型运算符 <: 用于表示所有 Rocket 的子类型都具有 IteratorSize 特性的 SizeUnknown 值。我知道这听起来可能有些复杂,但幸运的是,当你需要时通常可以查阅。了解特性比记住它们是如何工作的更重要。为 Rocket 定义了 IteratorSize 特性后,你现在可以使用 map、sum 和其他在集合上操作的高阶函数:
julia> booster = Rocket(secondstage,
LargeTank(),
Cluster(Rutherford(), 9)); ❶
julia> map(mass, booster) ❷
3-element Vector{Float64}:
13568.0
3053.0
718.0
julia> sum(thrust, booster) ❸
250120
❶ 如果还没有定义,请记住定义第二和第三状态。
❷ 获取每个阶段的重量。
❸ 将多级火箭上所有引擎的推力相加。
集合需要支持的最基本操作是迭代。你已经看到了如何在两种不同的集合类型上实现这一点。火箭集群的行为大多像数组;然而,你的分阶段火箭更像是一种称为 链表 的数据结构。在下一节中,你将比较链表和数组,以更好地理解不同的集合类型有不同的权衡。
12.6 链表和数组的比较
你通过 nextstage 将阶段相互链接的方式与 链表 的工作方式相同:它们通常与数组进行对比。使用数组时,你可以快速确定长度或根据索引查找任意元素。你将为 Cluster 子类型添加基于索引的查找支持,并在稍后将其与火箭阶段的链表进行比较。
回想一下,for 循环实际上会被转换为 while 循环,在访问和替换数组中的元素时也存在类似的情况。假设你创建了一个集群,在其中访问元素。
列表 12.22 基于索引的访问
cluster = Cluster(Rutherford(), Merlin())
engine = cluster[1] ❶
cluster[2] = Rutherford() ❷
❶ 读取第一个元素。
❷ 更改第二个元素。
列表 12.22 中的代码将通过 Julia 编译器经过几个阶段进行翻译。其中一个阶段,称为 lowering,将此代码转换为以下形式。
列表 12.23 基于索引的访问内部机制
cluster = Cluster(Rutherford(), Merlin())
engine = getindex(cluster, 1)
setindex!(cluster, Rutherford(), 2)
因此,为了使你的 Cluster 子类型支持通过索引访问元素,你需要向 Base 模块中找到的 getindex 和 setindex! 函数添加方法。这些是用于实现方括号中元素访问的函数:[]。
列表 12.24 向火箭集群添加基于索引的访问
import Base: getindex, setindex!
function getindex(cluster::MixedCluster, i::Integer) ❶
cluster.engines[i]
end
function setindex!(cl::MixedCluster, egn::Engine,
➥i::Integer) ❷
cl.engines[i] = egn
end
function getindex(cluster::UniformCluster, _) ❸
cluster.engine
end
❶ 获取集群中的第 i 个元素。
❷ 将集群中的第 i 个元素设置为 egn。
❸ 对于 UniformCluster,你不需要关心索引。
你可以看到 MixedCluster 和 UniformCluster 之间的一些差异,你可以轻松地支持从 UniformCluster 中获取元素,因为每个元素都是相同的。然而,你不能支持设置元素,因为这将不再保持均匀。因此,你没有为 UniformCluster 添加处理 setindex! 的方法。虽然你可以为链表定义基于索引的访问,但如以下列表所示,这并不高效。
列表 12.25 通过索引访问火箭阶段
import Base: getindex
function getindex(r::StagedRocket, i::Integer)
for _ in 1:i-1 ❶
r = r.nextstage
end
r
end
❶ 当你不在乎变量的名称时使用 _。
这样的查找是一个 O(n) 操作(线性)。阶段越多,for 循环需要重复的次数就越多。相比之下,在数组中的查找是一个 O(1) 操作。这就是说它是一个 常数操作。在包含三个元素的数组中查找元素的时间与在包含数百万元素的数组中查找元素的时间完全相同。
然而,你可以添加对其他类型操作的支持,这些操作运行得更快。让我们看看 Julia 中支持的一些最常见的操作,用于向集合中添加元素。
12.6.1 添加和删除元素
数组允许您向数组的末尾添加元素,也可以从两端删除元素。注意在示例中,push! 和 pushfirst! 如何允许您在单个函数调用中添加多个元素。
julia> xs = Int[7] ❶
1-element Vector{Int64}:
7
julia> push!(xs, 9, 11) ❷
3-element Vector{Int64}:
7
9
11
julia> pushfirst!(xs, 3, 5) ❸
5-element Vector{Int64}:
3
5
7
9
11
julia> pop!(xs) ❹
11
julia> popfirst!(xs) ❺
3
❶ 创建包含一个元素 7 的数组 xs。
❷ 将 9 和 11 添加到数组的末尾。
❸ 将 3 和 5 添加到数组的开头。
❹ 移除最后一个元素。
❺ 移除第一个元素。
图 12.4 可能有助于阐明这些操作的工作原理。

图 12.4 从数组中添加和删除元素
对于链表,从前面添加和删除元素是有效的(见图 12.5)。因此,您可以支持 pushfirst! 和 popfirst! 等操作。然而,您需要做一些调整和准备工作,以便更方便地实现这些函数。

图 12.5 终止的火箭级链表
在处理链表时,有一个明显的终止对象链是很有用的。通常这将是 nothing 对象,但在多级火箭中,一个 emptypayload 将是一个自然的选择。
列表 12.26 一个空的载荷对象,用于终止火箭级列表
struct EmptyPayload <: Rocket end ❶
const emptypayload = EmptyPayload() ❷
mass(::EmptyPayload) = 0.0 ❸
thrust(r::EmptyPayload) = 0 ❸
update!(r::EmptyPayload, t::Number, Δt::Number) = nothing ❸
function Rocket(tank::Tank, engine::Engine)
StagedRocket(emptypayload, tank, engine)
end
❶ 没有字段
❷ 每个实例都有与没有任何东西时相同的对象。
❸ 实现火箭接口。
具有空载荷的表示提供了一些优势,例如为单级火箭提供一个合理的默认构造函数,如列表 12.26 所示。然而,示例尚未完成。需要一个新类型,并且需要修改现有类型。StagedRocket 最初被设置为不可变的,这将阻碍例如 popfirst! 的操作,因为您需要修改 nextstage 字段。您不能修改不可变对象的字段(无法更改的对象)。
重要提示:每次更改类型定义,例如通过使结构可变,您都需要重新启动 Julia REPL。这是因为 Julia 类型是固定的;它们不能像许多其他动态语言那样在运行时修改。
接下来,我将介绍 SpaceVehicle 类型(列表 12.27)。图 12.6 显示了它在概念上与其他火箭部件的关系。

图 12.6 火箭术语概述
SpaceVehicle 是包含所有火箭级的整体。这种抽象有助于围绕级进行包装,以便您可以跟踪第一个火箭级开始的位置。这在实现 pushfirst! 和 popfirst! 时很有用,因为它允许您相对于其他东西添加和删除级。
列表 12.27 新的和修改的类型定义
mutable struct StagedRocket <: Rocket ❶
nextstage::Rocket ❷
tank::Tank
engine::Engine
end
mutable struct SpaceVehicle ❸
activestage::Rocket ❹
end
❶ 将 StagedRocket 更改为可变的。
❷ 您希望 nextstage 可以更改。
❸ 火箭级的包装器
❹ 当前引擎正在燃烧的级
在这些类型定义到位后,你就有了实现 popfirst!和 pushfirst!方法的基础(列表 12.28)。因为它们是 Julia 集合的标准函数,所以你从 Base 导入它们,并通过处理你的特定集合 SpaceVehicle 的方法来扩展它们。
列表 12.28 从底部移除阶段
import Base: popfirst!, pushfirst!
tail(r::StagedRocket) = r.nextstage
tail(r::Rocket) = nothing
function popfirst!(ship::SpaceVehicle)
r = tail(ship.activestage)
if r == nothing ❶
throw(throw(ArgumentError
➥
("no rocket stages left"))) ❷
else
discarded = ship.activestage ❸
discarded.nextstage = emptypayload ❹
ship.activestage = r ❺
end
discarded ❻
end
❶ 检查是否还有剩余的阶段。
❷ 不允许从空航天器中弹出阶段
❸ 丢弃底部阶段。
❹ 解链被丢弃的阶段。
❺ 下一个阶段变为活动阶段。
❻ 在 REPL 中显示被丢弃的阶段。
尾部功能需要一些解释。你添加了两种方法:一种用于处理 StagedRocket,另一种用于 Rocket 的子类型。这是一种简单的方式来检查 SpaceVehicle 上是否还有剩余的阶段。由于 SpaceVehicle 的 activestage 字段是 Rocket 类型,你不能保证存在下一个阶段。为什么不将其改为 StagedRocket 呢?因为你希望允许阶段在只剩下代表卫星或乘员舱的有效载荷之前被分离。
列表 12.29 在底部添加阶段
function pushfirst!(ship::SpaceVehicle, r::StagedRocket)
r.nextstage = ship.activestage ❶
ship.activestage = r ❷
ship
end
❶ 当前阶段变为下一个阶段。
❷ 新阶段变为当前阶段。
使用 pushfirst!,你将新阶段 r 放在现有活动阶段之前(列表 12.29)。旧的活动阶段改变其角色,成为新活动阶段的下一个阶段。你可以进行所有这些编辑和修改,并启动一个新的 REPL 来感受这些新函数的工作方式。为了使其更直观,我将展示一个名为 a、b 和 c 的火箭阶段示例。每个阶段都比前一个阶段有更大的油箱和更大的引擎:
julia> a = Rocket(SmallTank(), Curie());
julia> b = Rocket(MediumTank(), Rutherford());
julia> c = Rocket(LargeTank(), Merlin());
示例开始于创建一个带有小型 40 公斤有效载荷的航天器:
julia> ship = SpaceVehicle(Payload(40));
接下来,使用 pushfirst!向这艘航天器添加阶段:
julia> pushfirst!(ship, a)
SpaceVehicle(StagedRocket(Payload(40.0), SmallTank(370.0), Curie()))
julia> pushfirst!(ship, b)
SpaceVehicle(StagedRocket(
StagedRocket(
Payload(40.0),
SmallTank(370.0),
Curie()),
MediumTank(2050.0),
Rutherford()))
julia> pushfirst!(ship, c)
SpaceVehicle(StagedRocket(
StagedRocket(
StagedRocket(
Payload(40.0),
SmallTank(370.0),
Curie()),
MediumTank(2050.0),
Rutherford()),
LargeTank(9250.0),
Merlin()))
我通过添加空白和缩进来编辑 REPL 输出,以更清楚地显示在添加更多阶段时创建的结构。你可以看到最小的阶段位于最深的缩进级别。这是因为它是嵌套最深的。带有大型引擎和油箱的助推阶段位于底部。
你可以使用 popfirst!函数再次移除所有这些阶段:
julia> popfirst!(ship)
StagedRocket(EmptyPayload(), LargeTank(9250.0), Merlin())
julia> popfirst!(ship)
StagedRocket(EmptyPayload(), MediumTank(2050.0), Rutherford())
julia> popfirst!(ship)
StagedRocket(EmptyPayload(), SmallTank(370.0), Curie())
julia> ship
SpaceVehicle(Payload(40.0))
在每次 pop 操作中,被丢弃的阶段会被返回。注意,带有 Merlin 引擎的最大阶段首先脱落。下一个阶段涉及中等大小的油箱。带有小油箱的顶部阶段最后脱落。最后,只剩下包含初始 40 公斤有效载荷的航天器。
12.7 自定义类型的实用性
在实际代码中,链表并不常用,因为数组更灵活,并且在大多数情况下性能更好。然而,理解链表是有用的,因为这里应用的原则也适用于更复杂的数据结构。树结构和图也链接元素。
虽然你可能不会花太多时间编写完全通用的数据结构,例如数组、链表和字典,但将自定义数据结构(火箭阶段)转换为集合会很有帮助。一旦数据结构实现了已建立类型类别(如集合)的接口,你就可以使大量函数适用于它们。例如,通过使分阶段火箭可迭代,你就可以突然免费使用 map、reduce、filter、sum、collect 和其他函数。
摘要
-
使用方括号进行循环和索引访问都转换为 Julia 函数调用,例如 iterate、getindex 和 setindex!。
-
集合至少必须支持迭代。这是通过为你的集合类型实现两个 iterate 方法来完成的——一个用于开始迭代,另一个用于获取下一个元素。
-
Julia 类型可以使用神圣的特质模式配置不同的能力。Julia 集合可以使用不同的特质配置,例如 IteratorSize 和 IteratorEltype。
-
对于计算元素数量较慢的集合,应将 IteratorSize 配置为 SizeUnknown。
-
数组可以快速访问任何索引处的元素,但除了在数组末尾外,不允许快速插入元素。
-
链表通过索引访问元素较慢,但在前端的元素插入和删除较快。
-
实现已建立的 Julia 接口可以使你的数据类型更加灵活。例如,通过将你的数据结构转换为 Julia 集合,你可以利用许多预构建的与 Julia 集合相关的函数。
(1.)Markdown 是一种轻量级标记语言,用于使用纯文本编辑器创建格式化文本。
(2.)IDE 是集成开发环境的缩写。Visual Studio 和 IntelliJ IDEA 是 IDE 的一些例子。
13 使用集合
本章涵盖了
-
比较集合和数组之间的差异
-
以不同方式创建集合
-
使用并集和交集操作在不同类型的软件中定位项目
-
理解使用集合和搜索操作之间的权衡
定义集合并展示它们可以用于的操作并不需要花费太多时间。真正需要时间的是发展对使用集合可以解决何种问题的理解或直觉。
通过利用集合和集合操作,可以巧妙地解决许多与组织数据定位相关的问题,但这并不总是显而易见的。在本章中,我将介绍集合是什么以及你可以用它做什么,然后我会通过展示各种现实生活中的例子来展示在集合中存储数据的力量。
13.1 集合可以帮助解决哪些类型的问题?
许多软件需要组织大量数据,包括以下内容:
-
相册
-
邮件客户端
-
缺陷跟踪系统
-
在线购物
-
软件开发项目
-
专门软件,如地质学家使用的建模软件
很长时间以来,组织数据最流行的方式是通过树形结构。要找到一项物品,你会钻入子类别,直到找到你想要的。这种方法的缺点是,许多物品可能存在于多个子类别下,而不仅仅是单个子类别。
像 McMaster-Carr(图 13.1)这样的网店,销售大量机械部件,是这种问题的绝佳例子。

图 13.1 McMaster-Carr 网店展示了螺丝可以以多种方式分类
在屏幕的侧面,你可以看到各种螺丝的分类:
-
度量系统——螺丝尺寸是以英制单位还是公制单位给出的?
-
螺纹尺寸——螺纹的实际测量值和每英寸的螺纹数是多少?
-
材料——螺丝是由钢、塑料还是木头制成的?
-
长度——从螺丝头下方到螺纹末端的长度是多少?
-
头部类型——它是平的、圆形的还是六角形的?
这里展示的分类远不止这些。然而,重要的是你不能将其转换成树形层次结构。例如,塑料螺丝和钢螺丝都可以有圆形的头部。
另一个例子是相册。你是如何组织它们的?你可以按家庭成员组织照片,这样你的妻子和每个孩子都有自己的相册。或者,也许根据事件组织相册,比如访问巴塞罗那或夏威夷,更有意义。或者,也许有人更喜欢基于活动的组织,比如为特定类型的景点创建相册,如技术博物馆或动物园。组织很困难,所以让我们看看集合和集合操作如何帮助你完成这项任务。
13.2 什么是集合?
集合是一种集合类型,就像数组或字典一样。以下是一个创建集合的例子:
julia> fruits = Set(["apple", "banana", "pear", "orange"])
Set{String} with 4 elements:
"pear"
"orange"
"banana"
"apple"
julia> fruits = Set([:apple, :banana, :pear, :orange]) ❶
Set{Symbol} with 4 elements:
:pear
:apple
:banana
:orange
julia> odds = Set([1, 3, 5, 7, 9])
Set{Int64} with 5 elements:
5
7
9
3
1
julia> nodups = Set([3, 3, 3, 1, 2]) ❷
Set{Int64} with 3 elements:
2
3
1
❶ 用符号代替字符串
❷ 尝试添加重复项
在第一种情况下,创建了一个包含水果的集合,其中每个水果都由一个字符串表示。第二种情况类似,但使用符号而不是字符串来表示水果。这是一个有用的例子,因为符号在 Julia 中经常用来表示键。
集合类似于字典的键;没有元素重复。注意在最后一个例子中,nodups,3 被尝试添加多次。像字典一样,元素没有特定的顺序。当您遍历一个集合时,元素将以特定的顺序出现。然而,您无法控制这个顺序。如果您添加或删除元素,顺序可能会改变。
这种行为与数组不同,例如,您可以对数组中元素的增加和删除如何影响数组的顺序有完全的控制。如果您使用 push! 向数组中添加一个元素,那么每个元素都会保持原来的位置。每个元素都可以使用与之前相同的索引访问。
13.2.1 比较集合和数组的属性
通过比较集合的属性与数组的属性(表 13.1),您可以更好地理解什么是集合。
表 13.1 集合和数组之间的差异和相似之处
| 属性 | 集合 | 数组 |
|---|---|---|
| 允许重复 | 否 | 是 |
| 元素有序 | 否 | 是 |
| 随机访问 | 否 | 是 |
| 快速成员测试 | 是 | 否 |
| 可迭代 | 是 | 是 |
集合提供了以下两个期望的属性:
-
集合保证不包含任何重复元素。
-
检查一个集合是否包含一个特定的对象非常快。
相比之下,数组没有提供快速检查它们是否包含特定对象的方法,因为确定数组是否包含一个元素需要查看数组中的每个元素。所以在一个包含两百万个元素的数组中寻找特定元素,平均需要的时间是寻找一个包含一百万个元素的数组中元素的两倍。
这被称为 线性 关系。然而,对于集合来说,定位一个元素所需的操作数量不会随着集合大小的增加而增加。没有线性关系。
集合可以用不同的方式实现;因此,在某些变体中,平均需要 log(n) 次检查才能在包含 n 个元素的集合中查找一个元素。为了帮助您更好地理解使用集合的好处,让我们比较一下集合与针对不同类型操作优化的数组。
在有序数组中搜索
在有序数组中,您可以执行 二分搜索。考虑以下有序数字数组,以快速了解它是如何工作的:
A = [2, 7, 9, 10, 11, 12, 15, 18, 19]
这个有序数组有 9 个数字。比如说您正在寻找数字 18。这个数字在 1:9(索引范围)的某个地方。通常,找到这个数字需要 8 次比较,但使用二分搜索,您从中间开始 A[5] == 11,并询问 18 是否大于 11 或小于 11。
由于数组是排序的,你可以得出结论 18 位于数组的上半部分,或者更具体地说,索引范围 6:9。这个搜索过程通过检查这个范围的中间值来重复进行。由于这个范围内没有中间值,可以将索引向下取整到 A[7] == 15。你发现 18 大于这个值。因此,在 3 次比较中,而不是 8 次,你就可以找到答案。Julia 有几个用于执行此类搜索的函数:
julia> searchsorted(A, 18)
8:8
julia> searchsortedfirst(A, 18)
8
julia> searchsortedlast(A, 18)
8
使用排序数组的一个缺点是程序员必须确保数组始终是排序的。这会使插入操作变慢,因为每次插入都必须重新排序数组。集合的好处在于不仅允许快速检查成员资格(例如,确定一个元素是否在集合中),而且也允许快速插入和删除。
执行对象成员资格测试
你可以将数组 A 转换为集合 S。它们都支持使用 in 或其希腊语等价物∈进行成员资格测试。你还可以使用⊆或 issubset 来检查多个元素是否是成员:
julia> S = Set(A);
julia> 18 in S
true
julia> 18 ∈ A
true
julia> [11, 15] ⊆ A
true
julia> issubset([11, 15], S)
true
数组在行为上看起来很相似,但这些操作在集合上会更快速。例外情况是对于小集合。在元素较少的情况下,没有任何集合比数组更快。一旦超过 30-40 个元素,集合在成员资格测试上就会开始优于未排序的数组。
然而,如果保持一组唯一的元素很重要而顺序不重要,那么仍然建议使用集合。这有助于传达你代码的工作方式。使用更复杂的集合类型,如 Dictionary 或 Set,一旦你有大量元素,就会真正开始带来回报。
集合不允许重复
当你尝试创建一个包含重复元素的集合时会发生什么?以下示例探讨了这个问题:
julia> apples = ["apples", "apples", "apples"]
3-element Vector{String}:
"apples"
"apples"
"apples"
julia> appleset = Set(apples)
Set(["apples"])
julia> length(appleset)
1
julia> numbers = [1, 1, 1, 2, 2, 2, 3, 3];
julia> length(numbers)
8
julia> S = Set(numbers)
Set([2, 3, 1])
julia> length(S)
3
数组允许重复,但集合不允许。
元素的随机访问和排序
我将创建一个集合和一个具有相同元素的数组来演示随机访问和排序是如何完全不同的:
A = [3, 5, 7]
S = Set(A)
如果你使用 collect 或 foreach,它们将遍历集合。你可以看到顺序是不同的;它不是保证的,并且在不同版本的 Julia 之间可能会改变:
julia> collect(S)
3-element Vector{Int64}:
7
3
5
julia> collect(A)
3-element Vector{Int64}:
3
5
7
julia> foreach(print, S)
735
julia> foreach(print, A)
357
我可以使用方括号通过索引访问数组元素:
julia> A[2]
2
但这不可能用集合来完成:
julia> S[2]
ERROR: MethodError: no method matching getindex(::Set{Int64}, ::Int64)
使用数组时,push!会将每个元素添加到可预测的位置:
julia> push!(A, 9)
4-element Vector{Int64}:
3
5
7
9
然而,对于集合来说,元素可以出现在任何地方:
julia> push!(S, 9)
Set([7, 9, 3, 5])
使用数组时,pop!将移除最后添加的元素:
julia> pop!(A)
9
然而,使用集合进行此操作最好避免,因为你无法控制最终移除的是哪个元素:
julia> pop!(S)
7
在这种情况下,Julia 抛出异常可能更为合适,而不是让用户执行 pop!操作。
13.3 如何使用集合操作
集合操作用于组合集合以创建新的集合。然而,集合操作实际上并不限于集合。你还可以在数组之间执行集合操作。区别在于集合被设计来支持这一点,而数组则不是。数组只对元素数量较少的集合执行集合操作效率较高。
集合操作允许你回答如下问题:给我鲍勃访问西班牙和希腊时的照片。如果鲍勃代表你在照片应用中叔叔鲍勃的所有图片,西班牙是一个包含你所有西班牙照片的集合,希腊是一个包含你所有希腊照片的集合,那么这样的问题可以用这两个等效表达式中的任何一个来回答:
S = Bob ∩ (Spain ∪ Greece)
S = intersect(Bob, union(Spain, Greece))
这演示了使用并集和交集操作。这些也可以使用 ∪ 和 ∩ 符号来表示。通过使用维恩图^(1)(图 13.2)是可视化不同集合操作行为的最有效方式。

图 13.2 帮助解释集合操作的维恩图
每个例子中的两个圆圈代表集合 A 和 B。这些是重叠的集合,意味着 A 中的某些元素也存在于 B 中。
彩色区域显示了集合操作结果中包含哪些元素。例如,对于 集合并集,A 和 B 中的所有元素都包含在结果中。然而,对于 集合交集,只有 A 和 B 共享的元素才是结果的一部分。你可能已经注意到了与布尔逻辑中使用的 AND && 和 OR || 操作符的类比。对于 并集,元素必须在集合 A 或 集合 B 中。对于 交集,它们必须在集合 A 和 集合 B 中。
使用 集合差分 时,顺序很重要。setdiff(A, B) 在你从 A 中移除也存在于 B 中的元素后,返回 A 中剩余的元素。让我们看看这个实际应用的例子。想象一下有一些照片标题的集合:
bob = Set(["Bob in Spain", "Bob in Greece", "Joe and Bob in Florida"])
joe = Set(["Joe in Texas", "Joe and Eve in Scotland", "Joe and Bob in Florida"])
eve = Set(["Eve in Wales", "Joe and Eve in Scotland", "Eve in Spain"])
所以有三个人——鲍勃、乔和伊芙——他们去过国外的各种度假胜地,在那里他们拍了照片。在这个例子中,这些照片被表示为它们的标题文本。
在这种情况下,你想要使用集合操作来找到包含这些人中多于一人的照片。bob 是鲍勃参与的所有照片的集合,joe 是乔参与的所有照片的集合,eve 是伊芙参与的所有照片的集合。此代码找到鲍勃和乔一起度假的照片:
julia> bob ∩ joe
Set{String} with 1 element:
"Joe and Bob in Florida"
也许伊芙和乔分手了,所以你不想看到有乔的照片。然后伊芙可以使用 setdiff 来排除乔的照片:
julia> setdiff(eve, joe)
Set{String} with 2 elements:
"Eve in Wales"
"Eve in Spain"
也许乔想找到他和其他人一起度过的所有假期:
julia> (bob ∪ eve) ∩ joe
Set{String} with 2 elements:
"Joe and Eve in Scotland"
"Joe and Bob in Florida"
集合当然可以包含任何类型的对象。让我们用一些稍微不那么令人兴奋的数字集合操作来做一些事情。A 是一个主要由偶数组成的集合,而 B 包含了大部分的奇数:
A = Set([1, 2, 4, 6])
B = Set([1, 3, 5, 6])
你可以通过两种不同的方式得到集合的交集:
julia> A ∩ B
Set{Int64} with 2 elements:
6
1
julia> intersect(A, B)
Set{Int64} with 2 elements:
6
1
你还可以得到集合的并集:
julia> A ∪ B
Set{Int64} with 6 elements:
5
4
6
2
3
1
julia> union(A, B)
Set{Int64} with 6 elements:
5
4
6
2
3
1
最后,你可以得到 A 和 B 的集合差分:
julia> setdiff(A, B)
Set{Int64} with 2 elements:
4
2
julia> setdiff(B, A)
Set{Int64} with 2 elements:
5
3
如你所见,集合差分中的顺序很重要。
13.4 如何在代码中使用集合
集合的基本操作不难学习。学会何时在代码中使用集合需要更多的时间。
我经常对集合如何提供强大而优雅的解决方案来解决难题感到惊讶。很容易忘记集合就在你的工具箱里。
在以下章节中,你将看到可以使用集合解决的问题。我还会对比使用集合与其他解决方案。
我将首先展示如何使用集合构造函数创建不同产品类别的集合。之后,我将通过使用交集和集合差操作来演示查找符合不同标准的螺丝的过程。替代方案将基于定义具有不同属性的螺丝复合类型。我将展示如何使用过滤器函数来查找符合所需标准的螺丝对象。
13.5 使用集合操作搜索产品
当处理,比如说,网店中的产品时,你通常会使用 SQL^(2)数据库。这个过程在概念上与使用集合操作相似,这就是为什么我会扩展在线五金店购买螺丝的例子。
螺丝可以有不同的头部类型:
head_type = [rounded, flat, headless, tslot]
如果你想要螺丝与表面齐平或者需要一个无头的螺丝,比如用于轴套的螺丝,那么螺丝可以有一个驱动方式,这表明你需要什么样的螺丝刀尖端来转动螺丝:
drive_style = [hex, phillips, slotted, torx]
材料应该是显而易见的:
material = [aluminium, brass, steel, plastic, wood]
这是分类列表。列表中的每一项实际上是一个集合,该集合包含一个唯一标识该螺丝的产品编号。出于实际原因,我将演示如何发明一些三位数的产品编号。以下示例使用范围快速创建大量产品编号:
rounded = Set(100:4:130)
flat = Set(101:4:130)
headless = Set(102:4:130)
tslot = Set(103:4:130)
hex = Set(100:108)
phillips = Set(109:115)
slotted = Set(116:121)
torx = Set(122:129)
aluminium = Set(100:3:120)
brass = Set(101:3:120)
steel = Set(102:3:120)
plastic = Set(121:2:130)
wood = Set(122:2:130)
如果你仔细观察数字,你会看到它们是重叠的。例如,一些铝制产品编号与六角产品编号相同。
在定义了这些集合之后,我可以提出各种有用的问题,例如以下问题:在你的产品目录中,哪些螺丝是圆头、由木头制成,并且可以用扭矩螺丝刀拧紧?回答这个问题只需要一个简单的集合操作:
julia> intersect(rounded, torx, wood)
Set{Int64} with 2 elements:
124
128
或者你想得到所有可以用菲利普螺丝刀拧紧的钢制螺丝?
julia> intersect(phillips, steel)
Set{Int64} with 2 elements:
114
111
或者你可能只想知道是否存在非塑料制成的 T 型槽螺丝:
julia> setdiff(tslot, plastic)
Set{Int64} with 5 elements:
107
103
115
111
119
这是一种使用集合的方法,但你也可以用完全不同的设计实现相同的效果,根本不使用集合。相反,你可以定义一个螺丝为一个具有每个属性属性的更丰富的数据类型:
struct Screw
prodnum::Int
headtype::HeadType
drivestyle::DriveStyle
material::Material
end
而不是仅仅将螺丝视为数字,存在一个具有属性的 数据类型,您可以使用它来尝试匹配一些给定的搜索条件。您可以看到,各种属性由自定义类型 HeadType、DriveStyle 和 Material 表示。在不同的例子中,这些可以是这些字符串或符号,但它们被制作成特定的类型,以便在将非法类别分配给任何属性时捕获这些情况。
13.5.1 定义和使用枚举
为了表示不同的类别,您可以使用枚举,或简称为 enum。枚举存在于多种不同的语言中。在 Julia 中,它们有点特别,因为它们是通过宏来定义的。现在重新启动 Julia,因为这些变量已经定义了;否则,会有关于变量已定义的抱怨:
@enum HeadType rounded flat headless tslot
@enum DriveStyle hex phillips slotted torx
@enum Material aluminum brass steel plastic wood
提示是@前缀。您可以将六角、槽口和 Torx 视为 DriveStyle 类型的实例。实际上,您可以使用 DriveStyle 构造函数来创建它们:
julia> DriveStyle(2)
slotted::DriveStyle = 2
julia> DriveStyle(3)
torx::DriveStyle = 3
julia> DriveStyle(4)
ERROR: ArgumentError: invalid value for Enum DriveStyle: 4
然而,您可以在最后一个例子中看到增加的类型安全性。在定义枚举时,除了指定的值之外,无法为 DriveStyle 创建其他值。
13.5.2 创建测试数据以执行查询
为了演示使用此类型定位具有不同属性的螺丝,您需要创建一些测试数据来操作:
function make_screw(prodnum)
headtype = rand(instances(HeadType))
drivestyle = rand(instances(DriveStyle))
material = rand(instances(Material))
Screw(prodnum, headtype, drivestyle, material)
end
screws = map(make_screw, 100:150)
此代码创建了一个包含螺丝产品编号在 100 到 150 范围内的数组,并且您为每个属性随机选择值。instances 函数返回枚举的每个可能值的数组:
julia> instances(DriveStyle)
(hex, phillips, slotted, torx)
julia> instances(Material)
(aluminium, brass, steel, plastic, wood)
13.5.3 搜索螺丝
第一个例子展示了使用集合操作匹配您所需标准的螺丝。现在,您将通过搜索数组中的所有螺丝并检查每个螺丝是否满足所有所需条件来找到所需的螺丝。示例将通过指定谓词函数来展示如何做到这一点。谓词函数将螺丝作为参数,并根据谓词函数测试的标准是否满足返回 true 或 false。isroundwood 将测试给定的螺丝是否具有由木材制成的圆形头部:
function isroundwood(screw)
screw.headtype == rounded &&
screw.material == wood
end
这个谓词可以用来(返回布尔值的函数)过滤螺丝:
julia> roundedwood = filter(isroundwood, screws)
3-element Vector{Screw}:
Screw(100, rounded, torx, wood)
Screw(113, rounded, slotted, wood)
Screw(129, rounded, torx, wood)
那么,在商店里能找到哪些非塑料 T 型槽螺丝呢?
julia> function isnonplastic(screw)
screw.headtype == tslot &&
screw.material != plastic
end
julia> nonplastic = filter(isnonplastic, screws)
15-element Vector{Screw}:
Screw(105, tslot, hex, wood)
Screw(106, tslot, hex, wood)
Screw(107, tslot, hex, brass)
Screw(108, tslot, phillips, steel)
Screw(117, tslot, phillips, wood)
Screw(118, tslot, hex, wood)
Screw(125, tslot, phillips, wood)
Screw(128, tslot, phillips, wood)
Screw(130, tslot, phillips, wood)
Screw(131, tslot, torx, brass)
Screw(133, tslot, hex, wood)
Screw(134, tslot, slotted, wood)
Screw(138, tslot, hex, steel)
Screw(141, tslot, phillips, steel)
Screw(146, tslot, torx, brass)
13.5.4 将螺丝对象放入集合
对于每种情况,最佳解决方案并不总是容易确定,因此了解不同的方法是有价值的。有时结合解决方案是有意义的。您也可以将这些螺丝对象放入集合中。
您可以使用 filter 函数生成集合,这些集合以后可以重复使用:
julia> issteel(screw) = screw.material == steel;
julia> steel_screws = Set(filter(issteel, screws));
julia> ishex(screw) = screw.drivestyle == hex
julia> hex_screws = Set(filter(ishex, screws))
然后,您可以使用这些集合进行集合操作:
julia> steel_screws ∩ hex_screws
Set(Screw[
Screw(126, headless, hex, steel),
Screw(115, headless, hex, steel),
Screw(121, flat, hex, steel),
Screw(107, headless, hex, steel),
Screw(108, flat, hex, steel)
])
然而,这个解决方案还可以进一步改进。
13.5.5 使用字典查找螺丝
通常,买家知道他们想要的螺丝的产品编号,并希望使用这个编号而不是复杂的搜索条件来查找螺丝。通过将螺丝存储在字典中,其中键是产品编号,你可以解决这个用例:
julia> screwdict = Dict(screw.prodnum => screw for screw in screws)
Dict{Int64,Screw} with 51 entries:
148 => Screw(148, rounded, hex, brass)
124 => Screw(124, rounded, hex, aluminium)
134 => Screw(134, tslot, slotted, wood)
136 => Screw(136, rounded, torx, aluminium)
131 => Screw(131, tslot, torx, brass)
144 => Screw(144, rounded, slotted, steel)
142 => Screw(142, flat, slotted, steel)
150 => Screw(150, rounded, hex, steel)
...
julia> screwdict[137]
Screw(137, headless, phillips, aluminium)
julia> screwdict[115]
Screw(115, flat, phillips, aluminium)
这个代码更改允许你回到原始解决方案,其中你在集合中使用乘积数。让我们首先基于乘积数创建一些新的集合:
prodnums = keys(screwdict)
function isbrass(prodnum)
screw = screwdict[prodnum]
screw.material == brass
end
brass_screws = Set(filter(isbrass, prodnums))
function istorx(prodnum)
screw = screwdict[prodnum]
screw.drivestyle == torx
end
torx_screws = Set(filter(istorx, prodnums))
现在,你回到了使用集合操作根据集合中的产品键选择所需产品的优雅方式:
julia> brass_screws ∩ torx_screws
Set([100, 122, 144])
julia> [screwdict[pn] for pn in brass_screws ∩ torx_screws]
3-element Vector{Screw}:
Screw(100, rounded, torx, brass)
Screw(122, tslot, torx, brass)
Screw(144, flat, torx, brass)
13.6 使用集合在错误跟踪器中进行搜索
在开发较大的软件组件,尤其是在团队中,公司通常会使用某种形式的错误跟踪工具。通常,这些是允许测试者提交错误描述的 Web 应用程序。经理或产品专家随后可以审查这些错误,并在错误最终分配给软件开发者之前分配优先级和严重性。
记录给错误的常见属性包括以下内容:
-
项目—这是哪个软件项目的一部分?
-
优先级—修复这个错误有多重要?
-
严重性—这是一个小的烦恼还是一个关键功能崩溃?
-
组件—这是在用户界面、客户端、服务器等中吗?
-
负责人—目前谁被分配去处理这个错误?
就像产品一样,错误通常会被一个唯一的错误编号唯一标识。因此,可以采用与之前描述的非常相似的方法:你可以在字典中使用错误,其中键是错误编号。
我将演示定义由不同错误编号组成的集合。以下是一些你可以想象使用集合解决的问题:
在月球着陆器项目中分配给鲍勃的最关键的错误是什么?
bob ∩ critical ∩ lunar_lander
可能不实用为每个集合命名,并且集合应根据错误跟踪器中的字段进行组织。以下展示了使用字典来分组相关集合:
assignees["Bob"] ∩ severity[:critical] ∩ projects["Lunar Lander"]
当对多个对象进行集合操作时,可能更实用的是不使用操作符符号。这相当于:
intersect(assignees["Bob"], severity[:critical], projects["Lunar Lander"])
经理可能会问以下问题:
鲍勃和伊芙正在处理哪些顶级优先级的错误?
assignees["Bob"] ∪ assignees["Bob"] ∩ priorities[:top]
我们本可以查看更多的例子,但希望这能给你一个很好的想法,了解你如何在自己的应用程序中使用集合来简化问题。
13.7 关系型数据库和集合
如果你之前已经使用过 SQL 和关系型数据库,那么你在这个章节中看到的大部分内容可能看起来很熟悉。在 SQL 数据库查询语言中,可以执行许多类似于集合操作的操作。在数据库世界中称为内连接的操作相当于集合交集。
关系数据库建立在一种称为关系代数的数学分支之上,它涵盖了数据建模及其查询。在本章中,你已经探讨了集合论,这是更基础的。使用关系数据库,你可以创建具有多个列的表格数据,这些列与其他表有关。与数据库表最相似的数据结构称为 DataFrame^(3),它存在于 DataFrames 包中.^(4)有关 DataFrames 包的深入介绍,请参阅 Bogumił Kamin´ski 的《Julia for Data Analysis》(Manning,2022 年)。
摘要
-
集合可以帮助你组织数据,如相册、缺陷跟踪工具中的缺陷或在线商店中销售的商品。
-
与元素数组不同,集合没有重复项,并且允许非常快速的成员测试。
-
集合中的元素没有明确的顺序,与数组不同。元素不能在集合中的特定位置插入。
-
通过提供元素数组,如 Set([4, 8, 10]),来创建一个集合。
-
使用并集、交集和集合差等集合操作来组合集合。
-
使用 in 函数检查元素 x 是否在集合 S 中。这可以写成 in(x, S)或 x in S。
-
使用@enum 宏创建枚举类型。@enum Fruit apple banana 创建了一个具有有效值 apple 和 banana 的枚举类型 Fruit。
-
你可以通过在数组上使用 filter 来执行类似于集合操作的操作。然而,对于大型数据集,性能可能不会同样良好。
-
集合论和关系代数(用于关系数据库)允许你执行类似操作。然而,集合处理的是值,而关系数据库处理的是表及其关系。
(1.)维恩图通常用于说明两个或多个项目集合之间的逻辑关系。
(2.)结构化查询语言(SQL)是一种用于制定数据库查询的专用语言。查询是对数据库中符合一个或多个标准的数据请求。
(3.)数据框具有多个命名列。每一列可以包含不同类型的数据。
(4.)有关 DataFrames 包的更多信息,请参阅dataframes.juliadata.org/。
14 处理向量和矩阵
本章涵盖
-
在矩阵中处理数字并执行计算
-
数组的切片和切块
-
沿不同维度连接数组以形成更大的数组
在第四章中,你探索了一维数组(称为向量)的基本操作,如 push!。在本章中,你将更多地关注处理多维数组,如矩阵。
你可以用矩阵和向量做什么?它们可以组合起来解决大量问题。例如,在几何解释中使用向量很常见;在这种情况下,它们代表空间中的点。你可以使用矩阵来移动和旋转这些点。
矩阵甚至可以用来解决数学方程,并且在机器学习中非常受欢迎。矩阵可以用来表示图像。矩阵中的每个元素都可以代表单个像素的颜色。这些主题每个都值得有自己的章节或书籍,所以在本章中,我将只涵盖与向量和矩阵一起工作的基本知识。
14.1 数学中的向量和矩阵
矩阵或向量不仅仅是一个数字的愚蠢容器。例如,在数学中,集合、元组和向量可能都看起来像数字列表,因此看起来很相似。但你可以做的事情是不同的。
向量和矩阵的研究是数学领域线性代数的一部分。在线性代数中,单个值如 1、4 和 8 被称为标量。而一行或一列中的多个值是向量,表格是矩阵,如果数据按 3D 数组排列,它可能被称为立方体。向量可以进一步分为列向量和行向量(图 14.1)。

图 14.1 不同维度的数组
14.2 从行和列构造矩阵
矩阵可以通过指定堆叠在一起的多个行或依次排列的列来构造。当从行向量构造矩阵时,你用分号;;分隔每一行;注意你不用逗号分隔单个元素。如果你已经忘记了这一点,那么请回顾第四章中关于行向量和列向量的讨论:
julia> table = [2 6 12;
3 4 12;
6 2 12;
12 1 12]
4×3 Matrix{Int64}:
2 6 12
3 4 12
6 2 12
12 1 12
要从多个列创建一个矩阵,你可以分别定义每一列,然后将它们组合成一个矩阵:
julia> x1 = [2, 3, 6, 12]
julia> x2 = [6, 4, 2, 1]
julia> x3 = [12, 12, 12, 12]
julia> table = [x1 x2 x3]
4×3 Matrix{Int64}:
2 6 12
3 4 12
6 2 12
12 1 12
这与内联编写列向量相同:
table = [[2, 3, 6, 12] [6, 4, 2, 1] [12, 12, 12, 12]]
注意 Julia 如何提供结果的 Array 类型摘要,行 4×3 Matrix{Int64}。这告诉你 Julia 创建了一个有 4 行 3 列的数组,其中每个元素都是 Int64 类型。
你可以查询任意数组关于这些属性的信息:eltype 提供数组中每个元素的类型,ndims 提供维度数,size 提供每个维度上的组件(元素)数。通常,我们认为维度是长度、高度和深度,但在这个情况下,我通常会说行和列:
julia> eltype(table) ❶
Int64
julia> size(table) ❷
(4, 3)
julia> ndims(table) ❸
2
❶ 数组中每个元素的类型
❷ 行和列的数量
❸ 维度的数量
图 14.2 通过显示不同形状的向量和矩阵来帮助阐明这些不同属性的含义。它们具有不同数量的行和列,以及不同的方向和维度。

图 14.2 不同形状数组的属性
14.3 数组的尺寸、长度和范数
如果你来自其他编程语言,可能会很容易混淆这些数组概念:
-
size—数组的维度
-
length—数组中元素的总数
-
norm—向量的模
已创建一个 4 行 3 列的表格,总共包含 12 个元素:
julia> length(table)
12
范数函数比较难以理解。为了解释它,我将使用一个包含元素 3 和 4 的小向量:
julia> using LinearAlgebra
julia> norm([3, 4])
5.0
观察一个直角三角形将帮助你可视化范数的操作。你可以将向量的元素视为三角形中的边 a 和 b(图 14.3)。范数提供了最长边的长度,即 斜边。

图 14.3 一个边长为 a, b, 和 h 的直角三角形
勾股定理揭示了直角三角形中所有边之间的关系。你可以将范数视为应用勾股定理来确定 斜边 的长度:
5² = 3² + 4²
14.4 切片和切块数组
Julia 对选择不同维度数组的切片提供了很好的支持。这种灵活性来源于当你使用方括号 [] 访问元素或向其赋值时,会调用 setindex!和 getindex 函数。我现在将探讨这种切片是如何工作的(表 14.1)。
表 14.1 元素访问与 Julia 函数调用的关系
| 语法糖 | 转换为 | 描述 |
|---|---|---|
| xs[i] | getindex(xs, i) | 在索引 i 处获取元素 |
| xs[i,j] | getindex(xs, i, j) | 在第 i 行和第 j 列获取元素 |
| xs[i] = 42 | setindex!(xs, 42, i) | 在索引 i 处设置元素 |
| xs[i,j] = 42 | setindex!(xs, 42, i, j) | 在第 i 行和第 j 列设置元素 |
我将首先简单地查看一维数组上的单个元素访问。图 14.1 说明了如何在单维数组中选择一个或多个元素。虽然该图显示了行向量的选择,但相同的原理也适用于列向量。
你可以在方括号内使用 begin 和 end 来引用向量中的一行或一列的第一个或最后一个元素。在 Julia 中,数组的第一个元素默认索引为 1。然而,在 Julia 中可以创建具有任何起始索引的数组,这使得 begin 关键字非常有用(见图 14.4)。

图 14.4 以不同方式切片一维数组 A
注意到有多种方式可以访问相同的元素。例如,如果你有一个数组 A,那么 A[3] 和 A[begin+2] 就会表示相同的元素。
对于包含四个元素的数组,如前两个示例,A[4] 和 A[end] 指的是相同的元素。同样,A[3] 和 A[end-1] 获取的是同一个数组元素。你可以在 Julia REPL 中尝试这些概念:
julia> A = collect('A':'F')
6-element Vector{Char}:
'A'
'B'
'C'
'D'
'E'
'F'
julia> A[begin+1]
'B': ASCII/Unicode U+0042
julia> A[end-1]
'E': ASCII/Unicode U+0045
julia> A[2:5]
4-element Vector{Char}:
'B'
'C'
'D'
'E'
julia> A[begin+1:end-1]
4-element Vector{Char}:
'B'
'C'
'D'
'E'
如果你不在乎特定的索引,只想获取所有元素,你可以写 A[:]。这与只写 A 有什么不同?所有切片操作都返回数据的副本。这个例子将有助于澄清:
julia> A = [8, 1, 2, 7];
julia> B = A[:];
julia> B[2] = 42
42
julia> B
4-element Vector{Int64}:
8
42
2
7
julia> A
4-element Vector{Int64}:
8
1
2
7
你看到 B 的第二个元素被更改了,但 A 的第二个元素没有变化吗?如果你写的是 B = A 而不是 B = A[:],A 的第二个元素也会被更改,因为 A 和 B 会指向完全相同的数组对象。
但如果你想在不需要复制的情况下选择数组的切片呢?尤其是在处理大量数据时,频繁地在某些紧密的内循环中复制数千个元素可能会严重影响性能。在这些情况下,你可以在数组的子部分中创建一个所谓的“视图”。这些切片不是数组元素的副本,而是那些元素本身。你可以通过使用 @view 宏将切片转换为视图:
julia> B = @view A[3:end]
2-element view(::Vector{Int64}, 3:4) with eltype Int64:
2
7
julia> B[2] = 1331
1331
julia> A
4-element Vector{Int64}:
8
1
2
1331
最后的结果显示,更改 B 的第二个元素导致 A 的第四个元素也被更改。
许多这些示例应该与你之前章节中处理的一维数组相关。当你处理多维数组的切片,如矩阵时,情况会更有趣。
让我们创建一个二维矩阵 A 来进行实验,使用 Julia 的 reshape 函数。reshape 函数接受一个 AbstractArray 作为输入。让我解释下一个代码示例:范围 1:12 被用作输入。所有范围都是 AbstractArray 的子类型,因此 Julia 将范围视为一个包含 12 个元素的二维数组。reshape 函数将这些 12 个元素重新排列成一个 3 行 4 列的矩阵,称为 3×4 矩阵。
julia> A = reshape(1:12, 3, 4)
3×4 reshape(::UnitRange{Int64}, 3, 4) with eltype Int64:
1 4 7 10
2 5 8 11
3 6 9 12
我将演示如何以不同的方式切片矩阵,但首先我会给出一些关于如何思考切片的建议,这样你就可以理解演示了。
重要提示:矩阵的形状是指它有多少行和列。因此,在 Julia 中,改变行和列数量的函数被称为 reshape。请注意,矩阵的长度不能通过 reshape 来改变。你可以将包含六个元素的数组 A 调整为 3 × 2 或 2 × 3 的矩阵,但你不能将其调整为 3 × 3 的矩阵,因为那样会包含九个元素(见图 14.5)。

图 14.5 一个数组可以被重塑为具有相同元素数量的矩阵。交叉表示你不能将包含六个元素的数组重塑为包含九个元素的数组。
我喜欢用集合交集操作 ∩ 来思考数组切片。因此,A[2, 3] 可以读作以下内容:给我所有第 2 行和第 3 列元素的交集。
图 14.6 提供了这个想法的可视化。阴影方块代表你选择的行和列,而深色阴影的方块代表这些行和列选择之间的交集。

图 14.6 二维数组的切片。
这种概念化使得理解选择 A[2:3, 2:4] 更容易。你可以这样读它:给我所有第 2 行到第 3 行和第 2 列到第 4 列元素的交集。
按照这个逻辑,很明显如何在矩阵中选择整个行或列。你可以在 REPL 中进行实验:
julia> A[1, 2]
4
julia> A[3, 4]
12
julia> A[:, 4]
3-element Vector{Int64}:
10
11
12
julia> A[2, :]
4-element Vector{Int64}:
2
5
8
11
值得注意的是,即使是多维数组也可以被视为一维的:
julia> A[1]
1
julia> A[4]
4
14.5 矩阵和向量的组合
数据并不总是以你想要进行矩阵操作的方式和形式出现。你可能拥有 n 个向量,但实际上你希望有一个具有 n 列的矩阵。
幸运的是,Julia 有许多用于连接矩阵的函数。这个第一个例子展示了如何使用 hcat 或 vcat(图 14.7)水平或垂直地连接两个行向量。

图 14.7 行向量的水平垂直连接。
猫函数允许你指定沿着哪个维度进行连接。当你处理高维数组时,这很有用。你可以使用列向量执行类似操作(图 14.8)。

图 14.8 列向量的水平垂直连接。
同样的原则适用于矩阵的组合;你可以沿着任何维度进行连接。水平连接和垂直连接有自己的函数,hcat 和 vcat,因为它们被频繁使用(图 14.9)。

图 14.9 矩阵的水平垂直连接。
这些连接函数可以接受任意数量的参数;你不仅限于两个:
julia> x = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> y = [8, 6, 4]
3-element Vector{Int64}:
8
6
4
julia> hcat(x, y, x, y)
3×4 Matrix{Int64}:
1 8 1 8
2 6 2 6
3 4 3 4
julia> hcat(x, 2y, 2x, 3y)
3×4 Matrix{Int64}:
1 16 2 24
2 12 4 18
3 8 6 12
14.6 创建矩阵
当处理矩阵时,你经常需要特殊类型的矩阵。创建只包含零或一的矩阵如此常见,以至于有专门的函数来做这件事:
julia> zeros(Int8, 2, 3)
2×3 Matrix{Int8}:
0 0 0
0 0 0
julia> ones(2, 3)
2×3 Matrix{Float64}:
1.0 1.0 1.0
1.0 1.0 1.0
注意,你可以选择性地指定每个元素想要的数据类型作为第一个参数。如果你没有指定类型,那么它将默认为 Float64。
创建一整个随机数数组也是常见的做法。例如,在深度学习中,经常使用具有随机值的大矩阵。你经常会使用随机值来创建测试数据:
julia> rand(UInt8, 2, 2)
2×2 Matrix{UInt8}:
0x8e 0x61
0xcf 0x0d
有时候你只想用一个特定的值填充整个矩阵:
julia> fill(12, 3, 3)
3×3 Matrix{Int64}:
12 12 12
12 12 12
12 12 12
向量和矩阵是巨大的主题,如果我们有更多时间,我会介绍向量和矩阵的几何解释。有哪些可能的用途?你可以用一个矩阵表示火箭的方向,用向量表示其位置。你可以使用矩阵在坐标系中旋转或移动火箭。对于矩阵处理的深入讨论,Bogumił Kamin´ski 的《Julia for Data Analysis》(Manning,2022)是一本极好的资源。
摘要
-
数组可以用来定义列向量、行向量和矩阵。
-
矩阵是可以通过多种方式构建的二维度数组。最常见的是将其定义为一系列行,但也可以将矩阵定义为一系列列。
-
数组具有 ndims、size 和 length 等属性。这些描述了数组的维度数、每个维度上的元素数量以及数组中的总元素数。
-
可以通过指定范围来切片数组,这也适用于矩阵。你可以为行和列指定范围,以切割出子矩阵。
-
切片是数据的副本。如果你不希望切片是副本,而是直接引用原始数组中的数据,那么你可以使用@view 宏创建一个切片视图。
-
可以使用 hcat 和 vcat 在水平和垂直方向上组合矩阵和向量。对于更高维度的数组,你可以使用 cat 并指定要连接的维度作为参数。
-
可以使用 rand、fill、ones 和 zeros 等函数快速创建矩阵。
第四部分 软件工程
软件工程是关于我们如何组织和结构化更大的程序,以便它们更容易维护、修改和演进。第十五章涵盖了函数式编程以及它是如何帮助创建更易于维护的软件并鼓励对代码思考的新视角。
第十六章专注于将软件物理组织成模块、目录和文件,并将其与依赖管理联系起来。大型软件通常由不同团队制作的许多包组成。这部分专注于开发一个稳固的系统来处理相互依赖的软件包的版本管理。
15 Julia 中的函数式编程
本章涵盖
-
为什么在 Julia 中理解函数式编程很重要
-
函数式编程与面向对象程序设计之间的区别
-
高阶函数的实际应用
-
使用函数链提高代码的可读性
-
开发密码管理服务
Julia 是一种多范式编程语言,但在 Julia 中,函数式编程风格比你在可能熟悉的其他主流语言(如 Python、Ruby、Java 或 C++)中更为常见。因此,了解函数式编程的原则对于成为一名优秀的 Julia 开发者来说是自然而然的。
函数式编程并不总是解决每个问题的最佳方法。在本章中,你将学习以面向对象和函数式风格构建密码管理服务,让你能够探索不同编程风格(范式)的优缺点。在构建更大的代码示例之前,你将查看函数式编程的核心构建块,如高阶函数、闭包、函数链和组合。
15.1 函数式编程与面向对象编程有何不同?
让我们从高层次的角度来看一下函数式编程是什么以及为什么它在 Julia 编程书中被涵盖。讨论函数式编程的第一个问题是,没有单一的明确定义。在本章中,我将使用我认为是实用主义的方法。图 15.1 展示了函数式编程如何与其他编程范式相结合。

图 15.1 不同编程范式及其相互关系图
我想在图 15.1 中强调的最重要的一点是,过程式编程和函数式编程不是同一回事。用一大堆函数编写代码而不是使用面向对象的方法并不能自动使你的代码成为函数式。这种技术已经在 C、Fortran 和 Pascal 等语言中使用了很长时间,称为过程式编程。
相反,函数式编程通常涉及各种不同的实践和方法:
-
将函数作为一等对象处理,这意味着你可以传递函数并将它们存储为常规数据
-
支持高阶函数,即接受函数作为参数的函数
-
在迭代集合时使用 map、filter 和 reduce 而不是 for 循环来对它们执行不同的操作
-
优先选择闭包或lambda而不是具有方法的对象来管理状态
函数式编程提供了许多不同的方法来组合函数,并在函数级别模块化你的代码。
15.2 如何以及为什么你应该学会以函数式的方式思考
在函数式编程中,我们试图避免修改(改变)输入数据,这使得推理代码中数据流变得更容易。函数接受输入并将这些输入转换成输出,这允许您将程序视为数据流过的复杂管道。
以下列表使用第十一章中的 camel_case 函数来说明这个概念,这是一个多个函数调用的嵌套。每个调用都为下一个函数提供输入。
列表 15.1 将蛇形命名法转换为驼峰命名法
"Turns hello_to_you into HelloToYou"
function camel_case(input::AbstractString)
join(map(uppercasefirst, split(input, '_')))
end
我们可以使用数据流图来可视化函数之间数据流动的方式。数据流图中的圆形框代表数据转换,箭头标注了沿着它们流动的数据类型。例如,字符串和字符数据流入 split,然后字符串数组从 split 流出并进入 map。
命令式编程风格,如面向对象编程,通常使得对数据流的这种分析变得非常困难,因为函数或方法会改变它们的输入。与其思考数据流如何被转换,不如将这个过程概念化为对象相互发送消息以改变它们的状态。
15.3 避免使用函数链进行深度嵌套调用
在构建展示函数式编程优缺点的大型代码示例之前,我希望您更好地掌握一些您可用的基本构建块。上一节中的 camel_case 函数以相当函数式的方式实现,但阅读起来并不容易,因为它深度嵌套。您最终得到的并不像图 15.2 中的整洁管道。

图 15.2 camel_case 函数的数据流
然而,使用 Julia 的管道操作符 |> 可以构建类似该图的东西。它允许您将一个函数的输出传递到另一个函数中。以下是一个完整的示例,我将在之后对其进行分解。
列表 15.2 通过函数链实现驼峰命名法
splitter(dlm) = str -> split(str, dlm)
mapper(fn) = xs -> map(fn, xs)
function camel_case(input::AbstractString)
input |> splitter('_') |> mapper(uppercasefirst) |> join
end
在 REPL 中,您可以试验分隔符和映射器函数的工作方式:
julia> f = splitter('_')
#13 (generic function with 1 method)
julia> words = f("hello_how_are_you")
4-element Vector{SubString{String}}:
"hello"
"how"
"are"
"you"
julia> g = mapper(uppercasefirst)
#15 (generic function with 1 method)
julia> g(words)
4-element Vector{String}:
"Hello"
"How"
"Are"
"You"
要理解列表 15.2,您需要了解 -> 和 |> 操作符。-> 操作符用于在 Julia 中定义所谓的 匿名函数。
15.3.1 理解匿名函数和闭包
匿名函数是没有名称的函数。您可以使用 -> 操作符创建一行匿名函数。如果没有匿名函数,您将需要编写如以下列表所示的分隔符和映射器。
列表 15.3 没有匿名函数的分隔符和映射器
"Create a function which splits on `dlm` character"
function splitter(dlm)
function f(str)
split(str, dlm)
end
return f ❶
end
"Create a function applying function `fn` on all its input"
function mapper(fn)
function g(xs)
map(fn, xs)
end
return g ❷
end
❶ 返回值不是必需的;这只是添加了强调 f 被返回。
❷ 这个返回值也不是必需的,但最后表达式仍然被返回。
示例表明,返回的函数的名字并不重要。对于拆分器和映射器的用户来说,这些函数在内部被命名为 f 和 g 并不重要。因此,当名字不重要时,可以使用匿名函数。你可以从第四章中处理度数和正弦的代码示例中取一个,使其更整洁,如下所示。
列表 15.4 使用匿名函数简化代码
degsin(deg) = sin(deg2rad(deg)) ❶
map(degsin, 0:15:90) ❶
map(deg->sin(deg2rad(deg)), 0:15:90) ❷
map(0:15:90) do deg ❸
rads = deg2rad(deg) ❸
sin(rads) ❸
end ❸
❶ 命名函数变体
❷ 单行变体
❸ 多行变体
如果你无法将匿名函数放入单行中,那么 -> 操作符就不实用了。在这些情况下,你可以使用列表 15.4 结尾所示 do-end 形式。
拆分器和映射器返回的 f 和 g 函数被称为闭包。一个 闭包 是一个捕获了一些外部状态(不是作为参数提供的)的函数。f 函数只接受一个字符串 str 作为参数。用于拆分字符串 str 的分隔符 dlm 是从其包围的作用域中捕获的。在这种情况下,拆分器函数定义定义了作用域 f 内部。
g 函数只接受要处理的数据集合 xs。应用于 xs 中每个元素的函数 fn 是从由映射器函数定义的作用域中捕获的。
闭包不需要命名。列表 15.2 中的拆分器和映射器函数返回匿名函数。这些匿名函数也是闭包,因为它们从它们的作用域中捕获变量。事实上,认为闭包只是 匿名函数 的一个花哨术语是一种相当常见的误解。考虑到匿名函数经常被用来定义闭包,这种误解并不令人惊讶。
15.3.2 使用管道操作符 |>
Julia 管道语法用于将接受单个参数作为输入的函数链接在一起。这允许你将如 f(g(x)) 这样的调用重写为 x |> g |> f。
这个事实有助于解释为什么你需要创建拆分器(splitter)和映射器(mapper)函数。正常的拆分(split)和映射(map)函数需要多个输入,因此不能与管道操作符 |> 一起使用。拆分器和映射器返回的函数(闭包)可以在管道中使用:
julia> f = splitter('_');
julia> g = mapper(uppercasefirst);
julia> "hi_there_world" |> f
3-element Vector{SubString{String}}:
"hi"
"there"
"world"
julia> "hi_there_world" |> f |> g
3-element Vector{String}:
"Hi"
"There"
"World"
julia> "hi_there_world" |> f |> g |> join
"HiThereWorld"
下一个重要的函数概念,部分应用,是通过简单地提出以下问题而得到的:为什么你需要使用 splitter 和 mapper 的名字?难道你不能直接将它们命名为 split 和 map 吗?
15.3.3 使用部分应用方便地产生新函数
在计算机科学中,部分函数应用指的是将一定数量的参数固定到函数中,从而产生另一个接受较少参数的函数的过程。这种部分应用的定义可能听起来很复杂,但通过一个实际例子,你会发现它比听起来容易得多。
如果你导入了 split 和 map 函数,你可以为它们添加只接受单个参数的新方法,因此你可以定义 camel_case 函数的一个稍微更优雅的版本,如下所示。
列表 15.5 使用部分应用实现驼峰式命名
import Base: split, map ❶
split(dlm::Char) = s -> split(s, dlm) ❷
map(fn::Function) = xs -> map(fn, xs) ❸
function camel_case(s)
s |> split('_') |> map(uppercasefirst) |> join
end
❶ 允许我们扩展split和map函数
❷ 修复split函数的分隔符参数dlm
❸ 修复map函数的映射函数fn
实质上,您允许split函数的用户固定dlm参数。新的split函数作为固定的dlm值返回。同样的原则适用于map。将特定参数固定到函数的过程是部分函数应用。
由于这种能力非常实用,Julia 标准库中的许多函数都进行了扩展,方法接受所有必需参数的子集,而不是返回接受其余参数的函数。让我通过 Base 模块(始终加载的内置 Julia 模块)的一些内置函数来澄清:
julia> images = ["bear.jpg", "truck.png", "car.jpg", "duck.png"];
julia> findfirst(img->img == "truck.png", images)
2
julia> findfirst(==("truck.png"), images)
2
julia> filter(img->endswith(img, ".png"), images)
2-element Vector{String}:
"truck.png"
"duck.png"
julia> filter(endswith(".png"), images)
2-element Vector{String}:
"truck.png"
"duck.png"
您可以亲自验证,当endswith函数只提供一个参数时,它会调用一个返回函数的方法:
julia> ispng = endswith(".png")
julia> ispng isa Function
true
julia> ispng("truck.png")
true
julia> ispng("car.jpg")
false
这些是一些混合匹配函数的巧妙示例,这是函数式程序员所做的大量工作的一部分。
15.4 实现凯撒密码和替换密码
我承诺将演示如何以面向对象和函数式风格构建密码加密服务。在我这样做之前,我需要解释您如何使用密码来加密和解密密码。
密码是一种算法,它接受称为消息的输入,并使用一个密钥加密消息,生成称为密文的内容。当您可以使用相同的密钥进行加密和解密时,这被称为对称加密(图 15.3)。

图 15.3 使用密钥进行对称加密
我将演示实现两种不同的密码:凯撒密码和替换密码(图 15.4)。通过制作两种密码,我将向您展示如何配置密码保管服务以使用不同的密码。使密码可交换需要创建抽象,这提供了一个比较使用面向对象原则和函数式编程原则构建抽象的机会。

图 15.4 凯撒密码的内圈盘可以旋转。替换密码的内圈盘是固定的,但字母表的顺序将是随机的。
每个密码算法都是基于在外圈盘上查找输入消息中的字母。接下来,您将查看内圈盘上的相应字母,以确定它在密文中应该翻译成什么。
在凯撒密码中,您可以看到字母 A 将翻译成 C,而 B 将翻译成 D,依此类推。凯撒密码的内圈和外圈盘都是字母表。密码密钥是您旋转内圈盘(顺时针移动两个字母)的量。
当罗马将军们互相发送秘密信息时,每位将军都必须知道这个秘密密钥,才能知道内部拨盘旋转了多少。如果敌人得知这个秘密,他们就能解密任何截获的信息。
代换密码更复杂,因为内部拨盘本身是秘密密钥。你不需要旋转拨盘,而是完全替换它。为了两个当事人能够互相发送秘密信息,他们需要安装相同的内部拨盘。我们将代换密码描述为两个字母表之间的映射。外部拨盘上的字母形成一个字母表,它映射到内部拨盘上的字母;这被称为代换字母表。
我将首先展示两种密码的简单实现,然后再展示一个不灵活的密码保管服务,该服务与单个密码硬编码在一起。下一步是展示如何修改密码和密码保管服务,以便可以更换用于加密和解密密码的密码。在第一种方法中,我将展示如何使用面向对象的设计来实现这个目标,然后我将演示一种功能性的方法。
15.4.1 实现凯撒密码
在列表 15.6 中,我演示了实现凯撒密码。旋转的字母数量通过 shift 参数传递,ch 是要加密或解密的字符。对于 caesar_encrypt 和 caesar_decrypt,你需要检查输入字符 ch 是否实际上在字母表中。这确保了特殊符号和空白字符不会被加密或解密。
列表 15.6 凯撒密码加密和解密
# count number of letters in the English alphabet
const n = length('A':'Z')
function caesar_encrypt(ch::Char, shift::Integer)
if ch in 'A':'Z' ❶
'A' + mod((ch - 'A') + shift, n) ❷
else
ch ❸
end
end
function caesar_decrypt(ch::Char, shift::Integer)
if ch in 'A':'Z'
'A' + mod((ch - 'A') - shift, n)
else
ch
end
end
❶ 忽略不在字母表中的字符。
❷ 使用 mod 在字母表末尾时产生环绕。
❸ 字符不在字母表中,所以返回值不变。
在图 15.4 中,你会注意到我们得到了一个环绕效果。字母表末尾的字母,如 Y 和 Z,映射到字母表开头的 A 和 B。这就是为什么你不能只是给每个字母加上一个 Shift,ch + shift。模函数(取模运算符)让你的数字工作得像你在 12 小时模拟时钟上看到的那样:
julia> mod(1, 12)
1
julia> mod(9, 12)
9
julia> mod(13, 12) ❶
1
julia> mod(21, 12) ❶
9
julia> mod(17, 12) ❶
5
❶ 输入大于 12,所以它环绕。
在这个例子中,我将 ch - 'A'输入到 mod 中,这样我就可以将字母转换成 0 到 25 的值。这使得计算环绕值更容易。之后,我需要将 0 到 25 的数字转换成字母。幸运的是,Julia 在数字和字母之间的数学运算已经设置为以可预测的方式为你完成这项工作,如下一个 REPL 会话所示:
julia> 'A' + 1
'B': ASCII/Unicode U+0042
julia> 'A' + 4
'E': ASCII/Unicode U+0045
julia> ch = 'Z'; n = 26; shift = 2 ❶
2
julia> 'A' + mod((ch - 'A') + shift, n) ❷
'B': ASCII/Unicode U+0042
❶ 你可以用分号分隔语句。
❷ Z 环绕成为 B。
你可能会注意到我正在使用与图 15.4 中相同的位移值。
现在你已经知道如何加密单个字符,但你是如何使用这些知识来加密和解密整个消息的呢?你可以使用 map 函数来加密一条消息,然后尝试在之后解密它,以确保你得到你输入的内容:
julia> message = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG";
julia> shift = 2;
julia> cipher_text = map(ch -> caesar_encrypt(ch, shift), message)
"VJG SWKEM DTQYP HQZ LWORU QXGT VJG NCBA FQI"
julia> map(cipher_text) do ch
caesar_decrypt(ch, shift)
end
"THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
注意我使用了 do-end 形式来调用解密,这样你可以比较代码的可读性。有时我发现 do-end 形式更容易阅读,即使只是处理一行代码。
15.4.2 实现替换密码
要创建替换密码,我需要在两个字母表之间创建一个映射。为此,我需要使用在 Random 模块中找到的 shuffle 函数。
注意以下代码中使用了 'A':'Z' 的范围来快速创建包含字母表中所有字母的字符串。将 collect 应用到此范围将给出一个字母数组,但在此情况下,我需要一个字符串,所以我使用 join。
shuffle 将随机重新排列数组中的元素。记住,range 是 AbstractArray 的子类型,这就是为什么你可以像处理常规数组一样对 range 进行 shuffle:
julia> using Random
julia> join('A':'Z')
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
julia> shuffle([1, 2, 3])
3-element Vector{Int64}:
2
3
1
julia> shuffle(1:5)
5-element Vector{Int64}:
1
2
5
4
3
julia> join(shuffle('A':'Z'))
"PKTAVEQDXGWJMBZOFSLICRUNYH"
这些函数提供了在两个字母表之间创建字典映射所需的工具。在创建字典时,通常需要键值对,但在以下代码中,键和值作为单独的数组创建,那么我如何从这些中创建一个字典呢?
julia> alphabet = join('A':'F') ❶
"ABCDEF"
julia> substitute = join(shuffle('A':'F'))
"ACFBDE"
❶ 使用 A-F 的范围来缩短示例。
zip 函数解决了这个问题。zip 可以接受两个元素数组,并将它们转换成一个可迭代对象,当收集时提供一个成对的数组:
julia> collect(zip(alphabet, substitute))
6-element Vector{Tuple{Char, Char}}:
('A', 'A')
('B', 'C')
('C', 'F')
('D', 'B')
('E', 'D')
('F', 'E')
你可以将 zip 函数返回的可迭代对象传递给 Dict 构造函数。因为此可迭代对象在每次迭代时返回值对,所以我会使用 zip 返回的对象来创建一个字典:
julia> mapping = Dict(zip(alphabet, substitute))
Dict{Char, Char} with 6 entries:
'C' => 'F'
'D' => 'B'
'A' => 'A'
'E' => 'D'
'F' => 'E'
'B' => 'C'
这提供了可以用于替换密码加密函数的输入数据。
列表 15.7 基于字典查找的替换密码加密
function substitution_encrypt(ch::Char, mapping::Dict{Char, Char})
get(mapping, char, char)
end
这种方法有一个明显的问题:解密需要反向查找。将映射视为等同于加密密钥是一个糟糕的解决方案。对于对称加密,最好使用相同的密钥进行加密和解密。因此,而不是使用字典进行查找,我将搜索一个成对的数组,如下所示。
列表 15.8 基于数组查找的替换密码加密
using Random
alphabet = join('A':'Z')
substitute = join(shuffle('A':'Z'))
mapping = collect(zip(alphabet, substitute))
function substitution_encrypt(ch::Char, mapping::Vector)
i = findfirst(row->first(row) == ch, mapping) ❶
if isnothing(i) ❷
ch
else
mapping[i][2] ❸
end
end
function substitution_decrypt(ch::Char, mapping::Vector)
i = findfirst(row->last(row) == ch, mapping)
if isnothing(i)
ch
else
mapping[i][1]
end
end
❶ 找到第一个字符等于 ch 的索引。
❷ 如果字符不在映射中,则不返回任何内容。
❸ 返回映射行 i 的第二个字符。
解决方案与凯撒密码解决方案类似,即如果字符 ch 不在字母表中,则不需要执行任何加密或解密操作。它基于 findfirst 函数的线性搜索来查找你感兴趣键的元组索引。如果 row 是元组('B', 'Q'),那么 first(row)是'B',而 last(row)是'Q'。在加密时,首先使用 first(row)作为查找键,然后在使用解密时使用 last(row)作为查找键。
你可能会问,通过数组进行线性搜索难道不会比使用字典查找慢得多吗? 不。对于这样短的简单值数组,线性搜索会更快。你至少需要 100 个条目,查找性能才会有明显差异。
15.5 创建一个与加密算法无关的服务
想象你有一个使用加密的服务,并希望使其更容易更换所使用的加密方式。在这里,我将提供一个玩具示例来传达使用密码存储服务的概念。它维护一个以登录名作为键、加密密码作为值的字典。
列表 15.9 密码存储服务
mutable struct Vault ❶
passwords::Dict{String, String}
shift::Int64
end
function Vault(shift::Integer)
emptydict = Dict{String, String}() ❷
Vault(emptydict, shift)
end
function addlogin!(vault::Vault,
login::AbstractString,
password::AbstractString)
vault.passwords[login] = map(password) do ch ❸
encrypt(ch, vault.shift)
end
end
function getpassword(vault::Vault, login::AbstractString)
map(ch -> decrypt(ch, vault.shift), vault.passwords[login]) ❹
end
❶ 保险库被设置为可变的,因为必须能够添加密码。
❷ 使用空密码字典进行初始化。
❸ 将加密密码添加到密码字典中。
❹ 查找登录名对应的密码。
虽然代码可以工作,但这种方法存在许多问题:
-
它被硬编码为仅支持一种加密方案:使用凯撒密码。应该有选择任何加密方案的能力。
-
该服务假设加密,解密是逐字符进行的。加密应该被泛化以处理整个字符串,因为加密不一定是字符替换。
15.6 使用面向对象编程构建加密服务
在代码列表 15.9 中,加密是硬编码的;你希望能够用不同的加密方式替换它。让我们看看如何实现这一点。
解决方案是一个对密码加密的抽象接口,因此密码服务的用户不需要了解每种加密类型的具体细节。列表 15.10 展示了对此问题的面向对象方法。首先,我将 Cipher 定义为一个抽象类型,它必须支持一系列函数。我将为这些函数中的每一个添加方法以支持我特定的加密。
列表 15.10 定义抽象密码接口
abstract type Cipher end
function encrypt(cipher::Cipher, char::Char)
error("Implement encrypt(::", typeof(cipher), ", char)")
end
function decrypt(cipher::Cipher, char::Char)
error("Implement decrypt(::", typeof(cipher), ", char)")
end
function encrypt(cipher::Cipher, message::AbstractString)
map(ch -> encrypt(cipher, ch), message)
end
function decrypt(cipher::Cipher, ciphertext::AbstractString)
map(ch -> decrypt(cipher, ch), ciphertext)
end
这种代码的设置方式使得实现加密和解密对于消息字符串和密文字符串是可选的。默认实现将使用单字符的加密和解密。然而,在你的代码中,如果你没有实现这些,当你尝试使用你的加密方式执行加密或解密时,你会得到一个错误信息。
你可能会注意到,我在这里指定接口的方法与第十二章中采取的方法略有不同。通常推荐的方法是定义函数并记录你的 API 用户应该实现的方法。在这里,我明确地提供了一个错误信息,说明了你需要做什么。了解这两种实践都是有用的。在这种情况下,我认为这更实用,因为处理字符串和处理单个字符的函数是相同的。首先,我将实现凯撒密码的 Cipher 接口。
列表 15.11 实现 Cipher 接口的凯撒密码
struct CaesarCipher <: Cipher
shift::Int
end
const n = length('A':'Z')
function encrypt(cipher::CaesarCipher, ch::Char)
if ch in 'A':'Z'
'A' + mod((ch - 'A') + cipher.shift, n)
else
ch
end
end
function decrypt(cipher::CaesarCipher, ch::Char)
if ch in 'A':'Z'
'A' + mod((ch - 'A') - cipher.shift, n)
else
ch
end
end
这种新的凯撒密码实现几乎与之前的实现(列表 15.6)完全相同,只是在这次示例中,我从加密对象中获取偏移量,而不是直接获取。
列表 15.12 展示了面向对象的替换密码。它与原始实现(列表 15.8)相似,除了我将映射存储在 SubstitutionCipher 对象中,调用加密和解密时需要传递加密对象而不是映射。
列表 15.12 实现 Cipher 接口的替换密码
using Random
struct SubstitutionCipher <: Cipher
mapping::Vector{Tuple{Char, Char}} ❶
function SubstitutionCipher(substitute)
mapping = zip('A':'Z', collect(substitute)) ❷
new(collect(mapping)) ❸
end
end
function encrypt(cipher::SubstitutionCipher, ch::Char)
i = findfirst(row->first(row) == ch, cipher.mapping)
if isnothing(i)
ch
else
cipher.mapping[i][2]
end
end
function decrypt(cipher::SubstitutionCipher, ch::Char)
i = findfirst(row->last(row) == ch, cipher.mapping)
if isnothing(i)
ch
else
cipher.mapping[i][1]
end
end
❶ 用于查找字符应该加密为什么
❷ 创建一个字符对列表。
❸ 创建一个 SubstitutionCipher 实例。
现在,你可以修改你的密码存储服务,使其指向一个抽象的加密算法,而不是具体的加密算法(列表 15.13)。这允许你用任何实现 Cipher 接口的具体加密算法来替换所使用的加密算法。
列表 15.13 通用密码算法密码存储服务
mutable struct Vault
passwords::Dict{String, String}
cipher::Cipher
end
function Vault(cipher::Cipher)
Vault(Dict{String, String}(), cipher)
end
function addlogin!(vault::Vault, login::AbstractString,
➥ password::AbstractString)
vault.passwords[login] = encrypt(vault.cipher, password)
end
function getpassword(vault::Vault, login::AbstractString)
decrypt(vault.cipher, vault.passwords[login])
end
我现在可以用不同的加密算法尝试升级后的密码存储服务。我将首先使用凯撒密码来展示一个例子。我首先创建一个保险库来存储密码。保险库使用它将用于加密和解密存储在其内的密码的加密算法进行初始化。
接下来,我调用 addlogin! 来向保险库中添加密码。之后,我使用 getpassword 来确保我取出的密码与放入的密码相同:
julia> vault = Vault(CaesarCipher(23))
Vault(Dict{String,String}(), CaesarCipher(23))
julia> addlogin!(vault, "google", "BING")
"YFKD"
julia> addlogin!(vault, "amazon", "SECRET")
"PBZOBQ"
julia> getpassword(vault, "google")
"BING"
julia> getpassword(vault, "amazon")
"SECRET"
接下来,我将通过替换密码的例子进行展示。在这种情况下,我使用替换字母表初始化替换密码。你可以看到,字母 ABC 将被替换为 CQP:
julia> substitute = "CQPYXVFHRNZMWOITJSUBKLEGDA";
julia> cipher = SubstitutionCipher(substitute);
julia> vault = Vault(cipher);
julia> addlogin!(vault, "amazon", "SECRET")
"UXPSXB"
julia> addlogin!(vault, "apple", "JONAGOLD")
"NIOCFIMY"
julia> getpassword(vault, "amazon")
"SECRET"
julia> getpassword(vault, "apple")
"JONAGOLD"
15.7 使用函数式编程构建加密服务
首先展示如何使用面向对象的方法实现抽象的目的,是因为更多的程序员已经熟悉这种方法。在这种情况下,面向对象意味着我通过考虑类型层次和对象来解决问题。我将加密算法表示为一个对象,并定义了在加密对象上操作的方法。
使用函数式方法,我将致力于通过考虑函数(高阶函数和闭包)来解决问题。目的是让你了解两种不同的编程问题解决思路。要成为一名优秀的 Julia 程序员,你需要理解这两种思路。
15.7.1 定义功能凯撒密码
我将首先使用列表 15.5 中首次展示的部分应用技术来定义凯撒密码。这种方法允许扩展使用 caesar_encrypt 和 caesar_decrypt 函数(列表 15.6)开发的原始解决方案。
注意在列表 15.14 中,代码中不再有密码类型。没有表示凯撒密码的数据对象。相反,我正在向 caesar_encrypt 和 caesar_decrypt 添加新方法以允许部分应用,因此当只提供一个移位参数时,我将返回一个接受字符而不是加密文本中的字符的函数。
列表 15.14 带有函数风格的凯撒密码
const n = length('A':'Z')
# Original cipher functions
function caesar_encrypt(ch::Char, shift::Integer)
if ch in 'A':'Z'
'A' + mod((ch - 'A') + shift, n)
else
ch
end
end
function caesar_decrypt(ch::Char, shift::Integer)
caesar_encrypt(ch, -shift) ❶
end
# Implement a functional interface using partial application
function caesar_encrypt(shift::Integer)
msg -> map(msg) do ch ❷
caesar_encrypt(ch, shift)
end
end
function caesar_decrypt(shift::Integer)
msg -> map(msg) do ch ❷
caesar_decrypt(ch, shift)
end
end
❶ 避免实现几乎相同代码的技巧
❷ 返回捕获移位的闭包
让我们看看这些函数是如何使用的。我将首先使用 1 的移位值调用 caesar_encrypt。它返回一个用于加密的函数。然后,我将使用该函数加密文本字符串 "ABC"。创建和解密函数的类似模式也被使用:
julia> encrypt = caesar_encrypt(1)
julia> encrypt("ABC")
"BCD"
julia> decrypt = caesar_decrypt(1)
julia> decrypt("BCD")
"ABC"
julia> encrypt('A') ❶
0-dimensional Array{Char, 0}:
'B'
julia> decrypt('B') ❶
0-dimensional Array{Char, 0}:
'A'
❶ 这是因为 map 可以映射到单个字符。
这种解决方案的一个优点是,它很容易使用管道操作符 |> 连接结果:
julia> "HELLO" |> caesar_encrypt(2)
"JGNNQ"
julia> "HELLO" |> caesar_encrypt(2) |> caesar_decrypt(2)
"HELLO"
15.7.2 定义功能替换密码
为了制作替换密码,我将在列表 15.8 中编写的替换密码代码的基础上进行扩展。我将再次使用部分应用技术,向现有的 substitution_encrypt 和 substitution_decrypt(列表 15.15)添加两个方法。它们只接受映射作为参数,但返回加密或解密消息的加密函数。
列表 15.15 带有函数风格的替换密码
function substitution_encrypt(mapping::Vector)
msg -> map(msg) do ch
substitution_encrypt(ch, mapping)
end
end
function substitution_decrypt(mapping::Vector)
msg -> map(msg) do ch
substitution_decrypt(ch, mapping)
end
end
我将以类似凯撒密码的方式使用替换密码。主要区别在于,我使用映射而不是移位作为密码密钥:
julia> alphabet = join('A':'Z');
julia> substitute = join(shuffle('A':'Z'));
julia> mapping = collect(zip(alphabet, substitute))
julia> "HELLO" |> substitution_encrypt(mapping)
"NEPPR"
julia> "HELLO" |> substitution_encrypt(mapping) |>
➥ substitution_decrypt(mapping)
"HELLO"
现在,您已经知道了基于功能设计原则制作密码保管库的各个部分。
15.7.3 实现功能密码保管服务
现在让我们将所有这些放在一起,创建一个使用加密和解密函数来允许存储和检索登录和密码的密码保管库。有多种实现方式。在列表 15.16 中,我将故意做得过分,以与面向对象解决方案形成强烈对比。
列表 15.16 过度功能风格的密码保管服务
function makevault(encrypt::Function, decrypt::Function)
passwords = Dict{String, String}()
function addlogin(login::AbstractString, password::AbstractString)
passwords[login] = encrypt(password)
end
function getpassword(login::AbstractString)
decrypt(passwords[login])
end
addlogin, getpassword
end
让我们看看使用此实现定义基于凯撒密码的密码保管库的示例。保险库通过调用 caesar_encrypt 和 caesar_decrypt 分别初始化为两个函数对象:
julia> addlogin, getpasswd = makevault(
caesar_encrypt(2),
caesar_decrypt(2));
julia> addlogin("google", "SECRET")
"UGETGV"
julia> addlogin("amazon", "QWERTY")
"SYGTVA"
julia> getpasswd("google")
"SECRET"
julia> getpasswd("amazon")
"QWERTY"
使用替换密码时,需要设置更多的映射向量。在其他所有方面,密码保管库的设置方式与凯撒密码相同:
julia> using Random
julia> alphabet = join('A':'Z')
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
julia> substitute = join(shuffle('A':'Z'))
"TQRBVPMHNFUESGZOLIDXCAWYJK"
julia> mapping = collect(zip(alphabet, substitute));
julia> addlogin, getpasswd = makevault(
substitution_encrypt(mapping),
substitution_decrypt(mapping));
julia> addlogin("google", "SECRET")
"DVRIVX"
julia> addlogin("amazon", "QWERTY")
"LWVIXJ"
julia> getpasswd("google")
"SECRET"
julia> getpasswd("amazon")
"QWERTY"
现在是时候退后几步,反思一下为什么你想要以示例中所示的方式设计闭包了。目标是与面向对象的情况相同:向加密提供通用接口,这样你就可以在不更改密码管理实现的情况下更改所使用的加密方式。
我通过返回加密和解密函数来实现这一点,这些函数在其函数签名中不暴露任何实现细节。函数签名指的是函数接受的参数、它们的顺序和类型。凯撒密码和替换密码产生具有相同签名的加密和解密函数。这就是为什么它们可以互换使用。
我试图使每个展示的解决方案尽可能不同,以更清楚地说明函数式编程和面向对象编程之间的差异。有时夸张可以有助于传达观点。然而,对于实际解决方案,你应该始终尝试使用良好的品味,并在面向对象和函数式方法之间找到一个合理的平衡。
在制作密码管理解决方案的情况下,我认为面向对象的方法更优越,而将蛇形命名法转换为驼峰命名法与函数式方法配合得非常好。为什么会有这种差异?当你的问题可以简化为某种数据转换时,函数式编程工作得非常好。相反,当你处理本质上具有状态的东西,比如密码管理服务时,面向对象的方法更自然,因为面向对象编程的全部都是修改状态。
摘要
-
功能性和过程式编程通常被认为是可以互换的,但它们并不相同。在函数式编程中,函数是一等对象,可以用高阶函数传递和处理。
-
以功能为导向的代码更容易分析和调试,因为它形成了一个更清晰的数据流。
-
可以通过使用函数链操作符 |> 解决难以阅读的函数调用嵌套。
-
匿名函数是没有名称的函数。它们有助于简化内联闭包的创建。
-
可以使用 -> 操作符或 do-end 形式创建匿名函数。
-
闭包是一个捕获其封装作用域状态的函数;它是一个具有记忆的函数。闭包可以用来模拟具有状态的对象,促进部分应用,以及管理资源获取和释放(打开和关闭文件)。
-
部分应用是一种技术,当没有向函数提供所有参数时,你返回一个函数而不是一个结果。这简化了创建用于高阶函数(如 map、filter 和 reduce)的函数参数。
-
数组中的元素可以使用来自 Random 内置模块的 shuffle 函数随机打乱。许多加密算法需要随机打乱的输入,这也是测试排序函数输入的好方法。
-
在 Julia 中,结合函数式和面向对象的技术以获得最佳结果。不同的问题需要不同的方法。通过实践,你会培养出更好的直觉,知道何时一种方法比另一种方法更优。
16 组织和模块化你的代码
本章涵盖
-
介绍环境概念以管理依赖项
-
使用环境添加和删除包依赖项
-
在不同环境中开发代码并在它们之间切换
-
创建 Julia 包并向其中添加依赖项
-
在包内组织代码
-
向包中添加测试
-
探索模块和包之间的关系
对于大规模软件开发,你不能像前几章所展示的那样随意地将代码直接放入 Julia 源代码文件中。相反,代码必须进行模块化和组织。组织良好的代码更容易理解和导航。在本章中,你将学习如何将之前工作的几何代码组织成一个 Julia 包。
包很有用,因为它们为你提供了一种捆绑相关代码、分发它和进行版本控制的方法。包可以在复杂的依赖树中依赖于其他包。现实世界系统是通过将多个包组合成一个更大的系统来构建的。如果你在一个大型组织中的广泛项目中工作,那么不同的团队可能会创建不同的包,然后这些包将被组合起来以创建完整的系统。
在本章中,我将教你如何在包内组织代码,向其他包添加和删除依赖项,并为你的包设置测试。请记住使用如 using Statistics 和 using LinearAlgebra 之类的语句加载模块。我还会解释模块概念与包概念之间的关系。
16.1 设置工作环境
在开发软件时,你可能希望使用相同包的不同版本。当你为工作场所编写 Julia 代码时,你可能希望使用每个包最稳定和经过充分测试的版本;这可能对你的业余项目不是那么关键,你可能更愿意使用带有最新酷炫功能的最新版本。
Julia 环境允许你在具有不同包版本的工作和业余环境设置之间切换。我使用工作这个词而不是工作,以避免与工作目录混淆,工作目录指的是你当前正在工作的目录。在本节中,你将查看以下内容:
-
创建环境
-
向环境中添加和删除不同版本的包
-
激活和在不同环境之间切换
-
理解模块和包之间的关系
创建环境只是创建一个目录来存放每个环境的事情。在这个例子中,我将启动 Julia 并进入 shell 模式,通过按;键,然后我将发出 Unix shell 命令来创建目录。使用图形文件管理器,如 Finder 或文件资源管理器,是完全有效的替代方法:
shell> mkdir job hobby ❶
shell> ls ❷
hobby job
❶ 创建工作和业余目录。
❷ 列出当前目录内容。
记住,在 Julia REPL 中,你可以通过按 ] 进入包模式。在包模式下,你可以执行命令以激活不同的环境并向其添加包。当你切换到环境时,提示符将改变以显示你所在的环境。例如,如果你在 job 环境中,提示符将看起来像 (job) pkg>。在这个例子中,我将激活 job 环境,因此所有后续命令都将修改 job 环境:
(@v1.7) pkg> activate job
(job) pkg>
我将向这个环境添加一些包依赖。为了演示目的,添加哪些 Julia 包并不重要,但我选择使用 CairoMakie (makie.juliaplots.org) 绘图包和链接 ElectronDisplay (github.com/queryverse/ElectronDisplay.jl) 包来在窗口中显示图表。如果你使用 Visual Studio Code,你不需要 ElectronDisplay 包,因为它已经可以显示任何 Julia 图表。
注意,CairoMakie 是 Makie 相关 Julia 绘图包集合的一部分,称为 Makie。所有 Makie 包为用户提供相同的数据类型和函数,唯一的区别是生成的图形类型。CairoMakie 提供了创建高质量 2D 向量图的能力,而 GLMakie 则允许创建交互式 3D 图表。
假设你喜欢在你的业余项目中使用 CairoMakie 的最新版本,但你的雇主比较保守。因此,你必须在工作中使用版本 0.5.10:
(job) pkg> add CairoMakie@0.5.10 ❶
Updating registry at `~/.julia/registries/General.toml`
Resolving package versions...
[13f3f980] + CairoMakie v0.5.10
Updating `~/dev/job/Manifest.toml`
(job) pkg> add ElectronDisplay ❷
Resolving package versions...
Updating `~/dev/job/Project.toml`
[d872a56f] + ElectronDisplay v1.0.1
Updating `~/dev/job/Manifest.toml`
(job) pkg> status ❸
Status `~/dev/job/Project.toml`
[13f3f980] CairoMakie v0.5.10
[d872a56f] ElectronDisplay v1.0.1
❶ 将 CairoMakie 版本 0.5.10 添加到工作环境中。
❷ 添加 ElectronDisplay 的最新版本。
❸ 检查添加到这个环境中的包。
当你运行添加包的命令时,你会看到比这里显示的更多信息。我编辑掉了大部分内容,因为它可能会填满几页,但我保留了最重要的信息。
当调用 add ElectronDisplay 时,你会被告知 ~/dev/job/Project.toml 文件已被修改。这是什么文件?我的工作环境位于 ~/dev/job 目录下。如果你当前活跃的环境中没有 Project.toml 文件,那么 Julia 会为你创建一个,以存储你添加到环境中的包信息。
ElectronDisplay v1.0.1 行会告诉你安装了哪个版本的 ElectronDisplay。那是在我写这本书时的 2022 年的最新版本。
注意到添加 CairoMakie 的命令略有不同。我在包名后附加了 @0.5.10,以告知 Julia 的包管理器,我想要版本 0.5.10,而不是 CairoMakie 的当前最新版本。当切换到业余环境时,我将使用最新版本:
(work) pkg> activate hobby
Activating new project at `~/dev/hobby`
(hobby) pkg> add CairoMakie ElectronDisplay
Resolving package versions...
Updating `~/dev/hobby/Project.toml`
[13f3f980] + CairoMakie v0.7.5
[d872a56f] + ElectronDisplay v1.0.1
Updating `~/dev/hobby/Manifest.toml`
(hobby) pkg> status
Status `~/dev/hobby/Project.toml`
[13f3f980] CairoMakie v0.7.5
[d872a56f] ElectronDisplay v1.0.1
在业余环境中,我使用了略有不同的添加命令。我列出了所有我想添加的包,这样我就可以一次性完成。之后,Julia 通知我已安装了 CairoMakie v0.7.5 包。
你始终可以使用 status 命令来获取当前活动环境中已安装的包的概览。该命令读取 Project.toml 中存储的信息。这些信息用于在本地包仓库~/.julia/packages 中定位正确的包:
shell> ls hobby
Manifest.toml Project.toml
shell> cat hobby/Project.toml
[deps]
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
ElectronDisplay = "d872a56f-244b-5cc9-b574-2017b5b909a8"
以 13f3f980 和 d872a56f 开头的长字符串和数字分别是每个包的全局唯一标识符(UUID)。包名不必唯一。可能有几个开发者正在制作名为 CairoMakie 的包;因此,为了能够唯一地识别特定的包,它们每个都需要一个唯一的 UUID。因为目前没有其他名为 CairoMakie 的包,所以没有包名冲突。
如果存在另一个 CairoMakie 包会怎样?在这种情况下,需要使用以下命令添加包:
(hobby) pkg> add CairoMakie=13f3f980-e62b-5c42-98c6-ff1f3baf88f0
包系统自带帮助系统,因此你可以写? add 来获取所有不同添加包方式的全面概述。要删除包,请使用 rm 命令。你可以在添加包的同时检查 Project.toml 文件如何随着删除操作而改变。以下是一个添加和删除 Dates 包的示例:
(hobby) pkg> add Dates
Resolving package versions...
Updating `~/dev/hobby/Project.toml`
[ade2ca70] + Dates
(hobby) pkg> status
Status `~/dev/hobby/Project.toml`
[13f3f980] CairoMakie v0.7.5
[d872a56f] ElectronDisplay v1.0.1
[ade2ca70] Dates
(hobby) pkg> rm Dates
Updating `~/dev/hobby/Project.toml`
[ade2ca70] - Dates
(hobby) pkg> status
Status `~/dev/hobby/Project.toml`
[13f3f980] CairoMakie v0.7.5
[d872a56f] ElectronDisplay v1.0.1
16.1.1 在 REPL 中使用包
将包添加到你的活动环境中不会使该包提供的函数和类型在 Julia REPL 或你正在编码的 Julia 项目中可用。相反,它们在运行 using 或 import 语句之后才可用。Makie 有许多不同的绘图函数。我将展示如何使用两个函数:lines 和 scatter。除非你使用 VS Code,否则你需要在任何内容可见之前加载 ElectronDisplay 包。
我将使用 Makie 来绘制正弦和余弦曲线。为了实现这一点,我将生成许多x,y坐标,分别存储在 xs,ys1 和 ys2 中。
列表 16.1 使用 Makie 绘制正弦和余弦曲线
using ElectronDisplay ❶
using CairoMakie ❷
xs = 1:0.1:10 ❸
ys1 = map(sin, xs)
ys2 = map(cos, xs)
scatter(xs, ys1)
scatter!(xs, ys2) ❹
current_figure() ❺
❶ 创建窗口以显示绘图输出。
❷ 使 scatter,scatter!和 current_figure 可用。
❸ 使用步长为 0.1 创建从 1 到 10 的值。
❹ 通过添加余弦图来修改当前图形。
❺ 将当前图形发送到 electron 显示
当 Julia 使用 CairoMakie 进行评估时,它将在其当前环境中寻找名为 CairoMakie 的包。如果你的环境是 hobby,那么它将加载 Makie 的 v0.7.5 版本的代码。然而,如果你在 job 环境中评估此代码,将加载 v0.5.10 版本。如果你在 Julia REPL 中运行此代码,你应该得到一个包含两个虚线图的图形(图 16.1)。

图 16.1 Makie 的正弦和余弦函数散点图
这些图称为散点图。每个(x, y)坐标产生一个彩色点。如果你想连接每个坐标点的线条,你可以使用 lines 和 lines!函数,如下所示。
列表 16.2 使用平滑线条绘制正弦和余弦曲线
lines(xs, ys1, linewidth=5) ❶
lines!(xs, ys2, linewidth=5) ❶
current_figure()
❶ 绘制厚度为 5 的线条
执行代码将给出图 16.2 所示的图表。有许多命名参数可以修改每个图表的外观。例如,linewidth=5 的命名参数使线条变粗。

图 16.2 正弦和余弦函数的 Makie 线性图
你可以访问官方 Makie 网站 (docs.makie.org/stable/) 了解更多关于使用 Makie 在 Julia 中进行绘图的信息。
16.1.2 模块与包的关系
我对包加载过程的描述并不完全准确。包定义了一个与包同名的模块。因此,当你写下 using Dates 时,你是在查找 Dates 包并加载其中定义的 Dates 模块。一旦你定义了自己的包和模块,这种区别就会变得非常清晰。
你可以将 Julia 包想象成一个包含源代码、资源以及元数据(如版本、名称和依赖项)的物理包。这些对于 Julia 的包加载机制很重要,但不是语言结构。就像在 Julia 中有用于定义函数或复合类型的关键字 function 和 struct 一样,也有用于定义模块的 module 关键字。
当函数允许你将代码块分组时,模块允许你将相关的函数和类型分组。模块还创建了一个 命名空间,就像函数一样。这意味着你可以在不同的函数中使用相同名称的变量,而它们不会相互干扰,因为每个函数形成一个独立的命名空间。以下列表中的体积函数不会相互干扰,因为它们位于两个不同的模块中:Cylinder 和 Cone。
列表 16.3 不同模块中具有相同名称的函数
module Cylinder
volume(r,h) = π*r²*h
end
module Cone
volume(r, h) = π*r²*h/3
end
你可以在 REPL 中评估这些模块,并像这样调用不同的体积函数:
julia> Cylinder.volume(2.5, 3)
58.90486225480862
julia> Cone.volume(2.5, 3)
19.634954084936208
然而,在本章中,你将专注于每个包定义一个模块。这是 Julia 中最实用的解决方案。
16.2 创建自己的包和模块
在接下来的几页中,我将通过从第二章和第四章中提取体积和三角函数,并将它们组织到存储在包中的模块 ToyGeometry 中,向你展示如何创建自己的包和模块。你可以从头开始手动构建包,但使用 Julia 包管理器为你生成脚手架会更方便。
在生成包并查看其结构后,我将向你展示如何向包中添加代码。然后,你将学习如何通过绘图功能扩展你的包,这样你可以更好地掌握如何处理包依赖项。
16.2.1 生成包
我从 ~/dev 目录开始,其中包含爱好和职业目录。当然,你可以按任何你想要的方式组织它们。我使用 shell 模式跳转到爱好目录,在那里我想创建我的 ToyGeometry 包。在包模式下使用 generate 命令来创建一个包:
shell> cd hobby/
~/dev/hobby
(hobby) pkg> generate ToyGeometry
Generating project ToyGeometry:
ToyGeometry/Project.toml
ToyGeometry/src/ToyGeometry.jl
创建包的更复杂的方法是使用 PkgTemplate 库,但 generate 是一个很好的入门方式,因为它创建了一个极简的包。如果你查看包的内容,你会看到它只包含两个文件:Project.toml 和 src/ToyGeometry.jl:
shell> tree ToyGeometry
ToyGeometry
├── Project.toml
└── src
└── ToyGeometry.jl
你可能会惊讶地看到里面的 Project.toml 文件。这不是用来定义环境的吗?没错!实际上,一个 Julia 包就是一个环境。作为一个环境,Julia 包可以添加它所依赖的其他包。
注意:环境可以嵌套,但这没有实际意义。嵌套模块更有用,但在这本书中我不会介绍模块嵌套。
目前没有依赖项,所以 Project.toml 文件将只显示有关包的数据,例如其名称、唯一标识包的 UUID、包的作者以及当前包的版本:
shell> cat ToyGeometry/Project.toml
name = "ToyGeometry"
uuid = "bbcec4ee-a196-4f18-8a9a-486bb424b745"
authors = ["Erik Engheim <erik.engheim@mac.com>"]
version = "0.1.0"
让我们向 ToyGeometry 添加一个依赖包来展示添加依赖项的工作原理。我将添加两个包,Dates 和 Base64,这两个包存在于 Julia 标准库中(不需要从互联网下载)。由于我不想将这些依赖项添加到娱乐环境中,而是添加到 ToyGeometry 环境中,我首先必须切换活动环境:
(hobby) pkg> activate ToyGeometry/
Activating project at `~/dev/hobby/ToyGeometry`
(ToyGeometry) pkg> add Dates Base64
Resolving package versions...
Updating `~/dev/hobby/ToyGeometry/Project.toml`
[2a0f44e3] + Base64
[ade2ca70] + Dates
Updating `~/dev/hobby/ToyGeometry/Manifest.toml`
[2a0f44e3] + Base64
[ade2ca70] + Dates
[de0858da] + Printf
[4ec0a83e] + Unicode
(ToyGeometry) pkg> status
Project ToyGeometry v0.1.0
Status `~/dev/hobby/ToyGeometry/Project.toml`
[2a0f44e3] Base64
[ade2ca70] Dates
ToyGeometry 中的 Project.toml 文件现在将更新以显示包的依赖关系:
shell> cat ToyGeometry/Project.toml
name = "ToyGeometry"
uuid = "bbcec4ee-a196-4f18-8a9a-486bb424b745"
authors = ["Erik Engheim <erik.engheim@mac.com>"]
version = "0.1.0"
[deps]
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
我还将获得一个名为 Manifest.toml 的新文件,该文件存储有关 Dates 和 Base64 依赖的包的信息。例如,Dates 依赖于 Printf 包来格式化日期的文本字符串。此外,Printf 依赖于 Unicode 包:
shell> cat ToyGeometry/Manifest.toml
julia_version = "1.7.2"
manifest_format = "2.0"
[[deps.Base64]]
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
[[deps.Dates]]
deps = ["Printf"]
uuid = "ade2ca70-3891-5945-98fb-dc099432e06a"
[[deps.Printf]]
deps = ["Unicode"]
uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7"
[[deps.Unicode]]
uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
对于不属于 Julia 标准库的包,条目将更复杂。以下是从工作环境中的 Colors 包的 Manifest.toml 文件中的一个条目:
[[deps.Colors]]
deps = ["ColorTypes", "FixedPointNumbers", "Reexport"]
git-tree-sha1 = "417b0ed7b8b838aa6ca0a87aadf1bb9eb111ce40"
uuid = "5ae59095-9a9b-59fe-a467-6f913c188581"
version = "0.12.8"
你可以看到它不仅列出了 UUID,还列出了 Colors 的版本。你不需要知道 git-tree-sha1 字符串的含义,除了它有助于 Julia 在本地包仓库中定位正确的包以加载。
16.2.2 向你的包中添加代码
目前,这个包没有任何功能,所以让我们通过复制第二章和第四章中的体积和三角学代码来添加代码。我将首先创建两个文件,volume.jl 和 trig.jl,来存放这段代码:
shell> cd ToyGeometry/
~/dev/hobby/ToyGeometry
shell> touch src/volume.jl src/trig.jl
shell> tree .
.
├── Manifest.toml
├── Project.toml
└── src
├── ToyGeometry.jl
├── trig.jl
└── volume.jl
将列表 16.4 中的代码添加到 src/volume.jl 中。
列表 16.4 ToyGeometry/src/volume.jl
"""
sphere_volume(r)
Calculate the volume of a sphere with radius `r`
"""
function sphere_volume(r::Number)
4π*r³/3
end
"""
cylinder_volume(r, h)
Calculate the volume of a cylinder with radius `r`
and height `h`.
"""
function cylinder_volume(r::Number, h::Number)
π*r²*h
end
"""
cone_value(r, h)
Calculate the volume of a cone with radius `r`
and height `h`.
"""
function cone_value(r::Number, h::Number)
π*r²*h/3
end
接下来,我将添加第四章中使用的泰勒级数来计算正弦值的函数;稍后我将添加一个余弦函数。这段代码将放入 src/trig.jl 文件中,如下所示。
列表 16.5 ToyGeometry/src/trig.jl
"""
sine(x)
Calculate the sine of an angle `x` given in radians
"""
function sine(x::Number)
n = 5
total = 0
for i in 0:n
total += (-1)^i*x^(2i+1)/factorial(2i + 1)
end
total
end
ToyGeometry 包定义了一个名为 ToyGeometry 的模块。我希望我编写的所有函数都成为 ToyGeometry 模块的一部分。通过在模块定义内运行 include 函数,所有函数定义都会在 ToyGeometry 模块内进行评估,并成为其一部分。
列表 16.6 ToyGeometry/src/ToyGeometry.jl
module ToyGeometry
include("volume.jl")
include("trig.jl")
end # module
如果你像 C/C++ 开发者一样思考,你可能不会明显地理解 include 的作用,但实际上它非常简单。让我们做一个快速实验来演示。
首先创建一个名为 arithmetic.jl 的文件,并在其中写入 3 + 4。include 可以在任何地方调用,甚至是在函数定义内部:
julia> x = include("arithmetic.jl")
7
julia> function calc()
include("arithmetic.jl")
end
julia> calc()
7
你不必将代码拆分成多个文件并将它们添加到模块中,使用 include。你可以这样定义 ToyGeometry 模块。
列表 16.7 ToyGeometry/src/ToyGeometry.jl
module ToyGeometry
sphere_volume(r) = 4π*r³/3
cylinder_volume(r, h) = π*r²*h
cone_value(r, h) = π*r²*h/3
function sine(x)
n = 5
total = 0
for i in 0:n
total += (-1)^i*x^(2i+1)/factorial(2i + 1)
end
total
end
end # module
那为什么不用这种方式定义呢?将所有代码放入模块定义文件 ToyGeometry.jl 中并不适合扩展。随着你的包变大,将所有代码放在一个文件中变得不切实际。这就是为什么我将包的代码拆分成多个文件,并在模块定义中包含这些文件。
16.3 修改和开发包
如果你正在跟随,你现在有一个正确的包结构和定义;下一步是理解包开发过程。这是一个迭代过程,在这个过程中,你不断地测试你包的功能并添加新特性。
在你开发自己的包时,你可能想使用许多 Julia 包来帮助你开发工作,但这些包不应成为你包的依赖项。以下是我喜欢使用的、可以提高生产力的包:
-
OhMyREPL—在 Julia REPL 中提供语法高亮和历史匹配功能
-
Revise—监控加载到 REPL 中的代码更改,并使用这些更改更新 REPL
回到示例,我现在将切换到娱乐环境,因为我不想将这些包作为 ToyGeometry 的依赖项:
shell> pwd ❶
~/dev/hobby/ToyGeometry
(ToyGeometry) pkg> activate .. ❷
Activating project at `~/dev/hobby`
(hobby) pkg> add OhMyREPL Revise
Resolving package versions...
Updating `~/dev/hobby/Project.toml`
[5fb14364] + OhMyREPL v0.5.12
[295af30f] + Revise v3.3.3
Updating `~/dev/hobby/Manifest.toml`
❶ 只需检查你目前的位置。
❷ 娱乐环境在上一个目录。
我现在可以将 OhMyREPL 和 Revise 加载到 REPL 中,但尝试加载 ToyGeometry 会失败。你能猜到为什么吗?
julia> using ToyGeometry
ERROR: ArgumentError: Package ToyGeometry not found in current path:
- Run `import Pkg; Pkg.add("ToyGeometry")` to install the ToyGeometry package.
在这种情况下,错误信息并不很有帮助。例如,Julia 从不会在当前文件系统路径中查找包。实际问题是,我没有将 ToyGeometry 添加到娱乐环境中。我目前工作的环境不知道我的自定义包。
我可以通过在包管理器中使用 add 命令来告知它包的信息。然而,使用 add 的问题在于它会捕获最新的包版本并将其冻结。通常,这是好事,因为你不希望第三方包突然改变而添加到你的工作环境中。但是,当你积极开发一个包时,你的需求与使用包时不同。你希望所有代码更改都可在你的工作环境中可用,而无需使用显式的更新命令来获取最新更改。因此,我将展示如何使用 dev 包命令。dev 命令需要你的包的目录路径:
shell> pwd
~/dev/hobby
shell> ls
Manifest.toml Project.toml ToyGeometry
(hobby) pkg> dev ./ToyGeometry
Resolving package versions...
Updating `~/dev/hobby/Project.toml`
[bbcec4ee] + ToyGeometry v0.1.0 `ToyGeometry`
Updating `~/dev/hobby/Manifest.toml`
[bbcec4ee] + ToyGeometry v0.1.0 `ToyGeometry`
看看爱好环境的当前状态,你现在可以看到 OhMyREPL、Revise 和 ToyGeometry 都已经被添加:
(hobby) pkg> status
Status `~/dev/hobby/Project.toml`
[13f3f980] CairoMakie v0.7.5
[d872a56f] ElectronDisplay v1.0.1
[5fb14364] OhMyREPL v0.5.12
[295af30f] Revise v3.3.3
[bbcec4ee] ToyGeometry v0.1.0 `ToyGeometry`
现在当前工作环境知道 ToyGeometry 的位置,因此当你写 using ToyGeometry 时,它知道要加载哪些代码。记住,可能有多个名为 ToyGeometry 的包,所以你的环境需要明确告知应该加载哪个包:
julia> using ToyGeometry
[ Info: Precompiling ToyGeometry [bbcec4ee-a196-4f18-8a9a-486bb424b745]
julia> ToyGeometry.sphere_volume(4)
268.082573106329
julia> ToyGeometry.sine(π/2) ❶
0.999999943741051
julia> sin(π/2) ❷
1.0
julia> sine(π/2) ❸
ERROR: UndefVarError: sine not defined
❶ 调用你的自定义正弦函数。
❷ Julia 的内置正弦函数
❸ 尝试调用正弦函数而不指定模块名称
每次调用自定义正弦函数时都写 ToyGeometry.sine 是很尴尬的,但正如示例所示,如果你不使用模块名称前缀,Julia 目前不知道如何调用它。然而,正如你从其他包(如 CairoMakie)中看到的,你不需要使用模块名称前缀来调用绘图函数,如散点图和线条。你如何实现同样的效果?
使用导出语句的技巧是告诉 Julia 哪些函数和类型应该从模块中导出(公开)。在此过程中,我将添加一个余弦函数并将其也导出:
export sine, cosine ❶
"""
sine(x)
Calculate the sine of an angle `x` given in radians
"""
function sine(x::Number)
n = 5
total = 0
for i in 0:n
total += (-1)^i*x^(2i+1)/factorial(2i + 1)
end
total
end
function cosine(x::Number)
n = 5
mapreduce(+, 0:n) do i
(-1)^i*x^(2i)/factorial(2i)
end
end
❶ 将正弦和余弦函数设置为公开。
你可能会注意到,在余弦函数中,我用更优雅的 mapreduce 替换了笨拙的 for 循环,这在第四章中已经介绍过。mapreduce 是 map 和 reduce 两种高阶函数的组合。
你会注意到现在可以写正弦和余弦函数而不需要任何模块名称前缀。以下简单的测试显示,自定义函数的输出与内置函数相似:
julia> sine(π/2)
0.999999943741051
julia> sin(π/2)
1.0
julia> cosine(π)
-1.0018291040136216
julia> cos(π)
-1.0
Julia 如何突然能够知道正弦甚至余弦,而不需要重新加载任何包?这要归功于 Revise 的魔法。因为我是在加载 ToyGeometry 之前加载了 Revise,所以它会监控模块的更改,并将它们自动合并到 REPL 中。
你是否应该总是使用 Revise?有时你做的代码更改你不想立即反映在 REPL 中。只需使用常识即可。你甚至可以使用 Revise 对单个文件进行操作,通过调用 includet 而不是 include。
在编写代码时,你自然会犯错误。REPL 帮助你快速检查代码,以查看你是否得到了正确的结果。快速分析大量数据并发现问题的一种最佳方式是通过可视化数据。所以让我们绘制内置的正弦和自定义的正弦函数,看看你是否得到了相似的图形。在你的 REPL 中评估以下代码。
列表 16.8 比较内置的正弦与自定义的正弦
using ToyGeometry, CairoMakie, ElectronDisplay
xs = 0:0.1:2π
ys1 = map(sin, xs)
ys2 = map(sine, xs)
lines(xs, ys1, linewidth=5)
lines!(xs, ys2, linewidth=5)
current_figure()
哎呀!它们并不相同。在大约 x 轴 4.5 处,你的自定义正弦函数明显失败。

图 16.3 Makie 散点图展示正弦和余弦函数,n = 5
幸运的是,多亏了 Revise,你可以修改显示 n = 5 的行。尝试不同的 n 值以查看图表如何变化。将 n 设置为 8 应该能解决你的问题。8 之所以有效,并没有什么神奇的理由,除了更高的值能提供更高的精度之外,这意味着性能和精度之间存在权衡。
16.4 解决关于模块的常见误解
如果你来自不同的编程语言,你可能会发现许多与环境、包和模块相关的概念让你感到困惑。例如,在 Java 世界中,模块被称为包,而 Julia 的包更接近于JAR 文件。^(1) 表 16.1 概述了在不同编程语言中描述模块、包和环境所使用的术语差异。
表 16.1 不同流行编程语言中包和模块术语使用的差异
| Julia | Java | C++ | Python |
|---|---|---|---|
| 模块 | 包 | 命名空间 | 模块 |
| 包 | JAR | DLL | 包 |
| 环境 | 环境 | 沙盒或容器 | 虚拟环境 |
另一个问题在于主流编程语言往往将诸如 public 之类的关键字置于要导出或公开的函数之前,如下面的列表所示。
列表 16.9 比较 Julia 与 Java 风格的函数导出方式
#### The Julia way ####
export sphere_volume
function sphere_volume(r::Number)
4π*r³/3
end
#### A Java-style way of exporting ####
# IMPORTANT: This doesn't work in Julia!
public function sphere_volume(r::Number)
4π*r³/3
end
虽然 Julia 的 include 函数可能看起来与 C/C++中的#include 相似,但你必须考虑到在 Julia 中 include 是一个常规函数调用,它返回一个结果。C/C++中的#include 根本不是一个函数调用,而是一种将包含文件的内容粘贴到包含语句的文件中的机制。
16.5 测试你的包
当你开发一个包时,你可以在 REPL 中快速测试函数。对于长期和大规模的开发,这还不够。你需要能够快速验证你几周或几个月前编写的代码仍然有效。在现实世界的软件开发中,你将在许多不同的文件中进行代码更改。跟踪所有受你代码编辑影响的函数可能很困难;因此,你需要设置一个广泛的测试集,以验证所有之前编写的代码仍然有效。
测试是一个更大的主题,所以在这本书中,我只会介绍一些非常基础的内容。你可以阅读以下来自我的Erik Explores Substack 的文章,以进一步探索这个主题:
-
“Organizing Tests in Julia” (
erikexplores.substack.com/p/julia-test-organizing) -
“Julia Test Running: Best Practices” (
erikexplores.substack.com/p/julia-testing-best-pratice)
Julia 中的测试可以从包管理器运行。它将尝试执行名为 test/runtests.jl 的文件。在示例中,我没有这个文件或测试目录,所以我将不得不创建这两个:
shell> pwd
~/dev
shell> cd hobby/ToyGeometry/
~/dev/hobby/ToyGeometry
shell> mkdir test
shell> touch test/runtests.jl test/trigtests.jl
Julia 使用嵌套测试集的概念,这意味着如果一个测试集包含的所有测试集都成功,则该测试集成功。Julia 的惯例是将所有测试集放入一个顶级测试集中。这就是我在 test/runtests.jl(列表 16.10)中将要展示的;然而,我将通过将测试分散到多个文件中并在顶级测试集中包含这些文件来遵循相同的策略。
列表 16.10 ToyGeometry/test/runtests.jl
using ToyGeometry
using Test
@testset "All tests" begin
include("trigtests.jl")
end
测试是与你的模块分开运行的,因此你需要使用 using 语句加载你正在测试的模块。要访问测试宏如@testset 和@test,需要导入 Test 包。这个包不在你的 ToyGeometry 环境中。将 Test 添加到这个环境中没有意义,因为它只会在测试时使用。
Julia 为这个问题提供了一个整洁的解决方案。你可以将测试目录视为它自己的环境,并且只在该环境中添加 Test:
shell> pwd ❶
~/dev/hobby/ToyGeometry
(hobby) pkg> activate test
Activating project at `~/dev/hobby/ToyGeometry/test
(test) pkg> add Test
Resolving package versions...
Updating `~/dev/hobby/ToyGeometry/test/Project.toml`
[8dfed614] + Test
Updating `~/dev/hobby/ToyGeometry/test/Manifest.toml`
❶ 显示你正在运行包命令的目录。
在尝试运行测试之前,你需要实际上将它们添加到你的代码中。我将通过向 test/trigtests.jl 文件添加测试来演示这一点,该文件包含与三角函数相关的测试。
每个实际测试都由@test 宏指定。由于浮点数难以完全相同,我将不会使用==来比较函数结果,而是使用≈,你可以在 Julia REPL 中通过写入\approx 并按 Tab 键来输入。
有时默认的≈容差太严格,你需要一个更宽松的相等定义。在这些情况下,使用 isapprox 函数来比较值。它接受一个命名参数 atol,你可以用它来指定你认为两个比较结果之间的差异可以接受的极限,如下所示。
列表 16.11 ToyGeometry/test/trigtests.jl
@testset "cosine tests" begin
@test cosine(π) ≈ -1.0
@test cosine(0) ≈ 1.0
for x in 0:0.1:2π ❶
@test isapprox(cos(x), cosine(x), atol=0.05) ❶
end ❶
end
@testset "sine tests" begin
@test sine(0) ≈ 0.0
@test sine(π/2) ≈ 1.0
for x in 0:0.1:2π
@test isapprox(sin(x), sine(x), atol=0.05)
end
end
❶ 测试范围在 0 到 2π之间,以 0.1 为增量时所有余弦值是否大致相等。
如果你尝试在测试环境中运行此测试,它将讽刺地不工作,因为 test 实际上并不知道 ToyGeometry 包。因此,你需要切换到 hobby 或 ToyGeometry 环境来运行测试:
(test) pkg> test ToyGeometry ❶
ERROR: The following package names could not be resolved:
* ToyGeometry (not found in project or manifest)
(test) pkg> activate .
Activating project at `~/dev/hobby/ToyGeometry`
(ToyGeometry) pkg> test ToyGeometry
Testing ToyGeometry
Test Summary: | Pass Fail Total
All tests | 108 22 130
cosine tests | 43 22 65
sine tests | 65 65
ERROR: LoadError: Some tests did not pass:
108 passed, 22 failed, 0 errored, 0 broken
❶ 无法从测试环境进行测试
由于存在 for 循环,我能够在前面的列表中执行总共 130 个测试。余弦测试失败是因为我使用了 n = 5,这不足以使结果足够准确。因此,我将 n = 9 改为提高准确性。请注意,运行测试会产生大量的输出,我已经编辑掉了。
列表 16.12 在 trig.jl 中修改余弦函数
function cosine(x::Number)
n = 9 # modified
mapreduce(+, 0:n) do i
(-1)^i*x^(2i)/factorial(2i)
end
end
当我再次运行测试时,它们应该会通过:
(ToyGeometry) pkg> test
Testing ToyGeometry
Test Summary: | Pass Total
All tests | 130 130
Testing ToyGeometry tests passed
测试和环境的话题比我在这里能涵盖的更广泛。我们在本章中涵盖的内容为你提供了一个坚实的基础,你可以在此基础上进一步探索。以下是一些起点:
-
pkgdocs.julialang.org—这是 Pkg 模块的网站,它提供了包管理器的功能。
-
docs.julialang.org—这是官方 Julia 文档的网站。您在这里可以查找模块、包和环境的详细描述,包括测试。
摘要
-
环境就像是一个工作区,您可以在其中设置想要使用的包以及使用它们。
-
Julia 允许您维护配置为使用不同包及其不同版本的多个环境。
-
CairoMakie 是一个用于绘制 2D 向量图形的 Julia 包。它是 Makie 系列绘图包的一部分。
-
散点图和线条是 Makie 库中用于绘制函数的函数。
-
ElectronDisplay 是一个提供显示图形(如 Makie 的输出)窗口的包。它是 VS Code 编辑器中绘图的替代方案。
-
使用激活包命令在不同的环境之间切换。例如,当您想修改您正在开发的另一个包的依赖项时,可能会使用此命令。
-
要使模块在您的代码中可用,请使用添加包命令将其添加。
-
使用移除包命令移除程序中不再使用的模块的包。
-
使用状态命令检查已添加到环境中的包。您可以使用此命令检查您自定义的包有哪些依赖项。
-
在许多包具有相同名称的情况下,在将其添加到工作环境时,指定您感兴趣的包的 UUID。
-
使用生成包命令创建一个包。
-
Project.toml 显示您环境的直接依赖项,而 Manifest.toml 显示间接依赖项。
-
当添加您正在开发的本地包时,使用 dev 而不是 add。这确保了每次从该包加载模块时,都会包含最新的代码更改。
-
使用 Revise 包来监控加载到 Revise 后的包的代码更改。
-
使用 test <包名> 来运行包的测试。例如,test ToyGeometry 将运行 ToyGeometry 包的测试。
-
测试目录是其自己的环境,允许您仅将 Test 包作为依赖项添加到测试环境中。
^^(1.)Java 存档 (JAR) 是一种包文件格式,通常用于将 Java 类文件及其关联的元数据和资源(例如文本和图像)聚合到一个文件中以便分发。
第五部分 深入探讨
您现在有了深入学习的基石。在第五章中,由于 Julia 类型系统尚未介绍,I/O 只能进行表面介绍。第十七章通过专注于 I/O 类型层次结构来扩展第五章的内容。
在第十章中,您了解了如何使用参数化类型来表示各种形式的“无”,在第六章中,它们如何帮助定义强类型集合。在第十八章中,我将解释参数化类型如何帮助确保类型正确性、性能和内存使用。
17 输入和输出
本章涵盖
-
理解 Julia 的 I/O 系统
-
使用最常用的文件读写函数
-
读写字符串、套接字和进程
-
在火箭示例中添加代码以从 CSV 文件中加载火箭引擎
真实程序需要能够从用户那里读取输入并输出结果。在本章中,你将学习关于 Julia 的 I/O 系统(输入和输出系统)。它为处理文件、网络通信、进程间通信以及与控制台(键盘和屏幕)的交互提供了一个抽象层。
Julia 在数据科学领域非常受欢迎,我们在那里大量处理以 CSV 文件(逗号分隔值)形式存在的输入数据。这就是为什么主要的代码示例将集中在解析包含火箭引擎数据的 CSV 文件上,以及将火箭引擎数据写入 CSV 文件。
17.1 介绍 Julia 的 I/O 系统
让我们鸟瞰 Julia 的 I/O 系统。它以抽象类型 IO 为中心。它有具体的子类型,如 IOStream、IOBuffer、Process 和 TCPSocket。每种类型都允许你从不同的 I/O 设备读取和写入数据,例如文件、文本缓冲区、正在运行的进程(你启动的程序)或网络连接。
从图 17.1 中的类型层次结构中,你可以看到 print、show、read、readline 和 write 等函数对所有 I/O 类型都是可用的。一些函数,如 eof 和 position,并不是所有 I/O 类型都可用。

图 17.1 显示不同子类型用途的 I/O 系统类型层次结构。浅灰色框表示具体类型。
不同的 I/O 对象以不同的方式打开,但一旦创建,你就可以在它们上面使用许多相同的函数。我将演示如何处理来自文件、字符串和进程的类似数据。所使用的数据将来自名为 rocket-engines.csv 的 CSV 文件,其内容如下:
name,company,mass,thrust,Isp
Curie,Rocket Lab,0.008,0.12,317
RS-25,Aerojet Rocketdyne,3.527,1860,366
Merlin 1D,SpaceX,0.47,845,282
Kestrel 2,SpaceX,0.052,31,317
RD-180,NPO Energomash,5.48,3830,311
Rutherford,Rocket Lab,0.035,25,311
我将打开文件,从中读取直到到达末尾,然后关闭它。在列表 17.1 中,我使用 readline 函数逐行读取,并使用 eof 检查我是否已到达文件的末尾。我使用 split 函数通过逗号作为分隔符将每一行拆分成多个单词。每一行读取的内容都打印到控制台。所有这些函数都在 Base 模块中,该模块始终被加载。
列表 17.1 通过逐行读取 CSV 文件来演示基本的 I/O 功能
io = open("rocket-engines.csv")
while !eof(io)
line = readline(io)
words = split(line, ',')
println(join(words, '\t'))
end
close(io)
你可以以非常相似的方式处理文本字符串中的数据。我将通过创建一个包含 rocket-engines.csv 文件第一行的字符串,并查看如何使用不同的 I/O 函数来处理它来演示。我将使用 readuntil,它从 IO 对象中读取,直到遇到特定的字符或字符串。我将使用 position 来检查我在 IOStream 中有多少个字符,并定期使用 eof 来检查我是否已到达 I/O 对象的末尾:
julia> s = "name,company,mass,thrust,Isp";
julia> io = IOBuffer(s);
julia> readuntil(io, ',')
"name"
julia> position(io), eof(io)
(5, false)
julia> readuntil(io, ','), position(io), eof(io)
("company", 13, false)
julia> readuntil(io, ','), position(io), eof(io)
("mass", 18, false)
julia> readuntil(io, ','), position(io), eof(io)
("thrust", 25, false)
julia> readuntil(io, ','), position(io), eof(io)
("Isp", 28, true)
你可以在通过打开 rocket-engines.csv 文件获得的 I/O 对象上尝试执行相同的操作。记住,当你完成时调用 close(io);否则,你可能会泄漏有限的操作系统资源。特别是在写入 I/O 对象时,关闭它是很重要的,否则你可能会丢失数据。
17.2 从进程读取数据
Python、Ruby 和 Perl 等脚本语言的部分受欢迎程度是因为它们是好的 粘合语言。粘合语言擅长连接现有软件组件,这些组件通常是用不同的语言编写的。
你将简要了解 Julia 作为粘合语言的能力。让我们假设 Julia 缺乏搜索文本文件的能力,而你想要利用 Unix grep 工具 1 来实现这个目的。首先,你通过按分号进入 shell 模式,只是为了演示你的 grep 命令将做什么:它找到包含文本 "Rocket Lab" 的行。通过按退格键,你回到 Julia 模式。接下来,你将打开一个连接到你所启动的 grep 进程(spawn)。注意使用反引号来引用你想要运行的 shell 命令:
shell> grep "Rocket Lab" rocket-engines.csv
Curie,Rocket Lab,0.008,0.12,317
Rutherford,Rocket Lab,0.035,25,311
julia> io = open(`grep "Rocket Lab" rocket-engines.csv`);
julia> readuntil(io, ',')
"Curie"
julia> readuntil(io, ',')
"Rocket Lab"
julia> readuntil(io, ',')
"0.008"
julia> position(io)
ERROR: MethodError: no method matching position(::Base.Process)
julia> close(io)
与 Perl 和 Ruby 等许多脚本语言不同,Julia 中的反引号不会立即运行 shell 命令。相反,它们导致创建一个名为 Cmd 的类型的命令对象。当你对一个命令对象调用 open 时,命令实际上会执行并启动一个进程。返回的 io 对象是 Process 类型,代表对运行进程输出的连接。这样,你可以几乎像读取文件一样读取进程(图 17.2)。

图 17.2 open 函数启动一个子进程。两个进程通过 open 函数返回的 I/O 对象表示的管道连接。
为什么调用位置函数时你会得到错误信息?因为没有任何方法附加到位置上,可以在 Process 对象上工作。只有对文件工作的 IOStream 对象才有流中的位置概念。
17.3 从套接字读取和写入
套接字代表网络连接。我将通过使用 Unix netcat 工具快速演示一个网络连接的例子。Netcat 是一个简单的工具,用于实验基于 TCP/IP-socket 的通信。2 你可以将 netcat 作为客户端或服务器运行。
注意,netcat 已经安装在 Linux 和 macOS 上。Windows 用户可以从 nmap.org/ncat 下载 nmap 工具作为替代品。每次我在文本中写 nc 命令时,在 Windows 上你应该写 ncat。
按照以下步骤操作:打开两个终端窗口。在第一个窗口中启动 Julia,在第二个窗口中启动 netcat 作为监听端口 1234 的服务器。只要你选择的端口号没有被占用,你几乎可以指定任何你喜欢的端口号。启动 netcat 后,输入行 "name,company,mass,thrust,Isp" 并按 Enter 键:
shell> nc -l 1234
name,company,mass,thrust,Isp
在 Julia 窗口中使用 connect 函数连接到在端口 1234 上运行的本地服务器。connect 函数将返回一个类型为 TCPSocket 的 I/O 对象:
julia> using Sockets
julia> sock = connect(1234)
TCPSocket(RawFD(23) open, 0 bytes waiting)
julia> readuntil(sock, ',')
"name"
julia> readuntil(sock, ','), isopen(sock)
("company", true)
julia> readline(sock)
"mass,thrust,Isp"
套接字通常是双向的,因此你可以向套接字写入消息,并在运行 netcat 的窗口中看到它们弹出:
julia> println(sock, "hello netcat")
julia> close(sock)
你在第二个窗口中看到了文本字符串"hello netcat"弹出吗?
通过这些简单的示例,我已证明你可以使用相同的函数,例如 read、readuntil、readline 和 println,对于每种 I/O 对象,无论它代表文本字符串、文件还是网络连接。
17.4 解析 CSV 文件
让我们构建一个更全面的代码示例。你将通过从重复的示例中添加从 CSV 文件加载火箭发动机定义的能力来增强你的火箭代码。为了练习你的 Julia 包制作技能,创建一个名为 ToyRockets 的 Julia 包来包含你的火箭代码。我已经创建了此包并将其放置在 GitHub 上,网址为 github.com/ordovician/ToyRockets.jl,因此你可以跟随操作。
ToyRockets 包是通过 Julia 包管理器中的 generate 命令创建的。接下来创建一个数据目录来存放 rocket-engines.csv 文件。将以下文件添加到 src 目录中:
-
interfaces.jl—包含抽象类型的定义,例如 Engine
-
custom-engine.jl—具体发动机类型的定义
-
io.jl—用于加载和保存火箭部件的函数集合
现在运行必要的命令来实现这一点,注意提示。当提示说(@v1.7) pkg>时,意味着你必须先按]键进入包模式。当提示说 shell>时,意味着你必须按;键进入 shell 模式:
(@v1.7) pkg> generate ToyRockets
Generating project ToyRockets:
ToyRockets/Project.toml
ToyRockets/src/ToyRockets.jl
shell> cd ToyRockets/
~/Development/ToyRockets
shell> mkdir data
shell> cd src
~/Development/ToyRockets/src
shell> touch interfaces.jl custom-engine.jl io.jl
如果你正确遵循了说明并将 rocket-engines.csv 文件放在 data/目录中,那么你的 ToyRockets 包应该看起来像这样:
ToyRockets/
├── Project.toml
├── data
│ └── rocket-engines.csv
└── src
├── ToyRockets.jl
├── custom-engine.jl
├── interfaces.jl
└── io.jl
确保你将所有源代码文件包含在 ToyRockets.jl 文件(列表 17.2)中,该文件定义了你的包模块。
列表 17.2 src/ToyRockets.jl 文件
module ToyRockets
include("interfaces.jl")
include("custom-engine.jl")
include("io.jl")
end
接下来,你需要将 rocket-engines.csv 文件中的每一行转换为 CustomEngine 对象,因此首先你需要定义 Engine 类型。
列表 17.3 定义发动机类型
# interfaces.jl file
export Engine
abstract type Engine end
# custom-engine.jl file
export CustomEngine
struct CustomEngine <: Engine
mass::Float64
thrust::Float64
Isp::Float64
end
在接下来的两个部分中,你将加载和保存火箭发动机数据。
17.4.1 加载火箭发动机数据
在我详细讲解并解释其不同部分的工作原理之前,你现在将查看最终代码(列表 17.4)。代码首先读取 CSV 文件中的所有行,每行代表一个火箭发动机。遍历每一行,解析它,并将其转换为 CustomEngine 对象,该对象被添加到包含从输入文件加载的所有发动机的字典 rocket_engines 中。
列表 17.4 包含将发动机对象加载到字典中的代码的 io.jl 文件
export load_engines
function load_engines(path::AbstractString)
rocket_engines = Dict{String, Engine}()
rows = readlines(path)
for row in rows[2:end]
cols = split(row, ',')
if any(isempty, cols)
continue
end
name, company = cols[1:2]
mass, thrust, Isp = map(cols[3:end]) do col
parse(Float64, col)
end
engine = CustomEngine(
mass * 1000, ❶
Isp,
thrust * 1000) ❷
rocket_engines[name] = engine
end
rocket_engines
end
❶ 从吨到千克
❷ kN 到牛顿
load_engines 函数接受包含火箭发动机数据的 CSV 文件的路径,并将其解析为火箭发动机的字典。以下是如何使用它的一个示例:
julia> using Revise, ToyRockets
julia> pwd()
"~/Development/ToyRockets"
julia> engines = load_engines("data/rocket-engines.csv")
Dict{String, Engine} with 6 entries:
"RD-180" => CustomEngine(5480.0, 311.0, 3.83e6)
"Kestrel 2" => CustomEngine(52.0, 317.0, 31000.0)
"Curie" => CustomEngine(8.0, 317.0, 120.0)
"Merlin 1D" => CustomEngine(470.0, 282.0, 845000.0)
"RS-25" => CustomEngine(3527.0, 366.0, 1.86e6)
"Rutherford" => CustomEngine(35.0, 311.0, 25000.0)
julia> engines["Curie"]
CustomEngine(8.0, 317.0, 120.0)
load_engines 函数遵循我处理数据时使用的相当标准的模式,这些模式被整洁地组织在类似于 CSV 文件的行中(见第五章)。在这里,你使用 readlines 来获取文件中的行,并使用 split 来获取每行的每一列(图 17.3)。

图 17.3 火箭发动机的文件被分成几个部分,经过转换,并在多个步骤中组合成多个火箭发动机。
为了更好地理解代码的工作原理,将源代码行的一部分复制并粘贴到 REPL 中,以查看输入数据是如何被处理的:
julia> path = "data/rocket-engines.csv"
"data/rocket-engines.csv"
julia> rows = readlines(path)
7-element Vector{String}:
"name,company,mass,thrust,Isp"
"Curie,Rocket Lab,0.008,0.12,317"
"RS-25,Aerojet Rocketdyne,3.527,1860,366"
"Merlin 1D,SpaceX,0.47,845,282"
"Kestrel 2,SpaceX,0.052,31,317"
"RD-180,NPO Energomash,5.48,3830,311"
"Rutherford,Rocket Lab,0.035,25,311"
接下来,选择任意一行,将其拆分为列以验证解析是否按预期工作。偶尔,可能会有缺失的数据,所以请确保每个列都包含数据。你可以使用高阶函数 any(isempty, cols),它将 isempty 应用到每个列上。如果 任何 列为空,它将返回 true:
julia> row = rows[2]
"Curie,Rocket Lab,0.008,0.12,317"
julia> cols = split(row, ',')
5-element Vector{SubString{String}}:
"Curie"
"Rocket Lab"
"0.008"
"0.12"
"317"
julia> any(isempty, cols)
false
接下来,你将使用一种名为 destructuring 的 Julia 魔法,来提取发动机的名称和制造它的公司。使用 destructuring,你将多个变量放置在赋值运算符 = 的左侧。在右侧,你必须放置一个可迭代的集合,其元素数量至少与左侧的变量数量相同:
julia> name, company = cols[1:2]
2-element Vector{SubString{String}}:
"Curie"
"Rocket Lab"
julia> name
"Curie"
julia> company
"Rocket Lab"
cols[1:2] 给你一个包含两个元素的数组。Julia 会迭代这个数组,并将数组中的元素分配给 name 和 company。元组或字典同样适用。
下一个部分稍微复杂一些,因为你需要将最后三个元素 cols[3:end] 映射到浮点值,使用 parse(Float64, col) 函数。这会将质量、推力和 Isp 的文本表示转换为浮点值,你可以将这些值传递给 CustomEngine 构造函数来创建一个发动机对象:
julia> mass, thrust, Isp = map(cols[3:end]) do col
parse(Float64, col)
end
3-element Vector{Float64}:
0.008
0.12
317.0
julia> engine = CustomEngine(
mass * 1000,
Isp,
thrust * 1000)
CustomEngine(8.0, 317.0, 120.0)
最后一步是将这个发动机存储在字典中,键为发动机名称。
17.4.2 保存火箭发动机数据
在这个阶段,你可以在你的 io.jl 文件中添加代码,以便将火箭发动机保存到文件中。默认情况下,文件是以读取模式打开的。如果你想写入它,你需要将一个 "w" 传递给 open 函数的 write 参数。在这段代码中,还有一些其他的新概念,你需要更详细地查看。
列表 17.5 带有添加 save_engines 代码的 io.jl 文件
function save_engines(path::AbstractString, engines)
open(path, "w") do io
println(io, "name,company,mass,thrust,Isp")
for (name, egn) in engines
row = [name, "", egn.mass, egn.thrust, egn.Isp]
join(io, row, ',')
println(io)
end
end
end
你注意到你是如何使用 do-end 形式与 open 函数一起使用的吗?这意味着它将一个函数作为第一个参数。这有什么用意?研究以下实现,看看你是否能猜出答案。
列表 17.6 实现 open(f, args...)
function open(f::Function, args...)
io = open(args...)
f(io)
close(io)
end
这种解决方案的好处是,当你完成对 io 对象的处理后,你可以将关闭 io 对象的责任交给 Julia。你也会注意到 splat 操作符的使用。它用于表示可变数量的参数。无论你传递多少个参数给 open,它们都会被收集到一个元组 args 中。当调用 open(args...) 时,你会再次使用 splat 操作符将这个元组展开成参数。
关于 join 函数以 I/O 参数作为第一个参数的情况?与返回使用分隔符连接多个元素的结果不同,join 函数将结果写入提供的 I/O 对象。以下是将结果写入标准输出的演示:
julia> join(stdout, [false, 3, "hi"], ':')
false:3:hi
你现在应该对 Julia 的 I/O 系统有一个广泛的理解。使用内置的帮助系统研究这里涵盖的函数和类型的文档,以了解更多信息。
概述
-
IOStream、IOBuffer、Process 和 TCPSocket 是用于读写文件、文本字符串、运行进程或网络连接的 I/O 对象。
-
使用 readuntil、readline、readlines 和 read 等函数从任何 I/O 对象中读取数据。
-
使用 print 和 println 等函数将数据写入 I/O 对象。
-
split 是一个方便的函数,通过使用分隔符拆分字符串,可以将字符串转换为对象的数组。
-
解构赋值将集合中的多个元素分配给多个变量,提供了一种紧凑且优雅的访问元素的方式。
^(1.)grep 是 Unix 系统上的标准命令行实用工具,用于在文件中查找匹配给定搜索条件的行。
^(2.)TCP/IP 是互联网上用于通信的协议。
18 定义参数化类型
本章涵盖
-
使用和定义参数化方法和类型
-
使用参数化类型提高类型安全性和捕获错误
-
通过使用参数化类型提高内存使用率和性能
在第十章中,我介绍了参数化类型来帮助解释联合类型的工作原理。我们还讨论了与数组、字典和集合等集合相关的参数化类型。通过类型参数,你可以限制在集合中使用哪些元素,从而获得更好的类型安全。然而,所有之前的参数化类型使用都是作为其他人定义的参数化类型的用户。
在本章中,我将向你展示如何创建自己的参数化类型,并涵盖处理参数化类型时的一些常见误解和陷阱。但为什么你想创建自己的参数化类型呢?Julia 中的参数化类型有两个关键优势,我们将在本章中详细探讨:
-
启用更安全的类型代码。Julia 可以在运行时早期捕获错误,例如尝试将字符串放入数字数组中。
-
在处理大型数据集时提高性能。
你将通过在二维空间中的几何代码(为了简单起见)来探索这些主题。我将尝试通过你的火箭示例项目中的一个可能用途来激励你:为了表达火箭在空间中的位置,你需要一个 Point 类型来表示空间中的位置,以及一个 Vec2D 类型来表示不同方向上的力、加速度和速度。
18.1 定义参数化方法
我将简单地通过展示接受类型参数的方法,并探讨类型参数如何使你的方法更安全并减少样板代码。在深入之前,让我先回顾一下类型参数的概念。之前,我使用了一个函数调用的类比 y = f(x),其中函数 f 接收一个值 x 并产生一个新的值 y(图 18.1)。同样,你可以将类型表达式 S = P{T} 视为参数化类型 P,它接受一个类型参数 T 并返回一个新的具体类型 S。T 和 S 都是具体类型,而 P 只是创建类型的模板。

图 18.1 函数与参数化类型之间的类比。前者产生值,而后者产生类型。
列表 18.1 中定义的 linearsearch 函数在数组 haystack 中进行线性搜索,寻找元素 needle。
注意:使用线性搜索意味着你没有做任何聪明的事情。你只是从第一个元素开始,依次查看每个元素。当你找到你正在寻找的元素时,你返回该元素的索引。
linearsearch 是一个参数化方法,因为它接受一个类型参数 T。定义 T 为类型参数的是 where T 子句。
列表 18.1 在集合 haystack 中搜索元素 needle 的线性搜索
function linearsearch(haystack::AbstractVector{T}, needle::T) where T
for (i, x) in enumerate(haystack)
if needle == x
return i
end
end
nothing
end
在这个情况下,使用类型参数提供了哪些优势?你能否用 AbstractVector 类型注解 haystack,并给 needle 提供任何类型注解?不。那样不会在运行时提供相同强度的类型检查。你已经定义了 linearsearch,使得 needle 必须与 haystack 的所有元素具有相同的类型。让我在 REPL 中演示一下:
julia> linearsearch([4, 5, 8], 5)
2
julia> linearsearch([4, 5, 8], "five")
ERROR: MethodError: no method matching linearsearch(::Vector{Int64}, ::String)
错误信息告诉你没有方法可以接受一个包含 Int64 元素的向量和一个 String 搜索对象。我已经定义了 linearsearch,使得无论 T 是什么类型,它都必须与 haystack 元素和 needle 对象相同。
参数化类型不仅提高了类型安全性,还提供了减少样板代码的机会。假设你想实现你自己的 Julia typeof 函数版本。一个简单的方法是编写如下代码。
列表 18.2 typeof 风格函数的简单实现
kindof(x::Int64) = Int64
kindof(x::Float64) = Float64
kindof(x::String) = String
你可以在 REPL 中尝试 kindof 并看到它适用于 64 位整数值、浮点值和字符串。然而,仅此而已。尝试为 Julia 中的每个类型添加方法是一个徒劳之举。正如你可能已经猜到的,将 kindof 定义为参数化方法优雅地解决了这个问题,如下所示。
列表 18.3 使用类型参数实现 kindof
function kindof(x::T) where T
return T
end
虽然 T、S、T1 和 T2 等名称常用于表示类型参数,但你也可以使用任何名称;名称不是关键。是 where 子句将名称转换为类型参数。让我们通过重新启动 Julia 并定义一个名为 Int64 的类型参数来强调这一点:
julia> kindof(x::Int64) where Int64 = Int64
kindof (generic function with 1 method)
julia> kindof(3)
Int64
julia> kindof('C')
Char
julia> kindof(4.2)
Float64
julia> kindof(3 => 8)
Pair{Int64, Int64}
Int64 是一个实际类型的事实在这里并不重要。where 子句将 Int64 转换为类型参数,并防止它被解释为具体类型。当然,你应该避免使用已知类型作为类型参数的名称,因为这会极大地混淆代码的读者。
18.2 定义参数化类型
在整本书中,我们使用了数组、字典和元组等参数化类型,但我们并没有自己定义这些类型。让我们看看如何实现这一点。
我将定义 Point 和 Vec2D 类型。图 18.2 展示了坐标空间中点和向量之间的关系。点通常以点状绘制,而向量则以箭头形式绘制。向量表示在坐标系中每个轴上的位移。

图 18.2 点和向量之间的几何关系
从数学的角度讲,点和向量通过不同的运算相关联。如果你从点 F 减去点 E,你会得到向量 u。你也可以反过来,将向量 u 加到点 E 上以得到点 F。我将在稍后更深入地讨论这些细节。首先,我想带你详细了解参数化类型定义的细节(列表 18.4)。请在 REPL 中不要定义这些,因为你会修改这些定义。
列表 18.4 定义参数化类型 Point 和 Vec2D
"A point at coordinate (x, y)"
struct Point{T}
x::T
y::T
end
"A vector with displacement (Δx, Δy)"
struct Vec2D{T}
Δx::T
Δy::T
end
"Calculate magnitude of a vector (how long it is)"
norm(v::Vec2D) = sqrt(v.Δx² + v.Δy²)
使用 \Delta 获取 Δ 符号。它是数学中常用以表示差异或位移的符号。
Point 和 Vec2D 不应被视为类型,而应被视为创建实际类型的模板。要创建一个实际类型,你必须为类型参数 T 提供一个具体类型。如果没有参数化类型,你将不得不定义许多具体类型来处理不同的数字。每个方法都必须为每个类型定义,从而导致代码膨胀。例如,使用参数化类型,你可以只定义一次 norm。如果没有它,你将需要为每个具体的 2D 向量类型定义它,如下面的列表所示。
列表 18.5 没有参数化类型时的代码膨胀
struct IntVec2D
x::Int
y::Int
end
struct FloatVec2D
Δx::Float64
Δy::Float64
end
norm(v::IntVec2D) = sqrt(v.Δx² + v.Δy²)
norm(v::FloatVec2D) = sqrt(v.Δx² + v.Δy²)
然而,一个 Python、Ruby 或 JavaScript 开发者可能会反对这种做法,并说定义多个具体类型是完全不必要的。如果你想在 Δx 和 Δy 的类型上获得灵活性,只需省略类型注解,如下面的列表所示。
列表 18.6 没有类型注解的 Vec2 类型(任何类型)
struct Vec2D
Δx
Δy
end
没有任何东西阻止你以这种方式定义一个 2D 向量。那么为什么不这样做,从而避免参数化类型引入的所有额外复杂性呢?省略注解有多个原因:
-
你剥夺了 Julia JIT 编译器在运行时进行的宝贵类型检查。
-
你增加了内存使用并降低了性能。
我将更详细地介绍这些观点。
18.3 参数化类型带来的类型安全优势
我将通过比较有类型注解和无类型注解的 2D 向量来展示使用参数化类型的类型安全优势。我将首先创建两个临时类型,GVec2D 和 PVec2D,仅用于这个比较。我不会进一步构建这些类型。注意,在单行上定义类型定义是完全有效的——只需用分号分隔单个语句即可。GVec2D 是弱类型变体,而 PVec2D 是强类型变体:
julia> struct GVec2D Δx; Δy end ❶
julia> struct PVec2D{T} Δx::T; Δy::T end ❷
julia> v = GVec2D(2, 3.0) ❸
GVec2D(2, 3.0)
julia> u = PVec2D(2, 3.0) ❹
ERROR: MethodError: no method matching PVec2D(::Int64, ::Float64)
julia> u = PVec2D(2, 3) ❺
PVec2D{Int64}(2, 3)
julia> u = PVec2D{Int}(2, 3.0) ❻
PVec2D{Int64}(2, 3)
❶ 创建一个没有类型注解的复合数据类型。
❷ 在新数据类型的字段中添加类型注解。
❸ 对参数类型没有限制。
❹ 类型注解阻止了错误类型的输入。
❺ 从参数中推断类型参数。
❻ 明确设置类型参数为 Int。
GVec2D 不使用类型注解,因此当创建 q 点时,我从 Julia 那里没有收到关于使用两种不同的数字类型来表示 x 和 y 的 delta 的任何抱怨。如果你尝试使用具有类型注解的 PVec2D,Julia JIT 编译器会抱怨,因为你正在尝试使用两种不同类型的 Δx 和 Δy。由于这个问题,Julia 没有办法推断类型参数 T 应该是什么,它必须放弃并抛出异常。这有助于你捕捉到你没有注意代码中传递的数字类型的情况。
这个问题可以通过两种方式解决:要么确保每个参数具有相同的类型,要么使用花括号 {} 明确地声明类型参数,而不是让 Julia 推断它。然后 Julia 将知道每个字段的类型,并自动将其转换为该数值类型。这两种选择都是有效的。
使用参数化类型可以进一步提高类型安全性。你还记得 <: 子类型操作符吗?你已经在各种情况下使用过这个操作符,包括测试一个类型是否是另一个类型的子类型,以及表明一个复合类型是抽象类型的子类型。你还可以使用这个操作符来对类型参数 T 施加约束。目前,T 可以是任何类型,包括非数值类型。这并不理想,因为坐标是由数字表示的。下面的列表中显示的最终 Vec2D 类型将约束 T 为一个数字。
列表 18.7 定义 Point 和 Vec2D,使得类型参数必须是数值类型
import Base: +, -
struct Point{T<:Number}
x::T
y::T
end
struct Vec2D{T<:Number}
Δx::T
Δy::T
end
"Adding vector `v` to point `p` creates a new point"
function +(p::Point{T}, v::Vec2D{T}) where T
Point(p.x + v.Δx, p.y + v.Δy)
end
"Subtracting two points gives a vector"
function -(p::Point{T}, q::Point{T}) where T
Vec2D(p.x - q.x, p.y - q.y)
end
你可以看到创建一个字符的 PVec2D 没有问题,因为类型参数 T 没有以任何方式被约束。另一方面,Vec2D 将不接受字符作为参数。尝试自己用不同的值进行实验以验证类型约束是否有效:
julia> v = PVec2D('A', 'B')
PVec2D{Char}('A', 'B')
julia> u = Vec2D('A', 'B')
ERROR: MethodError: no method matching Vec2D(::Char, ::Char)
julia> u = Vec2D(8, 4)
Vec2D{Int64}(8, 4)
到目前为止,你只在使用所有表达式中使用了一个类型参数。但你知道从使用字典、元组和配对中,可以有多个类型参数。对于列表 18.7 中的点减法操作符,我要求每个点 p 和 q 必须具有相同的数值类型,但这不是必需的。列表 18.8 展示了使用点 p 和 q 的不同数值类型实现的减法操作符。
列表 18.8 定义减法操作,使得参数 p 和 q 不需要是同一类型
function -(p::Point{T}, q::Point{S}) where {T, S}
Vec2D(p.x - q.x, p.y - q.y)
end
function -(p::Point, q::Point) ❶
Vec2D(p.x - q.x, p.y - q.y)
end
❶ 无显式类型参数的简写版本
因为在这个例子中,我没有使用类型参数 T 和 S 来约束任何内容,所以我可以完全省略它们。在 Julia 中,编写 Point 等同于编写 Point{T},如果 T 没有约束(图 18.3 展示了这些类型关系)。例如,如果你有一个函数 sum,它接受一个向量作为参数,而你又不关心元素的类型,你可以写 sum(xs::Vector),这等同于写 sum(xs::Vector{T}) 其中 T。

图 18.3 参数化类型之间的子类型关系。接受参数化类型 A 的函数,例如,将接受类型 A{Int64} 和 A{Char} 的值。
在参数化方法中,使用命名类型参数(如 T)的关键原因是你想表达两个或多个参数使用相同的类型参数。在其他情况下,你不想强制执行这样的严格要求。相反,你只想让类型参数具有相似的类型。比如说,你只想让点之间的减法运算适用于整数(见以下列表)。
列表 18.9 参数化类型 p 和 q 被约束为具有基于整数的类型参数
function -(p::Point{<:Integer}, q::Point{<:Integer})
Vec2D(p.x - q.x, p.y - q.y)
end
在这种情况下,p 可以具有 UInt8 字段,而 q 可以具有 Int16 字段。这个例子当然是人为的,因为这个特定的限制没有意义。那么为什么我没有将 p 和 q 限制为 Number 呢?这不是更现实吗?不会,因为没有办法创建不包含数字的 Point 对象。记住,这是对 Point 类型及其相关构造函数的类型参数的约束。
18.4 参数化类型带来的性能优势
Julia 是一种高性能的动态语言。没有参数化类型,这是不可能的。让我们讨论参数化类型是如何影响性能的。了解这一点的好处是,你可以更容易地预测你代码中的性能问题。
动态类型语言运行缓慢的一个关键原因是所谓的装箱。这个名字来源于大多数值必须放入特殊的容器中,这些容器包含有关它们包含的值的信息,包括其类型和垃圾回收细节,这可能是一个标记或引用计数(见图 18.4)。垃圾回收发生的具体细节(即内存释放的方式)对于这个论点不是本质的。关键点是这些通用容器有一堆账本数据。

图 18.4 装箱值和非装箱值的区别
这种账本数据有什么用呢?它允许你在运行时处理任意值。想象一个简单的函数 multiply,用于将向量乘以一个常数 k(见以下列表)。
列表 18.10 使用抽象类型字段定义的 Vec2D,以模仿常规动态语言
struct Vec2D ❶
Δx::Number ❷
Δy::Number ❷
end
function multiply(u::Vec2D, k::Number)
Vec2D(k * u.Δx, k * u.Δy)
end
❶ 支持解释而非运行
❷ 需要装箱
这看起来很简单,对吧?但在动态语言中,执行这个操作需要运行很多代码。在以下列表中,我将带你了解 Julia 伪代码变体中正在发生的事情。
列表 18.11 在正常动态语言中乘法将如何工作的伪代码
function multiply(u::Vec2D, k::Number)
ux = getfield(u, :Δx)
if !isa(ux, Float64)
error("x must be a float")
end
uy = getfield(u, :Δy)
if !isa(uy, Float64)
error("y must be a float")
end
c = convert(Float64, k)
Vec2D(floatmul(c, ux), floatmul(c, uy))
end
这段代码并不是要准确表示正在发生的事情。把它看作是一种伪代码,有助于你大致了解动态语言在底层是如何工作的。
在大多数动态语言中,你不知道复合类型有哪些字段,直到运行时。这意味着你不能生成直接访问字段的代码,这就是为什么你在列表 18.11 中看到 getfield(u, :Δx)这一行。你必须验证每个字段实际上都存在并且是预期的类型。
Julia 没有这个问题,因为它对其类型施加了一系列在其他动态语言中不常见的限制:
-
字段由类型固定。类型的实例在运行时不能添加或删除字段。
-
你不能在复合对象上存储与字段类型不匹配的值。
这些限制大大简化了 Julia JIT 编译器在尝试生成优化机器代码时的任务。关于 Julia 方法调用的复习,请参阅第 7.4 节。关键要点是,当 Julia 为方法生成代码时,它确切地知道每个输入参数的类型。由于在 Julia 中类型不能改变,JIT 编译器也将确切知道参数具有哪些字段及其类型。这允许 Julia 生成高度优化的机器代码。
18.5 参数类型内存优势
参数类型的优势不仅在于允许编译器生成更优化的代码,还包括使内存中数据的优化布局更容易实现。如果你定义了一个类型为 Vector{Point{Int32}}的数组,包含N个元素,那么 Julia JIT 可以精确计算出存储所有这些元素所需的字节数。这允许你避免内存碎片化,从而减少可用内存和应用程序的性能。简而言之,参数类型为你提供了更好的类型安全、性能和内存使用。
摘要
-
参数类型在运行时提高了类型安全。
-
Julia 通过查看 where 子句来确定一个类型是否是函数定义中的类型参数。
-
类型参数可以命名为任何东西,包括实际的类型名称,例如 Int8 和 Char。然而,为了避免混淆阅读你代码的开发者,尽量使用 T、T1、T2 和 S 等名称。
-
对于复合类型,使用{T1, T2, T3}来指定具有三个不同类型参数的类型。
-
使用子类型运算符 T <: S 来约束参数类型 T 成为另一个类型 S 的子类型。
-
括号化是指存储值时带有账目信息,以帮助确定在运行时存储的数据类型。
-
括号化(Boxing)会降低动态语言的性能。在 Julia 中,参数类型与不可变类型结合使用,最小化了括号化的使用,从而允许编译器生成优化的机器代码。
-
参数类型为集合和单个对象提供了更优的内存布局,减少了内存使用并提高了性能。
附录 A. 安装和配置 Julia 环境
本附录涵盖了在 Linux、macOS 和 Windows 上下载和安装 Julia,我还会介绍如何配置您的 Julia 环境以提高效率。
本附录中的说明依赖于理解诸如命令工具的路径等概念,以及知道如何设置它;这些说明将根据操作系统而有所不同。对于类似于 macOS 和 Linux 的类 Unix 系统,您通过编辑配置文件(如 .zshrc、.profile 或 .config/fish/config.fish)来配置搜索路径。在这些文件中,您可以设置环境变量,如 PATH、EDITOR 和 JULIA_EDITOR。如果您不熟悉 Unix 命令行,以下是我 Substack 的 Erik Explores 提供的一些资源:
-
“Unix 命令行快速入门” (
erikexplores.substack.com/p/unix-crash-course) -
“Unix 命令、管道和进程” (
erikexplores.substack.com/p/unix-pipes) -
“Unix Shell 和终端” (
erikexplores.substack.com/p/unix-shells-and-terminals)
A.1 下载 Julia
-
导航到 Julia 下载网页(图 A.1):
julialang.org/downloads。 -
选择适合您操作系统的正确 Julia 版本。我建议安装 Julia 1.7 或更高版本。

图 A.1 Julia 主页
A.2 安装 Julia
阅读您使用的操作系统的安装说明。
注意:一些 shell 命令以 sudo 前缀开头,这会给您正在运行的命令超级用户权限。这些权限是修改不属于登录用户的文件或目录所需的。
A.3 在 Linux 上
Linux 发行版有很多种,但在这里的说明中,我假设有一个下载目录,您在其中存储下载的文件。根据您在 Linux 机器上存储下载的位置调整给出的命令。
-
使用类似 julia-1.7.3-linux-x86_64.tar.gz 的名称解压 .tar.gz 文件。
-
将解压后的目录移动到 /opt。如果 /opt 不存在,则创建它。同时创建一个 /opt/bin 目录,因为稍后需要它:
$ sudo mkdir -p /opt/bin
$ cd $HOME/Downloads
$ sudo mv julia-1.7.3 /opt
接下来,为了方便地从终端运行 Julia,创建一个符号链接:
$ sudo rm /opt/bin/julia ❶
$ sudo ln -s /opt/julia-1.7.3/bin/julia /opt/bin/julia
❶ 删除任何旧的链接。
A.3.1 在 macOS 上
-
打开名为 julia-1.7.3-mac64.dmg 的下载的 .dmg 文件。
-
将 Julia 应用程序包拖放到您的 /Applications 文件夹中。
安装完成。就这么简单!
下一个步骤是可选的但很方便。如果您想通过在终端中简单地键入 julia 来启动 Julia,而不是必须点击 Julia 应用程序图标,请执行以下步骤:
-
打开 Terminal.app 控制台应用程序。
-
从安装位置创建一个符号链接到您的路径中的一个目录,例如 /usr/local/bin。
$ ln -s /Applications/Julia-1.7.app/Contents/Resources/julia/bin/julia /usr/local/bin/julia
A.3.2 在 Windows 上
下载 .exe 文件,这是一个自包含的 Julia 安装程序。双击并按照提示安装 Julia。安装过程与其他大多数 Windows 软件类似。
A.4 配置 Julia
让我们配置 Julia,使其使用更加方便。Linux 和 macOS 的配置非常相似,因为它们都是类 Unix 操作系统。
A.4.1 在 Linux 和 macOS 上
要使从终端运行 Julia 更加方便,配置您的 shell 环境对 Julia 是有用的。如果您的 shell 是 Z shell (zsh),您需要编辑您主目录中的 .zshrc 文件。如果您使用 Bourne Again SHell,bash,您需要编辑 .profile 文件。Z shell 目前是 macOS 的标准。
以下是在 Linux 上使用 bash shell 配置 Julia 的示例,其中 Sublime Text (见 www.sublimetext.com),subl,被用作 Julia 代码的文本编辑器:
# ~/.zshrc file
export JULIA_EDITOR=subl
export PATH=/opt/bin:$PATH
您可以使用自定义 shell。例如,我使用 fish shell,这是一个适用于所有类 Unix 系统的现代、用户友好的 shell。在这种情况下,您需要编辑您主目录中的 .config/fish/config.fish 文件。在以下代码中,我正在配置我的 Mac 使用 VS Code 编辑器,该编辑器通过 code 命令启动:
# ~/.config/fish/config.fish file
set -x JULIA_EDITOR code
set -x PATH /usr/local/bin $PATH
A.4.2 在 Windows 上
在 Windows 上,环境变量通过图 A.2 所示的 GUI 进行配置。打开此对话框的步骤取决于您的 Windows 版本。对于 Windows 8 及更高版本,请按照以下步骤操作:
-
在搜索中,搜索并选择系统(控制面板)。
-
点击高级系统设置链接。

图 A.2 配置 Windows 二进制搜索路径的对话框
对于 Windows Vista 和 Windows 7,请按照以下步骤操作:
-
从桌面右键单击计算机图标。
-
从上下文菜单中选择属性。
-
点击高级系统设置链接。
在 Windows 上,您不需要设置 JULIA_EDITOR 环境变量,因为操作系统在需要时会打开一个对话框并询问您要使用哪个编辑器。Windows 将随后将一个应用程序与 .jl 文件关联起来。
在 Windows 上配置 shell 环境可能比在 Linux/macOS 上更不必要,因为命令行界面不像 Windows 开发者那样经常使用。对 Windows 上的命令行感兴趣的开发者可能更喜欢使用 Windows 子系统 for Linux (WSL; docs.microsoft.com/en-us/windows/wsl/about)。如果您使用 WSL,那么请遵循 Linux 安装和配置步骤。
A.5 运行 Julia
现在 Julia 已安装并配置,您可以尝试运行它。要么点击 Julia 应用程序图标,要么打开一个终端窗口,输入 julia,然后按 Enter。
当 Julia 程序启动时,它会进入被称为 Julia REPL(读取-评估-打印循环)的状态。Julia REPL 是一个接受 Julia 代码、评估它并打印该代码评估结果的程序:
$ julia
_
_ _ _(_)_ | Documentation: https:/ /docs.julialang.org
(_) | (_) (_) |
_ _ _| |_ __ _ | Type "?" for help, "]?" for Pkg help.
| | | | | | |/ _` | |
| | |_| | | | (_| | | Version 1.7.2 (2022-02-06)
_/ |\__'_|_|_|\__'_| | Official https:/ /julialang.org/ release
|__/ |
julia> print("hello world")
hello world
julia> 3 + 2
5
julia> reverse("ABCD")
"DCBA"
通常,每一行代码被称为一个 表达式。(许多其他语言区分语句和表达式。)按下 Enter 后,评估表达式,Julia 将显示该表达式评估的结果。
如何退出 Julia?您可以通过按住 Ctrl 键并按 C 键来中断 Julia 中的任何操作。我们将其写作 Ctrl-C。要退出 Julia,请按住 Ctrl-D 或输入 exit()。
Ctrl-C 通常用于停止执行陷入僵局的 Julia 代码。例如,如果您正在执行一个无限循环,您可能想要停止执行。
A.6 Julia REPL 模式
Julia REPL 可以处于不同的模式,这由当前显示的提示符指示。一个带有名称 julia> 的绿色提示符表示您处于标准 Julia 模式。以下是在代码示例中您将看到的其他模式:
-
help?>—查找有关函数或类型的帮助信息。通过按 ? 键进入帮助模式。
-
pkg>—Package 模式用于安装和删除包。通过按 ] 键进入包模式。
-
shell>—Shell 模式允许您发出 Unix shell 命令,例如 ls、cp 和 cat。通过按 ; 键进入 shell 模式。
您可以通过将光标移至行首并按退格键来退出模式。这将带您回到 Julia 模式。
在阅读这本书时,请查看提示符以确定我们处于哪种模式。如果您没有将 REPL 设置为正确的模式,您发出的命令将不会生效。以下是在帮助模式下的一个示例。提示符将是黄色:
help?> 4 / 2
/(x, y)
Right division operator: multiplication of x by the inverse of y on the right.
Gives floating-point results for integer arguments.
REPL 模式在第五章、第十六章和第十七章中进行了更详细的介绍。
A.7 安装第三方包
有几个第三方包,虽然不是必需的,但可以改善您的 workflow。包在第十六章中进行了更详细的介绍。
进入包模式以安装包。我们将在此安装 OhMyREPL、Revise 和 Debugger 包:
(@v1.7) pkg> add OhMyREPL, Revise, Debugger
Resolving package versions...
使用 using 关键字将包加载到 Julia 环境中。可以通过逗号分隔来加载多个包。
julia> using OhMyREPL, Revise, Debugger
OhMyREPL 包提供了语法高亮和更好的搜索历史记录。Debugger 允许您使用 @enter 宏逐步执行代码。例如,以下代码将进入 titlecase 函数的执行。通过按 N 键进行步骤,通过按 Ctrl-D 键退出:
julia> @enter titlecase("hello")
最有趣和有用的包是 Revise,它允许您监控代码更改。通常,您使用 include 函数将单个文件的代码加载到 Julia REPL 中。如果您使用 Revise 包中的 includet 函数,文件中的代码将被监控。假设您创建了一个名为 hello.jl 的文件,其中包含以下代码:
greet() = "hello world"
您可以使用 Revise 在 Julia REPL 中加载此文件:
julia> includet("hello.jl")
julia> greet()
"hello world"
您可以将源代码文件修改为“hello Mars”,并且这个更改将自动生效,无需再次显式加载文件:
julia> greet()
"hello Mars"
附录 B. 数值
本附录涵盖了编程中关于数字的一些常见问题和疑问。这些主题并非完全属于 Julia 独特;例如,我将讨论如果整数算术运算的结果超出了整数类型能表示的范围,会发生什么。我还会讨论为什么浮点数与整数不同,它们是不准确的。
B.1 不同数字类型及其位数长度
数字不仅仅是数字。存在不同类型的数字,例如整数、实数、无理数等等。例如,整数是整数,如 2、7、43、820、-52、0、6 和-4,而实数包含有小数点的数字,如 3.45、0.042、1331.0、78.6。
然而,数学家和程序员看待数字的方式在本质上是有区别的。在计算机上,我们关注的是数字的位数长度以及数字是有符号还是无符号的。1 如果你已经使用过 Java、C#、C 或 C++等编程语言,那么你可能已经非常熟悉这一点了。
如果你的编程背景是另一种动态语言,例如 Python、Ruby、JavaScript 或 Lua,这些概念可能对你来说很陌生。虽然 Julia 也是一种动态语言,但数字在其中扮演着更核心的角色。例如,在 Python 和 JavaScript 中,你不必过多关注不同的数字类型。在 Julia 中,这一点更为重要,因为数字已经被精心设计,使 Julia 适合高性能计算。
我会为你提供基础知识。当填写表格时,你可能熟悉可以输入的数字位数限制。计算机也是如此。如果你将数字存储为四位数,那么在任何计算中你可以使用的最大数字是 9999。关键的区别是,对于现代数字计算机,所有数字都是以二进制形式存储在内存中,而不是十进制形式。因此,你可以选择的数字类型不是 4 位数、8 位数等等。相反,你可以选择的数字类型,例如,8 位、16 位或 64 位数字。这在实际操作中对你有什么影响?
数字是用一和零来表示的。8 位数字是一个有 8 个二进制位的数字。用二进制形式表示,你可以用 8 位表示的最大数字是 0b11111111。这个数字上的 0b 前缀是为了明确它不是一个十进制数。转换成十进制表示,这将等于 255。因此,如果你尝试将 256 作为 8 位数字存储,它将失败。(注意,为了清晰起见,我已经缩短了错误信息。)
julia> UInt8(256)
ERROR: InexactError: trunc(UInt8, 256)
julia> UInt8(255)
0xff
julia> Int(ans)
255
但为什么你应该限制你处理数字的大小?为什么不每次都简单地使用可能的最大数字呢?因为你的计算机上没有无限的内存。如果你只使用几个数字,那么它们的大小并不重要。然而,如果你处理数百万个数字,那么位长度就开始变得重要了。其次,与处理小数字相比,对大数字进行计算通常需要更长的时间。Julia 默认使用 64 位数字,因为它们非常实用。一个有符号的 64 位整数最大值为 9,223,372,036,854,775,807。你不太可能处理比这更大的数字。
但你是如何知道数字类型可以持有的最大和最小值呢?幸运的是,Julia 提供了 typemax 和 typemin 函数,让你可以自己找出这些值。然而,目前你只需直接理解这些函数的工作方式即可。你给出 Julia 数字类型的名称,例如 Int8、Int16 或 UInt8,这些函数就会返回你可以用该数字类型表示的最高和最低数值。例如,一个 8 位有符号整数 Int8 无法表示大于 127 的值:
julia> typemax(Int8)
127
julia> typemin(Int8)
-128
julia> typemax(Int16)
32767
julia> typemax(Int64)
9223372036854775807
julia> typemin(Int32)
-2147483648
typemin(Int8) 返回 -128 的值,因为一个有符号的 8 位整数无法表示小于 -128 的数值。
虽然所有这些数字类型看起来可能很复杂,但在实际操作中你很少需要考虑它们。在大多数情况下,坚持使用默认类型,如 Int64,是最好的选择。只有当你处理大量数字并遇到性能或内存问题时,你才需要考虑其他整数数字大小。或者,你可能需要更大的数字,因为你正在处理非常大的值。在这种情况下,你可以考虑使用 Int128 或甚至 BigInt。
64 位和 32 位 Julia 安装之间的差异 如果你下载并安装了 32 位的 Julia 版本,那么默认的整数类型将是 Int32 而不是 Int64。本书中的代码示例将假设你运行的是 64 位 Julia。
你尝试找到 BigInt 的最大值,但无法让它工作吗?继续阅读以了解原因。
要了解用于表示特定数字字面量的类型,你可以使用 typeof 函数。只需给它一个数字,它就会返回表示该数字的数字类型。实际上,它可以用于任何类型。如果这让你感到困惑,不要担心,因为 typeof 将在稍后更详细地介绍:
julia> typeof(797298432432432432)
Int64
julia> typeof(797298432432432432709090)
Int128
julia> typeof(797298432432432432709090697343)
Int128
julia> typeof(7972984324324324327090906973430912321321)
BigInt
BigInt 是一个非常特殊的数字类型,因为它没有预定义的位数。相反,它简单地不断增长以适应所有位数,因此你的计算机内存是其唯一的实际限制;这就是为什么 BigInt 没有定义良好的最大值。
为什么不总是使用 BigInt 呢?这样你就不必考虑你需要多少位大小了。显然的答案是这会降低性能。因此,你应该尽量将 BigInt 的使用限制在你代码中从中受益且不是性能关键的部分。
B.2 溢出和有符号数与无符号数
让我们将第二章中学到的关于数字格式和位长度的所有知识汇总起来,来探讨一些重要的话题。首先是溢出的概念。想想机械计数器,就像图 B.1 中展示的那样。它有四个数字,那么当你达到 9999 时会发生什么?它会回绕,然后又回到 0000。

图 B.1 一个机械计数器。每次你点击金属按钮时,它都会增加。
数字在计算机上工作方式完全相同。因为每种数字类型都可以存储最大值,你可能会执行结果大于变量可以存储的值的算术运算。这里有一些实际例子:
julia> UInt8(255) + UInt8(1)
0x00
julia> UInt8(255) + UInt8(2)
0x01
julia> 0xff + 0x01
0x00
julia> 0xff + 0x05
0x04
因为一个 UInt8 只能持有到 255 的值,所以当你添加更多时,或者用更准确的语言来说,当你溢出时,你会得到回绕。在这种情况下,通过使用十六进制数来理解这个概念更容易。一个 8 位数字可以存储最多两个十六进制数字,十六进制中的最后一个数字值是 F。对于 16 位数字,你需要更高的值来得到溢出:
julia> UInt16(65535) + UInt16(1)
0x0000
julia> UInt16(65535) + UInt16(3)
0x0002
溢出对于有符号数和无符号数的工作方式不同。看看以下有符号数和无符号数的行为,看看你是否能理解它:
julia> UInt8(127) + UInt8(1)
0x80
julia> Int(ans)
128
julia> Int8(127) + Int8(1)
-128
julia> Int8(127) + Int8(2)
-127
julia> Int8(127) + Int8(127)
-2
这个输出看起来很奇怪,对吧?你正在添加正数,却得到了负数。这怎么可能呢?这与计算机内存只能存储数字的事实有关。没有任何地方存储负号。相反,你会使用回绕行为来模拟负数。回到图 B.1 中的机械计数器例子,存储四位十进制数;4 + 9998最终会变成 2。想象一下从 9998 开始点击机械计数器的四次;计数器会回绕并最终变成 2。
这意味着关于数字 9998 的另一种思考方式是想象它为数字-2。4 + (-2) = 2。这样,9999 就变成了-1,9995 变成了-5,以此类推。按照这个逻辑,1 可以解释为-9999。然而,不应该走得太远;否则,就无法用四个数字来表示正数。
现代计算机上使用的方案是将每个数字范围大致分成一半,所以一半的值用来表示负数,另一半用来表示正数。一个无符号 8 位整数可以表示从 0 到 255 的数字;然而,一个有符号 8 位整数表示的值从-128 到 127,这你之前已经看到了:
julia> typemin(Int8)
-128
julia> typemax(Int8)
127
julia> typemin(Int16)
-32768
julia> typemax(Int16)
32767
因此,存储在内存中的内容实际上并没有不同。唯一的不同之处在于在进行不同计算时如何解释存储的内容。当使用无符号数时,假设所有数字都是正数。当使用有符号数时,假设一半的值是负数。实际上,Julia 可以通过使用 reinterpret 函数向你展示内存中完全相同的位可以以不同的方式解释:
julia> reinterpret(Int8, UInt8(253)) ❶
-3
julia> reinterpret(UInt8, Int8(-1)) ❷
0xff
julia> Int(ans)
255
❶ 将无符号 8 位数字 253 重新解释为有符号 8 位数字。253 作为有符号数解释为 -3。
❷ 将有符号数 -1 的内存中的位重新解释为无符号数。8 位数的无符号 -1 与 255 相同。
B.3 浮点数
整数无法表示带有小数点的数字,例如 4.25、80.3 或 0.233;表示这类数字有不同方法。历史上,定点数被使用,但在现代计算机中,我们倾向于使用所谓的浮点数。定点数用于诸如货币计算这样的用途。在这种数字表示法中,小数点后的位数是固定的:你总是有两个小数位。
计算机无法在内存中存储符号或小数点。计算机内存只存储数字,并且只以二进制格式存储它们。这与,比如说,算盘并没有太大区别。在算盘上无法明确存储负数或表示小数点;然而,可以建立一些惯例。
你可以简单地决定,例如,你处理的数字的最后两位应该是小数点后的数字。这意味着,如果你想输入 1,实际上你需要输入 100. 同样,23 变成 2300. 以类似的方式,人们可以使用仅整数来模拟定点数。你可以取整数,如 4250 和 850,并假设在最后两位数字之前有一个小数点。因此,你可以将这些数字解释为 42.50 和 8.50. 在计算中,这没问题:
julia> 4250 + 850
5100
julia> 42.50 + 8.50
51.0
对于金钱的计算,这是合适的选择;因为此类计算通常涉及四舍五入到最接近的两个小数位。但对于需要很多小数位数的科学计算,这太不切实际了。这就是为什么我们有浮点数,其基于这样的想法:你可以以这种方式表示任何数字(图 B.2)。

图 B.2 使用整数乘以 10 的幂和正或负指数表示任何十进制数
考虑第一行:数字 1234 被称为尾数。第二部分表示数字的基数。在这种情况下,基数是 10,而指数是 -2。在内部,浮点数分别存储尾数和指数。这种安排使得小数点可以浮动。浮点数类型可以表示比整数更大的数字,但缺点是它们并不完全准确。为什么?这超出了这本入门书的范围,但我可以提供一些关于它的提示。考虑一个像 ⅔ 这样的数字。用小数表示,我们写成 0.6666666666666666。数字只是不断继续。在小数中,小数点后的数字代表分母是 10 的倍数的分数。因此,我们近似如图 B.3 所示。

图 B.3 用小数表示分数的近似
这永远无法完全准确。在我这本书中使用的例子中,这通常不会成问题,但如果你开始认真处理浮点数时,这是值得注意的。但不要假设每个浮点数都必须是不准确的。许多数字可以精确表示,例如 0.5 或 42.0。对于计算机来说,浮点数显然不是用十进制表示分数,而是用二进制表示分数。
(1.) 有符号数可以是负数,而无符号数只能为正数。
1 ↩︎


浙公网安备 33010602011771号