Julia-实践指南-全-
Julia 实践指南(全)
原文:
zh.annas-archive.org/md5/a05c54f717da887d4c2e0415a63b50b1译者:飞龙
前言
我常常觉得美国的程序员如果学习拉丁语,比学习另一个编程语言要更有益。
—埃兹杰·迪克斯特拉

Julia 是一种相对较新的编程语言。它在 2012 年进入公众视野,之前 MIT 的四位计算机科学家研究了两年半时间。Julia 的创造者解释了他们为何需要创建一种新语言:他们“贪心”。
已经有一些语言很快,比如 C 和 Fortran。这些语言非常适合用于编写在巨型超级计算机上运行的程序,用于模拟天气或设计飞机。但它们的语法并不最友好;这些语言中的程序要求一定的仪式感。并且它们并没有提供交互式体验;在终端中无法即兴创作和探索,必须遵循编辑-编译-运行的步骤。
也有其他语言,不需要仪式感,可以作为交互式计算器使用,比如 Python 和 MATLAB。然而,这些语言编写的程序速度较慢。而且,这些语言通常不适合保持大型程序的组织结构。
Julia 的创造者之所以“贪心”,是因为他们想要兼得一切:一种和 Python 一样易用的语言,但同时又有 Fortran 一样的速度。人们通常通过为 Python 等语言增加速度优化来解决这个问题,通常需要将程序中耗时的部分重写成更快的语言,比如 C。这样产生的“拼接体”意味着需要在两种语言中维护代码,这带来了组织、人员和心理上的额外负担。这就是所谓的“两语言问题”,而 Julia 的动机之一就是要解决这个问题。
Julia 现在被广泛誉为解决两语言问题的真正方案。事实上,它是仅有的三种属于“千万亿次俱乐部”的语言之一,在巨大的数值计算问题上达到顶级性能(另外两种是 Fortran 和 C++)。独特的是,Julia 将这种高性能与作为交互式计算器的能力结合在一起,无论是在其精心打磨的读取-求值-打印循环(REPL)中,还是在各种开发环境或基于浏览器的笔记本中。
对于那些曾使用过 Python、Octave、MATLAB、JavaScript(通过 Node)或其他基于 REPL 的语言系统的人来说,Julia 的使用体验将会非常熟悉。你只需在终端中输入julia,就会看到一个简短的启动信息和一个友好的交互式提示符。现在你可以输入表达式,并立即在终端中看到结果。你可以定义变量和函数,操作数组,导入函数库,从磁盘或网络读取数据,并且可以将语言作为一个复杂的计算器来使用。你无需声明变量的类型,也不需要编写任何额外的模板代码,这些代码并不会干扰你的工作。
这些是它与其他解释型语言的相似之处。你还会遇到一些不同之处。你可能会注意到偶尔会有几秒钟的延迟,而这种情况通常不会出现在像 Python 这样的语言中。这是因为 Julia 实际上并不是一种传统的解释型语言,它在幕后进行代码的预编译和即时编译(JIT)。
正如你将发现的,当你的计算变得庞大时,这种权衡是值得的。你对其他交互式语言的经验可能让你预期代码会变得非常缓慢,但你会发现,实际上,代码的执行速度与 Fortran 等编译语言一样快。
随着进一步的探索,你会发现 Julia 与其他你熟悉的语言不同。乍一看,它似乎与其他语言相似。你可以输入1 + 1并得到2的结果。但你会了解到,Julia 既不像 Python 那样面向对象,也不像 Haskell 那样传统的函数式编程语言,更不像 JavaScript 那样的语言。Julia 的设计基于一个不同的原理,这也是它强大之处的来源。
为什么科学家喜欢 Julia?
Julia 的设计围绕着一种叫做多重分派(multiple dispatch)的机制,这得益于其强大而灵活的类型系统。稍后,你会了解到这些概念的含义,以及如何在你的程序中利用它们。现在,请将这个概念记住并留作日后参考:多重分派系统是 Julia 在科学界取得成功的关键因素之一,与其著名的互动性和速度同样重要。虽然 Julia 不是第一个采用这一特性的语言,但它是第一个将此特性与其他优点结合,真正使其对科研界有用的语言。
正是这个设计特性使得代码重用和重组达到了前所未有的程度和简便性。这一点,与任何基准测试一样,是吸引那些已将 Julia 作为计算工具的研究人员的原因。Julia 在科学界的快速发展,主要是因为它使得研究人员能够相互使用代码,并将不同的库重新组合,创造出库作者未曾预见到的新功能。你将在后续章节中看到许多这样的例子,特别是在第二部分中。你还将看到类型系统和 Julia 的元编程能力如何使你能够完美地将语言与问题相匹配,而不牺牲性能。
这本书能为你做什么?
在阅读完第一部分和第二部分中你感兴趣的内容后,你将能够充分利用 Julia 解决任何遇到的计算问题。你将学会如何探索和可视化数据,解决方程式,编写仿真程序,并使用和创建库。本书的重点是将 Julia 应用于研究问题。方法直接且实用,理论计算机科学的内容尽量简化。我将教你如何编写高效的代码,使其能够在笔记本电脑或大型分布式系统上运行。无论你对科学研究、数学、统计学,还是仅仅为了娱乐感兴趣,你都将学会如何智能地使用这个工具,并享受使用的乐趣。
本书从基础开始,假设你从未接触过 Julia。我不假设你已经掌握了任何特定的数值方法或计算技术,会在需要时解释这些内容。我只假设你有一些基本的编程概念接触经验。换句话说,当我描述如何在 Julia 中写一个if语句时,我会期望你在一般意义上了解使用条件的概念。
如何使用本书
第一部分的内容是按顺序构建的,因此,理想情况下,你应该按顺序阅读这些章节。相对而言,第二部分的章节只依赖于第一部分的内容,而不相互依赖。你可以不看物理章节而直接阅读生物学章节。当然,我鼓励每个人都阅读每一章!原因如下:一些特定的技术是在最相关的应用章节中开发的。然而,由于科学研究的性质,任何计算知识都可能在任何学科中找到应用。例如,一位生物学家可能会发现物理章节中关于微分方程求解器的内容对模拟种群动态有帮助。由于第二部分中的章节没有固定顺序,最自然的做法可能是首先阅读你最感兴趣的章节,其他章节可以在闲暇时再回头阅读。
本书有一个详细的索引,应该能帮助你轻松找到任何内容,无论它藏在哪里。
为了最大限度地利用本书,建议你一边阅读,一边在 Julia 提示符下进行实践,这样你可以在遇到问题时进行尝试。实践的方法比单纯阅读更能有效地巩固理解。当你跟随本书内容时,你会想尝试我的示例代码的不同变体,通过反复试验了解语言的行为。你不会弄坏任何东西。如果你进入了一个不知如何解决的奇怪状态,可以简单地退出 REPL 并重新启动。此外,Julia REPL 有一个完善的文档模式,你可以在其中访问任何特定函数的详细信息,以补充书中的内容。
本书有一个配套网站,网址为https://julia.lee-phillips.org,你可以在网站上找到文本中的所有主要代码示例的可运行版本、程序使用的数据文件、插图的彩色版本、示例动画以及模拟视频。
书籍概述
在第一部分中,首先处理安装和编程环境的基础知识,然后重点学习 Julia:语法、数据类型、概念和最佳实践。本部分还包括有关模块和包系统以及可视化的章节。
第一章:入门 介绍了运行 Julia 和从本书中受益所需的硬件和经验,并提供了在各种操作系统上安装的指南。我们还回顾了最常见的编码环境,并给出了几点建议。
第二章:语言基础 介绍了 Julia 的概念、语法和数据类型,为你提供扎实的语言基础。
第三章:模块与包 描述了如何组织你的 Julia 程序,如何将他人的代码整合到自己的工作中,以及如何成为 Julia 社区的一部分。
第四章:绘图系统 重点讲解了 Julia 强大的Plots包。你将学习如何创建和自定义每种常见的 2D 和 3D 图表,以及如何创建交互式图形和用于出版的最终插图。
第五章:集合 介绍了数据类型,如集合、字符串、数组、字典、结构体和元组。本章涵盖了推导式和生成器、集合操作符、数组初始化和操作,以及 Julia 的各种字符串类型。
第六章:函数、元编程与错误处理 进一步探讨函数,讲解了不同的定义和传递参数的方式,以及高阶函数。包括元编程的介绍,涉及使用符号、表达式对象和宏来编写操作代码的代码。
第七章:图表与动画 展示了如何使用一个灵活且强大的包来制作数学和其他类型的图表,以及一个更专门化的工具来绘制节点和边图。我们将探索两个提供不同动画制作方法的包,并将在后续章节中使用这些包来制作插图和视频。
第八章:类型系统 详细介绍了 Julia 中不同种类的数字和其他对象、类型层次结构、类型断言与声明,以及如何创建我们自己的类型。它解释了如何将类型系统与多重分派相结合来组织程序,并讨论了类型与性能之间的关系。此外,关于绘图配方的部分揭示了 Julia 绘图系统的独特强大功能。
第二部分 包含了专门研究某一特定领域的章节,以及关于并行处理的最后一章。每个章节都使用一个或多个在某一应用领域广泛使用的专业包,并探讨至少一个该领域中的有趣问题。
第九章:物理学 展示了如何给数字添加单位和不确定性,这是许多领域的科学家可能感兴趣的主题。一个详细的热对流示例演示了如何使用强大的流体动力学包。该章节以介绍解决微分方程的最先进包作为结尾。
第十章:统计学 讨论了统计学和概率论中的概念,如分布,并将其与相关 Julia 包提供的函数和类型联系起来。它将这些概念应用于模拟感染的传播,并通过对 COVID 病例的实际数据进行切片和切割,介绍了数据框架。
第十一章:生物学 探讨了基于代理的建模,并展示了如何使用 Julia 的Agents包来模拟生物进化,生物学习如何避免被捕食者捕获。该章节基于统计学章节中的一些概念来分析结果。
第十二章:数学 聚焦于符号数学(计算机代数)和线性代数。它描述了第一主题的两种主要方法,包括混合数值-符号技术。它涵盖了使用线性代数包来求解方程,并通过利用类型系统高效地执行矩阵运算的基本方法。
第十三章:科学机器学习 探讨了一个相对较新的领域,其中利用机器学习的思想推断模型的属性。它展示了如何在多个场景中使用自动微分,并通过 Julia 的Turing包介绍了概率编程。
第十四章:信号与图像处理 聚焦于信号和图像。信号部分涵盖傅里叶分析、滤波和相关主题,使用鸟鸣声作为工作实例。图像部分通过在计数血细胞问题中的特征识别,探讨了图像大小调整、平滑处理和其他操作的多种技术。在这一部分,还进一步深入探讨了高级数组概念。
第十五章:并行处理 解释了如何在多个 CPU 核心或计算机上运行我们的程序。本章讨论了不同的并发范式,以及如何利用多线程和多处理技术。我们将看到如何在全球各地的网络机器上运行程序,而无需更改代码。
进一步阅读
-
要了解 Julia 语言的灵感来源,请阅读《我们为何创造 Julia》:https://julialang.org/blog/2012/02/why-we-created-julia/。
-
我的文章《Julia 编程语言的非理性有效性》发表于 Ars Technica,解释了 Julia 在科学家中广泛应用的根本原因:https://arstechnica.com/science/2020/10/the-unreasonable-effectiveness-of-the-julia-programming-language/。
-
如果你是 Python 程序员,并且想要简要了解语法上的差异,请查看 Dr. John D. Cook 在 http://www.johndcook.com/blog/2015/09/15/julia-for-python-programmers/ 上的文章《Python 程序员的 Julia 入门》。
-
如果你来自 Lisp,请阅读 Pascal Costanza 在 https://p-cos.blogspot.com/search?q=first+impression+of+Julia 上的文章《Lisper 对 Julia 的第一印象》。虽然这篇文章是 2014 年的,但仍然值得一读。
-
要了解解释新语言需求的原始理论依据以及 Julia 设计决策如何满足这些需求,请参阅《Julia:一种全新的数值计算方法》,该文由 Julia 的创造者 Jeff Bezanson、Alan Edelman、Stefan Karpinski 和 Viral B. Shah 合著(http://arxiv.org/abs/1411.1607)。
-
如果你想了解 Julia 创造故事的另一个版本,可以查看 Klint Finley 的文章《公开的:人类创造了一种编程语言来统治一切》(https://www.wired.com/2014/02/julia/)。
-
《Julia 加入了 Petaflop 俱乐部》由 Julia Computing 发布,展示了 Julia 在天文(两方面)应用中的一个案例(https://cacm.acm.org/news/221003-julia-joins-petaflop-club/fulltext)。
-
John Russell 的《Julia 更新:采用率持续攀升;它是 Python 的挑战者吗?》(https://www.hpcwire.com/2021/01/13/julia-update-adoption-keeps-climbing-is-it-a-python-challenger/)提供了一些有趣的历史视角。
-
Bradley Setzler 的《为什么我转向 Julia》是一个关于 Julia 在计量经济学中应用的案例研究,展示了在使用 NumPy 时,Julia 比 Python 的速度提高了 100 倍:https://juliaeconomics.com/2014/06/15/why-i-started-a-blog-about-programming-julia-for-economics/。
第一章:第一部分
学习 Julia
第二章:开始使用
你不需要看到整个楼梯,只需迈出第一步。
—马丁·路德·金博士

正如在介绍中提到的,要学习一门编程语言,光读书是不够的——即使是像本书这样优秀的书籍也不行。亲自实验和编写程序是至关重要的。在书中掌握一个关键概念或运行一个代码示例后,尝试构建代码的变体并运行它们。编写自己的变体将帮助你熟练掌握这门语言。
本章首先介绍如何在所有主要操作系统上安装 Julia,然后讨论各种类型的编码环境。我们将学习如何安装每种环境,并探索它们的独特功能、优缺点。
安装指南
当然,要能够完成这些,你需要访问一个 Julia 系统。如果你已经配置好了运行 Julia 代码的环境,你可以安全地跳过这一整部分。如果没有,你可以跳过你不使用的操作系统的安装子章节,但你应该阅读其他所有内容。
硬件要求
对于学习 Julia,几乎任何计算机都足够。它应该至少有 2GB 的 RAM,但拥有双倍内存将更加舒适。安装 Julia 大约需要 0.5GB 的空闲磁盘空间,但你应该至少预留 3GB 的额外空间,用于安装绘图和其他用途的包。
这些适中的要求足以用于学习语言,甚至可以进行许多实际计算,尽管对于大规模项目,你可能需要更强大的硬件。Julia 可用于各种规模的计算,它能高效地利用从笔记本电脑到 GPU 阵列处理器再到世界上最大的超级计算机等各种硬件(有关示例,请参见第 23 页的“进一步阅读”)。我已经在一台非常普通的笔记本电脑上运行了本书中的所有示例计算,因此本书中的所有代码应该可以在你使用的任何计算机上顺利运行。
Julia 可以在 Linux、FreeBSD、macOS 和 Windows 上运行。截止到写作时,Julia 在这些系统上完全受支持:
-
Linux 2.6.18+: x86-64(64 位)、i686(32 位)和 ARMv8(64 位)
-
FreeBSD 11.0+: x86-64(64 位)
-
macOS 10.9+: x86-64(64 位)
-
Windows 7+: x86-64(32 位和 64 位)
这些安装要求可能会发生变化,因此请查看https://julialang.org/downloads/以获取最新信息。
Julia 还可以在一些未列出的系统版本和架构上运行,但其支持较弱,保证性较差,或可能功能受限。它还可以利用更多专用硬件——例如,我们将在后续章节中讨论的图形处理单元阵列处理器。
先决条件
为了有效使用 Julia,你需要了解一些关于如何操作计算机的基础知识。你需要对终端和命令行有基本的了解:如何创建和更改目录(文件夹)、查看文件列表、查看硬盘上可用的存储空间以及删除文件。
每个操作系统都有各种图形化工具来完成这些任务,包括内置工具和第三方软件,但对于计算科学家来说,熟悉命令行并常规使用它是一个好主意。你很可能有一天会遇到远程计算环境,在这种情况下,命令行可能是与远程机器进行通信的唯一方式。
即使你的个人计算机使用的是其他操作系统,学习如何在 Linux 上执行这些基本任务也是个好主意,因为 Linux 在科学计算服务器中作为前端操作系统最为常见。如果你使用的是苹果设备,应该不会有问题,因为 macOS 的 BSD 派生终端中的基本命令几乎与 Linux 相同。如果你习惯了 Windows,可能需要学习一些转换,但这超出了本书的范围,而且你并不需要了解 Linux 方言就能在个人计算机上使用 Julia。
你还需要熟悉你系统上的一个编辑器,该编辑器能够将文件保存为纯文本格式。大多数程序员使用 Vim、Emacs 或更复杂的集成开发环境(IDE)——我们将在下一节进一步讨论这些选项。你可以使用任何你熟悉的编辑器,但图形化编辑器如 Word 并不是最佳选择。不过,如果你真的想使用这些程序,它们是可以工作的。只要确保将你的作品保存为纯文本文件,并使用等宽字体,这对编写代码更为合适。
Julia 版本
大多数人,无论使用何种平台,都会从官方 Julia 网站下载https://julialang.org/downloads/。无论你是在那里下载还是从其他地方获得,请记住,尽管 Julia 已经稳定了几年,但它仍在快速发展。在这个上下文中,稳定意味着你可以期待没有破坏性更改:你现在编写的程序,或者你使用任何版本的 Julia 从 v1.0 起写的程序,在未来升级 Julia 安装时依然可以正常工作,只有少数例外。然而,快速发展意味着你所安装的特定版本可能会带来显著差异。
关于语言实现本身,自从首次公开发布以来,Julia 团队在速度和响应性方面取得了持续的进展,这种进展很可能会继续下去,这也是推荐使用最新稳定语言版本的充分理由。关于生态系统,许多重要的软件包(即你可以在自己程序中使用的 Julia 代码库)也在快速进展,并且每个月都有新的软件包出现。较旧的 Julia 版本可能与重要软件包的新版本或更新版本不兼容。
在 Julia 网站的下载区,你会找到对应不同“发布版”或最近版本的 Julia 下载。大多数人最适合选择被标识为“当前稳定版”的下载。“即将发布版”是下一个稳定版的测试版本。它会有一些最近添加的新功能,但也可能会和一些软件包存在兼容性问题,且可能会出现一些小的 bug。根据你阅读本书的时间,“长期支持”版可能不包含本书使用的所有功能。通常,为了避免由于代码示例的行为不同而产生的混淆,确保你安装 Julia v1.6.0 或更高版本,并避免使用测试版。
安装
本节包含了在所有可用的操作系统上安装 Julia 的各种选项的说明。你只需要关注适用于你的部分。
作为这些说明的替代方案,你可以在与其他下载链接相同的位置下载 Julia 的源代码。由于 Julia 是完全免费的开源软件,源代码始终可以供专家检查和自行编译。如果你希望在某个没有提供二进制文件的特殊系统上运行 Julia,这是唯一的选择。
在 Linux 和 FreeBSD 上
几乎每个 Linux 发行版都有自己的软件包管理系统:一种官方机制,用于安装程序并保持它们的更新。使用官方的软件包管理器有两个优势。首先,它是集成的,这意味着所有安装程序之间的依赖关系应该会自动解决,一切都能协同工作。第二个好处是安全性:官方仓库中的软件包通常都会经过审查,不太可能包含恶意代码。
不幸的是,将软件打包并纳入大多数 Linux 发行版的官方仓库需要相当长的时间。像 Julia 这样快速发展的项目通常不应通过包管理器进行安装。发行版的版本会远远落后于你可以直接从 Julia 项目获取的当前版本。对于某些采用滚动发布策略并保持包更新的 Linux 发行版来说,这个问题较小,但例如对于基于 Debian 的发行版来说,使用包管理器安装 Julia 不是一个好的选择。
出于这些原因,如果你使用的是 Linux,最好的策略是访问 Julia 下载页面 https://julialang.org/downloads/。找到“当前稳定版本”标题,并在其下查找适合你机器架构的条目。大多数人需要下载“通用 Linux x86 64 位”版本。点击下载链接会将一个扩展名为 .tar.gz 的文件复制到你的计算机上,大小大约为 100MB 多一点。
大多数人的浏览器默认下载位置是在其主目录下的 Downloads 目录,但你的浏览器可能配置有所不同。找到下载位置后,你应该会看到刚刚下载的文件,名称类似 julia-1.X.0-linux-x86_64.tar.gz,这表示为 Linux 系统上的 x86 64 位架构构建的 Julia v1.X.0。双重扩展名表示这是一个压缩的 tar 文件。你可以使用一个命令来解压和解档文件(替换为实际下载的文件名):
tar zxvf julia-1.X.0-linux-x86_64.tar.gz
tar 命令应该已经在任何正常的 Linux 系统中安装好了。输入此命令后,你应该会看到大约 2000 个文件的名称在终端中滚动,表示正在创建子目录并解压初始安装 Julia 所需的文件。除了一个文件外,你不需要直接处理这些文件。整个过程完成后,应该在一分钟内,你会得到一个新目录,名称来自压缩包的前缀。例如,针对 julia-1.X.0-linux-x86_64.tar.gz 下载文件,这个目录是 julia-1.X.0。安装过程会占用大约是 tar 文件四倍的空间,安装完成后,你可以删除该 tar 文件。
下一步是设置你的系统,使得在终端输入 julia 可以启动你刚刚安装的 Julia 程序。
要完成最后的安装步骤,首先通过输入echo $PATH检查你的路径。如果路径中列出了/usr/local/bin,则进入该目录。如果没有,但路径中有你想用来存放本地命令的其他目录,则进入该目录。否则,最好创建这样一个目录,可以是/usr/local/bin或其他目录。根据你的 Shell 类型,执行方法略有不同。对于最常见的 bash 及兼容 bash 的 Shell,在你的.bash_profile启动文件中添加以下行(该文件位于你的主目录中):
PATH=/usr/local/bin:$PATH; export PATH
在你进入/usr/local/bin或选择的本地命令目录后,创建一个指向新 Julia 安装目录中/bin/julia文件的符号链接,并将其命名为julia。例如,命令如下:
ln -s $HOME/Downloads/julia/julia-1.X.0/bin/julia julia
创建链接时,你需要具备 root 权限,或者使用sudo。
你可以将下载的 Julia 安装文件保存在任何位置,但如果你将其移动,需要更新命令中设置的链接路径。
为了检查你新的 Julia 安装是否工作正常,打开一个新的 Shell 并输入julia。交互式提示符应该会出现,等待你输入第一行 Julia 代码。
在 macOS 上
你可以像安装其他应用程序一样在你的 Apple 电脑上安装 Julia。进入 Julia 下载页面,找到你需要的版本部分,点击表格中的 64 位链接。系统将下载一个普通的 macOS.dmg文件,并自动打开。你应该看到一个由红色、绿色和紫色圆圈排列成金字塔形状的 Julia 图标。按照惯例将其拖动到应用程序文件夹中。
当你双击这个图标时,终端应该会打开,并显示 Julia 的交互式提示符,准备接受你的第一个命令。
下一步是进行一些设置,以便你可以从终端命令行启动 Julia,而无需点击图标,这样以后会更加方便。这些准备工作还将允许你在不使用 REPL 的情况下运行已保存的 Julia 程序。
启用此行为需要两个步骤。如果 Julia 的交互式提示符仍在等待你输入,按下 CTRL-D 退出 REPL 或输入exit()。接下来,在 Shell 命令行中输入以下命令,以删除可能由以前的安装留下的任何julia命令:
rm -f /usr/local/bin/julia
然后输入以下命令(将 Julia-1.X.app 替换为你已安装的版本):
ln -s /Applications/Julia-1.X.app/Contents/Resources/julia/bin/julia /usr/local/bin/julia
你也可以检查路径中其他地方是否存在julia命令,例如在/usr/bin目录下,并删除它们或将它们移出命令路径,以避免意外调用旧版本的可执行文件。
该命令创建了一个符号链接,指向存储在你应用程序文件夹深处的实际 Julia 二进制程序。现在你可以在任何终端中输入julia来启动交互式 Julia Shell 或运行 Julia 程序。
在 Windows 上
一些 Windows 安装中没有设置现代终端。你将需要这样的程序才能有效运行 Julia,并跟随本书中的示例。如果你尚未安装合适的终端,一个合理的选择是 Windows Terminal,这是一个可以从 Microsoft Store 获取的免费程序。在做任何事情之前,请安装这个终端或其他同样强大的程序,并确保你知道如何启动和使用它。
访问 Julia 下载页面,找到你所需版本的部分(见 第 5 页中的“Julia 版本”)。
如果你知道自己使用的是 64 位版本的 Windows,请点击 64 位下载链接。如果你使用的是 32 位版本或不确定系统架构,请点击 32 位链接。这将为你下载适用于两种架构的 Julia 安装包,但如果你确定可以使用 64 位版本,使用该版本会有一些优势。
这将下载一个 .exe 安装程序,接下来你应该运行它。它会告诉你安装目录;请务必记下该目录。
以下在终端中运行 Julia 的设置说明适用于最近版本的 Windows。如果你使用的是 Windows 8 或更早版本,可以在 Julia 下载页面找到具体的安装说明。
最新版本的安装程序提供了一个复选框来设置 Julia 路径。如果你的安装程序没有提供这个选项,或者你更喜欢自己选择路径,请按照以下步骤操作:
-
按 Windows 键-R 打开
Run,输入以下命令打开系统变量窗口,以便编辑路径:rundll32 sysdm.cpl,EditEnvironmentVariables -
点击 New 并输入(或粘贴)安装程序告知你的路径(你应该已经复制了这些信息,对吧?)。如果你丢失了路径,可以在 C:\Users<your_username>\AppData\Local\Programs 中查找一个名称中包含“julia”的程序。
-
点击 OK,打开终端,输入 julia 测试你的设置。你应该会看到一个终端风格的 Julia 标志、简短的消息以及一个交互式提示,等待你输入第一行 Julia 代码。
另一个 Windows 上的选择是通过包管理器提供的。以流行的开源包管理器 Chocolately 为例,它可以安装一个相对较新的 Julia 版本。
使用 Docker
如果你了解 Docker,并且确信想通过 Docker 镜像安装 Julia,请阅读此部分。
如果是你,那么你很幸运,Julia 有一个社区支持的 Docker 版本。访问 https://hub.docker.com/_/julia,该页面提供了关于使用 Julia 的镜像说明。我不会列出支持的系统和版本的详细信息,因为这些信息可能会频繁更改。该页面包含有关在你的机器上安装和使用 Julia 容器的最新信息。除此之外,本书中其他的所有内容同样适用于从 Docker 容器中运行的 Julia 和传统安装方式的 Julia。
隐私提示
Julia 团队非常细心地指出了一个隐私问题,虽然这对大多数人来说不成问题,且在任何情况下大多数人都会理所当然地接受,但仍然值得一提。Julia 的包管理系统(我们将在后续章节中讨论)是基于你已连接互联网的前提下设计的,它会根据需要为你下载软件,帮助你完成任务。这意味着,不可避免地,你的 IP 地址、你下载的内容以及下载时间将被存储在某个服务器上,至少会存储一段时间。
Julia 编程环境
安装了基本的 Julia 系统后,接下来我们来看看与其互动的各种方式。不同的与 Julia 对话的方法适用于不同的场景。而且,如果你有喜欢的编辑器或 IDE,本节将解释如何在不改变工作流程的情况下用 Julia 编程。
表 1-1 是接下来讨论的编程环境的简要表格,以及它们的主要优缺点:
表 1-1: 编程环境比较
| 环境 | 优点 | 缺点 |
|---|---|---|
| REPL | 无需安装,快速,有用的模式 | 图形显示在单独的窗口中,输入重复 |
| 文本编辑器 | 文件组织,编辑方便,REPL 集成 | 无图形支持,交互性有限 |
| Jupyter | 大量社区支持,内嵌图形,交互性,支持多种语言,适合分享 | 组织结构差,无法版本控制,隐藏状态,浏览器文本输入 |
| Pluto | 内嵌图形,复杂的交互式控件,反应式且一致,完全支持 REPL,基于普通 Julia 文件 | 仅限 Julia,浏览器文本输入 |
| VS Code | 集成编辑器,REPL,图形支持,良好的语言支持 | 相较于 Vim 或 Emacs,作为编辑器功能较弱 |
让我们更详细地看看每种选项。
Julia REPL
当你在终端输入 julia 时,你进入了 REPL,即 读取-求值-打印循环。你将看到一条欢迎信息,提示符从系统的 Shell 提示符变为 Julia 的提示符。
REPL 模式
REPL 有几种模式。初始模式下,提示符是 julia>,这是你将在 REPL 中花费大部分时间的正常模式。在这里,你可以输入任何 Julia 表达式,按下 ENTER,Julia 会打印出该表达式的结果。即使你还不懂 Julia,也可以试试看,确保一切正常运行。输入一个算术表达式,如 1 + 1,按下 ENTER 后,你应该能立刻看到结果。
如果你使用过 Python、Node、APL 或任何其他基于 REPL 的语言,这种操作模式对你来说应该很熟悉。与 Python 不同,Julia 是编译型语言,而非解释型。这一差异将在后续章节中影响你使用 REPL 的方式,但现在,你可以像使用其他 REPL 一样使用 Julia 的交互界面。
Julia REPL 的常规模式是一个功能强大的环境,内置了几个小技巧,可以让你的工作变得更加轻松。它有一个“粘贴模式”,可以让你粘贴从网页等地方复制的代码示例,通常这些代码示例会带有 julia> 提示符,并且代码与说明文字交织在一起。REPL 会知道只执行任何以 julia> 开头的行,前提是粘贴的第一行也以该提示符开头。(截至写作时,粘贴模式在 Windows 上无法使用。)
REPL 完全支持 readline。这意味着你可以使用上下箭头来回顾之前的命令,并在重新执行之前进行编辑。这个功能即使对于多行代码块(如函数定义)也能很好地工作。要搜索之前的命令,你可以按 CTRL-R 并输入命令中包含的文本。你的命令和代码历史会在 REPL 会话之间保存,因此你可以退出 REPL,第二天再回来时仍然可以通过箭头键回顾之前的命令。历史记录保存在你主目录下的 .julia/logs/repl_history.jl 文件中。该文件包含你输入的所有代码,甚至为每条记录加上时间戳,但它不会记录 Julia 返回的结果。
另一个有用的 REPL 模式是帮助模式。按 ?,提示符会变为 help?>。输入任何 Julia 函数、数据类型、操作符或库,你将看到该项的格式化描述,通常还会附带一些有用的示例。
help?> Base
search: Base basename AbstractSet AbstractSlices Broadcast broadcast broadcast! AbstractString
AbstractDisplay
Base
The base library of Julia. Base is a module that contains basic functionality
(the contents of base/). All modules implicitly contain using Base, since this is needed
in the vast majority of cases.
稍后你将学习如何以一种方式记录你自己的函数,从而与 REPL 帮助系统连接。
REPL 还具有一个 shell 模式,通过按 ; 激活,允许你在 REPL 会话中输入系统 shell 命令:
julia> ilj = "I love Julia"
"I love Julia"
# Enter ";" here to switch to shell mode.
shell> echo $ilj
I love Julia
我们可以使用 shell 模式来执行简单的命令。正如列表所示,我们可以插入 Julia 变量,但管道和重定向功能无法使用。
另一个你会经常使用的 REPL 模式是包管理模式,通过按 ] 激活,我们将在第三章中探讨如何使用包和模块。现在,首先要知道的是,Julia 的包系统是语言和环境的一个重要组成部分,甚至已经内建于 REPL 中。Julia 开发者不需要与多个竞争的第三方包系统作斗争,也不需要面对一些其他语言中不可避免的“依赖地狱”问题。
要退出这些模式并返回到常规的(有时称为“Julian”)REPL 模式,请在光标处于起始位置时按 BACKSPACE 键。
TAB 在任何 REPL 模式下都可以生成上下文感知的补全。如果有唯一的补全,它会在光标处自动填入;否则,REPL 会展示一个选项列表给你选择。
REPL 颜色
为了帮助你了解自己处于哪种模式,REPL 会为每个提示符使用不同的颜色。颜色有助于在视觉上区分提示符与你输入的表达式及其结果。REPL 还会在某些类型的输出中使用颜色,例如帮助输出,用来区分关键词和变量等元素与普通文本。默认的颜色在使用黑色或深色背景的终端时效果良好,这是最流行的选择。然而,这些颜色在白色或非常浅的背景上容易看不清楚。在本书的插图中,我使用了这样的背景,因为它比我通常在电脑上使用的黑色背景打印效果更好。如果你使用浅色终端背景,或者仅仅希望外观与默认值不同,你可以编辑配置文件来更改任何 REPL 颜色。
在你的主目录中,你会找到一个名为.julia的目录(注意有一个点:在大多数人的 shell 配置中,除非你添加标志请求列出“隐藏文件”,否则使用常规命令时该目录通常不会列出,图形化文件管理工具可能会或可能不会默认显示该目录)。在.julia目录下,可能已经存在一个config目录;如果没有,请创建一个。进入config目录并编辑startup.jl文件(如果该文件不存在,则创建它)。将以下内容添加到startup.jl中:
function customize_colors(repl)
repl.prompt_color = Base.text_colors[28]
repl.help_color = Base.text_colors[178]
end
atreplinit(customize_colors)
你刚刚写了你的第一个 Julia 函数。Julia 每次启动 REPL 时都会运行startup.jl文件(.jl扩展名用于 Julia 程序)。这个函数简单地定义了两个变量:一个用于正常模式下提示符的颜色,另一个用于帮助模式下的颜色。方括号中的两个数字是 ANSI 颜色代码,现代大多数终端程序都能理解这些代码。我选择了两种在我的显示器上使用白色终端背景时效果良好的颜色。如果你想选择自己的颜色,可以通过在网上搜索“ANSI 颜色代码”来找到 256 种 ANSI 颜色及其代码表。我只重新定义了这两种颜色,因为其他默认颜色恰好合适。如果你想改变其他颜色,你也可以定义repl.shell_color、repl.input_color和repl.answer_color变量。
Julia 还理解几种颜色名称,但数量太少,无法提供理想的选择。
Unicode 字符
Julia 允许在变量名和其他标识符中使用 Unicode 字符。这意味着你可以在 Julia 程序中让公式看起来更像真实的数学公式,例如使用希腊字母和下标。有些人已经设置了他们的系统,以便轻松输入这些字符。即使你没有这样的设置,得益于 REPL 提供的 Unicode 输入模式,你仍然可以使用这些字符。如果你输入一个反斜杠(\),后跟一串 ASCII 字符,然后按下 TAB 键,接下来会发生三种情况之一。如果 REPL 识别该字符串为某个 Unicode 字符的代码,整个输入(包括反斜杠)将被该字符替换。如果你输入的代码是某个字符代码的开头,或者是多个可能代码中的一个,Tab 补全机制会以正常方式工作。如果 REPL 无法识别你输入的内容,它将不会做任何反应。
REPL 识别的 Unicode 字符代码的完整列表可以在 https://docs.julialang.org/en/v1/manual/unicode-input/ 查找。熟悉 LaTeX 语法的人会高兴地知道,所有希腊字母和一些其他具有 LaTeX 命令的符号都在列表中,并且没有变化。例如,要在 REPL 中输入 α,只需输入 \alpha 然后按 TAB 键。还有更多内容——甚至包括大量的表情符号。
如果你想知道某个特定 Unicode 字符的 LaTeX 风格缩写,可能是你从文档中复制的字符,可以在 REPL 中进入帮助模式,粘贴该字符并按下 ENTER。如果该字符有缩写,帮助系统会告诉你它是什么。
图 1-1 展示了使用扩展字符集时的一些简单示例。

图 1-1:在 REPL 中使用 Unicode
这不仅仅是为了好玩。能够使用更多种类的字符,包括希腊字母和下标,让我们可以让代码更加简洁和富有表现力。
文本编辑器
Julia 程序员通常使用文本编辑器,或是与 REPL 配合使用,或是作为补充。我将在这里介绍一些最常用的程序员编辑器的相关功能。如果你使用的是其他编辑器,务必搜索是否有特定于 Julia 的增强功能,无论是内置的,还是第三方插件形式的。这些增强功能通常包括语法高亮,有助于避免代码中的拼写错误,还可以包括更复杂的功能,如代码格式化和执行。
Vim
Vim 是一个非常优秀的编程编辑器,支持任何语言的开发,并且对 Julia 有很好的支持。我建议安装julia-vim插件,插件地址为https://github.com/JuliaEditorSupport/julia-vim,在那里你还可以找到它的文档。该插件要求 Vim 版本为 7.4 或更高。为了最大程度地利用julia-vim,确保启用了内置的matchit插件,通过执行:runtime macros/matchit.vim Vim 命令来启用,它应该已经在你的 Vim 启动文件中。该插件增加了一个 Julia 文件类型,提供语法高亮并识别 Julia 语法的代码块结构。它通过允许你在输入%时跳转到函数定义或其他代码块的开始或结束,扩展了matchit的功能。你还可以像在 Vim 中操作其他文本对象一样,选择或删除代码块或代码块的主体。
该插件还模拟了 REPL 的 LaTeX 风格的 Unicode 字符输入。为此,它提供了两个选项:你可以让它等待你按下 TAB 键,就像 REPL 一样,或者它可以在看到一个字符(通常是空格)表示输入结束时立即展开输入(然而,实时模式不支持表情符号)。
对于最近版本的 NeoVim 或 Vim,另一个选择是安装 tree-sitter 的语言支持,它为编辑器增加了语法感知的高亮和其他功能。如果你使用的是 Vim 8.0 或更高版本(我强烈推荐使用该版本)或 NeoVim 分支,你可以直接与任何 REPL 进行交互,包括 Julia REPL。所谓“交互”,是指你可以保持在一个包含 Julia 程序的编辑缓冲区内,并将选中的行、表达式或代码块直接发送到 REPL 执行。执行是异步进行的,所以你可以在 Julia 处理一个耗时的命令时继续编辑。与 REPL 的通信是双向的,因此你还可以将 REPL 中打印的结果发送回编辑缓冲区。以下说明适用于 Vim,但 NeoVim 用户应该能够将其适配到该程序中。
首先,安装vim-sendtowindow插件,插件地址为https://github.com/karoliskoncevicius/vim-sendtowindow。打开你选择的编辑缓冲区后,执行:term julia Vim 命令。如果你正确设置了julia命令(参见“安装”,位于第 6 页),一个新的 Vim 缓冲区应该会打开,Julia REPL 会在其中运行,并显示在编辑窗口下方。
现在,你可以在编辑窗口中选择任意文本,按空格键后再按 j 将其发送到 REPL。如果你希望使用其他快捷键来执行此操作,vim-sendtowindow 网页上有关于如何设置快捷键的说明。你还可以为将文本发送到右侧、左侧和上方定义快捷键,这对于从 REPL 发送文本,或者如果你喜欢将窗口垂直拆分时非常方便。:term 命令内置于 Vim,支持异步执行命令。该插件提供了在编辑和终端缓冲区之间便捷地发送文本的方式。vim-sendtowindow 的作者在其网站上维护了类似功能插件的列表。
使用早期版本的 Vim,也可以通过类似 ScreenSend 的插件实现类似的 REPL 交互,但在版本 8 中的 term 命令使 REPL 交互更加流畅且减少错误。
Emacs
Emacs 是一款功能强大的程序员编辑器,支持高级的 Julia 语言。Emacs 的官方 Julia 主要模式叫做 julia-emacs,该模式在 GitHub 上由 https://github.com/JuliaEditorSupport/julia-emacs 开发。Julia 语言的创造者是该项目的贡献者,这可能是该模式深度集成语言结构和语法的原因之一。安装后,Emacs 会使用各种颜色和字体样式显示 Julia 代码,以清晰地呈现其语法。同时,它还提供了对代码结构(如代码块)的导航和操作功能。
要安装 julia-emacs,首先启用 MELPA 仓库 (https://melpa.org),并在你的 Emacs 初始化文件中添加 (require 'julia-mode) 。对于大多数人来说,这个文件会位于家目录中的 .emacs。为了获得更顺畅的体验,你应该使用至少 24.1 版本的 Emacs。如果你的版本较早,建议升级以便更好地使用 Emacs 和 Julia。
Emacs 在与基于 REPL 的语言交互方面表现出色,Julia 也不例外。有几个专门用于 Julia 交互的次要模式。其中最受欢迎的是 julia-repl,同样在 GitHub 上开发,地址为 https://github.com/tpapp/julia-repl。该模式设计用于与前述的 julia-emacs 配合使用,且你必须安装至少 25 版本的 Emacs。
要安装 julia-repl,请编辑你的 .emacs 初始化文件,并添加以下几行:
(add-to-list 'load-path path-to-julia-repl)
(require 'julia-repl)
(add-hook 'julia-mode-hook 'julia-repl-mode)
现在,你可以直接在 Emacs 中启动 Julia REPL。它将在 ANSI 终端中运行,支持完整的文本颜色和格式设置。有关键盘快捷键的表格可以在该模式的 GitHub 页面找到。你可以像往常一样将片段、整个代码块或整个缓冲区发送到 REPL 进行执行。此外,Julia 的内建知识允许该模式执行一些操作,比如列出一个函数的所有方法,理解这些操作会在阅读 第八章 后更加清晰。
Jupyter Notebooks
你可以通过两种主要方式在网页浏览器中使用 Julia,这种方式被称为 笔记本 界面。较旧的方式是 Jupyter Notebook。Jupyter 在自由软件领域推广了笔记本概念,并广泛应用于 Julia、Python 和 R 社区。实际上,Jupyter 这个词是这三种编程语言名称的拼接。
如果你想使用或探索笔记本界面,而没有特别的理由使用 Jupyter,直接进入下一部分,了解 Pluto。Pluto 提供了与 Jupyter 相同风格的笔记本交互性,同时在概念上有所改进。对于那些需要使用 Jupyter 与他人合作、希望在相同的笔记本界面中使用其他语言(除了 Julia),或是希望探索现有 Jupyter Notebooks 的用户,本节旨在帮助你入门。
如果你已经在电脑上设置并运行 Jupyter,只需要安装 Julia 后端。在 Julia REPL 中,按 ] 进入包管理模式(见 第 11 页)。确保你的设备已连接到互联网,然后输入 add IJulia 命令以下载并安装用于笔记本的 Julia 后端以及其依赖的包。这是一个相对较大的安装过程,需要一些时间,但 REPL 会通过动画显示下载进度和模块的预编译进度,时刻通知你。安装完成后,输入 jupyter notebook 无论是在单独的系统 shell 提示符下,还是使用 REPL shell 模式,都可以启动笔记本。
如果你还没有安装 Jupyter,完成之前描述的安装步骤后,在 Julia REPL 中输入以下命令:
using IJulia
notebook()
Julia 会询问是否通过 Conda 包安装 Jupyter。请选择肯定答案。此安装阶段应比 IJulia 安装更快,但仍可能需要一些时间。当软件准备好后,Julia 会在你的默认浏览器中打开一个窗口或标签页,展示 Jupyter 启动页面。要在未来的会话中启动 Jupyter,只需在 Julia REPL 中重复这些命令。
当 Jupyter Notebook 页面打开时,你会看到一个下拉列表,列出了已安装的内核或语言后端。选择 Julia 内核,一个新标签页或窗口将打开。在该页面中,你可以在“单元格”中输入 Julia 表达式。当光标位于单元格内时,按 CTRL-ENTER,Julia 将计算表达式并在其下方的输出单元格中打印结果。
因为我们在网页浏览器中,系统可以利用格式化文本和显示图形的能力。图 1-2 展示了我执行了几个单元格后的 Jupyter Notebook。

图 1-2:在 Julia 中使用 Jupyter
最后一单元是创建一个表面图的命令,该图会直接嵌入到页面中。
使用 Jupyter 时,你无需担心保存工作内容,因为它会频繁自动保存,如图 1-2 中页面顶部的提示所示。
分享你的工作非常简单,只需将笔记本的磁盘版发送给同事即可。所有内容都保存在一个文件中,包括图表和其他图像,默认情况下,这些图像是以 SVG 格式编码的。Jupyter Notebook 文件的扩展名为 .ipynb,并保存在你启动 REPL 的目录中。
如果你打算广泛使用 Jupyter,可以参考详细文档,了解更多功能。
Pluto: 一个更好的笔记本
Pluto 是一个基于网页浏览器的 Julia 笔记本界面,类似于 Jupyter。尽管它是一个较新的项目,但已经被一个庞大的社区广泛使用,并且相较于 Jupyter,它有着显著的优势。它唯一的缺点是仅支持 Julia,但这种专注使得 Pluto 比支持多内核的前端更能充分利用 Julia 的优势。
Pluto 除了依赖现代网页浏览器和 Julia 外,不需要其他任何东西。要安装它,按 ] 进入 Julia REPL 的包管理模式,并执行添加 Pluto 的命令。下载和安装完成后,按 BACKSPACE 退出包管理模式,然后在 REPL 中执行以下代码:
using Pluto
Pluto.run()
在默认的网页浏览器中,将打开一个新窗口或标签页,显示 Pluto 欢迎页面,样式如图 1-3 所示。

图 1-3:Pluto 欢迎页面
在这里,你会看到打开一个新笔记本、继续现有笔记本工作或查看示例笔记本的链接。示例笔记本涵盖了各种主题,内容制作精良且富有教学意义。
Pluto 笔记本是一个网页,你可以在其中的“单元格”中输入 Julia 表达式。在单元格中光标处按 CTRL-ENTER 将使 Julia 执行该单元格中的代码以及所有依赖于它的单元格。例如,如果你在一个单元格中定义或重新定义了一个变量并执行了它,且有第二个单元格使用了该变量,Pluto 将在第一个单元格完成后执行第二个单元格。如果第三个单元格依赖于第二个单元格的结果,Pluto 将接着执行第三个单元格,依此类推。每次执行单元格后,其结果将显示在输入单元格的上方。你可以通过观察单元格左边的动态进度条,看到执行进度在单元格之间传递。
Pluto 通过计算页面上所有单元格的依赖图来决定执行顺序。使用依赖图意味着页面上显示的结果与它们排列的视觉顺序以及你决定执行单元格的顺序无关。你所看到的完全由单元格中的代码决定,因此你可以将你的笔记本分享给合作者,大家看到的都是相同且一致的笔记本。这是相较于其他笔记本的重大进步,例如 Jupyter,其中页面上显示的结果是单元格执行顺序的结果,甚至可能依赖于已经删除的单元格。
Pluto 的行为在某些方面类似于电子表格,并提供相同的实时、响应式体验。即使是像我这样的死忠终端用户,也喜欢在某些类型的探索性计算中使用 Pluto。它能够嵌入图形,并且正如我们将在后续章节中看到的那样,集成图形控件,如滑块和颜色选择器,为代码和数据实验提供了丰富的环境。
图 1-4 显示了一个包含简单矩阵计算的 Pluto 页面。我通过点击欢迎页面上的链接来创建这个页面并开始一个新的笔记本。Pluto 打开了一个新标签页,浏览器切换到了它,我在三个单元格中输入了表达式,并按 CTRL-ENTER 进行求值。

图 1-4:Pluto 笔记本中的矩阵计算
图 1-4 显示了 Pluto 界面的主要元素。顶部是笔记本文件的路径。在你输入路径之前,界面上会有提示信息邀请你填写。路径右侧是保存按钮,但只有在你更改文件位置并希望立即保存时才需要使用它。每次你执行一个单元格时,Pluto 会自动保存你的工作。
在前两个代码单元格中,我定义了两个小矩阵m和n,在第三个单元格中,我请求它们的矩阵乘积。(这预览了我们将在第二章中探索的数组操作。)请记住,在 Pluto 中,结果会显示在输入单元格的上方。
到目前为止,我们也可以在 REPL 中以相同的方式做这件事。这里的不同之处在于,如果我们改变 m 或 n 中的任何数字,并运行带有新定义的单元格,矩阵乘积会立即重新计算,并且修订后的结果会替换掉旧结果,用户无需做任何其他操作。在 REPL 中,我们必须重新输入 m * n,然后新结果会显示在下方,可能会把其他信息滚动出屏幕。
在 Pluto 中,由于显示的结果与它们在页面上出现的顺序无关,我们可以重新排列单元格,以提供良好的展示效果,而无需担心影响计算。我们可以将 Julia 表达式与使用 Markdown 或 HTML 格式化的文本结合起来,将笔记本转变为文章或实时解释文本。
在最后一个单元格中,我输入了一个问号(?),后跟一个数据类型的名称,Matrix。只要你输入问号并开始输入,实时帮助窗口就会打开,显示关于你已输入内容的文档。当你继续输入字母时,文档会随之变化,以反映你输入的内容,直到你看到你想要的内容为止。
帮助窗口会一直显示,提供关于你在任何单元格中输入内容的文档,无论你是否请求帮助。如果它变得分散注意力,可以点击小箭头将窗口收起。由于 Pluto 与 Julia 紧密集成,它还提供了其他便利功能,比如与 REPL 中一样的 Tab 补全功能。
支持笔记本页面的文本文件存储在你在顶部输入的位置,是一个普通的 Julia 模块文件。你可以将其导入到其他 Julia 程序中,直接编辑它,并进行版本控制。你并不被 Pluto 笔记本锁定,而是可以在其他 Julia 项目中使用你在那里开发的代码。
Pluto 是一种全新且创新的方式来开发程序并进行探索性计算,它使用起来非常有趣。即使你只是偶尔使用它,你也应该安装它,并熟悉其界面。关注 Pluto 的发展,查看更多文档,请访问 https://github.com/fonsp/Pluto.jl。
集成开发环境
通过安装各自部分中描述的插件,Vim 和 Emacs 都可以作为 Julia 的强大 IDE。传统的 IDE 对像 Julia 这样的语言并不像对 Java 或 C++ 这种冗长且仪式感十足的语言那样具有决定性的优势,在那些语言中,许多开发者认为它们是必不可少的。而编写 Julia 程序所需的,实际上只是一个文本编辑器。
然而,一些用户更喜欢使用“真实”的集成开发环境(IDE),或者已经习惯了某个 IDE。Julia 的 IDE 形势在本文写作时正处于变化之中。曾经由 Atom 编辑器的插件构成的 Juno 是 Julia 的官方 IDE,但目前已经停止开发。随着语言的发展,Juno 无法继续跟上脚步。Julia 的 IDE 开发已转向 Microsoft 的流行 IDE VS Code 的插件。
你可以从其 GitHub 仓库下载 VS Code,网址是https://github.com/microsoft/vscode,并为你的系统进行编译。对于 Linux、macOS 或 Windows 用户,更快捷的方式是从 https://code.visualstudio.com/Download 下载适当的安装包并按照系统的正常安装流程进行安装。微软还提供了品牌版本的二进制安装包,这些版本可能包含一些小的增强功能,并且是根据 Microsoft 产品许可证发布的。
安装完基础的 VS Code 程序后,你可以从 IDE 内部安装 Julia 插件。图 1-5 展示了如何操作。

图 1-5:在 VS Code 中安装 Julia 插件
截图显示了 VS Code 窗口的左侧区域,并且选择了扩展图标。我在顶部的扩展搜索框中输入了“Julia”,程序显示了与之匹配的公开扩展列表。当你执行此搜索时,列表可能会有所不同,但你要寻找的扩展名为“Julia”,在本例中位于列表顶部。点击蓝色的安装按钮进行下载和安装插件。
安装插件后,退出并重新启动 VS Code。如果你已按照“安装”部分的描述正确设置了路径,按下 CTRL-SHIFT-P(在 macOS 上为 CMD-SHIFT-P)打开命令窗口,执行 Julia: Start REPL 命令。此时,Julia REPL 应该会在窗口底部的一个面板中打开。它的行为与第 10 页上描述的正常 REPL 一样,所有 REPL 模式都可用,并且支持你自定义的颜色和其他设置。
除了直接在 REPL 中输入,你还可以打开现有文件或新文件进行编辑。Julia 代码具有语法高亮,并且提供了语法感知的命令,用于浏览代码并操作其结构。在编写本文时,https://www.julia-vscode.org/docs/stable/ 上的文档大部分是空白页面,但我预计这种情况很快会得到改善。打开命令窗口,输入 Julia: 来发现 Julia 特有的命令,然后滚动浏览命令列表。如果你发现一个常用的命令,列表中会显示每个命令旁边的按钮,允许你为其定义键盘快捷键。
我建议为“将当前行或选中的内容发送到 REPL”命令定义一个快捷键。这样你可以将任何表达式或语句直接从编辑器发送到 REPL 执行。
如果你在 REPL 中执行绘图命令,绘图会出现在 VS Code 窗口内一个专用的面板中。图 1-6 显示了我的笔记本电脑上该窗口的主要部分,选用了 VS Code 三个外观选项中的浅色背景。

图 1-6:在 VS Code 中使用 Julia 插件
在上面的面板中,我正在编辑一个包含几行 Julia 代码的文件,并将其直接发送到下面面板中的 REPL。虽然你可能还不完全理解所有的语言语法,但你可能已经能大致了解这些表达式的预期输出。在尝试了一些算术操作来检查设置是否有效之后,我定义了一系列数字,并将其赋值给 x 变量,然后绘制了应用于列表中每个值的函数。在右上方,绘图窗口已出现。
建议
由于工具的选择是个人偏好问题,我尝试提供足够的信息,介绍所有与 Julia 交互和编辑 Julia 程序的主要方式,以便你可以选择最适合自己的方法。如果你已经习惯使用 Vim、Emacs 或其他编程工具,你无需学习任何新东西或改变工作流程来使用 Julia。使用你熟悉的工具,因为 Julia 可以轻松适应它。
然而,如果你还没有决定使用特定的工具,我有一个建议。我建议你安装 Vim,并安装《文本编辑器》一节中描述的 Julia 专用插件(见 第 14 页)。Vim 需要一些时间来适应,但从长远来看是值得的,它是一个高效且灵活的编辑器,并且使得与 REPL 一起工作变得轻松。
如果你是 Vim 的新手,为了减轻同时学习新语言和陌生编辑器的负担,考虑在 Pluto 中操作并直接使用 REPL,同时花些时间适应新的编辑器。
请注意,这反映了我的个人偏好,你可能更喜欢不同的环境。例如,如果你觉得在基于浏览器的笔记本中工作很有吸引力,完全可以在 Pluto 中完成所有的 Julia 工作。我唯一不推荐的是坚持使用没有 REPL 或语言支持的原始编辑器,因为这样会在长期内拖慢你的进步。
进一步阅读
-
我的文章《科学家的 Linux 工具箱》在 Linux Pro Magazine 中刊登(https://www.linuxpromagazine.com/Issues/2020/241/Scientist-s-Toolbox)提供了更多关于 Julia 和其他对在 Linux 上进行计算的科学家有用的软件的信息。
-
在《Pluto 简介》(https://lwn.net/Articles/835930/)中,我描述了 Pluto 笔记本的开发过程,给出了它的一些使用示例,并将其与流行的 Jupyter Notebook 界面进行了对比。
-
一个有用的 ANSI 颜色代码表可以在https://misc.flogisoft.com/_media/bash/colors_format/256_colors_bg.png上找到。
-
访问https://gitforwindows.org,获取提供 Git、终端程序和其他一些便捷功能的 Windows 解决方案。
第三章:语言基础
学习另一种语言不仅仅是学习用不同的词表达相同的事物,而是学习另一种思维方式。
—弗洛拉·路易斯

有时刚接触编程的人会问,为什么有这么多计算机语言。它们都有不同的语法。有些使用大括号和分号,比如 C 和 JavaScript;有些使用空格,比如 Python;有些因括号多而臭名昭著,比如 Lisp 家族;还有些使用关键词,比如 Julia。
然而,语法差异并不是根本原因。有了经验,语言标点的差异变得微不足道。确实,一些语言比其他语言更快,或者在内存上有不同的要求,尽管这些通常是实现的属性,而非语言本身,但性能也不是根本原因。
不同语言和语言家族持续存在的根本原因在于它们基于不同的思想。每种语言代表了一个独特的概念框架,用来表达计算。当我们编写程序时,我们不仅仅是在告诉机器该做什么。如果真是这样,我们都应该直接写入程序最终会翻译成的机器代码。相反,我们是在告诉人类,包括我们自己,关于一个计算。计算机语言是人类语言。
当你开始学习 Julia 时,重要的是要牢记这一点。你并不是在学习一系列让计算机按你想要的方式做事的咒语。你是在学习一种思维方式:一套你可以用来组织计算概念的思想。如果你掌握了这些思想,你的程序将如你所期望的那样运行,性能良好,并且对其他人甚至是未来的你自己都清晰易懂。
尽管如此,这些宏观的概念将在第二部分的应用章节中展现出来。在这一章中,我们将深入细节:你将用来构建“大教堂”的砖石。
这些元素是你构建 Julia 程序的模块——函数、循环和决策——以及它们与之交互的数据类型,如字符串、各种数字和集合。完成这一章后,你将掌握足够的 Julia 知识,能够编写你的第一个程序。
语法:数据类型、表达式和代码块
在这一节中,我们将学习 Julia 语法的基础,了解在几乎每个 Julia 程序中都会使用的基本结构。我们还将介绍第一个 Julia 数据类型。
在本章中,我将提到 REPL,但这些引用同样适用于任何 Julia 的交互式环境,比如 Pluto 或 VS Code。
数字类型
Julia 中的所有值都有一个类型,就像几乎所有编程语言一样。基本类型之一是数字类型,但就像数学中一样,数字也有不同的类型。在数学中,我们有正数和负数,整数和实数,还有一些更为复杂的类型,如复数和四元数。正整数,或者说计数数,自古以来就存在,但其他类型的数字是由某些人发明出来的。在《用户自定义类型》一节中,第 234 页,你将学习如何在 Julia 中发明自己的数字类型,但现在让我们先看看一些内建的类型。
注意
也许比本书中的任何其他章节都更重要的是,在阅读本章时一定要打开 Julia REPL 并实践你所学的内容。你可以尝试本章中的示例变体,直到你对语法感到熟悉。你将在所有的程序中反复使用本章的内容,因此现在就将这些细节变成你的第二天性将对你有所帮助。
如果你在 REPL 中输入一个没有小数点的数字并按下 RETURN 或 ENTER,Julia 会返回相同的数字。单独的数字是一个表达式,意思是会返回一个结果的东西。由于计算一个普通数字的结果就是它本身,所以你会得到那个数字。这些整数默认被赋予 Int64 类型,意思是占用 64 位存储空间的整数。(我假设是 64 位系统,现在这个假设很安全。如果你使用的是 32 位系统,可以在本章中将 Int64 替换为 Int32。)
带有小数点的数字是 Float64 类型。数字 1 和 1.0 可能具有相同的值,但对计算机来说它们是不同的。第一个是 Int64,而第二个是 Float64。这种区别会在我们后续的工作中产生各种影响。
由于 Julia 的设计目的是进行科学计算,当然它也能处理复数。输入复数的语法使用 im 表示虚数单位(即 -1 的平方根)。因此,要输入数字 3 + 4i,你可以写成 3 + 4im。这个数字的类型叫做 Complex{Int64},因为数字部分恰好是整数。3.4 + 1.1im 的类型叫做 Complex{Float64}。这个表示法意味着它是一个 Complex 类型,包含 Float64 类型的部分。
你可以使用通常的计算机科学计数法表示非常大或非常小的数字:6.02e23 表示 6.02 × 10²³。以这种方式书写的数字是 Float64 类型,即使你将尾数写成整数。指数必须是整数,如果你愿意,也可以使用大写字母 E。
Julia 会将你的输入转换为“标准”的科学计数法。例如,如果你在 REPL 中输入 1234e19,它会以 1.234e22 的形式返回该值。而且显然,它更喜欢使用小写的 e。
还有一些其他的数字类型,比如无符号整数 UInt64,但目前这些已经足够了。我们将在 第八章 中深入了解类型系统。
运算与表达式
加法、减法和乘法在所有这些数字类型上都按预期工作。运算顺序与数学中的顺序相同,并且可以通过括号覆盖。
当需要时,Julia 会显式进行类型提升。表达式 1 + 1 仅涉及整数,结果将是整数 2;没有理由返回其他类型。但表达式 1.0 + 1 涉及浮点数,因此它将返回 Float64 类型的结果 2.0。
在 REPL 中尝试一些包含各种类型操作数的算术运算,包括复数,确保你理解类型提升是如何工作的。整数会提升为浮点数,而浮点数又会根据需要提升为复数。
除法与有理数
Julia 有三种除法。每种语言都必须决定如何处理类似 1/2 这样的表达式。问题是,两个操作数都是整数,但结果却不是。有些语言,比如 Fortran 和 Python 2,会将这个表达式的结果计算为零,因为这表示截取答案的小数点前的整数部分。其他语言会将结果提升为浮点数并返回 0.5;这就是 Julia 所做的。
如果你想要类似于 Fortran 的除法方式,可以使用除法符号 (÷):1 ÷ 2 给出 0,而 4 ÷ 3 给出 1。要在 REPL 中输入此运算符,输入 \div 然后按 TAB(请参阅 第 13 页的“Unicode 字符”部分)。
第三种除法形式使用 // 运算符定义 Rational 数字,即两个整数的比值。使用这种数据类型,你可以对有理数进行精确的算术运算,而无需将结果转换为浮点数。例如,表达式 1//2 + 1//3 计算结果为 5//6。Julia 会将有理数简化为最简形式,因此,如果你在 REPL 中输入 4//6,它将返回结果 2//3。
如果你在 REPL 中输入 1//2 + 1//2,你认为会得到什么结果?如果你尝试了,可能会惊讶地发现结果打印为 1//1,而不是简单的 1。仅包含 Rational 数字的表达式的结果是一个 Rational 数字。如果你改为计算 1//2 + 0.5,你将得到 Float64 类型的数字 1.0。
指数运算与无穷大
要将一个数字提高到幂,使用 ^ 运算符。以下是对不同类型数字进行指数运算的结果:
julia> 2³
8
julia> 2⁰.5
1.4142135623730951
julia> 2^-1
0.5
julia> (1 + im)²
0 + 2im
julia> (1 + im)^(1 + im)
0.2739572538301211 + 0.5837007587586147im
julia> 0^-1
Inf
julia> (0//1)^-1
1//0
所有这些结果应该都是预期的,但最后两个无限大结果值得讨论。除以零,如倒数第二个表达式所示或等同于 1/0,结果为 Inf,其数据类型是 Float64。而 Rational 类型的 1//0 也是无限大的,但它的数据类型是 Rational。它的行为符合无限大的特性:由于向无限大加上一个有限数不会改变它,我们有 1//0 + 1 结果仍然是 1//0。类型提升规则依然适用,因此如果我们评估 1//0 + 1.0,则会得到 Inf:依然是无限大,但它是 Float64 类型的无限大。
除以无限大得到零,这是符合预期的。然而,得到的零是一个 Rational 零或 Float64 零,这取决于操作数的类型:
julia> 1/(1//0)
0//1
julia> 1.0/(1//0)
0.0
浮点数还有其他的大小,就像整数一样。如果我们设法计算 a/b,其中 a 的值为 Float32 类型的 1.0,而 b 的值为相同数据类型的 0.0,Julia 会返回另一种类型的无限大:Inf32。你将在第 234 页的《用户自定义类型》章节中学习如何让变量包含你选择的类型。
模运算
另一个有用的运算符 %,用于返回第一个操作数除以第二个操作数后的余数。例如,5 % 2 返回 1。与其他算术运算符类似,整数会返回整数,浮点数则返回浮点结果。
表达式链
我们简要地看了如何使用分号分隔表达式,并且在 REPL 中使用分号来抑制结果的打印(参见第 11 页)。如果一行中有多个用分号分隔的表达式,那么一连串表达式的结果就是最后一个表达式的结果:
julia> 1; 2; 5+3
8
我们使用 = 运算符在 Julia 中给变量赋值。由于表达式链的值是最后一个,因此赋值
r = (1; 2; 5+3)
结果是 r 的值为 8。如果我们省略了括号,r 的值将被赋为 1,因为此时赋值 r = 1 会成为一个独立的表达式。
系数语法
在没有歧义的情况下,我们可以将一个字面量数与一个变量(或稍后我们将看到的函数)并排,以表示乘法。如果并排的组合造成歧义,Julia 会报错,我们必须改用 * 运算符。
这种写法的乘法与使用 * 运算符的乘法有一个重要的区别。它的运算优先级高于其他算术运算符,因此它是运算优先级规则的例外。几个例子应该能让这一点更清楚:
julia> w = 2
2
julia> 2w
4
julia> 2²w
16
julia> 2²*w
8
julia> 1/2w
0.25
julia> 1/2*w
1.0
在表达式 1/2*w 中,1/2 先计算,结果再与 2 相乘。但由于连接符的优先级高于显式的算术运算符,在表达式 1/2w 中,2w 会先计算。
这种不寻常的语法特性,以及能够使用希腊字母和其他 Unicode 符号,使得代码中的数学表达更像数学公式。
表达式块
另一种将表达式组合在一起的方式是使用begin...end块。这个代码单元从begin关键字开始,就像所有 Julia 中的块一样,最终用end关键字结束。你可以直接在 REPL 中输入这些块。Julia 会识别你正在定义一个块,直到结构完成,才会停止提示符的输出:
julia> begin
1
2
5 + 3
end
8
julia>
与由分号分隔的表达式链一样,这组表达式的结果是最后一个表达式的结果。你甚至可以将这个块的结果赋值给一个变量:
julia> eight = begin
1
2
5 + 3
end
8
julia> eight
8
在 REPL 和其他交互式环境中(如 Pluto),表达式的值会默认打印出来。然而,如果你在运行存储在文件中的程序,你需要使用print(expression)才能在终端上看到值。
逻辑
逻辑值由true和false表示,它们的类型是Bool。重要的逻辑运算符包括逻辑与(AND),用&&表示,以及逻辑或(OR),用||表示。这些运算符是短路的,这意味着,在从左到右的表达式中,一旦可以确定表达式的最终值是true或false,Julia 会停止计算并不会再评估剩余部分。例如,在表达式false && more stuff中,一旦 Julia 遇到&&运算符,它就会停止并返回false,而不会再尝试评估more stuff。它之所以能这么做,是因为这个表达式的结果必须是false,无论more stuff是true还是false。程序员需要意识到这一点,不要依赖逻辑表达式的所有部分都会被评估。在表达式如false && (cc = 17)中,&&后面的部分根本不会被查看,因此赋值操作也不会发生。
如果你需要确保逻辑表达式的所有部分都被评估,使用&和|运算符代替它们。这些是按位与和按位或运算符。它们会转换数字,正如我们将在后面的章节中看到的那样,但在应用于Bool类型时,它们也作为逻辑运算符使用。
Bool值通常来源于比较的结果,这些比较使用运算符>、<、<=、>=、==和===。等式比较的否定形式是!=和!==。<=运算符也可以用更好看的 Unicode 符号≤来表示,>=与≥同义。表达式1 < 5的结果是true,5 ≥ 5也是true,以此类推。
你可能已经注意到有两个等式比较。第一个==比较两个值,而不考虑类型。因此,5 == 5.0会返回true,即使一个数字是整数,另一个是浮点数。另一个等式比较测试两个值在各方面是否相同。只有当没有程序可以写出它能区分这两个值时,才会返回true。因此,表达式5 === 5.0返回false,因为程序确实可以区分整数和浮点数。
像>这样的比较运算符通常不需要配套的否定运算符,因为>的否定是<=。事实上,数学家有时会把这个比较说成“不是大于”。如果你需要将其表示为明确的否定,你必须使用语法!(a > b)来否定整个表达式,至少在写作时是这样。语言中是否包含否定的比较运算符(例如!<)正在考虑之中。
循环:while 块
到目前为止,我们学习了一种代码块:使用begin的表达式块。写一个循环(即一段会根据某个条件持续重复的代码)的常见方式是使用另一种代码块:while块。像所有代码块一样,它以end关键字结束。终止该块的条件使用我们在上一节中学到的比较运算符。清单 2-1 展示了一个在 REPL 中执行的简单while块示例。
julia> j = 0;
julia> while j < 5
println(j²)
j = j + 1
end
0
1
4
9
16
清单 2-1:在 REPL 中循环
println()函数会将其值打印在单独的一行上。但为什么我们需要使用它呢?因为 REPL 中的表达式应该自动打印。
begin块会返回一个结果,即该块中最后一个被评估的表达式。而while块不会返回结果,因此没有任何内容会被自动打印。我们想看到的任何内容都必须显式地打印出来。这可能是件好事,因为循环可能会评估许多表达式,并产生大量我们不想要的输出。
注意到在循环开始前对j变量的初始化。在 REPL 中,这会创建一个全局变量,任何地方都可以访问和修改它。循环结束后,j的值为 5。这是 REPL(以及其他交互式环境,如 Pluto)与文件中的程序行为之间的另一个不同之处。(我将在“作用域”一节中详细解释这个问题,见第 52 页。)
if 块
Julia 具有传统的条件评估控制流,使用逻辑比较运算符(参见“逻辑”一节,第 31 页)以及关键字if、elseif和else。你可以根据需要嵌套if块;每个if块以end关键字结束。
这是一个小程序,我们可以在 REPL 中运行它,告诉我们一个数字是偶数还是奇数:
if n % 2 === 0
"That number is even."
elseif n % 2 === 1
"That number is odd."
else
"I only deal with integers."
end
如果在进入这个代码块之前,你将n定义为一个数字,它将给你正确的答案。如果n未定义或不是数字,你将收到错误消息。
===比较两个整数时,代码会拒绝处理任何非整数类型的数字。试试将n = 6和n = 6.0代入代码,看看会发生什么。
与while块不同,if块会返回一个结果,因此不需要显式的print()语句。
注意
我在本书中通过缩进来明确代码块的结构。缩进在 Julia 中没有语法意义,但使用它是一个好习惯,可以让程序更容易阅读。你可以根据需要缩进代码行,或者根本不缩进,它不会影响代码的执行。空格用于分隔标记,换行符在作为语句和表达式分隔符的作用上等同于分号。除此之外,Julia 一般不在意空白符。
数组
到目前为止,我们看到的各种数字都是存储单一值的类型。数组是 Julia 数据类型的一类,用于存储值的集合。科学计算通常涉及对向量、矩阵或更高维度数组的操作,而 Julia 提供了一个便捷、简洁的语法来操作这些数据结构,同时提供了优秀的数组性能。
尝试在 REPL 中输入 [1, 2, 3]。这是创建一个一维数组的语法,也叫做向量,包含三个元素。它的数据类型叫做 Vector。与之前一样,REPL 会将表达式打印出来,但这次以不同的形式:
julia> [1, 2, 3]
3-element Vector{Int64}:
1
2
3
它还会在值之前打印出一些关于即将显示的值类型的信息。当在 REPL 中打印比简单数据类型更复杂的内容时,Julia 通常会这样做。提供这些信息是为了帮助你解读显示内容。这是有用的,因为在构造数组时,Julia 可能会在某些情况下更改你包含的某些元素的类型,了解这一点是很有帮助的。此外,关于数组形状的反馈可以告诉你数组操作是否按预期执行。
这是一个 Julia 改变某些数字类型的例子:
julia> a = [4, 5.0, 6]
3-element Vector{Float64}:
4.0
5.0
6.0
我们给 a 赋一个值,作为一个包含三个元素的字面量数组:一个整数、一个浮点数和另一个整数。REPL 返回的消息确认这是一个 3-element Vector,但 Vector{Float64} 表示 Vector 的元素都是 Float64 类型。Julia 已经将整数提升为浮点数。我们可以通过查看它打印的数字来确认这一点,现在这些数字都带有小数点。当你用像刚才显示的那种字面量表达式初始化数组时,Julia 总是会尝试通过提升值来使其元素类型统一。这有助于后续对数组的计算性能。数字的垂直排列是 Julia 在可能的情况下打印向量的方式。正如我们稍后将看到的,它有打印各种形状数组的惯例。
有时,无法对元素进行提升,使它们都具有相同的类型。数组的元素可以是任何东西,包括其他数组,如示例 2-2 所示。
julia> a = [4, [5.0, 6], 7]
3-element Vector{Any}:
4
[5.0, 6.0]
7
示例 2-2:一个异质数组
Julia 仍然遵循打印约定,将元素按列排列。第一个和第三个元素是整数,第二个元素是一个向量。但请注意,Julia 如何将该向量中的整数6提升为浮点数,以确保它的所有元素类型一致。REPL 中的信息告诉我们,这个完整向量的类型是Vector{Any},这意味着它是一个可以容纳任意类型混合的Vector。这个特定的数组包含两个Int64类型元素和一个Vector{Float64}类型元素。
我们可以通过索引使用方括号来获取或赋值数组元素。与 Fortran 及许多其他为科学和数学工作设计的语言一样,Julia 中的数组索引是从 1 开始的。
在以下示例中,我在进行 Listing 2-2 中的赋值后,将一些数组索引表达式输入到 REPL 中:
julia> a[1]
4
julia> a[end]
7
julia> a[2]
2-element Vector{Float64}:
5.0
6.0
julia> a[2][2]
6.0
注意使用关键字end来指向数组的最后一个元素;当你不知道数组长度时,这非常方便。数组的第二个元素是另一个数组;我们可以通过双重索引在一个表达式中索引该数组,如最后一个表达式所示。如果你确实需要找出数组的长度,可以使用length()函数。
范围
Julia 可以通过一种特殊的表示法构造数字范围。语法1:5表示一个从1到5(包含 5)的整数范围,步长为 1。你可以通过使用包含三个数字的语法版本来按 1 以外的数字进行计数。例如,1:3:12表示一个包含数字1, 4, 7, 10的范围。范围也可以倒序计数,使用负步长,例如5:-1:2。最后,范围中的任何数字都可以是浮点数而非整数,这种情况下范围中的所有数字都将是浮点数。
范围不是数组。它们处于一种潜在的维度中,随时可以通过使用将其带入现实。与此同时,它们几乎不占用空间。将它们带入现实的一种方法是使用collect()函数,将其转化为真正的Vector:
julia> collect(1:5)
5-element Vector{Int64}:
1
2
3
4
5
julia> [collect(1:2:10), collect(2.5:-0.5:0)]
2-element Vector{Vector{Float64}}:
[1.0, 3.0, 5.0, 7.0, 9.0]
[2.5, 2.0, 1.5, 1.0, 0.5, 0.0]
第一个示例将范围转换为向量,而第二个示例在字面向量内部使用了两个collect()操作,从而得到了一个包含两个向量的向量。
范围最常用的方式是在for循环中,具体内容请见“更多循环:for 块”章节,位于第 46 页。
范围在索引表达式中也很有用,可以从数组中提取多个元素:
julia> v = collect(0:5:20)
5-element Vector{Int64}:
0
5
10
15
20
julia> v[2:4]
3-element Vector{Int64}:
5
10
15
julia> v[end:-2:1]
3-element Vector{Int64}:
20
10
0
这些示例展示了我们如何通过使用递减范围提取数组的子集并方便地反转元素顺序。我们可以通过提供带有步长的范围来提取不连续的元素。例如,v[1:2:5]将得到[0, 10, 20]。
数组:超越第一维
到目前为止我们看到的Vector(向量)是一个一维的Array(数组)。尽管Vector的元素可能包含其他集合,但Vector本身仍然是一维的。Julia 有任意维度的数组。一维数组有自己的类型,因为它们是一个常见的特殊情况,并且可以对处理它们的程序进行优化。
矩阵
具有两维的数组也有一种特定类型,称为Matrix(矩阵)。矩阵在数学和物理学的许多场合以及各种计算中都有应用。它们表示线性变换,能够旋转向量,编码线性方程组的系数,用作简单的数据表等等。
可以把矩阵看作是一个矩形的值表。你可以直接输入这样的表来定义它们:
julia> m = [5 6
7 8]
2×2 Matrix{Int64}:
5 6
7 8
当我将矩阵m的定义输入 REPL 时,我在数字6后按下 ENTER 键以插入换行符。Julia 的 REPL 知道由于方括号未关闭,输入还未完成,因此不会尝试计算任何内容,而是等待更多的输入。在我关闭括号并按下 ENTER 键后,REPL 看到一个完整的表达式,将其赋值给变量m,并返回该表达式,前面附带了它的形状(2×2)、类型(Matrix)以及集合元素的类型(Int64)的描述。
你可以利用这种行为将表达式分割到多行,如下例所示:
julia> (1 + 1
+ 1
)
3
如果没有开括号,第一行的加法将会立即执行,因为它是一个完整的表达式。
矩阵与向量的向量
确保你理解2×2矩阵m和这个向量之间的区别:
julia> v = [[5, 6], [7, 8]]
2-element Vector{Vector{Int64}}:
[5, 6]
[7, 8]
后者是一个一维数组,而前者是一个二维数组。
一些索引操作应该能让这一点变得清晰:
julia> v[1]
2-element Vector{Int64}:
5
6
在这里,Vector v的第一个元素本身就是一个向量。
双重索引选择第一个元素的第二个元素:
julia> v[1][2]
6
在这种情况下,我们得到数字6。
单独的冒号表示选择所有内容——在这种情况下,选择整个第二个元素,即一个Vector(向量):
julia> v[2][:]
2-element Vector{Int64}:
7
8
在这个例子中,单独的冒号是不必要的,因为仅使用v[2]就能得到相同的结果。
由于m是一个Matrix(矩阵),即二维数组,我们使用两个索引来选择它的元素:
julia> m[1, 1]
5
在这个表达式中,索引[1, 1]表示第一行第一列,其中数字5位于此处。
在Matrix中,冒号索引非常有用:
julia> m[2, :]
2-element Vector{Int64}:
7
8
这里选择的是整个第二行。
标量索引
对于一个n维数组,通常的索引方式是使用n个索引:一个用于向量,两个用于矩阵,依此类推,如上面所示的例子。如果使用错误数量的索引,会出现错误:
julia> m[1, 2, 3]
ERROR: BoundsError: attempt to access 2×2 Matrix{Int64} at index [1, 2, 3]
Julia 在抱怨我们试图将一个二维数组按三维数组的方式索引。
如果我们仅使用一个索引来访问m,就像它是一个向量一样,你认为会得到什么结果?奇怪的是,我们并不会得到错误,但一开始可能不容易理解为什么会得到这些特定的结果:
julia> m[1]
5
julia> m[2]
7
julia> m[3]
6
julia> m[4]
8
显然,我们可以像访问一维数组一样访问这个矩阵的四个元素,它们似乎是按列排列的。确实如此,这反映了矩阵中的数字在内存中的排列方式。数字 5、7、6 和 8 是按列顺序读取矩阵内容的,从第一列开始,接着是第二列。这被称为列主序,是元素在内存中的存储方式。
“二维数组”这样的概念是为了简化计算和编程而提出的抽象。在计算机中,数组的元素是以一长行存储的。Julia 中的Vector、Matrix或其他Array类型的数字保证是连续存储的。使用单一整数作为索引被称为标量索引。
标量索引的范围可以从 1 到矩阵的总大小。如果我们尝试使用m[5],会得到一个错误信息,因为该矩阵只有四个元素。
Julia 程序员不必过于关注数据结构的机器表示,也不需要过多考虑它们在内存中的排列方式,但这个细节很重要。对矩阵元素进行循环的计算应该按照列主序进行,而不是行主序,因为前者会访问内存中连续的值,从而提高效率。
使用数组索引数组
除了数字和范围外,索引表达式的元素本身也可以是向量。列表 2-3 设置了一个稍大的矩阵,让我们有更多的空间来操作。
julia> m = [11 12 13 14
15 16 17 18
19 20 21 22];
➊ julia> m[2, [2, 3]]
2-element Vector{Int64}:
16
17
julia> m[[1, 2], [3, 4]]
2×2 Matrix{Int64}:
13 14
17 18
列表 2-3:使用向量进行索引
在定义了一个 3×4 的Matrix之后,我通过使用向量来表示列部分的索引表达式 ➊,提取了第二行的第二和第三列的元素。由于结果是一维的,Julia 会将这些元素放入一个Vector中。
然后我提取了前两行和第三、第四列的元素。由于结果是二维的,因此它变成了一个(较小的)Matrix。
我们已经看到,当我们使用单一索引访问多维数组的元素时,Julia 会将其解释为在按列主序排列的一个维数组中的索引。
在对数组进行索引时,您可以引用它的所有维度:
Array[rows, columns, third_dimension, fourth_dimension]
在这种风格下,每个用逗号分隔的表达式必须是一个Vector或数字。(数字被当作一个元素的Vector,正如5[1]的结果所示。)这些Vector可以是范围表达式或简单的冒号,冒号会被解释为它们所代表的Vector。
或者,您可以将其当作Vector来索引:
Array[Array]
使用第二种风格时,索引表达式中的 Array 可以具有任何形状。结果将具有该 Array 的形状。它可能比原始的 Array 更大,因为可以重复元素。唯一的限制是,如果原始 Array 有 n 个元素,你只能在范围 [1, n] 内使用索引。第一个风格也有相同的限制,但适用于每个单独的索引向量,其中 n 表示该维度上的数组长度。换句话说,你不能索引不存在的元素。
让我们再看看第二种索引风格,使用之前定义的 Array m,并通过几个示例来展示:
julia> m[[2 3
4 5]]
2×2 Matrix{Int64}:
15 19
12 16
julia> m[[end 1 9
9 1 end]]
2×3 Matrix{Int64}:
22 11 21
21 11 22
在这两种情况下,结果的形状与用作索引的数组相同。end 关键字选取源数组中的最后一个元素。在第一种索引风格中,它选取相关维度上的最后一个元素。
连接操作符
在定义矩阵时,使用换行符表示一行的结束并不总是方便的,因此在 Julia 中,你可以改用分号:
julia> m1 = [6 7
8 9];
julia> m2 = [6 7; 8 9];
julia> m1 == m2
true
换行符和分号都是表示 垂直连接操作符 的方式。这也有一个名字,叫做 vcat,因此构造 m1 或 m2 矩阵的另一种方式是使用 vcat([6 7], [8 9])。在这个表达式中,[6 7] 和 [8 9] 是传递给 vcat() 函数的两个 参数。
在之前的 m1 和 m2 定义中,数字 6 和 7 之间使用的空格也是一个操作符,叫做 水平连接操作符。它也有自己的显式函数,叫做 hcat()。理解 [6, 7](一个包含两个元素的 Vector)和 [6 7](一个通过空格进行水平连接的 1×2 矩阵)之间的区别很重要。(标签也可以代替空格用于此目的。)
以下是一些最终示例,用于阐明两种不同连接方式的结果。这里有一种构造矩阵的方式:
julia> [[6 7]; [8 9]]
2×2 Matrix{Int64}:
6 7
8 9
这种构造将垂直和水平连接结合在一个表达式中。数字之间的空格将它们水平连接成每个有一行的数组。分号将这些矩阵垂直连接成一个更大的矩阵,第一行位于第二行之上。
用空格替换分号会产生不同的形状,将两个一行的矩阵水平连接成一个更长的一行矩阵:
julia> [[6 7] [8 9]]
1×4 Matrix{Int64}:
6 7 8 9
在第三个示例中,我们将请求将两个 向量 水平连接:
julia> [[6, 7] [8, 9]]
2×2 Matrix{Int64}:
6 8
7 9
在这个示例中的结果让一些刚接触该语言的人感到惊讶。你可能不会立即明白为什么我们没有得到与前一个示例相同的结果。水平连接实际上意味着,对于一个 Matrix,沿着第二维度连接。由于 Vector 没有第二维度,Julia 必须先将每个 Vector 转换为 2×1 矩阵,然后沿着列维度将它们连接起来。
但是当我们要求 Julia 垂直 拼接向量时,不会遇到这种问题,因为这意味着沿着它们的第一个维度进行连接:
julia> [[6, 7]; [8, 9]]
4-element Vector{Int64}:
6
7
8
9
结果是一个更长的 Vector。
元组
Tuple 类似于 Vector,但重要的区别是,一旦创建,就不能再改变它。初始化 Tuple 的方式与创建 Vector 相同,只是使用圆括号而不是方括号,或者如果不造成歧义,可以完全省略圆括号,如 示例 2-4 中所示。
julia> tup1 = (5, 6)
(5, 6)
julia> tup2 = 5, 6
(5, 6)
julia> tup1 === tup2
true
➊ julia> tup1[1]
5
➋ julia> tup1[1] = 9
ERROR: MethodError: no method matching [...]
示例 2-4:元组的一些属性
这个示例展示了圆括号是可选的,并且两个包含相同值(顺序相同)的元组是不可区分的,因为它们通过了 === 比较。
注意
当一个元组只包含一个元素时,它必须用圆括号括起来,并在元素后面加上逗号——例如, (3,)。
我们可以像处理向量一样索引元组 ➊,但不能对元素位置 ➋ 进行 赋值,也不能以任何方式改变元组。
无法改变的类似向量的集合有什么用处?元组可以用来存储我们希望确保不会被意外修改的值列表。它们的主要用途是作为函数的参数和收集结果,正如我们稍后将看到的那样。
成员资格
Julia 提供了另一个逻辑运算符,用于测试某个元素是否属于某个集合。它是 in 运算符,也可以表示为 ∊,可以在 REPL 中通过输入 \in 然后按 TAB 键来实现。在这种情况下,建议使用 Unicode 版本,因为它有一个取反的形式,表示“not in”,看起来像 ∉,可以在 REPL 中通过输入 \notin 然后按 TAB 键来实现。
下面是一些示例:
julia> 2 ∈ [1, 2, 3]
true
julia> 2 ∉ [1, 2, 3]
false
➊ julia> 2 ∈ [1, 2.0, 3]
true
➋ julia> [2, 3] ∈ [2, 3, 4]
false
➌ julia> [2, 3] ∈ [[2, 3], 4]
true
成员资格使用值的比较 ➊,而不是对象身份,这可能不是你所期望的。
在 ➋ 中,我们得到 false,因为 Vector [2, 3] 不是 Vector [2, 3, 4] 的成员。在接下来的示例 ➌ 中,我们得到一个 true 结果,因为 Vector [2, 3] 是 [[2, 3], 4] 的成员。
字符串和字符
Julia 有点不同,单引号和双引号有不同的含义:单引号表示字符,双引号表示字符串。Char 和 String 是两种不同的数据类型。
字符
Char 是通过一对单引号输入的。Julia 是在 Unicode 时代诞生的,因此它免于像 Python 等旧语言的痛苦过渡。Julia 完全支持 Unicode。一个 Char 可以是任何 Unicode 字符,例如 '5'、'a'、'ñ' 或 '∑'。在底层,它是一个 32 位的值,代表该字符及其 UTF-8 编码。这个值具有一些数字的属性,但实际上并不是数字。
字符是有序的,所以你可以询问 'a' < 'z',Julia 会告诉你 true。
注意
在许多编程语言中,单引号和双引号可以互换使用,它们都表示字符串或字符,而字符是只有一个字母或符号的字符串。像 Elixir 和 SQL 一样,Julia 区分字符串和字符数据类型: "ab" 是一个字符串,而 'ab' 则是一个语法错误。
你可以将整数加到字符上,比如'a' + 1,Julia 会给你下一个字符'b'。减法也给出类似的结果。你甚至可以减去两个字符,找出它们之间的距离:'c' - 'a'等于2,这意味着'a' + 2等于'c'。然而,字符的加法是不允许的。
字符串
String用双引号表示,比如"François"。它是一种集合类型,在某些方面类似于Vector,但有一些复杂性。由于它是由字符组成的序列,你可以通过将单个字符连接起来创建一个字符串。用于此的操作符通常是*。Julia 的设计者决定不使用更常见的+操作符,原因有几个,其中之一是加法是交换的,但字符的连接显然不是:'a' * 'b'产生字符串"ab",但'b' * 'a'则产生不同的字符串"ba"。你也可以通过连接其他字符串来构建一个字符串:"Fran" * "çois"变成了"François"。
由于字符串是集合,你可以使用成员运算符来测试它们,但仅限于测试字符的出现情况:'a' in "abc"会返回true。
如果你想测试一个字符串中是否存在另一个字符串,甚至是一个由单个字符组成的字符串,可以使用occursin()函数:occursin("a", "abc")会返回true。
当像处理向量一样处理字符串时,会出现一个复杂的问题,即试图对它们进行索引时:
julia> n = "François"
"François"
julia> length(n)
8
julia> n[end]
's': ASCII/Unicode U+0073 (category Ll: Letter, lowercase)
julia> n[1]
'F': ASCII/Unicode U+0046 (category Lu: Letter, uppercase)
julia> n[5]
'ç': Unicode U+00E7 (category Ll: Letter, lowercase)
julia> n[6]
ERROR: StringIndexError: invalid index [6], valid nearby indices [5]=>'ç', [7]=>'o'
一切进展顺利,直到遇到最后一个表达式。从String中提取单个元素时,我们得到了预期的Char。为什么n[6]不能直接返回第六个字符呢?更奇怪的是,如果我们尝试n[8],我们并没有得到最后一个字母,而是得到了'i'。如果我们尝试n[end],我们确实得到了最后一个字母。
这些谜团的原因在于,不同的 Unicode 字符占用的空间不同。String中的索引是通过从String的开头开始计算字节数,或者说 8 位单位数来确定的。像“F”和“r”这样的普通 ASCII 字母每个占用一个字节,但“ç”恰好占用两个字节。因此,当按字节计数时,它从位置 5 开始,但下一个字符位于位置 7,正如错误信息所告知的那样。我们之所以收到错误,是因为不允许“内部”索引一个字符。
有一些复杂的方法可以避免这个问题,通过查找任何String的合法索引。幸运的是,你不必学习这些技巧,因为通常不需要直接索引字符串。如果你需要遍历String或任何其他集合的元素,有一种更简单的方法,我们将在下一节中介绍。
对于非常长的字符串,尤其是那些包含换行符并且可能包含引号字符的字符串,有一种更方便的语法。使用三个双引号来界定这些字符串:
julia> ls = """
Line one.
Line two "with a quoted section"!
We're done.
"""
"Line one.\nLine two \"with a quoted section\"!\nWe're done.\n"
julia> print(ls)
Line one.
Line two "with a quoted section"!
We're done.
在这个例子中,使用print()显示字符串与它们作为结果返回时有所不同。
更多循环:for 块
到目前为止,我们已经学会了通过使用while块并结合停止迭代的条件来迭代代码段或循环。这适用于我们想要重复执行某个操作直到某个条件变化的情况——例如,当从网络套接字读取数据直到套接字关闭,或者计算一个逐渐更精确的方程解直到误差小于某个容忍度。在其他情况下,我们只是希望循环固定次数或遍历集合的成员。此时,for循环就派上用场了。
要循环固定次数,可以使用范围表达式。这个循环重复计算清单 2-1 中的内容:
julia> for j in 0:4
println(j²)
end
0
1
4
9
16
这个版本更简单,因为我们不需要在每次迭代时给j加 1。变量会依次取范围表达式中的值,每次循环时进展到下一个值。与while循环一样,for循环不返回结果,因此我们需要显式的println()语句。
我们可以使用任何类型的范围表达式:
julia> for q in 8:-2:1
println(1/q)
end
0.125
0.16666666666666666
0.25
0.5
在这里,我们从 8 递减到 1,每次递减 2。
注意
你可以将 = 替换为关键字 in ,在任何 block 中使用,如果你更喜欢这样。还有一个更复杂的选项:你可以使用成员符号 ∈,我们第一次在“成员”中遇到它,见第 43 页**。
你可以根据需要嵌套任意数量的for块。如果你有一个连续的循环体,意味着在任何循环变量(例如以下清单中的计数器i和j)的更新之间不需要执行任何操作,Julia 提供了一种简洁的语法,避免了页面上深度嵌套的结构:
julia> for i ∈ 0:3, j ∈ 4:6
println([i, j, i + j])
end
[0, 4, 4]
[0, 5, 5]
[0, 6, 6]
[1, 4, 5]
[1, 5, 6]
[1, 6, 7]
[2, 4, 6]
[2, 5, 7]
[2, 6, 8]
[3, 4, 7]
[3, 5, 8]
[3, 6, 9]
所有循环指令都在一行中,我们只需要一个end语句。
相同的for块语法允许我们遍历向量、矩阵或其他容器:
julia> for x in [-19 23 0]
println(abs(x))
end
19
23
0
在这个例子中,x取1×3 矩阵中的值,并对每个值应用绝对值函数。
循环也可以用于Vector和Tuple数据类型,但是如果在for语句中使用Tuple,则需要将其括在圆括号内。
你可以遍历任何维度的数组:
julia> for x in [[-19 23 0]; [-1 22 -17]]
println(abs(x))
end
19
1
23
22
0
17
元素以列主序打印,反映了它们在内存中的布局。
由于字符串也是容器,你可以对它们进行循环:François
julia> for c ∈ "François"
print(c * " • ")
end
F • r • a • n • ç • o • i • s •
在这个例子中,我们不必担心 Unicode 字符的不同长度,因为for循环知道如何从一个字符步进到下一个字符。
函数
Julia 中的项目是围绕一组函数组织的。这些函数类似于数学中的函数,因为它们是值到其他值的映射。在 Julia 中,输入和输出的值可以是任何类型。
这是定义函数的方法:
julia> function double(x)
2x
end
double (generic function with 1 method)
double()函数接受一个数字并返回该数字的两倍。暂时不用担心 REPL 返回的信息,你会在“函数与方法:多重派发”一节中了解它的含义,详见第 229 页。
像这样的简单函数有一种替代语法。你可以将这个函数定义块简化为如下形式:
double(x) = 2x
注意,我们不需要print()语句,因为函数会返回它评估的最后一个表达式。通过在 REPL 中输入像double(-3.1)这样的表达式来试试吧。任何2x合理的地方都能正常工作,但如果你提供了一个不合适的参数,比如字符串,Julia 会返回一个错误信息。
在函数的定义中,(x)部分实际上是一个包含一个元素x的Tuple,它是double()的唯一参数。
函数可以有任意数量的参数。这里有一个函数,如果你提供它的终点的 x、y 和 z 坐标,它会返回从原点到该向量的长度:
julia> function length3d(x, y, z)
sqrt(x² + y² + z²)
end
length3d (generic function with 1 method)
julia> length3d(1, 1, 1)
1.7320508075688772
如果你希望函数停止并返回一个值,使用return语句。我们可以用它来修改我们的length3d()函数,只接受正坐标:
julia> function length3d(x, y, z)
if x < 0 || y < 0 || z < 0
return "I only work with positive coordinates."
end
sqrt(x² + y² + z²)
end
length3d (generic function with 1 method)
如果我们用所有正数的参数调用length3d(),一切正常:
julia> length3d(1, 1, 1)
1.7320508075688772
但是一个负数参数会触发return语句:
julia> length3d(1, 1, -1)
"I only work with positive coordinates."
当你用括号和参数调用函数的名字时,你是在用这些参数执行该函数。这被称为调用函数。如果你提供了错误的参数个数,比如尝试调用length3d(1, 1),你会收到一个错误。当我们想要引用函数而不调用它时,我们只需使用它的名字,不加括号或参数:例如,length3d。我们可以将函数赋值给变量,将它们作为参数传递给其他函数,并像对待其他任何值一样处理它们。
清单 2-5 中的函数接受一个值和另一个函数作为参数,并宣布应用提供的函数到该参数的结果。它适用于任何一个参数的函数,只要你提供一个f能够处理的参数x。
julia> function tellme(f, x)
print("The result is ")
f(x)
end
tellme (generic function with 1 method)
清单 2-5:一个函数接受另一个函数作为参数
现在,如果我们调用tellme(double, 3),我们将在终端看到字符串The result is 6。如果我们调用tellme(abs, -17),函数会打印The result is 17。
这两个例子使用了我们定义的double()函数和内置的绝对值函数。
我之前提到过,你不需要使用print()语句来查看函数返回的结果,所以你可能会好奇这里为什么会有一个。一个函数返回它最后计算的表达式的值,或者如果遇到return语句会立即返回。如果我们省略了print()语句,仅保留字符串,它的值会被计算出来,但不会被返回,因为它不是最后的表达式。函数的执行会继续到下一行,并返回值*f*(*x*)。
print()语句不是一个表达式,而是一个语句,意味着它不会返回结果;相反,它具有在终端上输出内容的副作用。因此,这个函数产生了副作用,然后继续执行下一行(最后一行),那是一个表达式,函数返回它的值。
副作用是指任何改变世界状态的操作,比如创建文件、打印到终端,或者从互联网下载某些内容。纯函数是没有副作用的函数,它只返回结果。尽可能编写纯函数可以使你的代码更容易调试和推理,并且帮助函数变得可组合,这是下一节的主题。
函数组合
就像在数学中一样,组合函数意味着将一个函数的输出作为下一个函数的输入。Julia 提供了三种函数组合的语法。前两种与常见的数学符号相同,而第三种则是一个稍微不同的概念。下面是所有三种方法,用于将我们的double()函数应用两次:
julia> double(double(3))
12
julia> (double ○ double)(3)
12
julia> 3 |> double |> double
12
第一种方法使用了应用函数到参数的语法,其中参数是应用于数字3的函数。数字被加倍,结果也被加倍。
第二种方法使用了数学家们有时用来表示组合的符号,外观更加整洁,特别是我们可以在第一个括号内组合任意多的函数,而使用第一种方法时会导致括号的不断增加。所有的函数被组合成一个单一的复合函数,应用到第二组括号中的参数列表上。你可以在 REPL 中输入小圆圈符号,通过\circ然后按 TAB 键。
最后一种选择是从左到右执行,而前两种从右到左执行。它使用管道操作符|>来创建一个管道。管道中的第一个函数接受开头的值(此处为 3),并将该值应用到该函数上,结果会传递到下一个函数,依此类推。这个方法是那些不喜欢括号的人特别钟爱的,它特别适用于表示数据通过一系列转换的处理过程。
三种函数组合的语法是完全等价的。选择使用哪一种语法取决于个人偏好以及特定情况中的便利性。
创建匿名函数
有时你需要在“临时”情况下定义一个函数,而不赋予它一个名称。这通常发生在你想将一个函数作为参数传递给另一个函数时,但你传递的函数只需要在外部函数执行的计算期间存在。也就是说,它是一次性的。
匿名函数的语法使它们作为映射操作的作用变得明确,使用运算符->来表示映射。要定义一个匿名的倍增函数,可以写作 x -> 2x。
如果函数有多个变量,请将它们用括号括起来:(x, y) -> x/(1 + y)。
我们将在第四章中广泛使用匿名函数,届时它们将使我们能够轻松地绘制数学函数。
广播
Julia 中最有用和最具创新性的运算符之一就是不起眼的点号。通过这个单一字符,你可以将任何函数转变为对数组逐元素操作的函数,这个过程称为广播。
你可以通过简单地在函数名称后加一个点号来将自己的函数转变为数组函数:
julia> f(x) = 2x
f (generic function with 1 method)
julia> f.([1, 2, 3])
3-element Vector{Int64}:
2
4
6
在这里,我们定义了一个倍增函数并将其广播到一个向量的元素上。当然,广播适用于任何形状的数组。
关于 Julia 中函数的核心思想还有很多要说的。和本章中的大多数话题一样,这只是一个简介。你将在后续章节中遇到函数的其他方面,随着需求的变化我们会进一步学习。
作用域
变量的作用域指的是代码中它可见且可修改的区域。当你在任何代码块之外定义一个变量,如 a = 1,变量 a 就是全局的,因为它是在全局作用域中定义的。
REPL 或 Pluto 中的交互式计算风格导致了全局变量的常规使用,因为我们在一个交互式的工作空间中进行即兴创作,方便让所有内容都能立即使用。然而,在编写文件中的永久程序时,最好限制对全局变量的使用。它们最好限制为需要在项目中多个函数之间共享的常量。
如果你需要使用这种全局常量,可以使用 const 关键字声明它们;例如,const e = exp(1)。这不仅确保你不会意外地更改它们的值,还能帮助编译器生成更快的代码。
这种做法有几个好处。首先,它允许你将一个函数从一个文件移动到另一个文件,或者在不担心它们是否依赖于其他地方定义的全局变量的情况下重用你的函数。它保持函数的自包含性。
在非交互式环境中,循环和函数的作用域规则有所不同。掌握它们之后,我们将学习一种规则的轻微修改,使得在 REPL 中工作更加方便。
不是所有的块都会创建局部作用域。以begin关键字开头的表达式块(参见《表达式块》在第 30 页)不会创建自己的作用域。它们的作用域与它们所包含的块相同。如果begin块位于顶层,那么它就在全局作用域中。
对于if块也是如此:就作用域而言,它们只是其直接环境的一部分。
本章引入的其他块建立了局部作用域,但有两种不同的类型。一个作用域适用于函数定义块,而另一个作用于for和while块。
函数的作用域规则
在函数定义内,所有变量默认为局部变量,除非你使用global关键字修饰它们。你可以在函数定义的任何地方使用这种符号一次,因为在同一个块内,变量只能属于一种类型。
如果你将一个尚不存在的变量赋值给一个变量,则会创建一个新变量。如果它已经存在,由于函数定义位于另一个块内,并且它已经在该块中定义,那么会使用那个预先存在的变量。
这些都与作为函数参数传递的变量无关。那些只是局部变量;但请参见《可变性》一节,见第 55 页。
一些例子应该有助于澄清这一点:
s = 0
function glos()
s = s + 1
end
glos()
如果你将这个列表保存到文件中,并通过输入julia *filename*来运行它,你会收到一个错误消息,提示s未定义。尽管s在全局作用域中已被定义为 0,但在函数定义内的赋值操作会创建一个新的局部变量。然而,在语句的右侧,这个变量是未定义的。
然而,这个程序文件在运行时没有错误:
s = 0
function glos()
print(s)
end
glos()
它输出0。由于在函数体内没有对s进行赋值,因此没有创建新的局部变量,函数使用的是现有的全局变量。
如果我们确实在第一个例子中打算使用那个全局的s呢?
s = 0
function glos()
global s = s + 1
print(s)
end
glos()
这个程序输出1。在函数内声明s为global意味着函数内部的变量与外部的变量是相同的。
我们已经查看了函数内定义的变量与全局变量之间的关系。我们还需要考虑,如果函数块内的变量与函数外的局部变量同名会发生什么。这种情况可能发生在一切都被包含在另一个块内——比如另一个函数定义中:
function outer()
➊ s = 0
function glos()
➋ s = s + 1
end
glos()
print(s)
end
outer()
当这个程序运行时,它输出1。变量s是一个局部变量,因为它在函数块内定义 ➊。因此,根据作用域规则,当它在内部函数glos() ➋内被赋值时,并不会创建一个新的局部变量;相反,会使用现有的变量。
循环的作用域规则
for块和while块都创建局部作用域,但它们与函数块的行为有一个小的不同之处。
如果在循环内给一个变量赋值,且全局作用域中已存在同名变量,两个事情会发生:变量在循环内被视为局部变量,循环内发生的任何操作都不会影响全局版本的值,Julia 会在你从文件运行程序时在终端显示警告(但在 REPL 中不会,如下一节所述)。
发出警告的原因是,遮蔽全局变量在循环内部会产生歧义:Julia 无法确定你是想创建一个新的局部变量还是使用全局变量。Julia 并不会拒绝运行你的程序,而是选择了一个选项,并警告你可能有不同的意图。通过在循环内使用local或global关键字来修饰变量,可以消除歧义。
对于程序文件,一个更好的解决方案是将循环和它引用的变量放入一个函数中。这样这些变量就不会在全局作用域中,Julia 也不会发出警告。通常,虽然我们在 REPL 中进行许多计算而不在函数内,但在编写程序文件时,将尽可能多的内容放在函数内部是一个好习惯。
因此,行为与函数块的情况完全相同,唯一的区别是警告。Julia 在循环中会发出这个警告,但在函数定义中不会,因为虽然函数通常只使用作为参数传入的变量和它们的私有局部变量,但循环通常会使用在循环外部设置的变量。当所有内容都在局部作用域内时,比如循环及其初始化都在函数内部时,程序员重复变量名的可能性很小。然而,当循环外部的变量是全局变量时,意外重复的可能性就大了。这个循环可能是从另一个文件中复制过来的,而那个文件恰巧使用了相同的变量名,或者全局变量可能在距离循环几千行之外的地方定义。为了帮助你避免这种情况,Julia 遵循作用域规则,并警告你关于在循环中被遮蔽的全局变量。
交互式上下文中的作用域规则修改
在 REPL、Pluto 或其他交互式上下文中,while和for循环的作用域规则与函数块不同,后者在交互式和非交互式上下文中使用相同的规则。
在 REPL 中,如果在循环内给一个变量赋值,且该变量在全局 REPL 作用域中不存在,则会创建一个新的局部变量。然而,如果已经存在同名的全局变量,那么将使用这个全局变量,并且不会发出警告。
这种对作用域规则的修改使得在 REPL 中的工作更加便捷,尤其是在处理所有全局变量时。它还简化了调试 REPL 内部函数部分的过程。想象一下,某个循环及其初始化从一个大函数中复制出来,并粘贴到 REPL 中。在文件中,初始化变量是局部的,但当粘贴到 REPL 中时,它们就变成了全局作用域。REPL 中的规则例外使得循环及其初始化在 REPL 中的行为与它们在原始环境中(函数块内)一致。
可变性
在本章的多个地方,===操作符作为严格相等的测试出现:只有当两个值具有相同的类型和相同的值时,它们在===意义上才相等,或者说相同。
现在我们了解得更多了,可以在其他上下文中重新审视===的比较。以下的例子可能会让你感到惊讶:
julia> [1] === [1]
false
比较的两边看起来一样:它们都是Vector,并且都包含相同的单一值,且都是Int64类型。而且,正如你所检查的,1 === 1为true。
上述例子中结果的原因是,每次你创建一个数组时,你都在创建一个新的对象,并且该对象在内存中有自己的位置。比较两边的两个数组并不相同,因为它们位于不同的内存地址。你可以编写一个程序来区分它们,这就是强制===比较返回false的正式标准。
数字则不同,它只是一个数字,在内存中没有特定的位置。整数1始终与自身相同。
如果我们将这些对象赋值给变量,就能更加清楚地理解。如果我们进行赋值v1 = [1]和v2 = [1],那么比较v1 === v2会返回false,而v1 == v2则返回true。这两个变量有相同的值,但它们是不同的对象。它们并不相同。可以把这些变量看作是对数组起始位置的引用,或者是指向内存地址的指针。
数组是可变的。以下是数组可变性的一个结果:
julia> a = [1]
1-element Vector{Int64}:
1
julia> b = a
1-element Vector{Int64}:
1
➊ julia> b[1] = 7
7
➋ julia> a
1-element Vector{Int64}:
7
julia> b === a
true
首先我们定义a为一个包含一个元素的Vector。然后我们将b设置为等于a。接着,我们将b的第一个(也是唯一一个)元素改为7。之后,当我们查看a时,我们发现它也发生了变化。它的第一个元素现在也变成了7。为什么会发生这种情况的线索就在最后一句:b和a不仅仅是相等,它们是相同的。当我们执行赋值b = a时,我们让b指向与a相同的内存地址。现在这两个变量都是指向同一对象的指针或名称。所以,如果我们更改或修改其中一个,另一个也会发生相同的变化。
在 Julia 中,有些对象是不可变的:
julia> a = 1
1
julia> b = a
1
julia> b = 7
7
julia> a
1
在对a和b进行赋值后,它们成为数字1和7的替代名称。一个表格跟踪我们为值所取的名称,并存储在内存中的某个地方,但变量是值的名称,而不是内存地址的名称。
在第二行,我们告诉 Julia 将 b 也作为数字1的名称。之后,我们改变主意,希望b代表7,但这并不会改变a作为数字1的名称。你不能修改数字1。它永远是1,也一直是1。但是,你可以通过改变一个数组的内容来修改它。
修改其参数的函数
我们可以通过直接为其元素分配一个值来修改数组。我们也可以通过将一个元素添加到数组的末尾来修改数组,从而使它变大。我们始终可以通过连接来做到这一点。例如,如果v是一个数字的Vector,v = [v; 7]会将数字 7 附加到末尾,使其长度增加 1。
然而,在我们需要多次进行此类计算的情况下,这样做效率不高。如果我们到达一个内存空间不足以让v的元素连续存放的地步,Julia 将不得不移动它,可能是多次移动。更高效的选择是使用为此目的而设计的内置函数。如果我们调用push! (v, 7),它就像连接版本一样修改v,但效率更高。当push!()没有足够的空间时,它会移动数组并为将来的扩展预留内存。每次它发现需要这样做时,它会预留一个几何增长的空间量。这个函数旨在处理常见场景:一个数组在时间和空间上都高效地被附加到一个循环中的情形。
push!()名称中的感叹号提醒用户这是一个会修改其参数的函数。它不是语法的一部分,而是一个强烈的约定。通常,Julia 中的函数将它们的参数作为输入用于计算并返回结果:我们之前称之为“纯函数”。带有!的函数会改变它们的参数,可能会或不会返回结果。push!()函数也会返回结果:被修改后的数组。
由于感叹号的使用是一个约定,而不是由语言强制执行的规则,因此任何函数都可以修改其可变的参数,但这个约定非常有价值,Julia 程序员在遵循它时非常小心。
push!()的反义操作是pop!(),它通过移除参数的最后一个元素并将该元素作为结果返回,来修改其参数。
字符串是不可变的
尽管String类型是一个集合,像Vector、Matrix和其他数组类型一样,但它是不可变的。
我们可以索引字符串,但不能为其元素赋值,因为字符串是不可变的:
julia> s = "abc"
"abc"
julia> s[1:2]
"ab"
julia> s[3] = 'Z'
ERROR: MethodError: no method matching setindex!(::String, ::Char, ::Int64)
这表明我们可以像索引一个向量一样索引字符串,但我们不能更改它的任何元素。
如果我们想要创建一个新字符串,就必须字面定义它,或者通过拼接和索引,从现有字符串的部分或字符中构建它。这里有一个小函数,它接受一个字符串并返回一个装饰版的字符串:
julia> function string_decorator(s)
decorated = ""
for char in s
decorated = decorated * char * " • "
end
decorated[1:end-5]
end
string_decorator (generic function with 1 method)
julia> string_decorator("Julia")
"J • u • l • i • a"
函数最后一行的end-5用于省略最后的符号和它前面的空格——一个符号占用四个字节。
一般来说,这里使用的通过反复重新定义字符串来构建字符串的技巧,仅适用于小字符串和有限次数的重新定义。因为字符串是不可变的,每次进入循环都会创建一个新对象,这样会浪费内存。
下面是如何编写一个函数来执行相同任务,而不创建一堆字符串:
julia> function better_string_decorator(s)
a = String[]
for char in s
push!(a, char * " • ")
end
join(a)[1:end-5]
end
better_string_decorator (generic function with 1 method)
julia> better_string_decorator("PARTY!")
"P • A • R • T • Y • !"
内置的join()函数接受一个字符串数组,并将它们连接成一个更长的字符串。如果有合适的方法,它会将其他类型转换为字符串,这意味着join([5, "6", 'X'])会返回"56X"。
join()的对立函数是split()。这个函数将一个字符串拆分成一个由较短字符串组成的数组:
julia> split("a b c")
3-element Vector{SubString{String}}:
"a"
"b"
"c"
julia> split("a||b||c", "||")
3-element Vector{SubString{String}}:
"a"
"b"
"c"
它会根据空白字符进行拆分,除非你提供一个第二个参数作为字符或字符串。在这种情况下,它将使用第二个参数作为分隔符;分隔符本身会被丢弃。
代码中的注释
语言介绍如果不包括注释的语法,就不算完整。
Julia 中的单行注释以井号(#)开头,可以单独占一行,也可以跟在一行代码后面。换句话说,Julia 会忽略井号后面的所有内容。
要包含多行注释,需要以#=开头,以=#结尾。
恭喜
如果你已经掌握了本章的所有内容,那么你现在可以编写有用的 Julia 程序来解决多种问题。
然而,你编写的大多数程序不会是完全自包含的。现代程序员通过将自己的代码与别人和自己编写的现有库中的函数结合,来构建解决方案。下一章将介绍 Julia 内置的一个系统,帮助你管理这些库和你自己的程序。
第四章:模块与包**
有关包的信息和包本身同样重要。
—FedEx 创始人弗雷德里克·W·史密斯

在前一章中,我提到过,Julia 程序是围绕一组函数组织的。这些函数是程序的动词,意味着它们描述了程序的功能。你可以在 REPL 或 Pluto 中度过一生,保存程序文件,仅使用函数、变量和数据类型定义。
但是,当你开始系统地开发基于之前工作的项目,或者希望其他人能够在他们的项目中使用你的代码时,你将希望利用 Julia 提供的结构来组织和共享你的程序。即使你从未重用或分发过自己的代码,你仍然会使用 Julia 标准库中的代码,使用其他官方 Julia 包中的代码,甚至可能使用其他研究者的代码。无论如何,熟悉 Julia 的模块和包系统至关重要。
模块
Julia 程序员在 REPL 和程序文件中广泛使用模块,借用现有的绘图、解方程、提供网站服务以及无数其他活动的功能已经成了常规操作。然而,创建模块在 REPL 中并没有多大用处。你创建的模块将保存在文件中,随时准备根据需要使用。
理解命名空间
命名空间是用于分组名称的方式,它将这些名称与其他组中存在的相同名称区分开来。我们需要命名空间,因为函数和变量可能在不同地方定义,但名称恰好相同,我们需要一种方法来明确我们所引用的是哪个对象。
当我们在 REPL 中定义一个对象时,我们可以稍后通过它的名称引用它。例如,在进行像a = 1这样的赋值操作后,变量a将返回1。我们说a是在全局命名空间中定义的。术语有所不同:有时它被称为顶级命名空间,有时称为主命名空间。无论如何,当前命名空间是我们正在使用的命名空间。
当我们需要引用在其他地方定义的对象时,我们有两种选择。我们可以通过它们的简单名称来调用它们,就好像它们在当前命名空间中定义过一样,或者我们可以通过像SomeModule.a这样的名称来引用它们。在后者的情况下,我们说a位于SomeModule命名空间中,我们使用了限定名称来引用它。
这两个名称SomeModule.a和a可以指代不同的对象——甚至可能是不同类型的对象。标识符a可能是我们在 REPL 中定义的一个变量,而SomeModule.a可能是SomeModule模块中定义的一个函数。在接下来的章节中,我们将学习如何从其他模块中引入对象,何时需要使用限定名称来引用它们。
使用已安装的模块
Julia 安装时自带许多模块可供使用。特别是Base和Core这两个模块中的资源总是自动可用的,这也是为什么我们可以直接调用前一章中使用的函数,如abs(),而无需显式加载任何内容。这些基本函数大多位于Base模块中。Base还提供了一些基础功能,如+运算符,实际上也是一个函数。Core模块存在于更深层次,包含了一些基础构件,如Int64数据类型。虽然没有Base你无法做太多事情,但你可以安排不加载它。然而,Core模块对 Julia 的运行是必需的,因此不可省略。
标准库是一个总是与 Julia 一起安装的模块集合,但你需要显式加载它们才能使用。标准库中的模块提供了在各种计算中常用的功能,但这些功能的基础性不如算术运算符等。你在任何特定的程序中都不需要标准库中的所有模块,但一个典型的程序会使用其中几个模块。
你可以使用using或import语句加载模块中的资源。
注意
大多数模块的名称首字母为大写,并采用“驼峰命名法”,例如 LinearAlgebra 模块来自标准库。虽然你可以在自己的项目中忽略这种命名约定(包括在《改变参数的函数》中解释的使用!符号,第 56 页),但遵循这些约定会让你的代码更容易被其他 Julia 程序员阅读。
using语句提供对模块中所有内容的访问。它将模块创建者标记为导出的所有名称引入当前命名空间。因此,例如,在执行using Plots之后,我们可以直接使用plot()函数,如plot(x -> x²)。
然而,我们可以使用任何已知的名称,即使它没有被导出。Julia 没有秘密。只需将未导出的名称与模块名和点符号组合。例如,Plots.surface(x, y, f)无论surface是否被导出都能正常工作。在这种情况下,我们是在调用Plots命名空间中的surface。
另一种使用其他模块资源的方式是import语句。import和using的唯一区别在于我们如何使用名称。如果执行import Plots,则Plots.surface(x, y, f)将正常工作,但直接使用surface(x, y, f)则不行。import语句提供对模块中所有内容的访问,和using一样,但不会在当前命名空间中提供。你必须使用模块的命名空间。
你可以使用带有逗号分隔的模块列表的语句:using *Module1, Module2, Module3*。
为了展示using和import语句的区别,我们将使用两个来自标准库的模块:LinearAlgebra模块,它包含用于解线性方程组、求逆矩阵和其他线性代数操作的函数,以及Random模块,它提供随机数函数。
列表 3-1 使用了一些来自标准库的函数。
using LinearAlgebra
import Random
➊ function randexp()
17
end
a = [1 1]
b = [0 1]
➋ dot(a, b) |> println
➌ Random.randexp() |> println
➍ randexp() |> println
列表 3-1:导入模块的两种方式
前两行使得来自两个标准库模块的资源在程序的其他部分可用。using和import语句之间的区别在于我们如何引用这些资源。
using LinearAlgebra语句允许我们直接使用该模块中所有的导出名称。导出名称是那些出现在模块的export语句中的名称。我们可以直接使用dot()函数 ➋,它计算两个向量的点积,因为它是由LinearAlgebra导出的,而using LinearAlgebra语句将其引入当前命名空间。([ a, b ]和[ c, d ]的点积是ac+bd。)我们也可以通过LinearAlgebra.dot()来引用这个函数;这两个名称指向相同的对象。
注意
有时,import和using语句会引起显著的延迟。Julia 正在预编译模块中的一些函数,以提高它们的使用效率。
另一种使用其他模块资源的方法涉及import语句,正如我们在第二行中使用的那样:import Random。import和using之间的唯一区别在于名称的使用。由于我们导入了Random,为了使用它的函数,我们必须在函数名前加上模块的名称 ➌。
如果我们使用using引入一个模块,并且程序中已经定义了该模块的一些名称,Julia 会打印一个警告。下一节将描述如何处理这个问题的其他方法。
当我们在多个模块中存在相同名称,或在导入的模块和程序中存在相同名称时,我们使用import语句。模块命名空间的使用将消除歧义。例如,我们的程序有我们自己的randexp()函数,它与Random模块中的函数不同。它返回17,这是我在编写函数时随机选择的,因此命名为randexp。
在定义randexp() ➊之后,我们定义了两个向量a和b。我们使用由LinearAlgebra导出的dot()函数计算它们的点积,并将其输出传递给println()以便我们查看结果。
下一行调用了Random中的randexp()函数并打印结果。这个函数从指数分布中随机选择一个数字。
最后,我们从程序的全局命名空间调用randexp() ➍并打印结果:17。
这是程序运行的一次输出:
1
0.11747991328811039
17
当你运行它时,第二个数字将会不同,因为它是随机生成的(请参阅第 307 页上的“Julia 中的随机数”)。
选择性导入和重命名
到目前为止,我们已经看过两条 Julia 语句,每条语句都允许程序引用在其他地方定义的对象。两者都可以访问目标模块中的所有内容,但在引用模块对象的方式上有所不同。
我们可以在任意一条命令后添加更多控制选项来进行补充。
as 关键字允许我们为程序中的模块选择一个名称。如果我们将清单 3-1 中的第二行改为 import Random as Rnd,我们需要将使用它的那一行改为 Rnd.randexp() |> println。
我们可以在模块名后加上冒号,以将导入限制为仅指定的对象。可选地,我们可以使用 as 关键字将这些对象重命名为我们选择的名称。这些方法可以避免与现有名称发生冲突。以下是经过一些修改的清单 3-1:
using LinearAlgebra
➊ import Random: randexp as rrexp
function randexp()
17
end
a = [1 1]
b = [0 1]
dot(a, b) |> println
➋ rrexp() |> println
randexp() |> println
这个程序的结果与之前的版本相同,但 import 语句 ➊ 仅从 Random 导入 randexp() 函数,并将其重命名为 rrexp()。当我们调用它时 ➋,必须使用其别名,因为原始名称 randexp() 在当前环境中无法识别。
创建模块
在 Julia 中,模块和文件之间没有直接关系,也没有文件名与模块名之间的关系。一个文件可以包含多个模块,而一个模块可以分布在多个文件中。
我们使用 module 关键字在程序文件中定义一个模块。它开始一个类似于块结构的内容,并以 end 关键字结束,但与第二章中描述的块不同。因为整个文件通常由一个模块的内容组成,所以惯用的风格是不对模块体进行缩进。这样做会导致大部分文件被无意义地缩进。另一个区别是作用域:在模块内定义的变量,但不在任何定义局部作用域的块内定义,都是模块的全局变量。每个模块都有自己的全局作用域,因此一个包含多个模块的文件会有多个这样的作用域。
举个例子,我们从一个简单的例子开始:清单 3-2 是一个小程序,包含两个模块,所有内容都在一个文件中。
module M1
export plusone
plusone(x) = x + 1
end
module M2
export minusone
minusone(x) = x - 1
end
➊ using .M1, .M2
println(plusone(99))
println(minusone(101))
清单 3-2:一个包含两个模块的程序
这个程序定义了两个模块,M1 和 M2。每个模块定义一个函数,并在 export 语句中列出它。通常,export 语句放在模块的顶部,但它们可以出现在任何地方。运行该程序会打印 100 两次。
using语句 ➊ 将两个模块的导出名称引入到文件的全局命名空间中。模块名称前的点表示我们引用的是当前模块内定义的模块。但是看起来我们并不在一个“当前模块”中:语句只是出现在文件的顶层。
在 Julia 中,我们总是在一个模块中。顶级模块如果我们没有自己命名,则默认叫做Main,所以M1和M2是Main模块中的模块。
如果我们使用的是import而非using,那么在调用函数时就必须提到模块命名空间。虽然在导入时需要使用点来表示模块的位置,但是其名称仍然是在module语句中给出的。例如,plusone()函数是M1.plusone()。
模块导入语句中的点,类似于在类 Unix 操作系统中目录名称中的使用,有着相似的意义。单个点表示当前的“目录”或模块,而双点则表示向上一级目录查找,即封装模块。
Listing 3-3 展示了一个示例。
module M1
export plusone
plusone(x) = x + 1
➊ module M2
export minusone
minusone(x) = x - 1
➋ using ..M1
println(plusone(200))
end
end
➌ using .M1, .M1.M2
println(plusone(99))
println(minusone(101))
Listing 3-3:相对模块导入
我们已将M2模块的定义移到M1内 ➊。在M2内,我们导入M1 ➋,现在M1是一个兄弟模块:双点告诉 Julia 在查找M1之前要向上一级。此using语句之后,plusone()在M2内可用,因此我们可以在println()语句中直接调用它。
回到顶层,也就是Main模块,我们再次想要将M1和M2中导出的每个名称导入到全局命名空间中,但这次我们需要指定M2是在M1内 ➌。
这个程序打印出201,随后输出与 Listing 3-2 中的示例相同的内容。
如果我们仅仅想将文件的内容插入到当前文件中,我们使用include()语句,并给出文件路径作为字符串参数。这等同于将文件内容粘贴到include()语句所在的位置。它不会使用任何模块命名空间机制,而是将包含的文件中的对象引入到模块的命名空间中。使用文件包含功能,我们可以将大型模块拆分到不同的文件中,有助于保持代码的组织性。
使用文档字符串记录函数
上一章描述了如何使用 REPL 的帮助系统来获取函数信息。我们可以为自己的函数编写文档,以便帮助系统提供格式化良好的信息。
在任何函数定义前面放置一个字符串字面量来进行文档注释,这样就创建了所谓的文档字符串。帮助系统以及其他任何 Julia 文档系统将会把这个字符串与函数关联起来,并在用户请求帮助时格式化并显示它。这里是一个有些傻的例子,我为 Listing 3-3 中的plusone()函数添加了一些帮助文本来进行文档注释:
module M1
export plusone
"""
plusone(x)
Add _one_ to the **number** `x`.
# Example
For example, `plusone(1)` returns 2.
"""
plusone(x) = x + 1
module M2
--snip--
在这个例子中,我使用了第 45 页上解释的三引号字符串语法,方便地嵌入换行符和其他字符,而不需要转义它们。大多数帮助字符串都是这样写的。
文档字符串中的 MARKDOWN
文档系统理解一种版本的 Markdown 语法,并会相应地格式化输出。Markdown 是一种简化的文本标记系统,你可以使用下划线、双下划线和反引号分隔符来指定斜体、粗体和代码,星号也可以作为下划线的替代符。空白行开始新段落,文本前四个空格缩进表示代码块。以井号开头的行不是评论(如同 Julia 代码中的做法),而是作为标题:# 标题、## 子标题,依此类推。
这个例子展示了 Julia 社区使用的一些文档约定。帮助文本从函数签名开始,接着是对函数功能的命令式说明。之后可以添加更多解释和示例。
图 3-1 是一个 REPL 会话的截图,其中我包含了modutst.jl 文件。

图 3-1:使用文档系统
在 REPL 中,println() 语句被执行并产生先前显示的输出。我按了 ? 进入帮助模式并输入了函数的名称。在显示名称的模糊搜索结果后,REPL 会呈现最可能的选择的文档字符串。终端 REPL 使用对比颜色并将代码以斜体和下划线呈现。其他环境可能使用不同的排版。
有关 Markdown 格式化的更多详细信息,请参见第 81 页的“进一步阅读”部分。
我们已经学会了如何使用点(.)引用当前文件中定义的模块,并通过 include() 扩展当前文件。之前,我们通过相同的 using 和 import 语句加载外部模块,但模块名前没有点。在那些情况下,Julia 以某种方式知道在哪里找到包含模块定义的文件,这也是下一节的主题。
包系统
与 Julia 的包系统交互的最方便方式是使用 REPL 的包模式。按 ] 进入该模式,按 BACKSPACE 退出该模式。
进入包模式后,再看看提示符。它看起来像 (@v1.8) pkg>,其中 v1.8 显示当前安装的 Julia 版本。括号内的部分告诉我们当前的环境是什么。我们总是在 REPL 中的某个环境中。环境是包模式应用其命令的项目。
当我们启动 REPL 时,我们位于默认项目中。我们在包管理器中的所有操作都适用于该环境,除非我们通过 activate 命令更改它。
输入 activate.可以将环境切换到当前目录,或者输入 activate path 将其切换到指定路径。简单的 activate 会切换到正在使用的 Julia 版本的默认环境。
如何添加和移除包
最重要的包命令是add。要使用它,请在 REPL 的包模式中输入 add packageName。
add命令做两件事:如果请求的包尚未安装,它会下载并预编译最新的兼容版本;然后它会将该包记录为当前环境的依赖项。第二步确保项目中使用的包版本集可以始终被重现,无论是由作者在不同的计算机上执行,还是由同事执行。
我们必须add任何不在标准库中的包。这包括 Julia 生态系统中的绝大多数包,例如用于制作科学图形的Plots,或用于计时和性能分析的BenchmarkTools。
如果之前使用add安装的包不再需要,我们可以通过rm PackageName 命令将其移除,同样是在包模式下执行。
rm包模式命令会从项目的直接依赖列表中删除一个包,但不会立即从磁盘中删除任何文件。一个自动垃圾回收进程会定期运行,通过清除那些没有被任何其他已安装包依赖且超过 30 天未使用的包来回收磁盘空间。要立即回收磁盘空间,可以手动调用垃圾回收器。详细说明请参见包系统手册(见第 81 页的“进一步阅读”)。
加载路径
当前环境会影响using和import查找包的位置,并定义包命令的默认位置。当执行像using Plots或import Random这样的语句时,Julia 会在一系列地方查找包,这些地方来源于一个名为LOAD_PATH的字符串向量。
我们可以请求 REPL 显示LOAD_PATH的默认初始值:
julia> LOAD_PATH
3-element Vector{String}:
"@"
"@v#.#"
"@stdlib"
LOAD_PATH的内容显然不是文件路径。它们是包管理器翻译成系统和安装所需路径的符号。要查看翻译结果和路径的当前值,我们可以从Base调用load_path()函数:
julia> Base.load_path()
2-element Vector{String}:
"/home/lee/.julia/environments/v1.8/Project.toml"
"/home/lee/Downloads/julia/julia-1.8.1/share/julia/stdlib/v1.8"
我们已经提到过,Base包含我们几乎总是需要的函数,但并不是所有函数都被导出。像load_path()这样不常用的函数,需要通过Base命名空间来访问。
我当前的加载路径包含两个项。第一个是 Julia 在我安装时设置的一个目录,它对应于我的默认环境。当我在 REPL 中执行像add Plots这样的命令时,如果我没有使用activate命令切换环境,包管理器会将Plots包的当前版本作为依赖项添加到默认的项目中。它记录了该项目依赖于某个特定版本的Plots可用,并且using Plots将导入该版本的函数。这个路径是LOAD_PATH中第二个元素"@v#.#"的翻译。这个符号表示“默认环境”;注意它的结构类似于包模式中的提示符。
包管理器将这些直接依赖项,即通过add命令指定的依赖项,记录在Project.toml文件中。这个文件包含如下内容:
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
这一行显示了特定版本的Plots包,使用一个名为UUID的唯一标识符来指定,它是包含此文件的项目的依赖项——在本例中是与我安装的 v1.8 版本的 Julia 相关联的默认项目。
Base.load_path()返回的第二个路径来自LOAD_PATH的最后一个元素,它指向标准库。如前所述,标准库包含的是 Julia 安装的一部分模块,因此它们不需要通过add命令安装。我把我的安装保留在了浏览器下载文件夹中,所以标准库也就位于那里。
LOAD_PATH有三个元素,但在Base.load_path()的当前翻译中我们只看到两个。第一个元素,仅仅是@,指的是当前环境。Julia 会按照它们在LOAD_PATH中出现的顺序搜索包,因此它首先会搜索当前环境。要更改当前环境,执行activate path命令。
当前环境有两个用途:它在加载路径中排在最前面,因此包的导入将加载作为环境依赖项添加的版本(如果有的话),而包管理器的add命令会在那里插入一个依赖项。
环境实际上不过是文件系统中的一个地方,里面包含一个Project.toml文件和一个Manifest.toml文件。后者是环境的依赖关系图的列表:所有需要加载的包,以满足显式add命令添加的包的依赖关系,包含它们的 UUID、每个依赖项的依赖关系列表,依此类推。如果我们在一个没有现有环境的路径上使用activate命令,并执行一个或多个add命令,Julia 将在该路径下创建这两个文件,并用指定的包信息填充它们。
注意
如果我们不能使用文件名 Project.toml 或 Manifest.toml ,因为它们与其他工具冲突,我们可以使用JuliaProject.toml和JuliaManifest.toml代替。如果 Julia 发现其中一个文件,它将使用该文件,并忽略没有Julia前缀的文件。
环境不包含任何 Julia 代码,仅仅是一个依赖列表。它们可能记录了一组为特定目的而工作的模块。例如,在使用 Pluto 之后,我们会发现 Julia 在默认环境旁边创建了一个环境,其中的Project.toml和Manifest.toml文件包含了 Pluto 正常工作所需的模块列表。
包的本质
我已经多次提到包,并且在 Julia 文档中,通常将这个术语与模块互换使用。现在,让我们精确定义这些概念之间的关系,并探讨包是如何与环境相关的。
包是与Project.toml文件相关联的 Julia 模块,该文件包含一些关键的资讯。包含模块的文件和Project.toml文件必须按照图 3-2 所示的方式在文件系统中排列。

图 3-2:包的文件系统布局
与Project.toml文件一同的是src目录,其中必须包含一个以包名命名的 Julia 程序文件。在这个文件中定义了一个与包同名的模块,在这个例子中是module SomePackage。如图 3-2 所示的结构通常会放置在一个同样以模块命名的目录中,在这个例子里是SomePackage,但这不是强制要求。
为了使这个排列符合包的要求,Project.toml文件必须提供包的名称、UUID、作者和版本号,格式如清单 3-4 所示。
name = "SomePackage"
uuid = "842ca1f4-56d0-4d49-a6c9-7b9c77404c7a"
authors = ["Ada Lovelace <ada.l@example.com>"]
version = "0.1.0"
清单 3-4:一个包的Project.toml* 文件
name必须与src/SomePackage.jl中定义的模块名称匹配。如果我们拥有这两个文件,其中一个位于src目录中,那么我们就拥有了一个包。我们可以把包看作是一个包含模块的环境,并且Project.toml中包含这四个信息。在实践中,一旦我们通过在包环境中执行add命令添加依赖项,我们将会在Project.toml文件旁边也看到一个Manifest.toml文件,其中包含完整的依赖图。
我们可以在.jl文件中完成所有 Julia 开发,可能会使用include()将代码拆分为多个文件,并通过电子邮件将这些文件发送给同事分享我们的工作。许多 Julia 程序员仅仅做到这一点,并且不会去创建包。
包的好处
在程序开发的探索性 REPL 阶段结束之后,如果是时候将你的代码保存在文件系统中,以便将来使用,可能是作为其他程序的资源,我建议你利用 Julia 的包系统。
它功能强大、易于使用,并且能够避免未来依赖冲突。大多数程序都使用标准库和其他包中的模块,所有这些都在特定版本的 Julia 上开发。当这些组件不断演化时,冲突的可能性也会出现,并且随着时间推移,使用大量外部资源的大型程序中,冲突是不可避免的。包系统记录了程序使用的所有资源的确切版本,这样你或其他人就可以在未来重现那个环境,并且程序始终可以正常工作。
在没有依赖管理的情况下,像 using Plots 这样的语句会导入在你运行程序的环境中使用的 Plots 的任何版本,包括你自己。你可能使用了一个功能,后来被包移除了,或者未来的版本可能引入一个错误,导致程序崩溃。没有包管理的情况下,你的程序加载的是不确定的代码,因为你并没有明确指定你所指的 Plots 是哪个版本。
包通常依赖于其他包。未来使用你程序的用户,如果遇到与 Plots 的冲突,可能会尝试通过使用不同版本来解决。然而那个版本会依赖于其他包的不同版本,其中一些包也会有它们自己的依赖关系。试图手动梳理包的依赖关系图,快速找到一个可行的版本集,最终变成了一项令人抓狂的任务。在没有良好包管理的语言中,这是一个常见的难题,因此有一个专有名词来形容它:依赖地狱。Julia 的包系统会自动管理依赖关系图。你可以在同一台机器上同时安装多个版本的 Julia 和任意数量的包而不会出现问题。如果你将程序保存在包中,你可以在不修改实际代码的情况下,升级它所导入模块的版本;如果新版本出现问题,你也可以根据需要降级。
如果你决定通过官方社区渠道分享你的程序,你必须使用包。官方的资源库是基于包和 Git 版本控制系统的,你可以通过 add 命令获取资源,这一点我将在《Julia 和 Git》的 第 77 页中详细讲解。
如何创建包
创建包是很容易的。首先,我们导航到文件系统中希望存放包的目录,并启动 Julia REPL。我们可以使用任何目录,之后总是可以移动它。
注意
我们不需要为了更改文件系统中的位置而重新启动一个新的 REPL 会话。为了在现有 REPL 会话中继续操作,我们可以在不退出 REPL 的情况下,使用两个 Julia 版本的熟悉的 Unix 命令 pwd 和 cd在文件系统中移动。REPL 会维护当前目录的概念,这就是我们启动 REPL 时所给出的 julia 命令的路径,除非我们更改它,否则它将一直保持在那里。REPL 中的pwd()函数返回当前目录的完整路径名作为字符串。要更改目录,可以输入cd(new_directory),将所需目标的名称替换进去。(该名称是由pwd()返回的字符串,因此必须用引号括起来。)
在 REPL 中,按]进入包模式,然后执行generate Floof。这就是我们创建名为Floof的新包所需做的全部操作。
返回到系统命令行,或者使用 REPL 的 shell 模式,我们会找到一个名为Floof的新目录,在其中可以看到最小的包文件,如图 3-2 所示。Floof 的Project.toml文件将包含类似清单 3-4 的行,但会有Floof的名字和一个新的唯一 UUID。authors字段会根据我们的 Git 配置进行填充,如果我们没有安装 Git,它将为空(参见第 77 页的“Julia 和 Git”部分)。generate语句为我们创建的新包分配了版本号0.1.0,我们可以修改这个版本号。
进入src目录,Floof.jl文件包含以下内容:
module Floof
greet() = print("Hello World!")
end # module
这定义了一个名为Floof的小模块,其中包含一个函数greet(),用于向世界问候。Julia 设置了一个最小的包,其中一切都已就绪,以便我们开始开发我们的模块。我们暂时对这个文件做一个更改:在第一行后添加export greet语句。
让我们来尝试一下这个新的迷你包。首先,我们将不使用包系统来测试它:
julia> include("/tmp/Floof/src/Floof.jl")
➊ Main.Floof
julia> Floof.greet()
Hello World!
julia> using Floof
➋ ERROR: ArgumentError: Package Floof not found in current path
julia> using .Floof
➌ julia> greet()
Hello World!
我们将Floof包放在了/tmp目录中。在 REPL 中的第一个操作是直接include程序文件。这相当于将其直接粘贴到 REPL 中。include()语句的反馈 ➊ 确认了Floof已加载到Main模块中,Main总是顶层模块的名称。
现在我们可以通过引用它的命名空间来使用Floof模块中的任何内容。它只有一个组成部分,即greet()函数,当我们调用它时,它会按预期执行。
我们希望能够在不输入模块名称的情况下调用此函数,因此我们需要将它的名称导入到当前的命名空间中。我们尝试通过using语句来实现这一点,但 Julia 不允许我们这样做 ➋。在记得需要在本地模块前加上点后,一切按预期工作 ➌。(我省略了错误消息中的堆栈跟踪,以节省空间,通常我都会这样做。)
导入一个名称,无论是通过using还是import,如果没有点前缀,都会告诉 Julia 导入一个包而不是本地模块。这会唤醒包管理系统,系统会查询LOAD_PATH来搜索包。尽管Floof确实是一个包,但它不在LOAD_PATH中,默认情况下,LOAD_PATH包含已激活的环境、默认环境和标准库,顺序是这样的。由于我们没有激活任何环境,并且Floof包既不在标准库中也不在默认环境中,Julia 无法找到它。
如果我们决定将greet()导入全局命名空间,我们可以激活包含Floof模块的环境。但首先,我们应该退出并重启 REPL。否则,这次新的导入尝试将生成一个错误,抱怨与现有名称冲突。重新启动一个新的 REPL 后,我们可以这样做:
(@v1.8) pkg> activate /tmp/Floof
Activating environment at `/tmp/Floof/Project.toml`
julia> using Floof
julia> greet()
Hello World!
(Floof) pkg> add Random
在使用其路径名激活Floof环境后,我们退出包管理模式。回到 REPL 的普通模式后,使用using将Floof的名称导入到全局命名空间中,简单地调用greet()即可调用该函数。这之所以有效,是因为我们编辑了Floof.jl并export greet。然后我们重新进入包管理模式——请注意提示符,现在它显示的是Floof环境。add Random命令将此包添加到Floof的依赖列表中,该包包含与随机数生成相关的工具。
我们可以手动将路径添加到LOAD_PATH:
julia> push!(LOAD_PATH, "/tmp/Floof/")
4-element Vector{String}:
"@"
"@v#.#"
"@stdlib"
"/tmp/Floof/"
julia> using Floof
julia> greet()
Hello World!
我们再次在一个新的 REPL 中执行此操作。包管理系统在*LOAD_PATH*的最后一个条目中找到了Floof;无论当前环境如何,它都会找到它。
Floof的Project.toml文件现在包含了两行额外的内容。以下是执行add Random后其内容:
name = "Floof"
uuid = "fdb9266c-3340-4b10-958f-2cb27e4e2988"
authors = ["Lee <lee@example.com>"]
version = "0.1.0"
[deps]
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
在[deps]标签之后的行将记录每个我们通过add语句手动添加的依赖项。
一个新的Manifest.toml文件与Project.toml文件一起出现,内容如下:
# This file is machine-generated - editing it directly is not advised
[[Random]]
deps = ["Serialization"]
uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
[[Serialization]]
uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
Manifest 文件用于记录包或环境的依赖关系图。对于每个手动添加的依赖项,系统会查找它的依赖项,以及所有这些依赖项的依赖项,依此类推,直到找到所有依赖项。每个依赖项都是另一个包;所有这些包以及它们之间的所有依赖关系构成了依赖关系图。正如你所想象的,Manifest 文件可能会变得相当大,但这个文件并不大,因为Random显然只有一个依赖项,一个名为Serialization的包,而Serialization没有任何依赖项。
现在我们有了自己的包,我们应该能够像添加Random和Plots等其他包一样,将其作为依赖项添加到其他包和环境中:
(Floof) pkg> activate
Activating environment at `~/.julia/environments/v1.8/Project.toml`
(@v1.8) pkg> add /tmp/Floof
ERROR: Did not find a git repository at `/tmp/Floof`
首先,我们使用不带参数的activate语句返回默认环境。我们尝试将Floof添加到该环境中,但包管理器报告未找到名为git repository的内容。
Julia 和 Git
Git 是一个版本控制系统:一个帮助你跟踪工作随时间变化的程序。此外,Git 还侧重于协作,尽管它对单独创作者也非常有用。
Git 独立于 Julia,但自 2005 年由 Linux 的创始人 Linus Torvalds 创建以来,它在所有版本控制系统中的优越性已导致其在自由软件社区几乎形成了垄断。Julia 是这个社区的一部分,Git 是该语言及其包开发的一个重要组成部分。
如果你还没有安装 Git,并且希望在不暂停安装 Git 和学习如何使用它的情况下继续学习 Julia,可以暂时跳过本节内容。你随时可以回来。请参阅 第 81 页 的“进一步阅读”部分,里面有一个很好的学习资源链接。此外,还有许多关于 Git 的文章和几本书。
我建议在你的个人代码库变得庞大之前安装 Git 并在项目中使用它。花费一些时间和精力熟悉一些基本操作,回报是巨大的。你将能够回到程序的过去状态,记录更改,创建程序的不同版本来尝试新想法,并在准备好时将这些想法合并到主开发线中。
如果你已经在使用旧版的版本控制系统,可以继续使用。然而,如果你想将自己的 Julia 程序贡献给社区,你就必须使用 Git。正如我们接下来要看到的,Git 也是在个人机器上将自己的包作为依赖项添加到自己的项目和环境中所必需的,即使你不分享程序,这也是你可能会做的事情。
如在 第 73 页 的“如何创建包”中所示,当我们尝试 add 我的 Floof 包时,Julia 报错了。包管理系统在我们将包放入 Git 仓库之前,不允许我们添加包。依赖管理,包管理系统的目的,不仅跟踪包本身,还跟踪包的版本。Julia 的包管理系统与 Git 一起工作来跟踪这些版本。本节其余部分假设 Git 已安装。
为了让包管理系统处理 Floof,我们必须将其放入 Git 仓库并进行初始提交。在 /tmp/Floof 目录中,我们在系统 shell 中执行 git init 来创建仓库,然后执行 git add. 和 git commit -m "开始仓库" 来开始跟踪内容。
回到 Julia 的 REPL,我们再次尝试:
(@v1.8) pkg> add /tmp/Floof
Updating git-repo `/tmp/Floof`
Resolving package versions...
Updating `~/.julia/environments/v1.8/Project.toml`
[fdb9266c] + Floof v0.1.0 `/tmp/Floof#master`
Updating `~/.julia/environments/v1.8/Manifest.toml`
[fdb9266c] + Floof v0.1.0 `/tmp/Floof#master`
(@v1.8) pkg> status
Status `~/.julia/environments/v1.8/Project.toml`
[336ed68f] CSV v0.8.4
[31c24e10] Distributions v0.24.15
[fdb9266c] Floof v0.1.0 `/tmp/Floof#master`
[23fbe1c1] Latexify v0.14.12
[91a5bcdd] Plots v1.10.6
[c3e4b0f8] Pluto v0.15.1
这次成功了:包管理器回应称已将 Floof 添加到 Project.toml 和 Manifest.toml 文件中。方括号中的字符串是包管理器为该版本的 Floof 分配的 UUID 的初始部分。#master 字符串表示 Git 中的分支名称。
status包命令返回当前环境中所有已添加依赖项的列表(而不是整个依赖关系图),我们可以看到Floof在其中。
如果我们想要移除一个依赖项——比如Floof——我们可以在包模式下输入rm Floof:注意,在移除包时,我们只使用包名,而不是文件系统中的完整路径。这不会对我们的文件做任何修改;它只是将Floof从Project.toml文件中移除。然而,它可能不会从Manifest.toml中移除,因为它可能作为其他包的依赖项列在那里。
如果你已经将你的程序做成了一个 Julia 包并使用 Git 进行版本控制,那么当有一天你觉得这个包对更广泛的用户群有用时,你可以请求将它纳入官方仓库。如果你完成了这一步,世界上任何地方的 Julia 用户只需要在他们的包 REPL 中输入add *YourPackage*,就能使用并在你的工作基础上进行扩展。共享和协作是 Julia 的 DNA 的一部分。第九章和第十二章展示了如何将多个包组合起来创建新功能的几个有趣示例。
包版本与 Git 提交之间的关系
我们已经看到如何在包提示符下请求status来查看依赖项列表和它们 UUID 的缩写,以及如何在Manifest.toml文件中查看完整的 UUID。我们可能知道 Git 使用唯一的哈希值来标识提交,但如果我们使用git log检查项目的哈希值,我们不会看到类似 Julia UUID 的内容。
下面是默认环境中Manifest.toml的相关部分,该文件位于用户主目录的.julia/environments/v1.8/Manifest.toml中:
[[Floof]]
deps = ["Random"]
git-tree-sha1 = "478b184e365f8d114ab757e18c6ab060fc590920"
repo-rev = "master"
repo-url = "/tmp/Floof"
uuid = "fdb9266c-3340-4b10-958f-2cb27e4e2988"
version = "0.1.0"
Random被列为依赖项,因为我们之前已经将它add到项目中。在最后两行中,我们可以看到完整的 UUID 和包管理系统分配的初始版本。在此之前,我们可以看到 Git 的路径和分支名称。更高处,我们看到了一个叫做git-tree-sha1的东西,它是一个 Git 哈希值,但它不是我们在输入git log时默认看到的提交哈希值。在Floof的目录中,如果我们带上选项输入此命令,我们可以看到更多信息:
bash> git log --pretty=raw
commit 460ef22bb5c86863d07493e36be791977acd62e7
tree 478b184e365f8d114ab757e18c6ab060fc590920
author Lee <lee@example.com> 1630788711 -0600
committer Lee <lee@example.com> 1630788711 -0600
make a repo
记录在Manifest.toml中的哈希值是树哈希。大多数 Git 用户并不了解这个哈希值,因为它很少用于其他用途。树哈希编码了提交中所有被跟踪文件的实际内容。Julia 的包管理器使用树哈希,而不是提交哈希,因为树哈希更可靠。Git 提供了强大的命令,如rebase,允许用户重写提交历史。如果发生冲突并且某些内容被破坏,理想情况下,我们希望能够识别参与程序的实际文件内容。在实践中,为了从Manifest.toml中识别一个提交,我们需要请求 Git 提供原始提交日志,并搜索树哈希。
版本更新和固定
另一个重要的包系统命令是 update *PackageName*。执行此操作可以将 *PackageName* 的最新版本安装到环境中。Julia 会检查注册表中是否有新版本,如果有,它会下载并预编译该版本。如果 *PackageName* 有任何依赖项,Julia 会检查它们的版本是否与已安装的版本匹配,并下载和预编译发生变化的部分。它会遍历整个依赖图,确保我们拥有一个一致的环境,无需我们采取进一步的操作。
如果 *PackageName* 是我们本地开发的项目,update 命令会使包管理器检查其 Git 仓库。如果 Git 日志中记录的跟踪分支的 HEAD 树哈希发生了变化,Julia 会将新版本安装到环境中并进行预编译。Manifest.toml 文件会包含新的树哈希。如果我们编辑了源文件但尚未进行新的 Git 提交,包管理器将不会采取任何操作。即使我们更改了 *PackageName* 的 Project.toml 文件中的版本号,也不会导致 Julia 采取任何措施。包管理器只关注树哈希。这意味着,例如,如果我们将 Git 版本软重置为早期的提交,再在 Julia 中执行 update,包管理器将回到 HEAD 当前指向的版本,从 Git 仓库中提取文件,而不是从我们的工作区提取。
更新(update)可能会导致冲突,即两个包的当前版本无法兼容。可以使用包管理器中的 pin 命令强制将特定包固定在某个版本。有时候,这是解决冲突的唯一方法,直到 bug 被修复。
pin 的三种用法分别是:pin *PackageName*,将 *PackageName* 固定在当前版本;pin *PackageName*@2.4.2,在本例中将 *PackageName* 固定在版本 2.4.2;以及 pin *PackageName=UUID*,使用包的 UUID 来标识版本,而不是使用版本号。
如何查找公共包
我们如何发现是否有一个 Julia 包能够帮助我们编写程序呢?最有效的方式可能是通过网络搜索与我们类似的项目或问题,尤其是使用 Julia——这种方法会迅速揭示出最受欢迎的相关包。当然,如果有一个相关的社区,向在同一领域工作的人请教是非常宝贵的。在 Julia Discourse 论坛上提问几乎肯定会得到有用的回复,除非我们的项目非常小众或深奥。
由于几乎所有公共 Julia 包的开发都在 GitHub 上进行,因此这是直接搜索解决方案的地方,特别是如果之前提到的方法没有找到合适的解决方案,或者我们的项目有特定的需求,如开发的时效性。
有几个网站似乎提供了搜索包的方法,但除了错误和过时的信息,以及更糟的界面外,什么也没有提供。搜索 GitHub 的最佳策略是使用语言限定符。例如,在项目搜索框中输入phylogenetics language:Julia,这样就能搜索到标题或关键词中提到“phylogenetics”并且用 Julia(也可能是其他语言)编写的项目。这是有效的,因为 Julia 包是用 Julia 编写的,且这是必要的,因为 Julia 包通常没有“Julia”关键词,所以如果只使用它作为搜索词,会错过很多项目。
关键是,我们可以根据多个标准对结果列表进行排序,包括最后更新时间和项目的“星标”数量。尽管后者与互联网流行度和游戏化有不愉快的联系,但实际上是一个有用的代理,可以帮助我们发现被广泛使用的包,这些包更可能有价值,并且会有一个社区支持它们。
GitHub 上的各个项目页面将包含其 README 文件的渲染,其中的内容从一些晦涩的短语到包含截图和动画的完整介绍和教程不等。README 有时会包含指向进一步文档的链接;如果没有链接,可以点击项目的文档徽章之一,但不能保证它会链接到实际的文档。缺乏文档并不是一个好迹象,但可能有语言或其他原因导致文档缺失。我们始终可以查看源代码,所有源代码都可以通过 GitHub 点击获取。Julia 代码异常易读,显然它是任何包操作的最终真相来源。
在发现我们想尝试的包后,是时候回到 REPL 并将其add到我们的项目中了。我们可以通过使用包的名称,轻松地在官方注册表中添加 GitHub 项目JuliaRegistries/General中列出的包。在可能不太常见的情况下,如果我们想添加一个不在通用注册表中的公共项目,我们可以使用它的 URL 来添加。在包模式下,我们输入
add https://github.com/developer/projectname
例如,要添加开发者developer的项目projectname。这仅在我们指向一个包含Project.toml或JuliaProject.toml文件的有效 Julia 项目时有效。添加项目后,它将在我们的Manifest.toml文件中显示,并带有额外的repo-url字段。
结论
本章描述了使用 Julia 有效编程的一些基本要素,并使其他人能够将我们的程序纳入他们的工作中。编程很少是孤立进行的。如果你的问题的某部分解决方案就像import一样触手可及,那么就没有必要重新发明轮子。在后续章节中,我们将进一步扩展这些思想,探索结合多个包资源的更强大方法。但首先,在下一章中,我们将探讨一个几乎所有科学 Julia 程序员都会使用的基础包,并深入研究绘图系统。
进一步阅读
-
关于文档字符串的更多细节,主要对包开发者有兴趣,可以在https://docs.julialang.org/en/v1/manual/documentation/找到。
-
在编写文档字符串时,您可能需要了解更多关于 Markdown 语法的知识:https://www.markdownguide.org/basic-syntax。
-
.toml 文件扩展名代表“Tom's Obvious, Minimal Language”,由 Tom Preston-Werner 设计:https://github.com/toml-lang/toml。
-
关于如何入门 Git 的好资源是https://git-scm.com。
-
关于包系统的详细信息,包括如何将你的创作提交到官方仓库或注册表的说明,请访问https://pkgdocs.julialang.org/。
-
包系统的总结,以及我第一次为公共包贡献的冒险,可以在https://lwn.net/Articles/871490/找到。
-
有关工作流程的技巧,请访问https://docs.julialang.org/en/v1/manual/workflow-tips/。
第五章:绘图系统
没有什么比一个模糊概念的清晰图像更糟糕的了。
—安塞尔·亚当斯

本章介绍了 Julia 中可视化这一庞大而丰富的主题。图形和图表在科学传播中的作用,与文字和方程式同样重要。Julia 的绘图生态系统丰富且强大;你将能够在不离开语言的情况下,解决任何类型的可视化挑战。将计算和其可视化放在一个程序中,简化了探索和报告结果的过程。
在 Julia 中,绘图是一个快速发展的热点。这大多数时候是件好事,因为这意味着新的功能和包会定期出现。然而,缺点是包之间的冲突发生率高于平均水平,文档不完整,而且存在 bug,尤其是绘图常常需要依赖外部图形库来加剧这些问题。考虑到这一点,本章只讨论那些看起来稳定和成熟的包。这里的示例应该能在长时间内有效。我避免讨论一些仍然存在太多问题的潜在有用包。
Plots
Julia 的主要、也是某种意义上的“官方”绘图包是Plots。本书后面会探讨其他图形方法,但本章主要讲解 Julia 可视化宇宙核心中的包。
Plots不在标准库中,因此我们需要通过包管理器安装它,使用命令 add Plots。初次安装会花费一些时间,因为Plots有许多依赖项,所有这些依赖项都需要被(自动)安装。这个包的预编译需要几分钟时间。
Plots是为编程语言提供绘图能力的独特方法。它通常被描述为一个绘图元包,因为Plots本身并不进行实际绘图。相反,它通过调用不同的后端来协调可视化的创建。
后端系统
后端是实际绘制图形的包。每个后端都有其特定的优缺点,适用于不同类型的应用。Plots的工作是提供一个统一的接口,连接所有后端,并通过一定的智能将我们的绘图调用转换成后端可以理解的形式。它试图弄清楚我们的意图,并生成我们想要的图形。
使用绘图元包的优势在于,我们可以在程序中更换后端,而无需更改绘图命令。在研究过程中,我们可能希望让一个仿真代码直接在终端中生成粗略图,或者生成可以用鼠标旋转的 3D 图。后来,我们可能希望再次运行仿真,但这次将高质量的图表保存到磁盘上。通过Plots系统,我们只需更改一行选择不同后端的代码即可实现这一点。
一些Plots的后端在我们安装包时会自动安装,但我们需要手动安装其他一些后端,作为单独的包(这些可能会发生变化,但当我们尝试使用某个后端时,会提示我们安装缺失的后端)。当我们add``Plots包时,一个总是随之安装的后端是默认后端。最近,默认后端是GR,它是一个相当快速且功能丰富的绘图引擎。要查看可用后端的列表,可以在 REPL 中执行backends()函数。要查看当前激活的后端,可以执行backend()函数。
要激活一个后端,我们使用backends()返回的适当名称来构建一个函数并简单地调用它。如果它已安装,函数将通过确认包的名称来响应。如果没有,我们将收到一条错误消息,解释我们需要add它。
下面是显示该过程的部分 REPL 会话:
julia> using Plots
➊ julia> backends()
10-element Vector{Symbol}:
:pyplot
:unicodeplots
:plotly
:plotlyjs
:gr
:pgfplo
:pgfplotsx
:inspectdr
:hdf5
:gaston
➋ julia> backend()
Plots.GRBackend()
➌ julia> unicodeplots()
Plots.UnicodePlotsBackend()
julia> hdf5()
ERROR: ArgumentError: Package HDF5 not found in current path:
- Run `import Pkg; Pkg.add("HDF5")` to install the HDF5 package.
➍ (@v1.6) pkg> add HDF5
Resolving package versions...
Updating `~/.julia/environments/v1.6/Project.toml`
[f67ccb44] + HDF5 v0.15.6
No Changes to `~/.julia/environments/v1.6/Manifest.toml`
julia> hdf5()
Plots.HDF5Backend()
julia> backend()
Plots.HDF5Backend()
请求可用后端 ➊ 返回一个Symbol类型的Vector列表,符号前有冒号。在第 167 页的“符号与元编程”部分将解释Symbol数据类型,但现在,可以将其视为字符串。
请求当前后端 ➋ 返回Plots.GRBackend()。每个后端的多个名称有些令人困惑,因为用于引用包的首字母大写形式与用于激活它的小写形式不同。backend()返回的形式没有被用于任何操作。
下一步是将UnicodePlots设为当前后端 ➌;操作已确认。然后我们改变主意,决定使用 HDF5 进行绘图,但我们尝试切换到它时出现错误,因为它不在加载路径中。显然,我们从未将其添加到环境中。在将 HDF5 以包模式添加 ➍ 后,我们切换到它并调用backend()进行确认。
包管理系统并不将各种后端视为Plots的依赖项。这是一个故意的选择,目的是避免用户在安装Plots时不得不安装所有后端,因为后端非常多,大多数用户只需要一个较小的子集。然而,这也会导致偶尔的不兼容,因为Plots及其各种后端在发展过程中,包管理系统无法自动同步它们。如果某些功能不起作用,尽量尝试切换到另一个后端;如果不行,可以在网络上搜索或查阅第 121 页中的“进一步阅读”资源,找到解决方案。
与 Plots 的交互模式
如果你在 Pluto 中跟随操作,可以在新的单元格中输入每个绘图命令,并在页面中嵌入一系列图形。如果你使用的是 REPL,每个图形应该重用第一次打开的显示窗口,替换现有的图形。如果你想关闭该窗口,可以使用closeall()语句。使用窗口管理器关闭时,有时会导致 REPL 中的错误,这是一个已知的 bug。
如果你在程序文件中保存绘图命令,可能会注意到运行时没有任何输出。首先,你需要在程序中插入gui()语句,放在你希望显示当前图形状态的位置。然而,这样创建的图形窗口在程序退出时会消失,可能会快得让你根本没时间看到窗口。你需要让程序暂停,直到你欣赏完图形。一个方法是在gui()后直接插入readline()语句。这个语句会在终端等待输入。当你准备好关闭图形窗口时,只需按下回车键,它就会消失,程序继续执行。
二维图形
2D 图形一词指的是涉及两个变量之间映射的各种可视化方式。基本类型是线形图,它以曲线或一组曲线的形式显示单个变量的函数,其中通常独立变量由水平 x 轴表示,因变量由垂直 y 轴表示,在矩形坐标系中。极坐标图将角度映射到从某个原点到达的距离,采用极坐标系。第三种常见类型是参数图,其中两个变量依赖于第三个变量,称为参数。这三种基本类型,以及条形图、饼图、散点图等其他变种,统称为二维图形,它们都由Plots包提供的plot()函数处理,并且所有后端都能理解。
本节中的示例可以使用任何后端,但我建议在执行using Plots时保持默认设置。这将始终是一个相对稳定且高效的引擎,可以在使用 REPL 时在新窗口中显示彩色图形,或者在使用 Pluto 或 VS Code 时直接在页面上显示。在本章中的所有示例中,假设都使用了using Plots。
plot()函数接受各种类型的参数,正如之前提到的,它通常会按预期执行。它返回一个图形对象形式的结果。在 REPL、Pluto 这样的笔记本界面,或其他交互式环境中,它会立即显示该图形,除非我们通过在调用后加上分号来抑制输出。我们也可以稍后通过调用gui()或者将图形对象存储在变量中并直接评估它来显示图形。
从向量绘图
我们可以使用单个Vector参数来调用这个函数:
julia> gr()
Plots.GRBackend()
julia> plot([0, 3, 1, 4, 1])
这将按顺序绘制Vector中的数字,并与一个给出其索引的自变量进行比较。图 4-1 显示了这个图。

图 4-1:绘制单个向量的图
我们得到一个图例,目前它的信息量不大。我们稍后会学习如何调整图例,并改变图表的其他方面,但首先让我们看一下使用plot()的不同方法。
注意
我为本章的所有示例创建了灰度版本,以便打印,但每个绘图命令的原始彩色输出可以通过本书的补充网站获得,网址是 julia.lee-phillips.org。
第二种形式提供了x和y变量,使用两个Vector:
julia> plot([0, 0.13, 0.38, 0.88, 1.88], [0, 3, 1, 4, 1])
结果(图 4-2)显示相同的y值在不同的水平位置上绘制。

图 4-2:绘制一个向量与另一个向量的关系图
绘制函数
要绘制一个函数,我们可以将一个向量作为第一个参数,第二个向量通过将函数广播到第一个参数上来创建(参见第 51 页中的“广播”部分):
julia> f(x) = sin(1/x)
f (generic function with 1 method)
julia> x = π/1000:π/1000:π
0.0031415926535897933:0.0031415926535897933:3.14
julia> plot(x, f.(x))
在这个例子中,我们首先使用简洁的一行函数定义语法创建一个函数f()。接下来我们定义一个范围并将其存储在 x 中;该范围排除了0,以避免出现奇点。plot()命令有两个Vector参数,如之前所示。该范围被实例化为一个Vector,f()后的点符号将该函数广播到x上,返回一个用于因变量的Vector。图 4-3 显示了结果。

图 4-3:一个在向量上广播的函数图
我们可以看到,随着我们接近原点,图形变得不准确,π/1000的分辨率无法跟上该区域内快速的振荡变化。
plot()函数及其相关函数提供了一个方便的简写方式。我们可以不必显式创建一个广播表达式作为第二个参数,而只需写出函数的名称,或构造一个匿名函数。plot()函数会将我们指定的函数广播到我们在第一个参数中传入的自变量向量上。
换句话说,我们可以将plot(x, f.(x))简化为plot(x, f)。如果我们没有事先定义f(),我们可以直接插入一个匿名函数,写作plot(x, s -> sin(1/s))。这三种调用plot()的方法是等价的。
我们甚至可以省略自变量,只提供函数名或匿名函数。在这种情况下,plot()会为我们绘制函数,自动选择自变量的位置并处理奇异点。我们可以使用第二和第三个参数为水平轴提供一个默认值范围,从–5 到 5。如果我们在第一个参数中使用一个函数的Vector,我们将获得所有函数在同一坐标轴上的图。使用相同的f定义,执行plot([sin, cos, f], -π, π)将生成图 4-4。

图 4-4:绘制三个函数
在这个plot()的用法中,我们提供了函数的名称。我们并没有调用这些函数,因此省略了括号。绘图是匿名函数的常见应用(详见第 51 页的“创建匿名函数”)。它们的目的是:将一个函数作为参数传递给另一个函数,在此例中是plot()。
绘制向量的向量或函数
如果我们在前两个参数位置提供向量的向量,plot()会依次循环这两个参数,必要时重复使用元素。例如,如果我们调用plot([x1, x2], [y1, y2]),我们将得到y1与x1,以及y2与x2的图,两个图会显示在同一坐标轴上。但如果我们调用plot(x1, [y1, y2]),我们将得到y1与x1,以及y2与x1的图。如果我们调用plot([x1, x2], y1),我们会看到y1与x1,以及y1与x2的图。
如果我们使用水平拼接,结果也会相同;换句话说,plot([x1, x2], [y1, y2])和plot([x1 x2], [y1 y2])会产生相同的图。当传入Matrix类型的参数时,plot()按列绘制。我们甚至可以调用plot([x1, x2], [y1 y2]),将一个向量的向量与矩阵混合,plot()会明白我们的意思,并绘制与前两个示例相同的图。
如果我们使用垂直拼接,我们将简单地创建更长的向量。我们可以用它来绘制不同范围内的不同函数:
julia> x = 0:5π/1000:5π
julia> plot([x; 5π .+ x], [sin.(x); -exp.(-x .* 0.2) .* sin.(x)])
在这个例子中,我们将x向量与右移 5π的自己拼接,并将结果作为自变量。然后,我们将绘制一个sin函数,它与相同的函数相乘并与衰减指数相结合(注意始终使用广播符号)。图 4-5 展示了结果。

图 4-5:连接向量以模拟阻尼振荡
该图可以解释为最初没有摩擦的振荡,在x = 5π时施加了阻尼。
显示与变换
我之前提到过,在程序文件中,我们通过调用gui()来显示图形。那么,gui()函数是如何知道显示哪个图形的呢?绘图系统在全局命名空间中维护一个当前图形,以及与图形显示相关的其他设置和状态。这在交互式绘图中非常方便,因为它允许我们通过变更当前图形来逐步调整和添加内容。plot()的变更版本是plot!(),符合约定(请参见第 56 页中的“变更其参数的函数”部分)。
使用变更操作,我们可以通过这三行代码生成图 4-4:
julia> plot(sin, -π, π)
julia> plot!(cos)
julia> plot!(f)
plot!()函数保持首次调用时设定的范围。我们可以使用该函数的变更形式,除了添加曲线外,还能改变图形的许多其他方面。
plot()和plot!()函数返回图形对象,我们可以将它们赋值给变量。我们在 REPL 或笔记本中调用这些函数时看到图形,是因为 Julia 在交互式环境中,每当从表达式返回图形对象时,会自动调用gui()。如果我们将某些图形赋值给变量,每次想查看其中一个时,只需在 REPL 中输入其名称并按下回车键即可。在程序文件中,我们可以将图形对象作为参数传递给gui()。
如果我们将一个图形对象作为plot!()的第一个参数,它将变更该图形,而不是当前图形。例如,如果我们执行ps = plot(sin),则ps是sin()函数的图形。调用plot!(ps, cos)将执行两件事:它将变更ps,向其中添加一条cos()曲线,并返回结果,因此修改后的图形将出现在屏幕上。使用不变更版本的plot()进行相同的调用,将显示包含两条曲线的图形,但不会修改ps。
我们可以将任意数量的图形对象作为参数传递给plot(),它会自动将它们排列成一个网格。有关如何更精确地控制这种排列,请参见第 117 页中的“布局”部分。
这个 REPL 会话创建了几个图形,然后将它们组合起来:
julia> parabola = plot(x -> x²);
julia> ps = plot(sin, 0, 2π);
julia> plot!(ps, cos);
julia> plot(ps, plot(f), plot(s -> s³), parabola)
所有的行都以分号结尾,除了最后一行,在这里我们希望看到图形。首先,我们将变量parabola赋值为一个表示抛物线的图形对象,该图形对象是通过匿名函数构建的。此时变量的值是一个数据类型,表示一个完整的图形,包含坐标轴、刻度线等内容。我们没有指定范围,因此抛物线会从-5 到 5 绘制。
然后我们将ps赋值为sin函数的图形,这次设置了从 0 到 2π的范围。
接下来,我们决定让ps也包含一条cos曲线,因此我们进行了更改;plot!()将保持现有的范围。
最后一行生成了图 4-6 中显示的图形。

图 4-6:绘制四个图形对象
我们通过四个绘图对象参数调用 plot()。第一个和最后一个是两个绘图变量,第二个是直接通过 plot() 函数在之前的 f 函数上创建的绘图对象,第三个使用匿名函数。
创建参数化图
平面中的参数化图也被归类为二维图,因为有一个自变量,现在称为 参数。在这种类型的图中,x 和 y 都依赖于参数。如果我们将两个都是函数的参数传递给 plot(),它会识别出这是一个参数化图的签名,并生成一个图形,其中 x 的依赖关系由第一个函数给出,y 的依赖关系由第二个函数给出(通常,x 被绘制在横轴上,y 被绘制在纵轴上)。我们必须通过两个附加参数来指定参数的定义域;然而,与非参数函数绘图时不同,这里没有默认值。
参数化绘图使我们能够渲染各种复杂的形状,如圆和螺旋线,如 图 4-7 所示。

图 4-7:两个参数化图
左侧的图形通过调用 circle = plot(sin, cos, 0, 2π) 创建,右侧的螺旋线通过 spiral = plot(r -> r*sin(r), r -> r*cos(r), 0, 8π) 创建。我们通过调用 plot(circle, spiral) 绘制复合图形。
与常规函数绘图一样,自变量(在这里是参数)可以是隐式的,正如我们绘制圆形时使用的调用。当要绘制的函数过于复杂,无法允许这种情况时,如螺旋线示例所示,我们必须使用一个虚拟变量,在本例中我们命名为 r。
制作极坐标图
极坐标图使用常规的极坐标系,而不是矩形坐标系。自变量是角度,从水平轴开始逆时针测量,因变量是距离原点的距离。
图 4-8 显示了两个简单的极坐标图。plot() 函数呈现坐标网格,以反映极坐标几何的对称性。

图 4-8:两个极坐标图
我们通过 plot(0:2π/500:2π, t -> 1 + 0.2*sin(8t); proj=:polar) 创建了左侧的图形,通过 plot(0:8π/200:8π, t -> t; proj=:polar) 创建了右侧的螺旋线。这些调用中的第一个参数是角度坐标数组,第二个参数是将角度映射到距离原点的函数,使用 t 作为虚拟变量。参数 proj=:polar 告诉 plot() 生成极坐标图。这是一个关键字参数,正如在 第 96 页 的“可选和关键字参数”部分所解释的那样。
制作散点图
到目前为止,我们看到的二维图形通过一组点绘制了一条连续的线。有时我们需要绘制一组点或其他标记,每个标记位于特定的(x, y)位置:散点图。scatter()函数与plot()函数的工作方式相同,但它绘制的是点集而不是曲线。
作为一个示例应用,假设我们想要可视化迭代映射的输出:

这个简单的映射产生了多种迷人的模式,且依赖于a的关系是不可预测的。Julia 版本如下:
julia> function ginger(x, y, a)
x2 = 1.0 - y + a*abs(x)
y2 = x
x2, y2
end
我将它命名为映射的常见别名:姜饼人。
我们将值的序列存储在两个向量x和y中,初始化为起始坐标,并迭代 4000 次:
julia> x = [20.0]; y = [9.0];
julia> for i in 1:4000
➊ x2, y2 = ginger(x[end], y[end], 1.76)
push!(x, x2)
push!(y, y2)
end
该列表使用了一种解构形式➊。ginger()函数返回一个元组,其第一个成员存储在x2中,第二个成员存储在y2中。
运行这个循环后,我们可以通过散点图查看x和y的内容。调用scatter(x, y; ms=0.5, legend=false)会生成图 4-9 所示的图形。

图 4-9:姜饼人迭代映射
在对scatter()的调用中,x和y参数后,我们在分号后添加了新内容。这两个可选关键字参数影响图形的外观,详细内容将在下一节中解释。
可选和关键字参数
在函数定义中,我们可以为参数提供默认值。这样做使这些参数变为可选,因为用户可以不使用它们就调用函数:
julia> g(x, y=2) = x + y
g (generic function with 2 methods)
julia> g(4)
6
julia> g(4, 9)
13
在这个例子中,g()的定义包括了y的默认值2。如果我们调用它时没有第二个参数,它将返回x + 2。当我们提供第二个参数时,它将使用该参数。
到目前为止,我们已经学习了如何定义和调用带有位置参数的函数。无论这些参数是否可选,值的分配是根据我们在调用函数时在参数列表中的顺序来确定的。
Julia 也有关键字参数,这些参数通过名称而非位置来识别。与其他一些语言不同,在定义函数时,我们必须区分位置参数和关键字参数;我们用分号分隔它们,正如这个例子所示:
julia> p(x; y=2) = x + y
p (generic function with 1 method)
➊ julia> p(4)
6
julia> p(4, 5)
ERROR: MethodError: no method matching p(::Int64, ::Int64)
Closest candidates are:
p(::Any; y) at REPL[346]:1
julia> p(4; y=5)
9
在这里,我们定义了p(),它有一个位置参数和一个名为y的关键字参数,默认值为2。我们可以省略关键字参数➊调用p(),因为默认值使其成为可选项。如果我们提供两个位置参数,将会返回一个错误,因为该函数只接受一个位置参数。确保理解g()和p()函数的区别:它们仅在函数签名上有所不同。
注意
在调用函数时,我们可以选择使用逗号代替分号,因为没有歧义的可能;然而,在函数定义中,分号是必需的。
Plots生态系统中的绘图函数使用位置参数来传递数据或函数,使用关键字参数来设置绘图选项。由于所有绘图选项都有默认值,直到现在我们都没有必要使用它们。
基础绘图设置
要调整绘图的外观,我们使用关键字参数。使用Plots包创建的可视化包含四个组件,每个组件都有一组适用的设置。
这四个组件是绘图、子图、轴和系列。绘图可以包含子图,而每个子图又可以包含轴或系列。
整体插图被称为绘图;它包含其他子图,若有多个子图,则类似于图 4-6。如总体标题和背景色这类设置适用于整个绘图。
在一个绘图中,每个子图可以有自己的标题、背景色、边距以及其他许多设置。
实际的曲线或其他函数或数据的可视化是系列,子图可以包含多个系列。
每个子图包含一个轴对象。其设置决定了是否在坐标轴上绘制箭头、刻度标签的颜色、坐标轴上的数字等内容。
在大多数情况下,我们只需要使用合适的关键字来设置我们可视化的属性,然后绘图系统会在合适的地方应用它。但在设计复杂的可视化时,我们有时需要针对特定组件进行调整。
官方绘图系统文档在https://docs.juliaplots.org/stable/中包含了所有组件的完整属性列表,以及哪些属性被哪些后端支持。以下列表提供了最重要的属性,并举例说明它们的效果:
标题
-
总体标题:
plot_title -
子图标题:
title -
图例标题:
legendtitle
其他标签
-
图例文本:
label -
图例存在与位置:
legend -
轴标签:
[x,y]guide -
在任意位置标注:
annotation=(x, y, "Text")
字体颜色
-
总体标题:
plot_titlefontcolor -
子图标题:
titlefontcolor -
图例:
legendfontcolor -
轴标签:
[x,y]guidefontcolor
区域颜色
-
边距区域:
background_outside -
仅绘制图形区域:
background_inside
曲线
-
线条颜色:
lc -
线条宽度:
lw -
线条样式:
ls
散点图
-
标记形状:
shape -
标记颜色:
mc -
标记大小:
ms
等高线图
-
为等高线添加标签(布尔值):
clabels -
等高线等级:
levels
轴与刻度
-
反转轴(布尔值):
[x,y]flip -
刻度标签旋转:
[x,y]rotation -
绘制轴线:
showaxis [x,y]ticks -
框架样式:
framestyle
网格
-
绘制网格(布尔值):
grid -
网格线透明度:
gridalpha [0,1] -
网格线样式:
gridstyle
坐标系统
- 使用极坐标:
:proj=polar
尺寸与边距
-
子图的边距:
[left,right,top,bottom]margin -
总体绘图大小:
sizes(a, b)(单位:像素) -
子图纵横比:
ratio
这些列表中的每个关键字都有一组缩写和替代拼写,所有这些都在官方文档中列出。我为每个案例选择了一个版本;它并不总是最简短的替代方案,而是一个设计上便于记忆并避免混淆的选择。
其中一些设置的目的,在我们稍后讨论之前可能不太明确,但我已经将它们都列在这里,供大家方便参考。
字体属性
要形成设置字体属性(如字体大小或字体家族)的关键字,可以查找前面列出的字体颜色设置对应的名称,并用所需的属性(如 fontsize 或 fontfamily)替换 fontcolor。例如,要使图表标题的字体大小为 30pt,可以使用设置 plot_titlefontsize=30。
字体家族取决于使用的后端类型。GR 后端的字体列表请参见 https://gr-framework.org/fonts.html。一些常用的字体家族(也可能在其他后端中可用)包括 Times(Roman、Italic、Bold)、Courier、Bookman、DejaVu Sans 和 Computer Modern。将设置作为字符串提供。如果我们设置 fontfamily 属性,它将应用于图表上的所有或大多数文本。例如,为了让刻度线、坐标轴标签和其他注释使用 Computer Modern 字体,但标题使用 Times 字体,我们可以使用 plot(...; fontfamily="Computer Modern", legendfontfamily="Times")。
如果我们修改一个包含子图的图表,并且我们正在添加或更改适用于子图的属性,那么我们必须指定要修改的子图,除非我们希望更改应用于所有子图。这就是 subplot 关键字的作用。将其设置为整数,索引 plot() 语句中按顺序出现的子图。例如,对于两个并排显示的图表 plot(p1, p2),我们可以通过 plot!(; xguide ="Time", subplot=2)) 在 p2 的横轴上添加标签。如果没有 subplot 关键字,两个图表都会得到标签。
框架样式
framestyle 设置决定了坐标轴的类型。图 4-10 展示了六种可能的选择。

图 4-10:六种可能的框架样式
我们将属性设置为图表中显示的符号版本。例如,要获取左下角的样式,我们可以使用设置 framestyle=:zerolines。
处理绘图设置
现在我们可以理解 scatter(x, y; ms=0.5, legend=false) 的调用,它生成了图 4-9。在前两个位置参数之后,表示要绘制的点的数组,我们看到一个分号,表示关键字参数的开始。第一个设置了较小的标记大小,第二个关闭了图例。
让我们使用“基础绘图设置”中列出的一些基本属性组合,来解决一些其他的可视化问题。第 98 页。
宽高比和标题字体大小
以下程序创建一个简单的图表,包含两个子图,分别显示圆形和抛物线:
julia> p1 = plot(sin, cos, 0, 2π; title="A Circle", ratio=1,
grid=false, ticks=false, legend=false)
julia> p2 = plot(x -> x², -1, 1; title="A Parabola",
gridalpha=0.4, gridstyle=:dot, legend=false)
julia> plot(p1, p2; plot_title="Two Shapes", plot_titlefontsize=20)
在这里,我们使用ratio关键字在第一行设置宽高比。你可能已经注意到,在图 4-7 中本应是圆形的图像被渲染成了一个非圆形的椭圆。Julia 图表的默认大小不是正方形,而是横向比纵向长,因此圆形被横向拉伸。如果这很重要(如本例中所示),我们可以使用ratio来解决这个问题。我们还关闭了该图的网格和刻度。
大多数后端的默认网格比较轻,所以我们通过增加抛物线图中的gridalpha值使其更加突出。默认值为0.1。
最后一行创建了一个组合图,设置了比默认值稍大的整体标题。图 4-11 展示了结果。

图 4-11:包含两个子图的图表
为了在两个子图之间增加间距,我们可以例如为左侧子图设置rightmargin。在设置边距之前,使用Plots .PlotMeasures命令,它允许我们在边距设置中使用实际尺寸;例如,rightmargin=10mm。其他可用的单位包括inch、cm、px和*pt*。
注意
plot_title是绘图系统中的一个新功能,它的实现还不完整。如果我们选择更大的标题字体,标题将与子图标题重叠,而且没有直接的方法解决这个问题。
标签和图例位置
在下一个示例中,让我们绘制x^(n)在一些n值下的图表:
julia> plot()
julia> for n = 1:5
plot!(x -> x^n; lw=3, ls=:auto, label=n)
end
julia> plot!(; legend=:topleft, legendtitle="Exponent")
首先,我们使用一个空的plot()命令清除任何现有的图表,然后针对每个函数依次更新空白图表。由于for循环没有返回结果,因此我们在循环后的最终调用之前看不到任何内容,最终调用仅仅是进行了一些图表设置。在绘图语句中,label设置定义了与该图表相关的图例文本。它期望的是一个字符串(或符号),但可以将整数n转换为字符串。lw设置使线条比默认值更粗。ls设置用于设置线条样式。它可以取值:auto、:solid、:dash、:dot、:dashdot或:dashdotdot。这里使用的选项:auto会循环使用其他五种样式,如果图表有超过五条曲线,则重复使用样式。当我们无法使用颜色时,这是一个适合打印的好选择。图 4-12 展示了结果。

图 4-12:五种线条样式
最后的plot()语句设置了legend,将图例放置在图表的左上角。我们可以使用其他类似的定位符号,也可以在前面加上outer,将图例放置在坐标轴外部。为了更精确的定位,我们可以使用(x, y)元组来指定图例框的坐标。最后,我们可以设置legend=false来省略图例。
LaTeX 标题与数据驱动的标签位置
让我们用不同的标签样式绘制相同的函数。我们将使用注释将每个指数的标签放置在对应曲线的顶部,如示例 4-1 所示。
julia> plot()
julia> for n = 1:5
xlabel = (0.2 + 0.12n)
➊ ylabel = xlabel^n
plot!(x -> x^n; lw=3, ls=:auto,
annotation=(xlabel, ylabel, n),
annotationfontsize=25)
end
julia> using LaTeXStrings
julia> plot!(; legend=false, xguide="x", yguide="y", guidefontsize=18,
➋ title=L"x^n \textrm{~labeled~by~}n", titlefontsize=30)
示例 4-1:使用计算得到的标签和 LaTeX 标题
在这里,我们为循环中的五个标签计算坐标。x 坐标随着指数的增大向右增加,以便将标签分开,避免重叠。标签的 y 坐标 ➊ 是与我们绘制的曲线相同的 x 函数,以确保它们精确地位于所标记的曲线上。
annotation 设置中的 n 是一个包含整数的变量,原本应该是一个 String,但 plot() 函数会帮我们转换它。
然后我们导入一个之前没见过的包:LaTeXStrings(注意大小写),它让我们可以在图表标题和注释中使用 LaTeX 语法写数学公式。即使是非 LaTeX 用户,有时也可能需要在图表中展示公式,而 LaTeX 的数学语法简单直观。在第 121 页的“进一步阅读”中可以找到相关指南的链接。导入这个包之后,我们可以在任何字符串前加上 L,将其转化为 LaTeX 字符串。在可以排版的环境中,比如图表中,Julia 会适当地排版该字符串。整个字符串都会被放入 LaTeX 数学模式,其中所有字母都被视为数学符号。因此,如果我们需要一些普通文本,如这个例子中的 ➋,我们必须用 LaTeX 命令将其包裹起来,强制排版为普通文本。在这些文本片段中,用波浪号(~)表示空格。图 4-13 中展示了这个 REPL 会话的结果。

图 4-13:使用计算得到的标签和 LaTeX 标题
除了单个图表元素的设置外,还有两个设置会产生更大范围的变化。thickness_scaling 设置非常适用于创建一个在演示中更易阅读的图表版本。它会加粗所有元素,包括刻度标签。它还会影响图表的边距,并可能改变图表元素的位置。将值设置在 1 和 1.7 之间可以得到较好的效果。使用小于 1 的值则会创建一个较瘦的图表版本。
回归线
smooth 设置会通过线性回归为图上的每条曲线或数据集绘制最佳拟合线。
让我们回到姜饼地图,并使用相同的初始条件,计算 20,000 次迭代,设置 a = 1.6,再次将结果存储在 x 和 y Vector 中。我们将绘制两个子图。第一个是类似图 4-9 的散点图,但会添加一条回归线,显示点的平均方向。第二个图将绘制前 100 个 x 值与迭代次数的关系,并显示一条回归线,展示距离原点逐渐增大的趋势:
julia> sc = scatter(x, y; smooth=true, ms=1, legend=false,
xguide="x", yguide="y", guidefontsize=18)
julia> pl = plot(x[1:100]; smooth=true, legend=false)
➊ julia> pl = plot!(x[1:100]; lc=:lightgray, legend=false,
xguide="iteration", yguide="x", guidefontsize=18)
julia> plot(sc, pl, plot_title="Gingerbread map with a = 1.6",
plot_titlefontsize=22)
首先,我们像之前一样创建姜饼地图的散点图,使用smooth=true设置添加趋势线,并将结果赋值给sc。然后,我们绘制初始的 100 个x值,同样添加趋势线。将这两个子图一起绘制并加上标题,就得到了图 4-14。

图 4-14:姜饼地图的趋势
与之前一样,plot_title属性为两个图表创建一个总标题。我们希望绘制的曲线和计算的趋势线有不同的样式,但没有为此提供设置,因此我们采用了一个技巧,使用不同样式的曲线进行叠加绘制,但没有趋势线➊。
保存图表
当你准备将创作保存到磁盘时,调用savefig(p, path),其中 p 是保存可视化内容的变量,path 是你希望存储图像文件的位置。路径中的文件名扩展名决定了格式,但不同的后端支持不同类型的图像。PDF 和 PNG 格式应该始终可用,SVG 也被广泛支持。
如果省略p,则默认保存当前图表。一种常见的工作流程是反复修改图表,直到满意为止,然后调用savefig(path)。
细节插图
插图是一个小图,位于较大图的框架内。它通常用于提供外部图部分的放大视图。Julia 的绘图系统内置了一个创建这种细节插图的函数,叫做lens!()。它仅以变更形式存在,因为插图只有在现有图的基础上才有意义。
lens!()的第一个参数是现有图表,或者省略以表示当前图表。接下来的两个参数是定义要放大的矩形区域的向量。必需的参数inset指定哪个子图需要插图以及插图的位置和大小。图 4-15 中的示意图展示了如何使用这些参数。

图 4-15:如何制作插图
图 4-15 使用一个带网格的空白图进行说明。注释“width”和“height”指的是外部图的宽度和高度。创建插图的完整命令显示在图的底部附近。
作为lens!()的应用,我构建了另一个姜饼地图的实例,这次设置α = 1.4,并进行了 100,000 次迭代,以产生更多细节。接下来的两行首先创建散点图,然后添加插图:
scatter(x, y; ms=0.1, legend=false)
lens!([-26, -22], [31, 38];
inset=(1, bbox(0.1, 0, 0.3, 0.3)),
➊ ticks=false, framestyle=:box, subplot=2,
linecolor=:green, linestyle=:dot)
在调用lens!()时,设置➊用于ticks和Framestyle应用于插图,而linecolor和linestyle设置应用于绘制放大镜,后者标示出扩展区域。全框架样式是插图的一个不错选择。
图 4-16 展示了结果。我使用插图图放大了姜饼地图的一个角落,显示其中的点模式。

图 4-16:使用细节插图放大姜饼地图的一部分
在创建插图的调用中,subplot=2设置确保其他图形设置仅适用于插图,这使得插图成为第二个子图。通过引用子图的编号,如果需要,我们还可以在插图中创建另一个插图。
3D 图形
几种类型的图形用来可视化依赖于两个独立变量的量。当使用矩形坐标时,依赖变量通常称为z,而两个独立变量称为x和y。表示这种关系的三种常见方式是:表面图、热图或等高线图。最有效的方式取决于数据的性质以及我们试图阐明的特征。
表面图
在通过using Plots导入绘图包后,我们可以使用几个 3D 绘图函数。对于表面图,我们使用surface()函数来创建一个 2D 表面嵌入在 3D 空间中的透视渲染图,表面的高度和着色表示z值。
这里是一些适用于表面图的额外设置:
-
绘制色条:
colorbar(true或false) -
表面不透明度:
fillalpha -
视角:
camera(方位角,仰角)(以度为单位) -
色条标题:
cbtitle -
表面调色板:
c
让我们将这些设置应用于绘制两个变量的高斯分布的表面图。在定义一个从-1 到 1 的x向量后,我们可以使用匿名函数语法绘制表面,代码如下:
surface(x, x, (x, y) -> exp(-(0.05x² + y²)/.1);
fillalpha=0.5, camera=(45, 50), c=[Gray(0), Gray(0.8)],
xrotation=45, yrotation=-45)
我们使用小于 1 的 alpha 值以便能够看到表面,并且旋转了轴标签,使它们更容易阅读,避免在坐标轴交汇处发生重叠。图 4-17 展示了表面图。

图 4-17:表面图
c设置定义了用于给表面着色的调色板。有几种方法可以定义调色板;之前使用的那种,通过Vector中的多个颜色进行平滑插值来创建调色板。Gray(0)为黑色,Gray(1)为白色,依此类推。我们还可以通过RGB(r, g, b)定义颜色,其中r、g和b分别是红、绿和蓝的分量,范围从0(没有)到1(完全饱和)。有 600 多种颜色名称作为符号可用,包括易记的名称如:red和:blue,以及没有实际意义的名称如:seashell3和:oldlace。
我们可以提供一个符号来指定预定义调色板的名称,而不是提供一个颜色的 Vector,预定义调色板有很多可以在 https://docs.juliaplots.org/latest/generated/colorschemes/ 中找到。常用的名称有 :blues 或 :grays,它们使用相同的色调并调整饱和度和亮度,但还有许多可供选择,适用于特殊用途。
热图
热图也可视化将两个自变量映射到一个因变量,但自变量的值通过颜色或灰度值表示。该调用类似于表面图,但使用 heatmap() 函数:
heatmap(x, x, (x, y) -> exp(-(0.05x² + y²)/.1);
c=:grays)
此调用创建了在 图 4-18 中显示的热图。

图 4-18:二维高斯热图
热图的颜色调色板与表面图相同。
等高线图
等高线图类似于热图,但它们使用等高线而非颜色来表示自变量的值。以下是一些与等高线图特定相关的重要属性:
-
等高线数量或特定的等高线级别:
levels(整数或级别向量) -
绘制等高线标签(布尔值):
clabels -
填充等高线之间的区域(布尔值):
fill
如果我们为 levels 提供一个整数,Julia 将绘制相应数量的等高线。如果我们还将 clabels 设置为 true,它将使用表示的值为等高线添加标签。不幸的是,这些数值标签通常打印出过多的数字,导致标签重叠。如果我们将 levels 设置为一个数字 Vector,则图形将在这些指定值处绘制等高线,并且它们的标签将使用与 levels 相同的精度。以下示例展示了如何使用 levels 和 clabels:
contour(x, x, (x, y) -> exp(-(0.05x² + y²)/.1);
clabels=true, levels=[0.1, 0.3, 0.5, 0.7, 0.9, 1.0],
colorbar=false, framestyle=:box)
这个调用使用相同的 x 向量,并绘制与图表 4-17 (ch04.xhtml#ch4fig17) 和图表 4-18 (ch04.xhtml#ch4fig18) 中的表面图和热图示例相同的函数。结果如图 4-19 所示,标签精度为一位数字。

图 4-19:带标签的等高线图
:box framestyle 与等高线图非常配合。去除颜色条也是一个好主意。我们可以通过设置 c 来为线条上色,但这并不总是适用于每个后端。如果发现颜色溢出到等高线中,可以通过 c=:black 来修复。
:dot 等线条样式有效,但 :auto 无效。
注意
当使用某些后端(包括GR)并手动设置等高线级别时,必须包括一个大于或等于数据最大值的级别,否则图形将无法正确绘制。
当 fill 属性设置为 true 时,会在等高线之间填充颜色,从而形成一种带有等高线的离散热图。c 属性定义这些颜色的调色板。contourf() 函数是 contour() 的别名,且 fill=true。
让我们重复之前的等高线图(图 4-19),但这次保留颜色条,开启 fill,并使用灰度调色板:
contour(x, x, (x, y) -> exp(-(0.05x² + y²)/.1);
clabels=true, levels=[0.1, 0.3, 0.5, 0.7, 0.9, 1.0],
fill=true, c=[Gray(0.4), :white])
图 4-20 显示了填充等高线图。

图 4-20:填充等高线图
在这种情况下,同时拥有等高线标签和颜色条有些多余,因为它们传达的是相同的信息,但这可能会使图表更易于解读。科学可视化的艺术就在于创造一个既直观清晰又定量精确的结果。
3D 参数化图
3D 参数化图与 2D 中的工作方式相同,但它们在 3D 空间中绘制路径,单个参数的三个函数分别给出 x、y 和 z 坐标。与 2D 参数化图不同,我们必须提供三个向量,而且它不适用于函数。以下是一个示例:
julia> t = 0:2π/100:2π;
julia> xp = sin.(3 .* t);
julia> yp = cos.(3 .* t);
julia> zp = t .* 0.2
julia> plot(xp, yp, zp; lw=3, gridalpha=0.4, camera=(30, 50))
plot() 函数在接收到三个向量作为位置参数时知道该怎么做,生成的 3D 参数化图如 图 4-21 所示。

图 4-21:3D 参数化图示例
我们可以像处理普通的 2D 图一样使用线条属性,并像处理表面图一样设置相机角度。
向量图
向量场将空间中的每个点映射到一个向量,这个向量可以用箭头表示。Plots 包提供了通过 quiver() 函数创建的向量图。它的前两个参数是 x 和 y Vector,它们包含了向量起点的坐标。从这些坐标到向量端点的位移存储在另外两个 Vector 中,放在一个 Tuple 中,并分配给一个同名的关键字参数 quiver。
以下示例演示了如何使用 quiver():
julia> xc = 0:.3:π;
julia> yc = sin.(xc);
julia> quiver(xc, yc; quiver=(xc .- π/2, yc .- 0.25), lw=3)
这三行代码生成了 图 4-22 中的向量图。

图 4-22:使用 quiver() 的向量图
quiver() 函数接受所有曲线属性;在这里我们设置线条宽度以获得更粗的箭头。
3D 散点图
Plots 可以将散点图扩展到三维。可视化某个量的 3D 分布的一种方法是绘制一个常规的 3D 网格标记,同时将某个标记属性(如大小或不透明度)设置为该量的函数。首先,我们需要通过创建 x、y 和 z 向量来建立网格:
x = []; y = []; z = [];
for i in 0:20, j in 0:20, k in 0:20
push!(x, i/10 - 1)
push!(y, j/10 - 1)
push!(z, k/10 - 1)
end
这将创建从 -1 到 1 的坐标数组。
让我们想象一个行星位于网格的中心。我们可以通过首先定义一个势能函数,然后使用它来设置标记大小,从而绘制由于行星产生的引力势的形状:
pot(x, y, z) = 1 / sqrt(x² + y² + z²)
scatter(x, y, z; ms=min.(pot.(x, y, z), 5), ma=0.4, legend=false)
在行星附近,势能变得很大,因此我们需要使用 min() 函数限制标记的大小。实际上,在 (0, 0, 0) 处,它会变得无限大,但 Julia 会优雅地处理这个问题。结果如 图 4-23 所示。

图 4-23:一个 3D 散点图
我们设置了透明度,以便能够看到标记的背后。这是我们在 2D 中使用的相同scatter()函数,但如果我们提供三个位置参数,Julia 会知道该怎么做。
有用的后端
当前的默认后端GR有一个优点,就是速度快,且能够生成大多数基本类别的可视化图形。
还有一些其他后端可供特定用途使用,但大多数都需要我们在包管理器中add后才能使用。
UnicodePlots
unicodeplots后端直接在终端中绘图。它适合快速查看一些数据,用字符进行绘制。我们也可以用它生成图形,然后粘贴到电子邮件中,但显然它不适合用于生成出版用的图形,而且不能保存图形。
要在终端快速绘图,首先在包模式下执行add UnicodePlots,然后调用unicodeplots()来激活后端。
unicodeplots后端不支持所有的绘图类型。它可以绘制 2D 图形,包括散点图,但不能绘制等高线或表面图。不过,unicodeplots可以在终端中呈现彩色热力图。
PyPlot
pyplot后端使用 Python 的 Matplotlib,因此对于已经熟悉该系统的人来说,它可能是一个不错的选择。尽管有时可能会稍慢,但在某些情况下,它能够生成比默认后端更好的图形。
PlotlyJS
使用plotlyjs后端,我们可以为网页创建交互式图表。将图形保存为.html文件扩展名时,会创建一个包含 HTML 片段的文件,我们可以将其粘贴到网页中。该片段加载一些第三方 JavaScript,提供用于平移、缩放以及对于 3D 图形,旋转 3D 空间的交互控件。其他交互形式会根据图形类型适当变化。二维图形在用户悬停在曲线上时显示数据值,而表面图则在鼠标指针的z值处绘制等高线。
绘图并不算非常快速,尽管结果看起来不错,交互也非常流畅。使用plotlyjs的等高线图比使用GR要好,特别是对于彩色等高线,但线宽或线型的属性没有效果,手动设置的等级也不起作用。
在 REPL 中绘图时,每个图形都会弹出一个独立的窗口,使用与 HTML 文件中相同的 JavaScript 交互性。
PGFPlots 和 PGFPlotsX
我不会多说这些,因为它们只对那些安装了 LaTeX 并且对 LaTeX 图形系统 PGFPlots 有一定了解的人有用。使用这些系统的人应该知道有两个 Julia 接口可以使用。两个版本的区别在于,PGFPlotsX 的语法更接近 LaTeX 中直接使用的语法。通过 PGFPlots,我们可以制作出难以通过其他方式实现的极其出色的可视化。对于那些不熟悉该系统的 LaTeX 用户,可能需要了解一下。这个后端确实依赖于 LaTeX 安装——这是一个不容小觑的要求。
HDF5
HDF5 代表分层数据格式,第 5 版。这个后端不会直接显示图表;它的目的是将数据和图表一起打包成 HDF 文件。对于任何在研究中使用 HDF 的人来说,这个包是必不可少的,但其他人则不会用到它。
该后端不仅可以写入 HDF 文件,还可以将其读入 Julia 会话,配合其他 Plots 后端进行显示。
Gaston
Gaston 是 gnuplot 的接口,依赖于 gnuplot 的安装。这个后端对那些已经在使用这个古老且强大的图形程序的人来说会很有兴趣。
Gaston 既快速又强大,因为 gnuplot 快速且强大。如果你经常需要绘制其他后端无法处理的复杂 3D 图,或者需要更精细的控制来制作用于出版的图表,那么安装 gnuplot 并与 Gaston 一起使用可能是最佳选择。
布局
在本章前面我们看到,plot() 函数会将图形排列成一个网格,如果传入多个图表对象。有时我们需要对插图中的子图布局进行更多控制。在这种情况下,我们可以使用 Layout 系统。
绘图包将图表组合成更大插图的方法是该系统的一大亮点。考虑到它所允许的复杂性,它的使用却非常直观。
在接下来的演示中,Vector s 包含六个图表,每个图表显示一个突出的数字,从 1 到 6。这样可以清楚地看到布局引擎如何定位每个图表。
如果你想跟着一起操作,你需要创建自己的 s 向量,包含你选择的图表。
制作简单的矩形布局
要将默认的方形网格布局替换为不同的矩形排列,只需将所需的行数和列数作为元组赋值给 layout 属性:
plot(s[1], s[2], s[3], s[4]; layout=(1, 4))
如图 4-24 所示,这个调用通过一行四列的方式排列图表。

图 4-24:一行布局
布局元组中隐含的图表数量必须与子图的数量完全匹配。在这种情况下,默认值相当于 layout=(2, 2)。
使用 grid()
之前示例中的简单布局使所有子图的大小相同。要控制行和列的高度和宽度,可以使用 grid() 函数,如下例所示:
plot(s[1], s[2], s[3], s[4];
layout=grid(2, 2; widths=(0.2, 0.8), heights=(0.7, 0.3)))
这个调用创建了图 4-25 中的布局。我们可以省略 height 或 width 中的任意一个规格,以便在该方向上获得相等的长度。

图 4-25:使用 grid() 函数的布局
使用 grid() 时,维度的总和可以小于 1,这将简单地留下空白空间,但总和不应大于 1。
使用 @layout 创建复杂布局
我们可以创建任意复杂度的布局。下一级需要使用 @layout 宏。我们尚未看到宏,它将在“宏”一章中介绍,参见第 170 页。现在,我将展示如何使用这个特定的宏来创建图表布局。在我们对语言有了更多了解后,会更好地理解其背后的工作原理。
@layout 宏创建的布局遵循我们提供给宏的矩阵形状。我们使用空格来水平排列子图,使用换行符或分号来垂直排列子图,就像构建实际的矩阵一样。然而,这些 @layout 矩阵不需要具有匹配的维度。如以下示例所示,行可以包含不同数量的元素。我使用 a 来表示一个子图,但我们可以使用任何标识符。它们没有特定含义,因为布局引擎只会按照我们在 plot() 函数中提供的顺序使用这些图。以下是宏的一个简单使用示例:
plot(s[1], s[2], s[3], s[4], s[5], s[6];
layout = @layout [ a a a
a a
a ] )
图 4-26 展示了最终的布局。观察子图的排列如何遵循宏中使用的 a 占位符的排列。

图 4-26:使用 @layout 宏
以这种形式使用 @layout 宏会使子图分配的空间相等。要更改它们的高度或宽度,可以使用以下示例中的符号:
plot(s[1], s[2], s[3], s[4], s[5], s[6];
layout = @layout [ a a a
a{0.68w} a
a{0.5h} ])
花括号内的规格是宽度或高度,占整个图表的比例。这个调用创建了图 4-27 中的布局。

图 4-27:使用 @layout 宏与维度规格
我们可以通过在 @layout 参数中使用 grid() 调用来实现更大的布局灵活性,如以下示例所示:
plot(s[1], s[2], s[3], s[4], s[5], s[6];
layout=@layout [ grid(2, 2) a{0.3w}
b{0.2h} ])
传递给 @layout 宏的子图数量必须等于传递给 plots() 的位置参数中的数量。这里的 grid(2, 2) 调用表示四个子图,剩余的两个由 a 和 b 表示。图 4-28 展示了结果。

图 4-28:在布局中使用子网格
在第 106 页中,“Detail Insets”解释了如何创建放大主图部分的插图。我们可以使用那里使用的inset和subplot属性来制作任何类型的插图,不仅仅是使用lens!(),并且可以将其与任何布局结合使用。
在前一个示例中创建布局后,我们可以通过以下调用将插图添加到该布局中:
plot!(x -> sin(7x); inset=bbox(0.2, 0.2, 0.3, 0.3), subplot=7,
background_inside=RGBA(1, 1, 1, 0.3), lw=5, framestyle=:box,
legend=false, lc=:black)
inset属性被设置为一个bbox。由于我们没有为其提供位置参数,bbox的参数将使得图表相对于整个布局进行定位,而不是相对于某个特定的子图。subplot=7设置将插图变成一个新的子图,这是确保该功能按预期工作的必要条件,因为布局已经包含了六个子图。RGBA与我们之前看到的RGB类似,但有一个额外的参数用于透明度。
图 4-29 展示了添加插图后的结果。

图 4-29:向布局添加浮动插图
结论
本章涵盖了你在大多数科学图形中需要使用的Plots包的所有内容:主要的图形类型、镜头和注释、如何自定义外观以及如何布置图形集合形成复合图。在第七章中,我们将学习如何制作动画,并探索一些创建图表的包,而在第八章中,我们将重新审视绘图系统,学习绘图配方。
进一步阅读
-
Plots包的官方参考可以在https://docs.juliaplots.org/latest/找到。 -
这是你可以找到关于
Plots的视频链接,视频由其创作者提供:http://www.breloff.com/plots-video。 -
要获取关于制作出版质量图表的有用指南,请访问https://nextjournal.com/leandromartinez98/tips-to-create-beautiful-publication-quality-plots-in-julia。
-
更多关于 HDF5 格式的信息可以在https://www.hdfgroup.org/solutions/hdf5找到。
-
关于如何在 Julia 中使用 HDF5 文件的文档可在https://juliaio.github.io/HDF5.jl/stable/找到。
-
要了解更多关于预定义调色板的信息,请访问https://docs.juliaplots.org/latest/generated/colorschemes/。
-
Gaston总部位于https://mbaz.github.io/Gaston.jl/stable,并且包含了精心挑选的插图。 -
相关信息和 gnuplot 软件的下载可在http://gnuplot.info找到。
-
LaTeX 数学语法的基础知识可以在 https://www.cs.princeton.edu/courses/archive/spr10/cos433/Latex/latex-guide.pdf 中找到(见第七部分)。
-
关于 Julia 封装器的文档,可以通过强大的plotly.js交互式绘图系统获取,文档位于
plotlyjs包中:https://plotly.com/julia/。
第六章:集合**
我不想属于任何接受我作为成员的俱乐部。
—归因于格劳乔·马克思

集合是一个作为容器的数据结构。它包含的值称为其元素。Julia 的集合通过它们可以包含的内容、是否可变、元素的访问方式、内容是否有序以及其他几个特征来区分。
我们已经使用过数组、字符串和其他类型的 Julia 容器。在本章中,我们将进一步了解这些集合,并接触一些新的集合类型。
控制循环执行
在 Julia 中,循环和集合之间有着密切的关系。例如,for循环依赖于一个集合或可迭代对象,循环会逐一访问其中的元素。
我们已经知道如何使用while和for语句块来编写循环。在本节中,我们将探讨如何通过break和continue语句进一步控制循环的执行,并学习如何使用推导式编写简洁的循环,这是一种创建集合的简洁方法。
break 语句
有时我们需要根据某个条件结束一个循环,并阻止它达到“正常”的终止状态。这就是break命令的用途,它可以终止while和for循环。
例如,以下循环会重复要求用户输入一个数字并打印其平方根:
while true
println("Enter a number, or 0 to quit.")
x = readline()
x = parse(Float64, x)
if x ≤ 0
➊ break
end
➋ println("The square root is ", sqrt(x))
end
while条件是true,这个条件永远不会改变,因此,如果没有break语句 ➊,循环会一直运行下去,直到用户输入0(或负数)时终止。这是一个常见的模式,当我们希望循环永远执行,直到由某个条件中断。
readline()语句从终端读取一行输入,当用户按下 ENTER 时终止,并将结果存储到一个字符串变量中。我们需要将这个字符串解释为数字,这就是parse()函数为我们做的事情。parse()的第一个参数指定要将字符串转换成哪种数据类型。println()函数的多参数版本会将它的参数连接起来,并根据需要将数字转换为字符串➋。
break语句也可以终止for循环。在下面的示例中,我们遍历一个向量中的数字,并在遇到完美平方数时停止:
n = [12, 53, 19, 64, 16, 8]
for x in n
if round( √x ) == √x
println("Found a perfect square in the list: ", x)
break
end
end
当这个代码运行时,它会打印出以下内容:
Found a perfect square in the list: 64
一个数字是完美平方数,如果它的平方根是一个整数。代码通过将该数字的平方根与round()函数四舍五入后的相同值进行比较来测试这一点。由于round()函数会将数字四舍五入到最近的整数,因此,如果数字本身已经是整数,它们的值就会相同。当找到第一个完美平方数时,break语句终止循环,所以我们不会看到 16。
continue 语句
continue 语句跳过当前循环迭代中的进一步处理,直接进入下一次迭代。该程序打印出前 100 个质数(我们将跳过输出部分):
for n in 1:100
possibly_prime = true
x = 2
while x ≤ √n
➊ if n % x == 0
possibly_prime = false
break
end
x += 1
end
if !possibly_prime
➋ continue
else
println(n)
end
end
程序通过检查每个整数的平方根以内的整数除数来测试前 100 个整数。如果 x 是 n 的除数,那么当我们计算 n/x 时将没有余数;这就是 n % x == 0 检查的内容 ➊。如果找到一个除数,n 就不是质数,因此我们设置一个标志并 break 出 while 循环。在外部的 for 循环中,如果当前的 n 不是质数,我们希望 continue ➋ 到下一个 n,但如果是质数则打印它。
推导式与生成器
我们通常可以更简洁地使用 数组推导式 来编写创建数组的 for 循环。作为一个简单的例子,假设我们需要一个 Vector,包含前五个完美的平方数。我们可以通过一个 for 循环来构建它,如下面的 REPL 会话列表所示,但首先我们需要初始化一个空的 Vector:
julia> xs = [];
julia> for x in 1:5
push!(xs, x²)
end
julia> xs
5-element Vector{Any}:
1
4
9
16
25
我们可以通过一个数组推导式来完成相同的任务:
julia> xs = [x² for x in 1:5]
5-element Vector{Int64}:
1
4
9
16
25
我们不需要初始化 xs 向量,因为推导式在一步中创建并填充它。这些示例中的结果类型不同,这是我们将在 第八章 中探讨的主题。
数组推导式通常包含两部分,分别由关键字 for 分隔。第一部分是一个涉及虚拟变量的表达式,在此为 x。第二部分,从 for 开始,形式与熟悉的 for 循环的第一行相同,并使用第一部分中的虚拟变量。第一部分成为隐式循环的主体,每次迭代时将一个新元素添加到结果数组中。
推导式创建的数组将具有与其第二部分容器相同的形状。请考虑以下例子:
julia> [2x for x in [1 2
3 4]]
2×2 Matrix{Int64}:
2 4
6 8
在这里,由于容器遍历的是一个 2×2 矩阵,因此结果的形状也是这个矩阵。
数组推导式可以包含任意数量的隐式循环。结果的形状取决于循环是由 for 还是逗号分隔的。以下示例说明了这两种可能性:
julia> [x * y for x in 1:3 for y in 1:3]
9-element Vector{Int64}:
1
2
3
2
4
6
3
6
9
julia> [x * y for x in 1:3, y in 1:3]
3×3 Matrix{Int64}:
1 2 3
2 4 6
3 6 9
在第一种情况下,结果是一个 Vector,这正是我们从两个嵌套的 for 循环中预期的结果。第二种情况,使用逗号分隔隐式循环,产生一个 Matrix。我们可以通过添加更多逗号分隔的循环子句,将其扩展到任意维度。
请考虑以下示例,确定乘法表中哪些偶数能被 7 整除:
julia> [x * y for x in 1:9, y in 1:9
if x * y % 2 == 0 &&
x * y % 7 == 0] |> unique
4-element Vector{Int64}:
14
28
42
56
推导式末尾的 if 语句过滤结果;推导式的最终结果是一个 Vector。这个例子依赖于操作顺序以避免不必要的括号:乘法的优先级高于(绑定得更紧密)模运算符 %。我们将推导式的结果传递给 unique() 函数,该函数会从集合中移除重复项。
生成器表达式的形式与数组推导相同,但没有包围的方括号。它创建的是一个迭代器,而不是一个已填充的数组。我们可以遍历或迭代这个对象,以一次使用它的一个成员,但它几乎不占用任何内存。这样它应该让你想起范围表达式及其与向量的关系。
在实际操作中,我们有时需要将生成器表达式括起来以避免歧义,就像其他表达式一样。这种情况出现在以下示例中,我们在其中创建了一个生成器版本的乘法表:
julia> multiplication_generator = (x * y for x in 1:9, y in 1:9)
我们需要括号,因为这里有一个双重for循环。如果没有它们,Julia 就无法将其识别为生成器表达式。
现在我们可以像以前一样提取表中能被 7 整除的偶数:
julia> [n for n in multiplication_generator
if n % 2 == 0 && n % 7 == 0]
|> unique
4-element Vector{Int64}:
14
28
42
56
如果表格很大,而不仅仅是 9×9,使用生成器而不是填充数组将节省大量内存。我们始终可以使用collect()从迭代器中生成已实现的表格。
更多连接字符串的方法
在 Julia 中,字符串是一个集合。它的元素是字符。我们第一次在“字符串和字符”部分遇到String类型,见于第 44 页,在这里我们将探索你可以对字符串执行的最重要操作。
我们已经看到如何使用*运算符或使用join()函数将多个字符串连接成一个字符串。我们还可以使用string()函数,它将任何数量的字面量字符串和字符串值变量连接成一个更大的String:
julia> comma_space = ", ";
julia> string("Hello", comma_space, "François")
"Hello, François"
由于comma_space是一个字符串,string()函数简单地将它与我们提供的其他字符串连接起来。如果它是其他类型的对象,例如数字,只要该对象有字符串表示,调用仍然有效。在这种情况下,函数首先将其转换为字符串,然后进行连接。
repeat()函数将一个字符串与自身连接指定的次数:
julia> repeat("ABC ", 5)
"ABC ABC ABC ABC ABC "
在这个例子中,新字符串是由“ABC”重复五次组成的,正如第二个参数所指定的那样。
我们还可以使用这个函数创建数组,正如我们在“repeat()函数”中看到的那样,见于第 139 页。
非标准字符串字面量
字符串字面量是像"abc"这样的表达式,直接表示一个String。Julia 支持多种非标准字符串字面量,通过在字符串字面量前加上一个关键字,前缀指定表达式表示的是什么类型的特殊用途字符串。这些对象的意义超出了它们作为字符串的存在。
我们已经遇到过其中一个对象的例子。在《LaTeX 标题和标签定位》一书的 第 103 页 中,我们描述了如何使用 LaTeX 字符串作为图表标签。这些字符串以大写字母 L 为前缀,如 L"e^{iπ} + 1 = 0"。当绘图程序看到普通字符串作为标签时,字符串会原样打印在图表上,但如果标签是 LaTeX 字符串,程序会知道打印其经过 LaTeX 处理后的形式。LaTeX 字符串在 LaTeXStrings 包中定义。一些非标准字符串文字在它们自己的包中定义,使用前需要导入,而其他一些则是内建的。
在幕后,非标准字符串文字是作为宏实现的(参见《宏》一节,在 第 170 页)。宏的名称是字符串文字的标签,后面跟着 _str。换句话说,实现 LaTeX 字符串的宏名称是 @L_str。要在 REPL 中查看非标准字符串文字的文档,我们可以输入 ?@L_str 或 ?L""(对于 LaTeX 字符串)。
原始字符串
一个有用的内建非标准字符串文字是 原始字符串,通过在前面加上 raw 来表示。大多数非标准字符串的关键字是单个字母,但 raw 是一个例外。原始字符串用于按字面意思表示某些字符序列,在标准字符串中它们会被解释为控制字符或其他内容。例如,通常情况下,\t 序列在打印字符串时会被转换为 TAB 字符,但在原始字符串中,它会按字面意思被解释:
julia> print(raw"a\tb")
a\tb
julia> print("a\tb")
a b
在第二个打印命令中,未转义的 TAB 字符被呈现为一个水平空格。
因此,在原始字符串中,反斜杠会按字面意思解释,唯一的例外是——它们仍然需要用来转义双引号:
julia> print("I said, \"No\".")
I said, "No".
julia> print(raw"I said, \"No\".")
I said, "No".
如果在原始字符串中出现反斜杠,除非它紧跟在双引号之前,否则它会被按字面意思解释。
语义版本字符串
软件版本发布的版本通过类似 v1.7.1. 的标签来标识。不同的项目使用不同的版本标签系统,其中一种系统被称为 语义版本控制。字符串中的字段表示主版本和次版本号,另外还可以包含其他版本信息。有关详细规范的链接,请参见《进一步阅读》一节,在 第 151 页。
在语义版本字符串前加上 v。我们可以比较版本并提取字段的数值,这些数值以十六进制数形式返回,前面带有 0x:
julia> v"1.6.1" < v"1.6.2"
true
julia> version = v"1.7.2"
v"1.7.2"
julia> version.major, version.minor, version.patch
(0x00000001, 0x00000007, 0x00000002)
Julia 项目本身使用这个方案的扩展版来编号语言和包的发布版本,因此语义版本字符串已内建于语言中。
字节数组字面量
在字符串前添加b会创建一个字节数组字面量:一系列无符号的 8 位整数,表示字符串中字符的 UTF-8 编码顺序。如在第 44 页的“字符串与字符”中所述,字符可以占用 1 到 4 个字节。下面是将一个三字符字符串转换为字节数组的示例:
julia> b"a2∑"
4-element Base.CodeUnits{UInt8, String}:
0x61
0x32
0xce
0xa3
字符a和2各自由一个字节表示,但字符∑占用了两个字节。
我们可以在 REPL 中输入大写的西格玛字符,以了解更多关于它的信息:
julia> '∑'
'∑': Unicode U+03A3 (category Lu: Letter, uppercase)
响应告诉我们,03A3是字符的 Unicode代码点。代码点是一个单一的、可能较大的十六进制整数,唯一标识该 Unicode 字符。
注意
Unicode 字符在打印时可能不对应一个单独的字符。一些字符与一个或多个邻近字符结合,形成重音符号或连字。
我们可以直接在字符串中使用代码点,使用转义码\u,它们会被转换为所代表的字符:
julia> "a2\u03a3"
"a2∑"
为了避免转换,我们可以转义反斜杠或使用原始字符串。
字符串查找与替换
replace()函数用另一个子字符串替换指定的子字符串。它可以接受任意数量的替换,按从左到右的顺序应用,但有一个前提条件,即每个字符只能经历一次替换。以下示例展示了语法以及前提条件的影响:
julia> s = "abc"
"abc"
julia> replace(s, "b" => "XX", "c" => "Z")
"aXXZ"
julia> replace(s, "c" => "Z", "Z" => "WWW")
"abZ"
该条款意味着最后一个例子中的第一个替换中的"Z"不会被替换成"WWW"。
注意
字符串中的多重替换 replace() 函数首次出现在 Julia v1.7 中。如果你使用的是早期版本,可以按照此处描述的使用 replace() 函数,但只能进行一次替换。
occursin()函数测试子字符串是否存在于字符串中:
julia> occursin("abc", "abcdef")
true
julia> occursin("abc", "abCdef")
false
它测试第二个参数中是否存在第一个参数给定的字符串,并且是区分大小写的。
occursin()函数延续了其他函数的传统,如iseven(),用于测试条件并返回true或false(请参见第 163 页的“filter() 操作符”)。
findfirst()和findlast()函数分别查找一个字符或字符串在另一个字符串中的位置。如果我们要求某个字符的位置,这些函数会返回其首次或最后一次出现的索引:
julia> findfirst('a', "abcabc")
1
julia> findlast('a', "abcabc")
4
如果我们在第一个参数中提供的是一个字符串,而不是一个字符,函数将返回一个范围,给出字符串在第二个字符串中的位置:
julia> findfirst("abc", "abcabc")
1:3
julia> findlast("abc", "abcabc")
4:6
如果我们要查找的字符或字符串在第二个字符串中不存在,这些函数会返回nothing。
findnext()函数行为类似,但它接受一个第三个参数,用于指定搜索开始的位置:
q = "To be or not to be, that is the question"
i = 0
locations = []
➊ while i != nothing
i = findnext('e', q, i + 1)
push!(locations, i)
end
print("""The letter "e" was found at locations """,
join(locations[1:end-1], ", ", " and "), ".")
这展示了join()的第三个可选位置参数,它会替代第二个参数给定的分隔符,插入到最后两个元素之间。
当我们运行这个程序时,它会打印:
The letter "e" was found at locations 5, 18, 31 and 35.
如果 findnext() 或其他字符串搜索函数没有找到它们要找的内容,它们会返回“什么也没有”,或者更具体地说,返回一个叫做 nothing 的特殊值。我们在 while 条件中 ➊ 利用了这一点,当找不到更多的 e 字符时结束循环。
本节中描述的所有搜索和替换函数也可以与正则表达式一起使用。Julia 使用 Perl 兼容的正则表达式;有关语法的链接,请参阅《进一步阅读》部分,见 第 151 页。
要定义正则表达式,我们使用带有 r 关键字的非标准字符串字面量。例如,r"A.*B" 是一个正则表达式,匹配 A 后跟任意数量的字符,最后是 B。
这里是一个简单的正则表达式示例,用来删除特定字符对之间的所有内容:
julia> s = "abc<ABC>def"
"abc<ABC>def"
julia> replace(s, r"<.*>" => "")
"abcdef"
正则表达式中的括号片段成为我们可以在替换文本中使用转义整数引用的目标。这些整数按照括号片段的顺序排列,所以第一个片段用 \1 引用,以此类推。在普通字符串中,这些转义的整数会被解释为控制字符;因此,Julia 为此目的提供了另一种非标准字符串字面量,使用 s 关键字:
julia> replace(s, r"(.*)<(.*)>(.*)" => s"\1\3, \2")
"abcdef, ABC"
这个替换操作将带有定界符的字符串移动到末尾,前面加上一些标点符号,而不是删除尖括号之间的字符串。
字符串插值
Julia 高兴地借鉴了其他语言的好点子。Perl 不仅有强大的正则表达式,还具有方便的 字符串插值 语法,你也可以在 Julia 中使用。
当我们想将变量或表达式的值插入到字符串中时,我们使用字符串插值。插值语法告诉 Julia 创建这些值的字符串表示,并将它们放置在更大的字符串中。插值让我们避免了凌乱的字符串连接序列,取而代之的是更整洁的代码:
julia> function name_length()
println("Hi. What's your name?")
➊ name = readline()
➋ println("Hello, $name. Your name has $(length(name)) letters.")
end
name_length (generic function with 1 method)
julia> name_length()
Hi. What's your name?
Emily
Hello, Emily. Your name has 5 letters.
这个例子演示了两种类型的字符串插值,都在 println() 调用的参数中 ➋。在用户输入的名称被存储在变量 name 中 ➊ 后,我们可以通过字符串插值来访问它的值。要插入变量的值,只需在美元符号($)后面使用它的名称。要插入另一种类型的表达式,将其放在 $ 后面的括号内。我们这样做是为了插入用户名称的长度 ➋。
我们可以将任何表达式插入到字符串中。如果我们想排除空格(因为它们不是字母)来计算名称的长度,我们可以使用如下方式:
println("Hello, $name. Your name has $(length(replace(name, " " => ""))) letters.")
如果你需要实际的美元符号,可以用反斜杠转义:\$。自然地,raw 字符串不会参与插值过程。
附加集合类型
本节描述了其他类型的集合,它们都是日常 Julia 编程的一部分:字典、集合、结构体和具名元组。
字典
Julia 的Dict类型类似于 Python 中的字典或 Bash 中的关联数组。它是一个一维集合,像向量一样,但通过键而不是位置进行索引。列表 5-1 展示了初始化字典的两种方式之一。
julia> bd = Dict("one"=>1, "two"=>2)
Dict{String, Int64} with 2 entries:
"two" => 2
"one" => 1
列表 5-1:从键值对创建字典
在这个初始化之后,新字典包含了两个键值对。这个字典中的每个键恰好是它所索引的数字的名称。
除了将键值对作为单独的参数提供外,我们还可以提供任何可迭代对象,当迭代时,它会生成键值对。例如,我们可以通过Dict(["one"=>1; "two"=>2])从列表 5-1 初始化字典。
字典中的键和值可以是任何类型。在bd中,两个键都是字符串,且它们指向的值是整数。
索引字典的语法与索引向量相同,但索引是键而不是位置:
julia> bd["one"]
1
julia> bd[2]
ERROR: KeyError: key 2 not found
我们使用两个键值对初始化了bd字典;由于 2 不是其中的键,我们尝试用 2 来索引字典时会产生错误。
keys(bd)函数返回bd中键的列表;相应的values()函数返回值的列表。
Dict中的键必须是唯一的。如果我们定义一个已经存在的键,后面的定义将替换掉现有的:
julia> bd["one"] = 9;
julia> bd
Dict{String, Int64} with 2 entries:
"two" => 2
"one" => 9
在第一行中我们重新使用了"one"键。显示字典时,可以看到新值已替换了之前的值。
初始化字典的另一种方式是向Dict()传递一个单一的参数。该参数可以是任何生成元组的可迭代对象;每个元组生成一个键值对:
julia> Dict([("one", 1) ("two", 2)])
Dict{String, Int64} with 2 entries:
"two" => 2
"one" => 1
注意,字典按看似随机的顺序打印出来。这是正常现象,因为字典是无序的集合,不像向量。
集合
Julia 的Set数据类型实现了数学集合的许多属性。Julia 中的集合是通过其中包含的元素来定义的。元素没有顺序,并且集合不能被索引。如果你添加一个已经存在的元素,集合不会改变,因为它已经包含该元素,而且每个元素只能出现一次。
让我们定义两个简单的集合,用来说明我们可以在集合上执行的一些操作:
julia> s1 = Set(1:5)
Set{Int64} with 5 elements:
5
4
2
3
1
julia> s2 = Set(4:8)
Set{Int64} with 5 elements:
5
4
6
7
8
Set()函数接受任何可迭代对象,并初始化集合。集合的成员会按任意顺序列出,因为在集合中顺序是没有意义的。
让我们来查看我们刚刚创建的两个集合的交集和并集:
julia> intersect(s1, s2)
Set{Int64} with 2 elements:
5
4
julia> union(s1, s2)
Set{Int64} with 8 elements:
5
4
6
7
2
8
3
1
两个示例中的结果也是集合。交集是两个集合中共有的元素,而并集是出现在任一集合中的元素。
我们可以使用issubset()函数来测试集合之间的子集关系,该函数有一个 Unicode 同义词,也可以作为二元操作符使用:
julia> issubset(4:7, s2)
true
julia> 4:7 ⊆ s2
true
➊ julia> 4:7 ⊇ s2
false
要创建子集或超集 ➊ 字符,可以分别输入 \subseteq 或 \supseteq,然后按 TAB 键。函数将 4:7 范围自动转换为一个集合,并告诉我们 Set(4:7) 是 s2 的子集,因为前者的每个成员都是后者的成员。
我们可以使用 setdiff() 函数找到两个集合之间的差集,即一个集合中不在另一个集合中的元素:
julia> s1
Set{Int64} with 5 elements:
5
4
2
3
1
julia> setdiff(s1, 3:5)
Set{Int64} with 2 elements:
2
1
结果显示我们在从 s1 中移除 3、4 和 5 后剩下的部分。
该函数的变异形式将第二个集合的成员从第一个集合中移除。要向集合中添加新元素,可以使用 push!():
julia> push!(s1, 999);
julia> setdiff!(s1, 1:3)
Set{Int64} with 3 elements:
5
4
999
在这个例子中,首先我们使用成员 999 扩展 s1,然后移除 Set(1:3) 中的元素。
结构体
struct 是一组命名值的集合,这些值在一个标识符下打包在一起。例如,列表 5-2 创建了一个结构体,用来保存标识网页的两条信息。
julia> struct Website
url
title
end
julia> google = Website("https://google.com", "google")
Website("https://google.com", "google")
列表 5-2:定义一个结构体
首先我们定义一个新的结构体,叫做 Website,然后创建一个名为 google 的变量,用于存储一个具有特定 url 和 title 值的 Website 实例。
常规的风格是将结构体的名称首字母大写。结构体的名称作为一个构造函数,用来创建具有该结构体类型的复合对象。因此,定义一个结构体就等于通过向语言中添加新类型来扩展 Julia。通过 typeof(google) 请求 Julia 获取 google 的类型时,将返回 Website。有关用户自定义类型的更多信息,请参见 第 234 页的“用户定义类型”部分。
我们可以使用属性符号来引用复合对象的字段,例如结构体和命名元组(下文会描述):
julia> google.title
"google"
julia> google.title = "Google"
ERROR: setfield!: immutable struct of type Website cannot be changed
发现我们忘记了将网站标题大写后,我们尝试修正它,但 Julia 不允许修改,因为结构体默认是不可变的。
我们可以通过重新定义 google 来修复我们的错误,但如果我们计划经常变更 Website 对象,我们可以将其定义为可变结构体:
julia> mutable struct MutableWebsite
url
title
end
julia> google = MutableWebsite("https://google.com", "google")
MutableWebsite("https://google.com", "google")
julia> google.title = "Google"
"Google"
现在我们可以随时更改 google 的字段值。
命名元组
命名元组就像是 Julia 的普通 Tuple,只是我们可以给它的值命名:
julia> nt = (a=1, b=2, c=3);
julia> nt.c
3
现在我们有了一个新的命名元组,叫做 nt,它有三个字段,分别叫做 a、b 和 c。如本例所示,我们使用属性符号从命名元组中提取值,就像使用结构体一样。
命名元组是不可变的,就像(不可变的)结构体和普通元组一样:
julia> nt.a = 17
ERROR: setfield!: immutable struct of type NamedTuple cannot be changed
尝试给不可变数据类型的字段赋值是不允许的。
元组和命名元组与函数参数列表密切相关,正如我们将在“函数及其参数”部分(第 154 页)中探讨的那样。
使用函数初始化数组
Julia 提供了一些函数来初始化数组。使用这些函数通常比我们之前一直在使用的字面量数组定义更方便简洁。
repeat() 函数
repeat()函数沿着你提供的参数所对应的每个维度重复一个数组指定的次数:
julia> repeat(['a' 'b' '|'], 4, 3)
4×9 Matrix{Char}:
'a' 'b' '|' 'a' 'b' '|' 'a' 'b' '|'
'a' 'b' '|' 'a' 'b' '|' 'a' 'b' '|'
'a' 'b' '|' 'a' 'b' '|' 'a' 'b' '|'
'a' 'b' '|' 'a' 'b' '|' 'a' 'b' '|'
在这个例子中,单行数组['a' 'b' '|']的元素在第一(列)方向上复制了四次,在第二(行)方向上复制了三次。
我们已经在《更多字符串连接方法》一节中遇到过repeat(),它是一个复制字符串的函数,出现在第 128 页。
fill()函数
fill()函数接受其第一个参数提供的值,并创建一个由后续参数指定形状的数组,用该值填充它。列表 5-3 展示了它是如何工作的。
julia> XY = fill(['X' 'Y'], 3, 4)
3×4 Matrix{Matrix{Char}}:
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
列表 5-3:填充数组
这里,值['X' 'Y']用于填充一个 3×4 的数组。与repeat()不同,fill()可以接受作为元组的维度以及单独的参数,因此我们可以将上述写作fill(['X' 'Y'], (3, 4))。
repeat()和fill()之间最重要的区别是,前者将第一个参数中提供的数组元素连接成所请求的形状,而后者则连接整个数组本身。这可以通过刚才展示的两个示例的结果看到。
fill()和 repeat()函数的可变性
让我们尝试修改在列表 5-3 中定义的矩阵XY的一个元素。我们会尝试把右上角的'X'改成'O':
julia> XY[1, 4][1] = 'O';
julia> XY
3×4 Matrix{Matrix{Char}}:
['O' 'Y'] ['O' 'Y'] ['O' 'Y'] ['O' 'Y']
['O' 'Y'] ['O' 'Y'] ['O' 'Y'] ['O' 'Y']
['O' 'Y'] ['O' 'Y'] ['O' 'Y'] ['O' 'Y']
结果对许多首次遇到它的人来说是令人惊讶的。在改变XY的一个元素时,我们改变了所有元素。这是因为fill()并不会将它的第一个参数复制到结果中的多个位置。XY的每个元素都是相同的一行矩阵;这里的输出显示了变异这个矩阵后的结果。
如果我们不是变异元素,而是替换它,事情就会有所不同:
julia> XY = fill(['X' 'Y'], 3, 4);
julia> XY[1, 4] = ['O' 'Y'];
julia> XY
3×4 Matrix{Matrix{Char}}:
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['O' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
现在XY包含了两个不同的矩阵,其中一个出现了 11 次。
如果我们在额外的一对方括号内放入第一个参数,使用repeat()时会观察到完全相同的行为:
julia> XY = repeat([['X' 'Y']], 3, 4);
这样,在repeat()提取第一个参数的内容并将它们连接起来后,我们仍然得到了一个数组的数组。
为了得到一个不同的数组,而不是fill()构造的指向单一数组的引用数组,我们可以使用列表推导式:
julia> xy = [['X' 'Y'] for i in 1:3, j in 1:4]
3×4 Matrix{Matrix{Char}}:
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
julia> xy[1, 4][1] = 'O';
julia> xy
3×4 Matrix{Matrix{Char}}:
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['O' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
['X' 'Y'] ['X' 'Y'] ['X' 'Y'] ['X' 'Y']
现在修改其中一个数组不会影响xy的其他元素,因为每个元素都是独立的数组。
zeros()和 ones()函数
zeros()和ones()函数是fill()的特例,它们的第一个参数分别是 0.0 或 1.0。像fill()一样,它们接受元组或单独的数字作为维度:
julia> zeros(4, 5)
4×5 Matrix{Float64}:
0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0 0.0
zeros()函数创建一个 4×5 的矩阵,并用 0.0 填充它。
当我们需要初始化一个将通过直接索引填充的浮动点数数组时,使用zeros()或ones()是常见的做法。这种方法比使用push!()逐步增大数组更快,因为编译器知道数组的初始大小,因此不需要重新分配内存。然而,如果你事先不知道数组的大小,并且不想为数组未使用的内存分配空间,push!()可能是更好的选择。
reshape() 函数
你可以使用reshape()将数组转换为新形状:
julia> a1 = collect(1:6);
julia> a2 = reshape(a1, (3, 2))
3×2 Matrix{Int64}:
1 4
2 5
3 6
julia> reshape(a1, 2, 2)
ERROR: DimensionMismatch("new dimensions (2, 2) must
be consistent with array size 6")
前两个示例展示了如何使用reshape():将数组作为第一个参数传入,并将其新的维度以元组形式或作为一系列单独的参数传入。最后一个示例会产生错误,因为reshape()不会改变元素的总数。
reshape()函数不会创建一个新的数组,而是返回原始数组并将其重新塑形。你可以看到这种影响,当你修改数组的任一形态时:
julia> a1[5] = 0;
julia> a2
3×2 Matrix{Int64}:
1 4
2 0
3 6
修改a1的第五个元素也会修改a2的第五个元素,其中元素始终按列主序排列。
reshape()的行为应让你联想到《标量索引》一节中第 38 页的相关内容:数组在计算机的一维内存中是连续存储的,这反映在它们的标量索引中。我们在程序中使用的数组的多维形式是一种抽象,如果没有这些抽象,算法在代码中表达起来将更加繁琐。
在数值算法中常用的数组操作
数组是科学和数值计算中最重要的数据类型之一,仅次于数字。我们的算法通常表现为对向量、矩阵和更高维数组的一系列变换和操作。Julia 强大的数组处理能力帮助我们通过对整个数组的高层操作来表达这些计算,而不是通过对其元素的冗长循环。当我们能使用这种编程风格时,它在概念上更清晰,也更不容易出错。本节概述了在科学代码中反复出现的几种数组操作。
常规拼接
我们已经讨论过分号作为沿第一个维度进行拼接的运算符,正如这个示例所示:
julia> m = [[1 2]; [3 4]]
2×2 Matrix{Int64}:
1 2
3 4
作为替代方法,我们可以将单个分号替换为换行符,使输入的格式类似于 Julia 在 REPL 中打印矩阵的方式。
注意
本节中描述的重复分号的用法是在 Julia v1.7 引入的。在早期版本中,重复的分号被视为单个分号。
一系列的n个分号会沿着第n个维度进行拼接,并根据需要添加新维度,因此两个分号会沿第二个维度拼接,这也是空格拼接的方式:
julia> m = [[1 2];; [3 4]]
1×4 Matrix{Int64}:
1 2 3 4
julia> m = [[1 2] [3 4]]
1×4 Matrix{Int64}:
1 2 3 4
这两个例子执行相同的操作:[1 2] 与 [3 4] 在第二维(即列)上连接,增加了列数。
使用三个分号可以创建一个新的第三维度,并沿其连接:
julia> m = [[1 2];;; [3 4]]
1×2×2 Array{Int64, 3}:
[:, :, 1] =
1 2
[:, :, 2] =
3 4
在这个例子中,[3 4] 数组被放置在 [1 2] 数组的“上面”,这种操作有时称为创建一个新的 平面。
逻辑索引
Julia 可以通过其 BitArray 数据类型以空间高效的方式存储布尔值数组。在 BitArray 中,或者其子类型 BitVector 和 BitMatrix 中,true 和 false 分别由 1 和 0 表示。这些逻辑数组用于索引操作,它们充当过滤器,选择与 1 对应位置的元素,并排除与 0 对应位置的元素。在用于索引时,被索引的数组和 BitArray 必须具有相同的元素数量。
我们创建一个 BitArray,通过逻辑条件将其广播到一个数组中。例如,下面的代码创建了一个 BitArray,选择 1:9 中能被 3 整除的元素:
julia> s3 = (1:9) .% 3 .== 0
9-element BitVector:
0
0
1
0
0
1
0
0
1
为了返回结果,Julia 将范围表达式实例化为一个数组。每个当除以 3 后余数为零的位置用 1 表示,其他位置用 0 表示。
我们将 BitArray 分配给一个变量,这样可以在其他表达式中使用它。我们可以直接在 1:9 范围上使用它:
julia> (1:9)[s3]
3-element Vector{Int64}:
3
6
9
s3 中的 1 选择了 1:9 中能被 3 整除的元素。
我们还可以用它从任何包含九个元素的集合中选择每第三个元素:
julia> ('a':'i')[s3]
3-element Vector{Char}:
'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)
'f': ASCII/Unicode U+0066 (category Ll: Letter, lowercase)
'i': ASCII/Unicode U+0069 (category Ll: Letter, lowercase)
虽然我们正在索引的集合和 BitArray 必须具有相同数量的元素,但如果 BitArray 是 BitVector 类型,集合可以具有任意形状;否则,数组和 BitArray 必须具有相同的形状。
这是一个使用 BitArray 索引的例子,也叫做逻辑索引,它是一种简洁的方式,打印出 [1, 100] 范围内所有能被 17 整除的整数:
julia> (1:100)[(1:100) .% 17 .== 0]
5-element Vector{Int64}:
17
34
51
68
85
这里唯一的区别是我们在一步操作中创建比特索引并在索引表达式中使用它,而不是将其存储在变量中以便后续使用。
伴随矩阵和转置矩阵
矩阵的转置是通过将矩阵沿其对角线翻转形成的矩阵,因此
,其中 M^' 是 M 的转置。矩阵的伴随矩阵是通过取其转置并将每个元素替换为其共轭复数得到的(这一术语与线性算子的伴随概念一致,如果矩阵被视为通过常规矩阵乘法应用于向量的线性变换)。
要将包含实数的矩阵 MR 沿其对角线翻转,我们可以使用三种表示法:MR',adjoint(MR),或 permutedims(MR)。Listing 5-4 显示它们都会给出相同的结果。
julia> MR = [[1 2]; [3 4]]
2×2 Matrix{Int64}:
1 2
3 4
julia> MR'
2×2 adjoint(::Matrix{Int64}) with eltype Int64:
1 3
2 4
julia> MR' == adjoint(MR) == permutedims(MR)
true
Listing 5-4: 矩阵伴随表示法
由于 MR 的元素是其自身的共轭复数,因此它的伴随矩阵就是其转置矩阵。
然而,如果矩阵的元素几乎是其他任何类型,adjoint()和permutedims()通常会给出不同的结果;'运算符是adjoint()的同义词。permutedims()函数仅在对角线周围翻转矩阵,不做其他处理,正如这里所示,而adjoint()则做同样的翻转,称为转置,但还会对每个元素取复共轭。这个操作也被称为厄米共轭。考虑这个例子:
julia> M = [[1+im 2+2im]; [3+3im 4+4im]]
2×2 Matrix{Complex{Int64}}:
1+1im 2+2im
3+3im 4+4im
julia> M'
2×2 adjoint(::Matrix{Complex{Int64}}) with eltype Complex{Int64}:
1-1im 3-3im
2-2im 4-4im
矩阵像之前一样被翻转,但现在每个元素都被其复共轭所替代。请注意,我们不能对非数值矩阵使用adjoint()函数,因为在这些矩阵中,元素的复共轭没有意义。
除了adjoint()和permutedims(),我们还有transpose()函数:
julia> Mt = transpose(M)
2×2 transpose(::Matrix{Complex{Int64}}) with eltype Complex{Int64}:
1+1im 3+3im
2+2im 4+4im
结果看起来像是对M进行了简单的转置,没有进行复共轭处理,但这正是permutedims()应该做的。那么,为什么我们有两个看起来做同样事情的函数呢?
adjoint()和transpose()函数一方面,permutedims()另一方面,表现得非常不同。前两个函数是递归的:如果M的元素本身是矩阵,adjoint()和transpose()会先对M进行操作,然后对M的元素进行操作,依此类推,一直处理下去。相比之下,permutedims()只是翻转M并停止。
第二个区别在于,像reshape()一样,adjoint()和transpose()返回的是相同数组的不同形式,因此对结果进行修改会修改原始数组,这与permutedims()不同,后者返回一个新数组。
通常来说,要翻转数值表格,我们使用permutedims()函数。其他两个函数则用于更专业的线性代数应用。
conj()函数,它对一个数字进行完全的共轭,可以通过使用点操作符广播到数组的每个元素。然而,和大多数其他数学函数不同,它在数组上逐元素操作,而不会进行广播:
julia> conj(M)
2×2 Matrix{Complex{Int64}}:
1-1im 2-2im
3-3im 4-4im
在这里,我们已经对每个元素进行了复共轭操作,以转换矩阵。
矩阵乘法
乘法运算符(*)在给定一对矩阵或一个矩阵和一个向量时执行矩阵乘法。举个例子,我们将创建一个旋转矩阵并进行矩阵乘法,以旋转一个向量:
julia> a = π/2
1.5707963267948966
julia> RM = [[cos(a) -sin(a)]; [sin(a) cos(a)]];
julia> RM * [1, 0]
2-element Vector{Float64}:
6.123233995736766e-17
1.0
精确结果应该是[0, 1]:单位向量指向“右侧”,当其逆时针旋转π/2 弧度时的旋转结果。我们得到的答案是浮点数运算中的舍入误差。
对于矩阵、线性方程组及相关领域的深入工作,你应该导入LinearAlgebra包,我们将在《线性代数包》一节中访问它,见第 399 页。这个包有一个计算矩阵逆的函数,但我们也可以通过直观的符号在不导入包的情况下计算矩阵逆:
julia> MR^-1
2×2 Matrix{Float64}:
-2.0 1.0
1.5 -0.5
julia> MR^-1 * MR
2×2 Matrix{Float64}:
1.0 0.0
2.22045e-16 1.0
在这个例子中,我们使用了在列表 5-4 中定义的矩阵 MR。将一个矩阵与其逆矩阵相乘(矩阵乘法)应该得到单位矩阵(对角线为 1,其它位置为 0),这是我们通过浮点数舍入误差所得到的结果。
枚举和合并
Julia 提供了多个函数,常见于现代高级编程语言,用于枚举和合并数组。前者指的是将集合的索引与元素关联,而后者则指的是将两个集合按元素逐一连接。本节中的所有函数都返回迭代器,这些迭代器可以是元组集合的迭代器,也可以是键值对集合的迭代器。
enumerate() 函数
enumerate() 函数接受一个集合,并返回一个迭代器,该迭代器生成一个元组集合,每个元组包含迭代的编号作为第一个元素,以及从集合中获取的成员作为第二个元素。列表 5-5 显示了元组集合与原始集合的形状相同。
julia> collect(enumerate([10 20; 30 40]))
2×2 Matrix{Tuple{Int64, Int64}}:
(1, 10) (3, 20)
(2, 30) (4, 40)
列表 5-5:使用 enumerate()
由于 enumerate() 返回的是一个迭代器,我们需要使用 collect() 来查看它。与范围(ranges)和由生成器(参见第 125 页的“推导式与生成器”)创建的其它迭代器一样,这些迭代器几乎不占用空间,直到我们使用它们来循环遍历集合,或使用 collect() 函数将它们转化为实际的数组。
列表 5-5 中 collect() 返回的数组按照传递给 enumerate() 的参数排列,迭代编号(即元组的第一个元素)反映了数组遍历时的列主序。
enumerate() 返回的迭代编号并不保证是数组的合法索引。即使它们是合法的,也不一定能返回其所索引的元素。换句话说,如果 enumerate(A) 返回的某个元组是 (i, e),那么 A[i] 可能会出错。如果没有出错,也有可能 A[i] 不等于 e。
在数字数组的情况下,比如在列表 5-5 中,元组的第一个元素 可以 用作数组的标量索引,而 enumerate() 有时也会用于这个目的。
一个不能将迭代编号用作索引的例子涉及到我们的老朋友 François:
julia> for letter in enumerate("François")
println("Letter number $(letter[1]) is $(letter[2]).")
end
Letter number 1 is F.
Letter number 2 is r.
Letter number 3 is a.
Letter number 4 is n.
Letter number 5 is ç.
Letter number 6 is o.
Letter number 7 is i.
Letter number 8 is s.
迭代编号告诉我们每个字符在字符串中出现的位置,但正如我们在第 44 页的“字符串与字符”一节中看到的,并非所有字符位置都是合法的索引:
julia> "François"[5]
'ç': Unicode U+00E7 (category Ll: Letter, lowercase)
julia> "François"[6]
ERROR: StringIndexError: invalid index [6], valid nearby indices [5]=>'ç', [7]=>'o'
总结一下,不要将迭代编号与索引混淆。
pairs() 函数
pairs() 函数与 enumerate() 类似,唯一不同的是它创建的是键值对的迭代器,而不是元组的迭代器:
julia> collect(pairs("François"))
8-element Vector{Pair{Int64, Char}}:
1 => 'F'
2 => 'r'
3 => 'a'
4 => 'n'
5 => 'ç'
7 => 'o'
8 => 'i'
9 => 's'
pairs() 返回的索引是集合的合法索引,而不是像 enumerate() 那样的迭代编号。
enumerate()返回的迭代器中的对象是元组;pairs()返回的迭代器中的对象是键值对。这些键值对有自己的数据类型:Pair。如果p是一个Pair,我们可以通过p.first访问它的键,通过p.second访问它的值。因此,如果我们需要一个指向我们法国朋友名字的索引向量,可以通过以下方式获得:
julia> [p.first for p in pairs("François")]
8-element Vector{Int64}:
1
2
3
4
5
7
8
9
我们可以通过构造函数Pair(9, 's')来创建一个Pair,或者使用=>运算符,例如9 => 's'。
在清单 5-1 中,我们从一系列直接输入的键值对创建了一个字典。每个字面量的键值对都是一个Pair;这里还有另一种构造字典的方法:
julia> p1 = "one" => 1
"one" => 1
julia> p2 = Pair("two", 2)
"two" => 2
julia> Dict([p1, p2]) == Dict("one"=>1, "two"=>2)
true
我们使用=>运算符创建p1,并使用Pair()构造函数创建p2。将它们传递给Dict()函数,创建与清单 5-1 中相同的字典。
字典是一个无序的Pair集合。遍历字典会依次返回每个Pair,但顺序是不可预测的:
julia> Dict(pairs("François"))
Dict{Int64, Char} with 8 entries:
5 => 'ç'
4 => 'n'
7 => 'o'
2 => 'r'
9 => 's'
8 => 'i'
3 => 'a'
1 => 'F'
zip() 函数
zip()函数接受任意数量的集合,并返回一个迭代器,该迭代器合并了各个集合的元素,形成一个包含元组的集合。
当传入的集合具有相同的形状时,返回的迭代器也将具有该形状:
julia> zip([1 2; 3 4], ['a' 'b'; 'c' 'd']) |> collect
2×2 Matrix{Tuple{Int64, Char}}:
(1, 'a') (2, 'b')
(3, 'c') (4, 'd')
每个集合的第一个元素被配对在一起,接着是第二个元素,依此类推。在这种zip()用法中,参数的形状必须匹配。
如果其中一个集合是列表,另一个集合可以具有任何形状。列表中的元素与另一个集合的元素按照列主序进行配对:
julia> zip([1, 2, 3, 4], ['a' 'b'; 'c' 'd']) |> collect
4-element Vector{Tuple{Int64, Char}}:
(1, 'a')
(2, 'c')
(3, 'b')
(4, 'd')
这里,一个一维列表与一个 2×2 矩阵进行配对;每个都包含四个元素。
使用向量时,元素的数量不必匹配;zip()会继续直到元素耗尽:
julia> zip(1:3, ['a' 'b'; 'c' 'd']) |> collect
3-element Vector{Tuple{Int64, Char}}:
(1, 'a')
(2, 'c')
(3, 'b')
julia> zip(1:5, ['a' 'b'; 'c' 'd']) |> collect
4-element Vector{Tuple{Int64, Char}}:
(1, 'a')
(2, 'c')
(3, 'b')
(4, 'd')
在第一个示例中,三元素向量在 2×2 矩阵的元素耗尽之前已经耗尽。在第二个示例中,第二个参数在我们用完1:5中的所有元素之前就已耗尽。
结论
Julia 是一个相对“庞大”的语言:它有大量的语法和数据类型。这些特性是有目的的,它们有助于提升 Julia 的强大功能和便利性。幸运的是,你并不需要在每个程序中都使用语言中的所有内容。在本章中,我们遇到了一些新的语法,它使得处理集合更加简洁和直观。在下一章,我们将探索一些新的概念,为 Julia 程序员提供更高的灵活性和控制力。
进一步阅读
-
语义版本控制的规范可以在https://semver.org找到。
-
欲了解更多关于 Perl 兼容正则表达式的信息,请访问http://www.pcre.org。
第七章:函数、元编程与错误
一开始的小错误,最终会变成一个大错误。
— 托马斯·阿奎那

在本章中,我们将探讨三大主题,这些主题在编写程序时能提供更大的能力、控制力和灵活性。我们将更深入地研究函数这一核心主题,并进一步探讨函数参数和高阶函数。我们将看到元编程和宏如何让我们创建新的语法,并以大多数编程语言无法做到的方式扭曲 Julia 来满足我们的需求。最后,我们将看到如何控制错误系统,并利用它来操控程序的执行。
函数及其参数
在第四章,我们学习了函数的定位参数和关键字参数。本节将扩展我们对函数的知识,并学习提供额外参数的其他方式。
关键字参数的简洁语法
关键字参数通常具有反映其用途的名称,这意味着在调用函数时,使用变量作为某些关键字参数时,这些变量的名称通常与函数定义中的名称相同。如果我们在定义这些变量时就考虑到了它们最终在调用函数中的用途,这种情况发生的可能性更大。
在这种情况下,我们的函数调用看起来像这样:
somefunction(pos1, pos2; keyword1=keyword1, keyword2=keyword2)
Julia 提供了一种语法选项,可以减少这种视觉噪音和不必要的输入。我们可以用以下方式替代之前的调用:
somefunction(pos1, pos2; keyword1, struct.keyword2)
如示例所示,我们可以使用与关键字同名的变量,或者具有匹配关键字名称属性的复合对象。
展开与吸取操作符
... 操作符(三个点)根据上下文可以是 splat 或 slurp。当我们向函数提供参数时,我们可以使用 splat,而在定义函数时,我们可以使用 slurp。
展开操作符
假设我们编写一个函数,接收三个参数并将它们加在一起:
julia> function addthree(a, b, c)
return a + b + c
end;
julia> addthree(1, 2, 3)
6
julia> v3 = [1, 2, 3];
julia> addthree(v3)
➊ ERROR: MethodError: no method matching addthree(::Vector{Int64})
当我们提供三个参数时,正如函数定义所要求的,结果会返回和它们的和。然而,如果这三个值是向量的一部分,当我们将向量作为参数传递给函数时,会发生错误➊。这是因为函数的定义没有包含接受单个 Vector 参数的方法;唯一的选项是传递三个独立的值。
我们可以通过将 v3 中的值提取到三个独立的变量中,然后将这些变量传递给 addthree() 来处理这种情况,但由于这种情况经常发生,Julia 提供了一种更简单的方法,通过一个由三个点组成的操作符,称为 splat:
julia> addthree(v3...)
6
这里,展开操作符会解包集合中的值,并将它们作为独立参数传递给被调用的函数。
列表 6-1 展示了我们如何使用命名元组展开存储的关键字参数。
julia> function addthreeWithCoefficients(a, b, c; f1=1, f2=1, f3=1)
return f1 * a + f2 * b + f3 * c
end;
julia> coeffs = (f1=100, f2=10)
(f1 = 100, f2 = 10)
➊ julia> addthreeWithCoefficients(1, 2, 3; coeffs...)
123
列表 6-1:展开一个命名元组
在这个例子中,我们创建了一个新函数addthreeWithCoefficients(),它接受三个关键字参数f1、f2和f3,并在返回和之前先将位置参数乘以它们。然后我们创建一个命名元组coeffs,它有两个与两个关键字参数匹配的属性。当我们用命名元组➊应用 splat 时,f1和f2会从元组的相应属性中获得值。f3在元组中不存在,所以它会获得默认值 1。
尽管结构体也有属性,但我们不能像在命名元组或普通元组中那样对结构体进行 splat 操作。这一限制与我们无法像遍历命名元组或普通元组那样遍历结构体有关。
然而,字典只要关键字名称作为符号出现,依然有效:
julia> csd = Dict(:f1=>100, :f2=>10);
julia> addthreeWithCoefficients(1, 2, 3; csd...)
123
这里字典的键:f1和:f2对应于清单 6-1 中函数定义中的参数f1和f2。
Slurping
在函数定义中,三个点表示slurp操作符。Slurping 是一种与 splatting 相反的操作:它不是将一个集合解包成单独的参数,而是将任意数量的单独参数打包成一个可迭代对象。如果我们希望一个函数接受未知数量或可变数量的位置参数,我们可以使用 slurping:
julia> function addonlythreeWithNote(a, b, c, more...)
if length(more) > 0
println("Ignoring $(length(more)) additional arguments.")
end
return a + b + c
end;
julia> addonlythreeWithNote(1, 2, 3, 99, 100, 101)
Ignoring 3 additional arguments.
6
addonlythreeWithNote()函数返回我们提供的前三个参数的和,就像addthree()函数一样。然而,这个版本接受任意数量的额外参数,并将它们打包成一个名为more的元组。
我们还可以对关键字参数进行 slurp。下面这个例子中的函数对作为位置参数传入的字符串进行两个可选测试。如果它得到一个名为palindrome的关键字,它会测试该字符串是否为回文;如果它得到一个名为onlyascii的关键字,它会使用isascii()函数检查字符串中是否有非 ASCII 字符:
julia> function examine_string(s; checks...)
if :palindrome in keys(checks)
if s == reverse(s)
println("\"$s\" is a palindrome.")
end
end
if :onlyascii in keys(checks)
if isascii(s)
println("\"$s\" contains only ASCII characters.")
else
println("\"$s\" contains non-ASCII characters.")
end
end
end;
➊ julia> examine_string("step on no pets"; kw1=17, palindrome=1, onlyascii=1)
"step on no pets" is a palindrome.
"step on no pets" contains only ASCII characters.
julia> examine_string("step on no pets"; palindrome=1)
"step on no pets" is a palindrome.
因为我们在定义examine_string()函数时使用了 slurping 来处理关键字参数,所以即使传入额外的参数➊也无关紧要;它们会被忽略。由于我们在函数定义中为关键字参数提供了默认值,因此即使某些关键字参数缺失,也没有问题。最后,由于程序只检查关键字参数是否存在,传入调用中的值是任意的。
我们也可以像之前一样调用函数并传入一个 splat 值。不同之处在于,我们传入的对象可能包含多余的关键字,但不会引发错误。下面是一个例子:
julia> kws = Dict(:palindrome => 1, :anyOtherKeyword => 17)
julia> examine_string("step on no pets"; kws...)
"step on no pets" is a palindrome.
使用“slurped”关键字参数定义函数对于用户来说非常方便。例如,Plots 包中的一些函数就是以这种方式工作的。我们可以使用它们不需要的关键字来调用它们;它们会使用自己能处理的关键字,忽略其他的。这种情况可能出现在 REPL 中,如果我们使用一个包含关键字列表的绘图函数创建了一个图表,然后决定使用另一个函数。我们可以按上箭头键,改变函数名,而不必查看文档来确认它是否理解我们之前使用的所有关键字。
Julia 还允许向此类函数提供关键字参数的另一种方式。我们可以以 :kw=>value 的形式单独列出它们,关键字以符号出现,或者我们可以展开一个字典,但字典的所有键必须是符号。
解构
解构 是指将一个元组的值解包为具有单一赋值的命名变量:
julia> x, y = (3, 4);
julia> x
3
julia> y
4
这个功能在解包来自函数的元组返回值时尤其方便。如前所述,只要省略括号不会引起歧义,元组就不需要写括号,所以我们可以像 x, y = 3, 4 这样编写示例中的赋值。
列表 6-2 展示了另一种解构方式,它从结构体中解包关键字参数,使用以下语法:
julia> (; url, title) = google
Website("https://google.com", "google")
julia> url
"https://google.com"
julia> title
"google"
列表 6-2:解构一个结构体
在这个示例中,来自 列表 5-2 的 google 定义生效。在这种类型的解构中,赋值左侧的变量名必须与右侧复合类型的字段名匹配。
注意
从 Julia v1.7 开始,结构体的关键字解构被首次引入。在早期版本中, (; a, b) 是语法错误。
这种解构方式的实用性可能不会立即显现。毕竟,没有这种特殊语法,我们仍然可以做到这一点:
julia> url, title = google.url, google.title
它与 列表 6-2 中的形式相比并没有更多的冗长,并且具有相同的效果。
然而,这种解构语法的一个优势在于它为定义接受从结构体中提取的关键字参数的函数提供了一种简洁的方式。在以下示例中,我们首先定义一个具有三个字段的结构体,并从该结构体创建一个对象:
julia> struct Fco
f1
f2
f3
end
julia> someco = Fco(100, 10, 1)
Fco(100, 10, 1)
➊ julia> function addthreeWithCoefficients(a, b, c, (; f1, f2, f3))
return f1 * a + f2 * b + f3 * c
end;
julia> addthreeWithCoefficients(1, 2, 3, someco)
123
然后,我们创建了一个与 Listing 6-1 ➊ 中创建的 addthreeWithCoefficients() 函数不同的版本。这个版本没有使用关键字参数的列表,而是使用了一个第四个位置参数,具有结构体解构的语法。当我们调用该函数并将复合对象作为第四个位置参数传入时,函数会执行赋值 (; f1, f2, f3) = someco。参考 Listing 6-2 中的语法,我们可以看到这将把 100 赋值给 f1,10 赋值给 f2,1 赋值给 f3。作为参数传递的结构体可能包含函数没有解构的字段,因为解构语法并不要求解构所有字段。
运算符也是函数
Julia 中的二元运算符,如 * 和 +,也叫做 中缀运算符,是两个参数的函数。每个运算符都有一个更明确的函数形式:
julia> +(1, 2, 3)
6
julia> *(8, 2)
16
在第一个示例中,+ 函数对参数 1、2 和 3 进行操作,将它们相加并返回 6。中缀运算符的函数形式在我们有多个参数时可以更加简洁。
由于二元运算符是函数,我们可以将它们作为参数传递给高阶函数(有关示例,请参见 Listing 6-5)。
在涉及中缀运算符的表达式中,操作顺序或运算符的 优先级规则 决定了结果。例如,表达式 3 + 2 * 5 计算结果为 13,因为乘法先于加法执行。
在使用运算符的函数形式时,没有优先级规则,因为函数应用的语法使得操作顺序变得明确。例如,表达式 3 + 2 * 5 相当于 +(3, *(2, 5))。语法显示乘法先于加法执行。
Julia 允许我们使用某些字符来定义自己的二元运算符。如果我们创建一个函数并将其中一个字符作为其名称,我们就可以在中缀位置使用该函数。
然而,我们不能从任何字符创建中缀运算符。Julia 解释器的源代码提供了可用字符的完整列表(请参阅 第 187 页 的“进一步阅读”)。该源代码还通过将字符分组到具有相同优先级的类中,标明了每个字符的优先级。在决定中缀运算符的符号时,仅仅挑选一个看起来合适的符号是不够的。我们必须决定该运算符如何融入优先级层级,并选择合适组中的符号。
三个主要的优先级组是乘法、加法和比较。 Figure 6-1 显示了每个组的几个字符示例。

Figure 6-1: 一些运算符字符
比较运算符在这三种类型中优先级最低,因此表达式 2 * 3 + 2 > 7 相当于 ((2 * 3) + 2) > 7,并返回 true。
让我们使用这些字符中的一个来创建一个新的中缀操作符,将减法的概念扩展到计算两个向量之间的欧几里得距离。我们希望它与加法和减法操作符具有相同的优先级,因此我们会选择一个看起来与减法有关的符号(在 REPL 中输入 \boxminus 后按 TAB 键以输入该函数的名称)。
julia> function ⊟(a, b)
return sqrt((b[1] - a[1])² +
(b[2] - a[2])²)
end;
注意
要学习任何其他特殊字符的快捷键,请在进入帮助模式后粘贴它。
在这个定义之后,我们有了一个新的函数,名称是单个字符。
由于该字符在允许用作中缀操作符的字符列表中,它应该能够正常工作:
julia> v1 = [0, 1];
julia> v2 = [1, 0];
julia> v1 ⊟ v2
1.4142135623730951
这个结果是正确的。
让我们在一个包含更高优先级运算的表达式中使用新操作符,检查它是否遵循所需的优先级规则:
julia> 3 .* v1 ⊟ 4 .* v2
5.0
乘法运算是在向量减法之前进行的,正如预期的那样(结果可能会让你想起高中三角学中的 3-4-5 直角三角形)。
我们可以通过点前缀将自己创建的中缀操作符转换为广播版本,就像内置操作符一样:
julia> v1a = [v1, v1, v1]
3-element Vector{Vector{Int64}}:
[0, 1]
[0, 1]
[0, 1]
julia> v2a = [v1, v2, [0, 0]]
3-element Vector{Vector{Int64}}:
[0, 1]
[1, 0]
[0, 0]
julia> v2a .⊟ v1a
3-element Vector{Float64}:
0.0
1.4142135623730951
1.0
广播操作将我们的函数应用于这对向量(向量的向量)中所有相应的元素。结果是一个包含每对相应向量之间欧几里得距离的向量。
映射、过滤和归约操作符
高阶函数是一个接受一个或多个函数作为其参数的函数。通常,它们要么将函数转换为其他函数,要么将它们应用于作为进一步参数提供的数据。三个操作符map()、filter()和reduce()是高阶函数,它们将提供的函数应用于集合。
map() 操作符
map()操作符对集合的每个元素应用一个函数,并返回另一个集合:
julia> double(x) = 2x
double (generic function with 1 method)
julia> map(double, [2 3; 4 5])
2×2 Matrix{Int64}:
4 6
8 10
这里map()对矩阵的每个元素单独应用double(),返回一个与矩阵形状相同的结果。
对于中缀操作符,map在所有提供的集合的相应元素之间应用它:
julia> map(+, [2 3], [4 5], [6 7])
1×2 Matrix{Int64}:
12 15
结果的形状与map()操作的集合相同。结果的第一个元素是将+运算应用于所有集合的第一个元素;第二个元素 15 是 3 + 5 + 7。
理解map()的关键是理解zip(),因为map()操作符通过zip()将我们提供的数组的元素结合在一起:
julia> map(+, 20:10:40, [2 3; 4 5])
3-element Vector{Int64}:
22
34
43
julia> map(+, 20:10:90, [2 3; 4 5])
4-element Vector{Int64}:
22
34
43
55
在这些示例中,map()在列主序的情况下,对向量和 2×2 矩阵的元素应用了+操作符。在这两种情况下,它都会在其中一个集合的元素用完时停止。
在某些情况下,map()返回的结果与使用点操作符的等效广播相同。之前double()的映射本可以这样写:
julia> double.([2 3; 4 5])
2×2 Matrix{Int64}:
4 6
8 10
然而,映射(mapping)和广播(broadcasting)并不相同。在中缀操作符的情况下,我们可以清楚地看到这一点:
julia> [20 30] .+ [2 3; 4 5]
2×2 Matrix{Int64}:
22 33
24 35
在这个例子中,左侧的数组形状与右侧的数组不同。然而,它的形状与右侧数组的行形状相匹配。广播操作符.+将数组[20 30]扩展或广播到另一个数组的行上。
如果我们将左侧数组改为单列而不是单行,它将会在另一个数组的列上进行广播:
julia> [20, 30] .+ [2 3; 4 5]
2×2 Matrix{Int64}:
22 23
34 35
通过检查本节中的例子,应该能清楚地区分映射和广播。与广播不同,map()并不是对整个数组进行操作,而是逐个元素地进行操作,并在背后使用zip()。在这个最后的例子中,使用map()会得到一个不同的结果:
julia> map(+, [20, 30], [2 3; 4 5])
2-element Vector{Int64}:
22
34
最后一个参数的[3, 5]列从未被使用,因为map()在到达该位置之前就已经没有元素可用。
filter() 操作符
filter()操作符接受一个由一个变量组成的函数作为第一个参数;这个函数应该返回true或false。它将这个函数应用到第二个参数的每个元素上,第二个参数应该是一个集合。它返回一个新的集合,包含那些函数返回false的元素被过滤掉或删除。
与map()一样,清单 6-3 展示了filter()如何常与匿名函数一起使用。
julia> filter(x -> x % 17 == 0, 1:100)
5-element Vector{Int64}:
17
34
51
68
85
清单 6-3:使用 filter() 和匿名函数
这里我们创建了一个包含从 1 到 100 之间能被 17 整除的整数的列表。
Julia 提供了一些可以方便地与filter()一起使用的测试函数,比如isodd()、iseven()、isfinite()和isfile(),它们分别回答由函数名所指示的问题。
isascii()函数告诉你一个字符是否属于旧的 ASCII 字符集;我们可以在字符串上使用它来过滤掉非 ASCII 字符:
julia> filter(isascii, "François")
"Franois"
我们返回了一个字符串,其中“ç”被过滤掉。我们还可以反转条件,通过filter(!isascii, "François")过滤掉 ASCII 字符,返回"ç"。
reduce() 操作符
我们已经多次使用了sum()函数。它将数组中的所有数字相加,最终返回一个单一的数字。reduce()高阶函数将这一概念进行了概括。它将一个由两个变量组成的函数(作为第一个参数)应用到一个集合(作为第二个参数)上。
让我们考虑一个例子,帮助理解它的工作原理。如果没有sum()函数,我们可以用reduce()来代替。我们可以通过sum([1, 2, 3])计算和1 + 2 + 3,也可以用reduce(+, [1, 2, 3])来做同样的事情。
我们可以在reduce()中使用任何二元操作符或任何由两个变量组成的函数。例如,清单 6-4 展示了一个将第一个参数除以第二个参数的函数,并在reduce()中使用它。
julia> q(a, b) = a/b
q (generic function with 1 method)
julia> reduce(q, 1:3)
0.16666666666666666
julia> (1/2)/3
0.16666666666666666
清单 6-4:reduce() 函数
最后一行展示了reduce()如何在元素之间插入函数,逐步累积结果。
然而,使用除法进行归约引入了一个复杂性。虽然+和*运算符是结合的,但除法和减法不是。结合性意味着我们如何分组并不重要:(1 + 2) + 3和1 + (2 + 3)给出的结果是相同的。除法并不满足结合律:1/(2/3)等于 1.5。
注意
事实上,加法和乘法在对实数(以及数学领域中的其他数系)进行运算时是结合的,但在应用于计算机中的浮点数时,它们并不真正满足结合律。尽管结合的数值效果,即(a + b) + c* 和* a + (b + c) 之间的差异通常很小,但当数值精度或可重复性很重要时,最好使用接下来我们将介绍的折叠操作符。
在函数或运算符不满足结合律的情况下,使用reduce()的结果是未定义的:我们不能假设它是从左到右进行的。在这种情况下,我们应该使用foldl()或foldr(),它们的工作方式与reduce()相似,但分别从左或从右进行结合:
julia> foldl(q, 1:3)
0.16666666666666666
julia> foldr(q, 1:3)
1.5
示例 6-5 展示了reduce()操作符如何接受关键字参数dims来沿指定维度进行归约,而foldl()或foldr()则不接受该参数。
julia> reduce(+, [1 2; 10 20]; dims=2)
2×1 Matrix{Int64}:
3
30
julia> reduce(+, [1 2; 10 20]; dims=1)
1×2 Matrix{Int64}:
11 22
示例 6-5:沿指定维度进行归约
这里dims=1导致沿着行进行归约,而dims=2则沿着列进行归约。如果我们省略dims参数,结果是对所有元素进行归约,得到单一数字 33。
所有三个归约函数都接受另一个关键字参数,用作在遇到空集合时的默认值。这个参数叫做init:
julia> reduce(+, []; init=0)
0
在这个例子中,当遇到空集合[]时,reduce()返回指定的值 0。
如果归约函数遇到空集合,并且没有默认的中立元素,并且也没有提供init参数,则会返回错误。一些归约函数可能会在集合不为空时使用init的值作为归约的起始值,但这种行为在正式上没有明确规定,未来这些函数的实现可能会有所变化。因此,为了确保结果正确,当init存在时,它应该是所应用操作的正确中立元素。对于加法来说,这个元素是 0,对于乘法来说,中立元素是 1。
一些归约操作在程序中出现得非常频繁,以至于 Julia 为它们提供了专门的版本。我们已经见过sum();prod()类似,但执行的是乘法而不是加法:
julia> prod(1:7)
5040
julia> factorial(7)
5040
这个例子中的第一个表达式将 1 到 7 之间的所有整数相乘;由于这是 7!的定义,因此第二个表达式返回相同的结果。
maximum()和minimum()归约函数找到集合中的最大或最小元素:
julia> maximum(sin.(1:.01:2π))
0.9999996829318346
julia> minimum(sin.(1:.01:2π))
-0.999997146387718
在这个例子中,我们通过对一个区间应用sin()函数来创建集合。
any() 和 all() 减少测试会对集合应用一个测试:
julia> any(iseven, 3:2:11)
false
julia> all(isodd, 3:2:11)
true
这两个操作回答了以下问题:集合中的 任何 或 所有 元素是否满足第一个参数中的测试?
mapreduce() 函数
强大的 mapreduce() 函数正如其名字所示:它结合了 map() 和 reduce()。例如,下面是两种加总前 100 个平方数的方式:
julia> mapreduce(x -> x², +, 1:100)
338350
julia> reduce(+, map(x -> x², 1:100))
338350
第二种方法准确地展示了 mapreduce() 调用的作用。然而,几乎总是最好使用 mapreduce() 而不是组合 map() 和 reduce(),因为前者使用的内存更少,速度更快;随着集合的增大,效率的提升显著。主要原因是,组合 map() 和 reduce() 会创建一个中间集合来进行归约,而 mapreduce() 一次性完成计算,无需分配集合。
do 块
Julia 中的许多函数将函数作为第一个参数,而我们通常希望提供一个匿名函数,因为我们不需要在其他地方重用该函数。我们已经在 plot() 和相关绘图函数中看到了这一点,以及本章中描述的映射和归约函数。
使用 x -> ... 语法构造匿名函数可能繁琐或不可能。例如,我们可能希望它包含循环或 if 块。在这种情况下,我们可以先创建一个命名函数,再将其传递给高阶函数,但 Julia 提供了另一种方法。
do 块是一种仅用于创建匿名函数的函数定义块。该函数作为紧接着 do 块前的函数调用的第一个参数被插入。
让我们回顾一下使用 q() 函数的归约,请参见列表 6-4。如果从集合中取出的任何分母为 0,则归约将返回 Inf。但如果我们只想跳过这些分母呢?
julia> foldl(q, 3:-1:0)
Inf
julia> foldl(3:-1:0) do x, y
if y == 0
return x
else
return x/y
end
end
1.5
do 块定义了一个匿名函数,接受两个变量,返回第一个变量除以第二个变量,并处理 0 除数的特殊情况。调用 foldl() 看起来不对,因为它只传递了一个参数,但由 do 块定义的函数作为缺失的第一个参数被插入。
符号与元编程
我们在几个地方使用过 Symbol 类型,例如在绘图函数中设置属性时,但直到现在我们才对 Julia 中的符号是什么进行彻底讨论。
要理解符号的含义,我们必须引入 Julia 中的 元编程 概念。元编程是指一种语言设施和相关技术的通用类别,用于编写能够检查自己、修改自己,甚至修改或添加语言语法的代码。在本节中,我们将介绍基本概念,并将其应用于下一节中描述的代码转换程序,称为 宏。
科学代码通常不使用太多的元编程。然而,Julia 及其许多包提供了一些不可或缺的宏,例如我们在 第 118 页 中使用的 @layout 宏。即使你从未自己编写过宏,了解它们如何工作仍然是值得的。在 Julia 编程中,经常使用少量的不可或缺的宏,因此,能够智能地使用它们并在出现问题时进行调试是非常重要的。
表达式对象
Julia 具有操作 Julia 代码的能力。这是因为 Julia 代码本身可以作为一种数据类型来表示,语言可以对其进行操作,就像它对数字、字符串和数组进行操作一样。这个数据类型被称为 Expr。具有这种数据类型的对象被称为 Expr 对象或 表达式对象。表达式对象不同于 表达式,后者是返回结果的语言形式,例如 3 * 5。
表达式对象通常涉及 Julia 的 Symbol。我们可以通过在名称前加上冒号来创建一个 Symbol,就像我们在绘图时使用的 :red 这样的属性一样。我们也可以通过 Symbol() 函数将字符串转换为符号,例如:Symbol("red") == :red。
我们还可以通过在冒号后面加上括号中的表达式来构造表达式对象。再强调一下:3 * 5 是一个表达式,而 :(3 * 5) 是一个表达式对象。如果我们在 REPL 中输入 3 * 5,Julia 会评估该表达式并返回 15。如果我们输入 :(3 * 5) 或任何其他表达式对象,它会直接返回我们输入的内容。
为了评估 Expr 对象所表示的表达式(即括号内的部分),我们使用 eval() 函数。如果我们在 REPL 中输入 eval(:(3 * 5)),Julia 会返回 15。
注意
我们可以使用 Meta.parse() 将字符串转换为表达式——例如,Meta.parse("3 * 5") 返回* :(3 * 5)*。
有时将整个表达式对象放在一行上不太方便。Julia 有一个名为 quote 的块,用于定义此类对象:
julia> ex = quote
a = 3
a + 2
end;
julia> typeof(ex)
Expr
julia> a
ERROR: UndefVarError: a not defined
julia> eval(ex)
5
julia> a
3
示例中的赋值语句将 quote 块的结果赋值给 ex。由于 quote 块创建了表达式对象,因此 ex 的类型就是这种类型,正如我们在下一行中确认的那样。评估该表达式会执行块中的操作,接下来的两行验证了这一点。
这个块的名称来源于引号的概念,它意味着将一个表达式转换为表达式对象,无论是通过使用 :(...) 来包围一个表达式,还是使用 quote 块来完成。
在英语中,有时我们需要区分使用一个单词或表达式和谈论这个单词或表达式。我们通过将我们讨论的术语放在引号中来实现这一点。在 Julia 中,引号有相同的作用。我们将表达式加上引号,这样就可以把它作为表达式来处理;表达式对象就是被引号包围的表达式。
大多数语言没有办法自我描述。所有能够做到这一点的语言,如 Julia、所有的 Lisp 语言和 Elixir,都有一种引用表达式的方式。
表达式对象插值
我们可以像在字符串中插值一样,将值插入到表达式对象中。作为一个简单的例子,定义一个变量并创建两个表达式对象,一个使用该变量,另一个使用该变量的插值值:
julia> w = 3
3
julia> ex = :(w * 5)
:(w * 5)
julia> ey = :($w * 5)
:(3 * 5)
在 ey 的定义中,w 的值在表达式对象创建时就被插入到其中。表达式对象 ex 则包含了变量 w。在更改 w 的值之前和之后对这些表达式对象应用 eval(),可以明确看出它们的后果:
julia> eval(ex)
15
julia> eval(ey)
15
julia> w = 4
4
julia> eval(ex)
20
julia> eval(ey)
15
改变 w 所赋的值不会改变 ey 的计算结果,因为该表达式并不包含 w 作为变量。相反,它使用的是 w 的值进行定义。
仅凭这些简单的元编程工具,我们已经能够执行一整类没有它们就无法完成的编程技巧。例如,假设我们想要创建一个函数,给定一个字符串和一个值,创建一个由字符串命名的变量并将值赋给它。列表 6-6 展示了一个执行此任务的函数。
mkvar(s, v) = eval(:($(Symbol(s)) = $v))
列表 6-6:将表达式对象付诸实践
mkvar() 函数将 s 字符串转换为一个 Symbol。然后,它创建一个表达式对象,将该符号的插值值赋给 v 的值。最后,它对该表达式对象应用 eval()。这个 eval() 的结果是一个新变量,它的名称与提供的字符串 s 相同,并且值为 v。
这是它的实际应用:
julia> mkvar("Arthur", 42);
julia> Arthur
42
这种功能需要元编程。特别是,我们不能做 "Arthur" = 42,因为我们不能将值赋给一个字符串。
前面的例子清楚地表明了符号到底是什么:它们是 Julia 在表达式对象中表示变量的方式。换句话说,符号是 Julia 自我表示变量的方式。正如我们所展示的,它们也经常被用作关键字参数和其他用途,但这种用法是与它们的基本身份无关的。符号之所以流行,正是因为它们比字符串更高效。
mkvar() 函数不仅仅是一个魔术技巧。它所处理的字符串可能来自,例如,从文件中读取的一个数据表的标题。在这种情况下,mkvar() 或类似的东西可以根据这些标题创建变量,并将它们赋值给下面的对应数据列。我们将在第十章中探索这些思想的应用。
宏
宏 是一个接受表达式、符号和字面量作为参数并返回表达式对象的函数。该表达式对象会在运行时自动求值。
宏与我们到目前为止学习的其他函数之间有一个至关重要的区别,包括操作表达式对象的函数。函数在运行时评估,使用当前全局变量的值。
与此不同,宏的处理发生在程序运行之前的一个单独的编译阶段。宏返回的表达式对象被插入到代码中的宏位置并通过eval()执行。这个特性使我们可以使用宏来改变或添加语言的语法。
如何创建宏
以下是我们在列表 6-6 中定义的mkvar()函数的宏版本:
julia> macro mkvarmacro(s, v)
ss = Symbol(s)
➊ return esc(:($ss = $v))
end
@mkvarmacro (macro with 1 method)
➋ julia> @mkvarmacro "color" 17
17
julia> color
17
通常,为了避免与调用上下文中的名称发生冲突,宏会将其包含的所有变量的名称改为私有版本。如果我们希望这些变量在使用宏时仍然引用同名的全局变量,可以使用esc() ➊来绕过私有命名过程。这就是这种情况,因为这个宏的目的是从我们提供的字符串创建一个变量并为其赋值。
我们通过在宏的名称前加上@符号来调用宏 ➋。提供参数的语法比函数更灵活。我们可以像这个例子一样,用空格将参数单独列出,或者将用逗号分隔的参数列表放在括号内,就像我们对待函数那样:@mkvarmacro("color", 17)。如果参数是字面量数组,我们可以省略空格和括号,直接调用宏,例如:@macroname[1 2 3]。
一旦调用宏,带有插值的Expr对象($ss = $v)被评估,字面量color替代ss,17替代v,因此 17 被赋值给变量color。
作为我们如何使用宏为 Julia 添加新语法的示例,假设我们不喜欢在while循环中使用end关键字。在列表 6-7 中,我们将创建一个简单的宏,接受一个条件和一个循环体,不需要end。我们不能重复使用while关键字,所以我们将宏命名为@during。
macro during(condition, body)
return quote
while $condition
➊ $(esc(body))
end
end
end
列表 6-7:使用宏创建新的语法
我们使用esc()函数 ➊是因为我们希望循环体能够使用宏外部定义的变量。
以下是如何使用这个宏:
julia> i = 0
0
julia> @during i < 10 (println(i²); i+=1)
0
1
4
9
16
25
36
49
64
81
julia> i
10
最后两行显示宏确实引用了全局作用域中的变量i。
利用我们新的能力,我们可以发明一种语言中不存在的循环。让我们创建一个“until”循环,直到满足某个条件才重复执行一个代码块。这和while循环的工作方式相同,只是条件不满足时继续执行,而在条件满足时停止。基于这个思路,我们的新宏只是对列表 6-7 中的宏进行简单的修改:
macro until(condition, body)
return quote
while !$condition
$(esc(body))
end
end
end
让我们在 REPL 中测试一下,看看它是否按预期工作:
julia> i = 0
0
julia> @until i == 11 (println(i³); i+=1)
0
1
8
27
64
125
216
343
512
729
1000
我们的@until循环按预期工作,将i递增直到i == 11。
编写宏本质上比编写普通函数要困难一些,部分原因是需要跟踪引用级别和自我引用。幸运的是,你永远不需要编写一个宏来执行科学计算或数值工作。然而,如果你发现自己经常重复写“模板”代码,而且这些重复的代码不能通过普通函数表达出来,那么宏的代码编写能力就能帮你节省一些工作。
有用的宏
尽管你可能永远不会编写自己的宏,但你会经常使用它们。标准库和许多包通过各种宏提供有用的功能。本节概述了几种常用的宏供一般使用。
广播宏
我们已经描述了 Julia 的点运算符如何扩展函数和运算符,使其对整个数组进行逐元素操作(详见“广播”章节,第 51 页)。我们经常需要编写长表达式,其中所有或大多数函数都需要对它们的数组参数进行广播。广播宏使我们无需在这样的表达式中到处加点符号——例如:
julia> r = 1:10
julia> [r (@. exp(r) > r⁴) (exp.(r) .> r.⁴)]
10×2 BitMatrix:
1 1 1
2 0 0
3 0 0
4 0 0
5 0 0
6 0 0
7 0 0
8 0 0
9 1 1
10 1 1
这个例子构建了一个三列矩阵,展示了指数函数何时大于其参数的四次方(exp(x) 是 Julia 中的 e^(x) 函数)。第二列是通过广播宏构造的表达式,而第三列是具有相同意义的表达式,但使用了显式的点运算符。这两列是相同的。
要从宏的自动广播中排除某个函数,只需在其前面加上美元符号($)。例如,这里是前 10 个平方的和:
julia> sum((1:10).²)
385
sum() 函数,它对数组中的所有数字求和,通常没有点操作符,因为它是作用于整个数组,而不是逐个元素。
如果我们使用广播宏重写该表达式,我们应该将 sum() 排除在自动点运算之外:
julia> @. $sum((1:10)²)
385
如果没有前缀美元符号,sum() 将会对每个元素单独应用;然而,这并不是我们想要的,因为当 n 是一个单独的数字时,sum(n) 就等于 n。
@chain 宏
@chain 宏是一个更方便的替代管道操作符(|>),用于通过一系列表达式转换数据。管道操作符有一些局限性,例如,它仅与具有单一参数的函数配合使用。@chain 宏是 Julia 生态系统中几种创建更灵活管道机制的方式之一。
首先,让我们通过一个简单的示例来看看内置管道的语法:
julia> "hello" |> uppercase |> reverse
"OLLEH"
我们已经通过两个函数将字符串 "hello" 进行了转换。
假设我们想继续管道,添加occursin()作为第三个函数,检查字符串"OL"是否出现在结果中。occursin()函数将要查找的字符串作为第二个参数,因此没有显而易见的方式将其扩展到管道中使用。
我们可以使用@chain宏代替管道操作符来完成这个任务:
julia> @chain "hello" begin
uppercase
reverse
➊ occursin("OL", _)
end
true
@chain宏通过一系列表达式创建一个管道,而无需使用任何额外的运算符。它可以处理任意数量参数的函数。默认情况下,每个表达式的结果会传入下一个函数的第一个参数。如果要将结果插入到非第一个位置的参数中,可以用下划线➊标记该位置。
@time 宏
@time宏会告诉我们计算消耗了多少机器时间,并提供一些关于内存分配的信息:
julia> @time sum((1:1e8).²)
0.661141 seconds (2 allocations: 762.940 MiB, 1.01% gc time)
3.333333383333333e23
首先,REPL 会打印一行关于所使用资源的信息,然后是计算结果。
注意
@time宏对于大致了解时间消耗很有帮助,但对于更系统的基准测试或分析,我建议导入BenchmarkTools包,并使用@btime宏和其中的其他工具。BenchmarkTools宏可以多次运行一个表达式并取平均值,分离运行时间和编译时间等。
性能宏
Julia 提供了几个宏,可以用来改变编译器的行为,有时能导致代码更高效。这些宏需要小心使用,因为它们的使用并非没有风险。本节讨论的两个宏在某些情况下可以显著加速程序;在其他情况下,它们几乎没有效果。通常需要通过实验来确定它们是否能带来任何好处。这两个宏以及类似的策略应该在性能调优的最后阶段进行探索。在算法或程序开发过程中,尝试这种优化可能会成为一种适得其反的干扰。
通常,编译器会检查我们的索引表达式,以确保我们不会索引不存在的数组元素。如果我们索引超出数组末尾,或者使用非正索引,则会返回BoundsError。在某些例程中,这种边界检查可能会影响性能。如果我们确定某段代码中不会出现索引错误,可以使用@inbounds宏指示编译器跳过该位置的边界检查:
x = (1:1e6).²; s = 0
@inbounds for i in 1:2:1000
s += x[i]
end
for循环开始时的@inbounds指令告诉编译器在循环期间不必担心x[i]会成为非法访问。我们负责确保i保持在x的边界内。
在最新版本的 Julia 中,inbounds的效用大大减少;在 1.8 版本之后使用它已经没有什么好的理由。然而,我们会在许多现有代码中遇到它,因此了解它的作用非常重要。
注意
一个常见的错误是尝试用1:length(A)生成数组 A 的索引。这并不能为每种类型的数组创建合法的索引,如果在@inbounds部分中使用它来访问 A,可能会导致静默的越界错误。我们应该使用eachindex(A),它总是返回一个合法索引的迭代器来访问 A*。
正如本章前面提到的,浮点数的加法和乘法不是可交换的:结果可能依赖于我们加法或乘法的顺序。因此,Julia 编译器通常会按我们写出的顺序执行算术运算,即使改变操作数的顺序或重写表达式成“实数等价物”可能会更高效。这样做可以确保在不同版本的编译器上运行程序时,所有的算术运算都按照相同的顺序进行,从而得到相同的数值结果。
在结果的最后几个小数位不重要的情况下,我们可以通过允许编译器重新排列我们的表达式来牺牲部分可重现性,从而获得更高的速度。这一指令由@fastmath宏提供:
julia> const d = 1.0045338347428372e6
1.0045338347428372e6
julia> @time sum(i/d for i in 1:1e9)
5.248617 seconds
4.9774331456739935e11
julia> @time @fastmath sum(i/d for i in 1:1e9)
3.856526 seconds
4.977433145673994e11
在这里,我们进行了相同的求和操作

两次计算,第二次使用@fastmath指令。我们的表达式不必要地进行了额外的十亿次除法运算。对于@fastmath,一个显而易见的优化是将常量d提取出来。这个宏能让我们获得约 26%的加速。同时,它也稍微改变了结果的最后两位数字。两个结果都不算更“正确”。这是一个例子,说明浮点数运算如何依赖于计算的细节。
字符串格式化宏
Printf包提供了两个宏,用于使用 C 风格的格式说明符格式化字符串,这些格式说明符已经成为多个编程语言的事实标准。以下示例展示了宏如何使我们的代码更加简洁,让我们在不使用括号和逗号的情况下列出要格式化的变量:
julia> using Printf
julia> @printf "10! is about %.2e and √2 is approximately %.4f" factorial(10) √2
10! is about 3.63e+06 and √2 is approximately 1.4142
格式说明符是字符串中以%开头的片段,小数点后的数字决定了结果中小数点后打印的数字位数。关于所有格式说明符的列表和语法指南,请参见第 187 页的“进一步阅读”。
这个宏的伴侣是sprintf,它的行为相同,但返回格式化后的字符串作为结果,而不是直接打印。使用sprintf可以将生成的字符串存储在变量中。
信息宏
有几个宏始终可用,它们提供有关调用它们的环境的信息。@__MODULE__、@__DIR__、@__FILE__ 和 @__LINE__ 宏分别返回它们被调用的模块、目录、文件路径和行号。这些宏在调试、编写构建脚本、代码格式化、测试和其他用途时非常有用。(这一段中的每个宏名称前后都带有双下划线。)
调试宏时的一个重要工具是名为 @macroexpand 的宏。只需将其加到宏调用前,它就会展示宏在每个变量和引用上使用的内容。
错误处理
像大多数现代编程语言一样,Julia 提供了处理、操作和创建错误(也称为 异常)的方法。到目前为止,我们在本书中已经看到许多错误的例子:它们出现在 REPL 会话中或在运行程序时,当 Julia 遇到无法继续计算的情况时。这些情况包括调用函数时传入了不被接受的参数、数组越界、使用未定义的名称等。每一个错误都是为了展示语言的某个特性,但在实际使用中,我们遇到错误通常是因为出现了意外情况,或者发生了需要防范的事情。
在本节中,我们将探讨如何处理错误以及将其纳入程序流程控制的一些方法。Julia 的类型系统和函数调度方法(在第八章中讨论)提供了一种更清晰的方式来完成其他语言中依赖于异常处理的部分内容。由于这些更符合 Julia 风格的技术能够让编译器进行更多优化,因此应该优先使用。然而,某些情况下,本节中描述的方法仍然是完成编程任务的最便捷方式。
错误类型
Julia 使用大约 25 种不同类型的异常。有些异常很少发生,而有些则是我们希望它们能更少发生的。以下是最常见的异常:
julia> 1 + "1"
ERROR: MethodError: no method matching +(::Int64, ::String)
julia> [1, 2, 3][4]
ERROR: BoundsError:
attempt to access 3-element Vector{Int64} at index [4]
julia> notdefined
ERROR: UndefVarError: notdefined not defined
julia> 'abc'
ERROR: syntax: character literal contains multiple characters
julia> [1 2] * [3 4 5] ➊
ERROR: DimensionMismatch:
matrix A has dimensions (1,2), matrix B has dimensions (1,3)
julia> log(-1)
ERROR: DomainError with -1.0:
log will only return a complex result if called with a complex argument.
Try log(Complex(x)).
julia> 1 ÷ 0 ➋
ERROR: DivideError: integer division error
julia> Int(2.1) ➌
ERROR: InexactError: Int64(2.1)
julia> Dict(["a" => 1, "b" => 2])["c"]
ERROR: KeyError: key "c" not found
julia> factorial(55)
ERROR: OverflowError: 55 is too large to look up in the table;
consider using `factorial(big(55))` instead ➍
julia> "François"[6]
ERROR: StringIndexError: invalid index [6], valid nearby indices [5]=>'ç', [7]=>'o'
紧跟在 ERROR: 之后的标识符是错误的名称。它通常会跟随一些解释,有时甚至还会提供一些建议。
大多数错误信息都能自解释。MethodError 表示某人尝试调用一个函数,但传入了该函数不支持的参数类型。像 + 这样的运算符是以中缀语法书写的函数。(有关方法和错误信息含义的更多信息,请参见第 230 页中的“创建多个方法”部分。)
当 * 运算符应用于数组时,它执行矩阵乘法,这要求第一个参数的第二维与第二个参数的第一维匹配 ➊。
我们可以用浮点数 0 进行除法,这会得到Inf或-Inf,意味着我们可以做1/0,因为/运算符会转换为浮点数。然而,使用整数除法运算符(÷)除以 0 会导致DivideError ➋。
如果我们尝试进行会丢失信息的数值类型转换,结果将是InexactError ➌。
普通整数类型的大小不足以存储factorial(55)的结果,但正如错误消息后面建议的那样 ➍,我们可以使用另一种类型的数字。我们将在《'大'数字与无理类型》一节中详细讨论big类型,参见第 216 页。
调用栈
假设我们有一系列函数调用,其中一个函数调用第二个函数,第二个函数调用第三个函数,以此类推。当这一链中的最后一个函数完成工作时,编译器需要知道接下来该做什么。为了知道下一个指令是什么,编译器需要跟踪“我们是如何到达这里的”。这些信息,包括函数调用链的详细情况,称为调用栈。它通常构成错误消息中较长的部分,我通常为了节省空间在本书的示例中省略它们。
注意
实际上,编译器在可能的情况下会通过“内联”优化嵌套函数调用。这种优化会将嵌套调用替换为直接将被调用函数的代码插入调用函数中。但调用栈这一逻辑概念仍然存在,错误报告打印出这一逻辑栈,并标明任何内联的情况。
为了演示调用栈如何工作,列表 6-8 设置了一系列五个函数,每个函数都定义为按顺序调用下一个函数,最后一个调用log()函数。
function a(n)
b(n)
end
function b(n)
n -= 1
c(n)
end
function c(n)
n -= 1
d(n)
end
function d(n)
n -= 1
e(n)
end
function e(n)
return log(n)
end
列表 6-8:一系列函数调用
函数a()调用b(),传递参数n。函数b()将该参数递减并调用c(),传递新的值,接着c()同样调用d()。最后,e()调用log(n),此时n比原始n小了 3。
列表 6-9 显示了多次调用a()。
julia> a(5)
0.6931471805599453
julia> a(2)
ERROR: DomainError with -1.0:
log will only return a complex result if called with a
complex argument. Try log(Complex(x)).
Stacktrace:
[1] throw_complex_domainerror(f::Symbol, x::Float64)
@ Base.Math ./math.jl:33
[2] _log(x::Float64, base::Val{:e}, func::Symbol)
@ Base.Math ./special/log.jl:292
[3] log
@ ./special/log.jl:257 [inlined]
[4] log
@ ./math.jl:1350 [inlined]
[5] e
@ ./REPL[215]:2 [inlined]
[6] d
@ ./REPL[214]:3 [inlined]
[7] c
@ ./REPL[213]:3 [inlined]
[8] b
@ ./REPL[212]:3 [inlined]
[9] a(n::Int64)
@ Main ./REPL[211]:2
[10] top-level scope
@ REPL[217]:1
列表 6-9:错误发生时的调用栈
首先我们调用a(5),这最终会调用log(5-3),即log(2),并返回预期的结果。当我们调用a(2)时,结果是log(2-3),即log(-1),尝试对负数取对数会产生预期的DomainError。接下来是堆栈跟踪:它提供了出错时调用栈的相关信息。这些数据,可能比这个人工示例要长得多,是调试的有力助手,帮助我们了解导致错误的程序状态。
方括号中的数字是 REPL 中打印的追踪信息的一部分,显示了函数调用的顺序,从最近的错误发生处开始,并逐步向上显示调用链。第一个条目是实际处理错误的函数,接着是日志函数本身,然后是我们的函数 e() 直到 a()。最后一个条目告知我们 a() 是从 REPL 中调用的。堆栈追踪还告诉我们哪些函数是编译器内联的。
try...catch 块
我们可以通过拦截错误来避免它们直接停止程序的运行。在 Julia 中,我们使用 try...catch 块来处理错误,它与 if 块一样是一种控制流程的方式。以下是一个示例:
function friendly_log(n)
try
return log(n)
catch oops
if oops isa DomainError
@warn "you may have supplied a negative number: $n"
➊ @info "Trying with $(-n)."
log(-n)
elseif oops isa MethodError
➋ @error "please supply a positive number."
end
end
end
friendly_log() 函数在内置的 log() 函数中封装了一些错误处理。普通的 log() 函数会拒绝负数参数,并抛出 DomainError,但这个版本会使用参数的绝对值再次尝试,并警告用户它正在做什么。try 部分包含我们希望拦截错误的代码;catch 部分拦截这些错误,并可选择将错误本身赋值给变量,这里是 oops。在 catch 块中,我们放置一个普通的 if 块,使用 isa 来测试错误的类型(“实践中的类型”部分在 第 214 页 中详细解释了 isa 和类型)。如果 oops 恰好是 DomainError,则 @warn 宏会向终端打印警告信息,之后我们使用 @info 宏发出另一个消息,解释程序如何修改有问题的参数 ➊。接着我们使用修改后的参数调用 log()。
如果错误不是 DomainError,而是 MethodError,那么参数存在其他问题。在这种情况下,我们不知道该怎么办,程序应该停止。@error 宏 ➋ 会打印一条错误消息,然后程序继续运行。由于没有其他操作,程序将退出。@error 宏和 @warn 宏一样,仅仅打印格式化的消息;这两个宏并不会创建错误条件,也不会影响程序的流程。在彩色设备上,警告信息以黄色显示,错误信息以红色显示,并且两者都会尝试指示问题发生的程序位置。@info 宏生成的消息会以蓝色显示在 REPL 中,并且不包含程序位置。这三种宏都是 Julia 日志系统的一部分。有关如何使用这些日志信息的更多内容,请参阅“进一步阅读”部分中的文档链接,在 第 187 页 中了解更多信息。
由于我们在 catch 块中“处理”了错误,它们不会停止程序的运行,也不会导致堆栈追踪的生成:
julia> function call_fl(n)
friendly_log(n)
end
julia> call_fl(-3)
Warning: you may have supplied a negative number: -3
@ Main REPL[222]:6
[ Info: Trying with 3.
1.0986122886681098
如果我们没有在 catch 块中拦截错误,它将像上一节中那样导致堆栈追踪,并且 call_fl() 会成为调用堆栈的一部分。
使用 throw()
REPL 的帮助模式解释了throw()将一个对象作为异常抛出。大多数 Julia 教程将其描述为程序员用来创建错误的一种方式。这两种描述都是正确的,但它们仅仅讲述了部分内容。在深入了解throw()的强大功能之前,让我们来看一个简单的例子,说明我们可能需要在没有错误的情况下主动创建一个错误。
创建错误
log()函数允许我们以 0 作为参数调用它,并返回-Inf作为结果。假设我们想要一个不允许 0 作为参数的对数函数,因为我们希望从结果中排除无穷大值。finite_log()函数就是实现这一目标的一种方式:
function finite_log(n)
if n == 0
throw(DomainError(n, "please supply a positive argument; log(0) = -Inf."))
end
return log(n)
end
一个if语句块检查输入是否为 0,如果是,它会调用throw()。throw()的参数是一个转化为函数的错误名称;Julia 的每个错误都有一个与之关联的函数,用于构造该错误。finite_log()函数如果接收到 0 作为参数,则会引发DomainError。我们可以引发任何我们想要的错误,但因为这里的目的是排除某个值在域中的存在,使用DomainError是合理的:
julia> finite_log(2)
0.6931471805599453
julia> finite_log(0)
ERROR: DomainError with 0:
please supply a positive argument; log(0) = -Inf.
Stacktrace:
[1] finite_log(n::Int64)
@ Main ./REPL[230]:3
[2] top-level scope
@ REPL[234]:1
julia> log(0)
-Inf
在这里,finite_log()的行为类似于log(),除非它接收到 0,在这种情况下,它会因DomainError而停止执行。我们在throw()中包含的消息将与错误消息一起打印出来。
大多数错误构造函数都接受用于包含在错误消息中的信息参数。要查看允许的参数,可以向 REPL 提问:
help?> DomainError
search: DomainError
DomainError(val)
DomainError(val, msg)
The argument val to a function or constructor is outside the valid domain.
文档告知我们有两个版本,一个只包含引发错误的值,另一个版本则像我们使用的那样,包含一个解释性消息。
将 throw()与 try...catch 块结合使用
将throw()与try...catch块结合使用,能够释放其全部潜力。二者结合后,创造了一种新的流程控制形式,允许我们将任何值传递给调用堆栈,直到它被catch拦截,此时我们可以终止程序或执行其他操作。
作为例子,清单 6-10 修改了清单 6-8 中的函数链。
function a(n)
try
b(n)
catch oops
if oops[1] == 0
@warn "$(oops[2]) Attempted to call log(0) = Inf."
else
@error "$(oops[2]) Attempted to call log($(oops[1]))."
end
end
end
function b(n)
n -= 1
c(n)
end
function c(n)
n -= 1
d(n)
end
function d(n)
n -= 1
e(n)
end
function e(n)
if n < 0
➊ throw((n, "Got a negative number."))
elseif n == 0
throw((0, "Got 0."))
end
return log(n)
end
清单 6-10:抛出和捕获
首先看看e()函数,我们在原本唯一一行的上方添加了一个if语句块。在尝试计算对数之前,它会检查参数n。如果这个参数不是正数,它会调用throw(),并传入一个Tuple作为参数➊。无论哪种情况,元组的第一个元素是n,第二个元素是一个字符串。throw()函数将这个元组传递给调用堆栈,并在不尝试计算对数的情况下从e()返回。如果n == 0,我们将发送一个不同的消息到调用堆栈。
throw()发送的消息会在函数调用之间传递,直到它被try...catch块在函数a()中拦截。catch语句将该消息(在这种情况下是一个Tuple)赋值给变量oops,然后在if语句块中进行检查,打印出适当的警告或错误消息。
下面是它的实际应用:
julia> a(5)
0.6931471805599453
julia> a(3)
Warning: Got 0\. Attempted to call log(0) = Inf.
@ Main REPL[1]:6
julia> a(2)
Error: Got a negative number. Attempted to call log(-1).
@ Main REPL[1]:8
这与清单 6-9 中显示的错误报告有着显著的不同。这里我们没有看到调用栈,只是看到了在try...catch块中构造的消息。throw()...catch机制允许我们将消息“越过”调用栈中的任何数量的函数,直接传递给第一个准备好接受适当catch语句的函数。清单 6-9 中没有catch来拦截错误,因此 Julia 停止了程序并打印了完整的调用栈供我们诊断使用。
finally 子句
try...catch 块可以选择性地以 finally 子句结尾,该子句在程序退出之前执行。我们通常用它来做“清理”工作,比如释放外部资源或文件句柄,这些资源或句柄在出现错误时可能处于不确定的状态。
让我们给清单 6-10 中的a()添加一个finally子句:
function a(n)
try
b(n)
catch oops
if oops[1] == 0
@warn "$(oops[2]) Attempted to call log(0) = Inf."
else
@error "$(oops[2]) Attempted to call log($(oops[1]))."
end
finally
@info "Calculation completed with input n = $n."
end
end
像之前一样调用它,我们将看到以下内容:
julia> a(5)
[ Info: Calculation completed with input n = 5.
0.6931471805599453
julia> a(2)
Error: Got a negative number. Attempted to call log(-1).
@ Main REPL[11]:8
Info: Calculation completed with input n = 2.
这个例子展示了无论是否捕获到消息,finally子句总是会被执行。
结论
通过本章讨论的更高级语言特性,我们已经达到了更高水平的 Julia 掌握。我们现在更加准备好处理[第二部分中的详细应用,在那里我们将看到如何应用我们的技能解决多个领域中的问题。
进一步阅读
-
包含用于中缀运算符的字符列表及其优先级的源代码,请访问https://github.com/JuliaLang/julia/blob/master/src/julia-parser.scm。
-
要了解更多关于页面表格化 C 格式说明符的信息,请访问https://web.archive.org/web/20220127135451/https://www.journaldev.com/35137/format-specifiers-in-c。
-
你可以阅读 Stefan Karpinski 对符号究竟是什么的精妙解释,见于https://stackoverflow.com/a/23482257。
-
关于日志系统以及
@info、@warn和@error宏的进一步使用文档,请访问https://docs.julialang.org/en/v1/stdlib/Logging/。
第八章:图表和动画
告诉我,Steed,一切都是按比例的吗?
—Mrs. Peel

图表是科学交流和教育的重要形式。本章讨论的图表类型不同于我们在第四章中处理的数据或数学函数图表。在这个上下文中,图表指的是数学结构的插图、实验设置的图示、描述算法或处理流程的流程图,以及类似的图形描述。
动画现在常常作为科学论文的附加材料,在线提供以展示模拟结果。它们也是教育中一个宝贵的工具,并在科学和数学交流中有着广泛的应用。在本章中,我们将探索几个可以帮助你创建各种类型图表和动画的 Julia 包。
使用 Luxor 绘制图表
Luxor 包非常复杂且高度通用,可以让你创建几乎任何类型的图表。要安装它,请在包管理器中输入添加 Luxor。
该包采用命令式风格逐步构建图形。你输入一系列命令来操作全局状态,每个命令都可能为图形添加某些元素。每个绘图命令的效果取决于执行时的状态。例如,要画一个蓝色圆圈,首先将颜色设置为蓝色,然后输入画圆命令,命令参数包括位置、大小以及是否需要“描边”(画出轮廓)或填充。轮廓或填充将使用当前设置的颜色。每个元素——圆圈、多边形、线条、文本或其他各种对象——都需要单独的命令,并且颜色、样式、不透明度等设置是在每个命令执行前全局设定的。
通过一个具体的例子,假设我们创建一个简单的图表(见图 7-1),展示太阳系行星的相对大小,按它们距离太阳的顺序排列。

图 7-1:行星的相对大小
清单 7-1 显示了完整的 REPL 会话,该会话创建了图 7-1 中的图示。
julia> using \captionlst{Luxor}
➊ julia> planet_diameters = [4879 12104 12756 ;;
3475 6792 142984 120536 51118 49528 2370];
julia> planet_names = ["Mercury", "Venus", "Earth", "Moon",
"Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"];
julia> dimenx = 1000;
julia> dimeny = 500;
➋ julia> @png begin
dscale = 500.0
➌ origin(Point(planet_diameters[1]/(2*dscale), dimeny/2))
ledge = 0.0
diameter = 0
➍ fontface("Liberation Sans")
fontsize(32)
for i in 1:10
ledge += diameter/2.0
name = planet_names[i]
diameter = planet_diameters[i]/dscale
➎ ledge += diameter/2.0
setcolor("black")
setdash("solid")
circle(Point(ledge , 0), diameter/2.0, :stroke)
txtstart = Point(100*(i-1), 180 + 35*(i%2))
text(planet_names[i], txtstart)
setcolor("blue")
➏ setdash("dot")
line(txtstart, Point(ledge, 0), :stroke)
end
➐ end dimenx dimeny "planets.png"
清单 7-1:使用 Luxor 创建太阳系图表
程序从 NASA 网站获取行星的直径,这些直径单位是公里(请参阅第 211 页中的“进一步阅读”)。当从 NASA 表格中复制并粘贴数据时,数字是以空格分隔的。这会创建一个 1\times10 数组(包括地球的月球和冥王星),这没问题;我们只需要一个可以遍历的列表。
行尾的双分号➊将数组的字面量输入分为两行(这个特性是在 Julia v1.7 中添加的)。虽然空格和双分号都表示沿第二维度的连接,但通常你不能在一个字面量数组定义中混合使用它们。这里的用法是一个特例,仅为此目的使用。
两个变量dimenx和dimeny保存了我们图表的尺寸。Luxor中的尺寸单位是点(points),每个点是 1/72 英寸。
Luxor提供了几个宏来方便地设置绘图环境。@png宏➋初始化 PNG 插图,定义坐标系统的原点为图片的中心,并在代码块结束时显示结果➐。在最后的end语句之后,我们给出图像的尺寸和文件名(虽然可以省略,但通常不建议)。默认尺寸为 600×600,默认文件名为luxor-drawing-,后跟时间戳和文件扩展名。随着绘图代码的不断发展,这可能会在磁盘上产生大量文件,因此你可能希望指定一个文件名,该文件名在每次运行时会被覆盖。文件扩展名是可选的,如果省略,Luxor会自动提供一个。
我们需要一个缩放因子来处理较大的行星直径,这个因子被赋值给dscale。
该宏将坐标系统的原点设置为图表的中心,使用我们的变量,它会是(dimenx/2, dimeny/2)。如果我们在x方向上设置原点➌,使得第一个行星的左边缘从左边界开始,代码会更加整洁。
我发现,在我的系统中,如果我没有设置fontface ➍,输出中会出现难看的位图字体。这个特定的字体可能在你的系统上不存在,所以你可以根据需要进行调整。如果请求一个Luxor找不到的字体,它会继续运行并进行替代。
当前圆心的 x 坐标被赋值给ledge,该值会被更新两次➎,每个行星都会增加:一次是增加前一个行星的半径,另一次是增加即将绘制的行星的半径。最终结果是一系列相切的圆。
在打印每个标签之前,颜色被设置为蓝色,虚线样式设置为点线➏。与本章其他图表一样,你可以在在线补充资料中找到彩色版本,链接为 https://julia.lee-phillips.org。
如果你在 REPL 中运行示例 7-1 中的代码,当你运行代码时,默认的图像查看应用程序会打开一个窗口,显示图表文件。REPL 会一直停顿,直到你退出该应用程序。如果你在 Pluto 或 Jupyter 中运行该代码,图表将嵌入到代码下方的单元格中。
宏的其他选项包括@svg和@pdf,它们分别创建这两种类型的文件。然而,PDF 文件无法嵌入到笔记本中。
除了线条、圆形和文本,Luxor还提供了用于绘制其他几何形状的命令,甚至可以绘制如圆的切线等几何构造。(有关手册的链接,请参见第七章中的“进一步阅读”部分。)
图形包
只要有足够的耐心,你就可以使用Luxor创建任何类型的图表。然而,通常对于特定的标准类型图表,使用专门的包会更容易。
本节内容讨论的是数学意义上的图和其可视化。图这个词通常与第四章中讨论的绘图类型同义使用,但对于数学家而言,图是由节点通过边连接起来的集合,这就是我们在这里讨论的图的类型。这种类型的图用于表示各种各样的系统。每当你有一组对象,通过网络中的关系连接起来时,你就有了一个图。例如,植物或动物的分类、计算机程序中的调用位置、句子的语法结构、组织结构图以及小说中人物之间的关系,都是图的例子。在这样的图中,物体(组织的一部分或小说中的人物)被称为节点,节点之间的连接被称为边。
Julia 的Graphs包包含了生成多种类型图表的函数。它依赖于Plots和GraphRecipes来实际绘制表示图形的图像。我们在第四章中已经熟悉了第一个包;第二个包则是一个绘图配方集合,它通过Plots绘制图形。配方机制允许用户和包的作者扩展Plots,使其能够可视化新的数据类型或生成新的图表类型。要理解这些配方如何工作,我们需要了解更多关于类型系统的内容,因此在第八章中的“绘图配方”部分对配方进行了介绍。
作为Graphs包的介绍,我们将构建一个程序,用于创建一个关于位于美国东部切萨皮克湾的 14 个物种之间的捕食–被捕食关系的图表:
using Plots
using Graphs
using GraphRecipes
creatures = ["Striped bass", "Atlantic croaker", "White perch",
"Summer flounder", "Clearnose skate", "Bay anchovy",
"Worms", "Mysids", "Amphipods", "Juvenile weakfish",
"Sand shrimp", "Mantis shrimp", "Razor clams",
"Juvenile Atlantic croaker"]
foodchain = SimpleDiGraph(14)
首先,我们导入三个必要的库(Plots、Graphs和Graph Recipes),并创建一个包含生物名称的向量。这些名称将成为图表中的标签,并且还将作为图中节点的参考。
目前程序的最后一行创建了一个空的有向图,包含 14 个节点(Graphs.jl称之为“顶点”)。有向图是指边有方向,通常通过箭头表示。在这个例子中,边的方向代表了哪个生物吃了哪个生物。在无向图中,边只是表示连接,没有层级关系。
下一步是向foodchain添加表示捕食者-猎物关系的边。add_edge!(foodchain, a, b)函数通过在图的第一个参数中添加一条从节点a到节点b的边来修改图。这正是我们想要的,但不太方便,因为a和b需要是整数,代表列表中节点的顺序。为了输入这些参数,我们需要为每个关系遍历creatures列表。例如,要输入一条表示条纹鲈鱼吃蠕虫的边,我们必须调用add_edge!(foodchain, 1, 7)。
让我们通过定义一个字典和一个函数来使这个过程更方便,这样我们就可以通过名称来引用生物:
food_dict = Dict([creatures[i] => i for i in 1:14])
function ↪(predator, prey)
add_edge!(foodchain, food_dict[predator], food_dict[prey])
end
food_dict字典将每个生物字符串与其在列表中的顺序关联,以便于引用。新的函数允许我们通过命名捕食者及其猎物来添加边。我们为这个函数使用了一个可以作为中缀运算符的名称(参见“运算符也是函数”章节,见第 159 页)。这个字符的 REPL 快捷键(和 LaTeX 命令)是\hookrightarrow。
在引入钩形箭头函数后,我们可以列出一组来自切萨皮克湾生态研究的捕食者-猎物关系:
"Striped bass" ↪ "Worms"
"Striped bass" ↪ "Amphipods"
"Striped bass" ↪ "Mysids"
"Striped bass" ↪ "Bay anchovy"
"Atlantic croaker" ↪ "Mysids"
"Atlantic croaker" ↪ "Worms"
"White perch" ↪ "Worms"
"White perch" ↪ "Amphipods"
"Summer flounder" ↪ "Bay anchovy"
"Summer flounder" ↪ "Mysids"
"Summer flounder" ↪ "Juvenile weakfish"
"Summer flounder" ↪ "Sand shrimp"
"Summer flounder" ↪ "Mantis shrimp"
"Clearnose skate" ↪ "Mantis shrimp"
"Clearnose skate" ↪ "Razor clams"
"Clearnose skate" ↪ "Juvenile Atlantic croaker"
graphplot(foodchain; names=creatures, nodeshape=:rect, fontsize=5,
nodesize=0.14, method=:stress)
add_edge!()函数通过添加边来修改foodchain图形。最后一次调用生成了如图 7-2 所示的插图。在图中,箭头指向从捕食者到猎物,反映了我们定义的边的方向。

图 7-2:切萨皮克湾的捕食者-猎物食物网
如果你运行这个程序,你会发现你的图看起来有所不同。实际上,每次运行时,图的布局都会有所不同,尽管结构始终相同——相同的生物被相同的捕食者吃掉。这是因为节点和边的排列存在随机因素。事实上,我运行了这个程序大约五次才得到了我喜欢的结果。有些生成的图表效果较差,节点重叠。
调用graphplot()时的最后一个参数method选择了图形布局的算法:即通过决定节点的位置来将结构转化为图像。stress算法通过尽量使节点之间的距离差异最大化,从而实现这一点,衡量的标准是距离理论最优值的偏差。随机因素出现在算法通过变形一个随机的初始状态来找到这个最大值。
邻接矩阵
在内部,通过调用add_edge!()建立的边的列表会转换成一个邻接矩阵。我们可以看到邻接矩阵,如清单 7-2 所示。
julia> foodchain_matrix = adjacency_matrix(foodchain)
14×14 SparseArrays.SparseMatrixCSC{Int64, Int64} with 16 stored entries:
. . . . . 1 1 1 1 . . . . .
. . . . . . 1 1 . . . . . .
. . . . . . 1 . 1 . . . . .
. . . . . 1 . 1 . 1 1 1 . .
. . . . . . . . . . . 1 1 1
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
. . . . . . . . . . . . . .
清单 7-2:邻接矩阵是一个稀疏数组。
结果作为一个稀疏数组返回,它是SparseArrays包中定义的一种数据类型,Graphs包会自动加载这个包。稀疏数组的行为类似于普通数组,但它特别适用于当只有一小部分元素被定义时的高效处理。REPL 将它们显示为示例 7-2 所示,未定义的位置用点表示。
邻接矩阵的元素设置为 1,以记录图中边的存在。例如,foodchain_matrix[1, 6] 为 1,因为从节点 1 到节点 6 有一条边(由“条纹鲈鱼”↪“海湾鳀”建立)。邻接矩阵编码了图的结构,因此包含了图的完整定义,因为图与其结构是等价的。我们可以通过调用graphplot(foodchain_matrix)来绘制图;其余的参数仅提供一些细节信息,例如用于标记节点的名称,以便显示。如果邻接矩阵是对称的(M[i, j] == M[j, i]),它表示一个无向图。否则,如在食物链示例中,它表示一个有向图,graphplot()将使用箭头而不是简单的线条来绘制它。在绘制图之前,邻接矩阵必须至少有一个非零元素,或者至少用add_edge!()定义一条边。
该包使用稀疏矩阵以提高效率,但如果我们像下一个示例中那样直接构造邻接矩阵,我们也可以选择使用普通矩阵。在这种情况下,0 元素表示没有边,而非零元素表示边的位置。
因子树
在冒着唤起高中代数课糟糕回忆的风险下,我们的下一个示例(见示例 7-3)将是一个绘制因子树的程序:因子树是显示一个数字不断被分解成更小因子的图形,最终显示它的唯一素因子。它将展示如何通过构造邻接矩阵来构建图,并提供一个具有树形结构的无向图示例。以下是生成因子树的完整程序。
using Primes: factor
using Plots
using Graphs
using GraphRecipes
function factree(n)
➊ factors = factor(Vector, n)
lf = length(factors)
if lf == 1
println("$n is prime.")
➋ return
end
names = [n; n ÷ factors[1]; factors[1]]
for f in factors[2:end-1]
push!(names, names[end-1] ÷ f, f)
end
nel = length(names)
➌ a = zeros(nel, nel)
println("Prime factors: $factors")
j = 1; i = 1
a[1, 2] = 1
a[1, 3] = 1
for i in 2:2:nel-3
a[i, i+2] = 1
a[i, i+3] = 1
end
graphplot(a;
nodeshape=:circle,
➍ nodesize=0.12 + log10(n) * .01,
axis_buffer=0.3,
curves=false,
color=:black,
linewidth=2,
names=names,
fontsize=10,
➎ method=:buchheim)
end
示例 7-3:创建因子树的程序
新的导入是程序的第一行,它为我们引入了 factor() 函数 ➊,该函数返回其参数的素因数。该程序仅适用于大于 1 的整数 n。传递给 factor() 的第一个参数要求其返回一个向量结果,这是构建因子树所需的形式。默认情况下,返回的是一种专用类型,列出因子及其多重性。如果只有一个因子,n 就是一个素数,因此程序立即停止 ➋,并宣布原因。程序继续遍历素数列表,进行除法运算并将结果拼接到 names 向量中。接着,我们初始化邻接矩阵 a ➌ 并记录每对因子及其乘积在树上的链接。最终调用 graphplot() 时,将邻接矩阵作为第一个参数;关键字参数设置插图的详细信息。nodesize 参数设置圆圈的额外大小,以容纳标签所需的空间。扩大圆圈的算法并没有完全成功地使其足够大,因此我们添加了与标签中数字个数成比例的额外部分 ➍。调用 factree(14200) 会生成图 7-3。

图 7-3:数字 14,200 的因子树
该包提供了两种布局方法来创建树状图。:tree 方法有效,但结果略显自由形式。:buchheim 方法 ➎ 产生了图 7-3 所示的规则树。尽管素因数分解是唯一的,但导致其结果的因子树以及程序的结果可能并非唯一。
使用 Javis 制作动画
广泛使用的 Javis 包是制作几乎任何类型动画图表的好选择。它构建在 Luxor 之上(参见第 190 页的“使用 Luxor 绘图”),这意味着你可以基于对该包的了解来创建动画。一个 Javis 程序通过 Luxor 的绘图命令创建对象,并通过一系列直观的调用将它们转换为视频,操作包括旋转、平移或沿路径移动它们,同时在时间上改变形状参数。
闭包
为了有效使用 Javis,了解一种称为 闭包 的编程技巧是有帮助的。对于熟练的程序员,若知道如何使用闭包,可以直接跳过这一部分。
闭包是由另一个函数创建并返回的函数。我们将返回的函数称为 内函数,而创建它的函数称为 外函数。大多数现代编程语言都允许程序员创建闭包,但有些语言比其他语言更方便。由于 Julia 具有词法作用域和方便的函数定义语法,它使得闭包既简单又直观。
闭包的关键在于,内部函数可以访问在外部函数中定义的变量。我们说它们是封闭的,因此得名。外部函数成为一个函数工厂,返回一个其行为取决于传递给外部函数的参数,但可能具有完全不同函数签名的函数。
列表 7-4 展示了一个简单的闭包示例,我们很快会发现它有一个有用的应用。
function power(n)
return function(x)
x^n
end
end
列表 7-4:定义一个闭包
通过这个定义,当我们调用power(5)时,我们得到一个单变量的函数,它将该变量提升到第五次方并返回结果。换句话说,如果我们这样定义两个函数:
p = power(5)
q = x -> x⁵
然后p和q具有相同的行为:
julia> p(4) == q(4) == 1024
true
函数power()返回的是匿名函数,但我们可以像对待其他函数一样将其分配给变量,这里是p。
现在power()是一个函数工厂,它生成将其参数提升到任何期望指数的函数。正如在“绘图函数”部分中提到的,在第 88 页的plot()函数版本中,Plots包接受单变量函数的简单名称进行绘图。我们可以在不提及变量或定义数组的情况下绘制这些函数。
似乎这种便利性无法被用于绘制依赖于除了自变量之外的其他参数的函数,因此我们需要使用命名函数或匿名函数语法来将这些函数传递给plot()。例如,如果我们想绘制f(x, n) = x^n,我们不能仅仅调用plot(f),因为f()需要两个参数,但如果n已经定义,我们可以调用plot((x) -> x^n)。闭包是传递匿名函数的替代方法,适用于此类情况。
一旦我们定义了列表 7-4 中的闭包,我们就可以进行以下绘图调用:
plot([power(1), power(2), power(3)]; legend=:top)
这会生成图 7-4 中的图。

图 7-4:使用闭包绘图
这个绘图示例只是闭包的一种应用。它们是生成捕获定义时状态的函数的强大技术。
外循环动画
使用Javis的模式是定义生成每个要动画化的对象的函数,然后调用一系列引用这些对象并使其动画化的语句,改变它们的位置或其他属性。
注意
由于 Javis 是基于 Luxor 构建的,因此它导入 Luxor 本身,并重新导出该包的函数。结果是,包含 using Javis 的程序不得同时包含 using Luxor ,因为这会导致名称冲突。如果你已经在 REPL 中使用过 Luxor ,你必须在使用 Javis 之前重新启动 REPL。
创建对象的函数使用一个或多个Luxor函数来绘制圆形、线条、文本或其他通过Luxor提供的图形实体,并可以选择性地返回有关该对象的信息,以便在动画调用中使用。
Luxor文档描述了三种将对象创建函数传递给动画函数的方法。我们将学习另一种基于闭包的方法,它更通用,并且能生成更简洁、易读的代码。
本示例的目标是创建一个程序,生成以托勒密风格展示太阳系模型的动画。该古代宇宙学模型将地球置于宇宙的中心,并解释了行星的观测现象,认为行星的运动是由于它们的圆形轨道,而这些轨道本身又环绕着更大的轨道。这些圆形轨道被称为本轮;任何一颗行星的运动都可以通过一个或多个本轮来建模,最终形成一个围绕一个略微偏离地球的点的大圆,偏离的距离称为偏心率。
为了构建程序,我们将从创建行星和轨道的函数开始。这是一个行星的函数:
function planet(radius=15, color="green"; action = :fill, p=0)
return function(video, object, frame)
sethue(color)
circle(p, radius, action)
return p
end
end
这是一个闭包。对planet()的调用返回一个接受三个位置参数的函数,并根据传递给planet()的原始参数,而不是传递给返回函数的参数,绘制一个具有特定半径、颜色和位置的圆形。
这种间接性是必要的,因为执行动画的Javis函数期望函数作为它们的第一个也是唯一必需的参数。它们不接受形状作为参数,而是接受一个绘制形状的函数。它们将三个值(video, object, frame)传递给这个函数:分别表示视频、正在动画化的对象和帧号的整数数据类型。该函数可以使用它们中的任何一个,或者像planet()创建的函数那样,不使用它们。
闭包返回圆形的位置。如果其他动画函数需要知道该位置(如我们的视频中所示),我们必须这样做。
绘制轨道的函数几乎是相同的:
function orbit(radius, color="orchid1"; p=O)
return function(video, object, frame)
sethue(color)
circle(p, radius, :stroke)
return p
end
end
轨道将有轮廓,但不会填充颜色。
使用这两个函数,我们可以绘制可动画化的行星和轨道,这几乎是我们所需的全部。但也希望能够展示行星在太阳系中的漂移如何转化为其相对于恒星的观测位置随时间的变化。我们将通过行星位置在水平方向上的投影来近似这种运动。Javis提供的pos()函数返回对象的位置,并具有方便的x和y字段来提取相应的坐标。
以下函数接受一个对象并绘制另一个与其共享水平方向坐标、接近视频顶部的圆形:
function observed_position(orbiter; radius=10, color="orangered")
return function(video, object, frame)
sethue(color)
➊ y = 0 - video.height/2 + 50
x = pos(orbiter).x
circle(Point(x, y), radius, :fill)
end
end
这里,observed_position()使用了video参数的height字段 ➊,这是动画函数自动提供的。
我们还想绘制一个对象:在空间中可视化行星轨迹的曲线。我们将把这条路径记录为一个全局positions向量中的一系列点。在每一帧中,函数会将新位置添加到向量中,并绘制一系列小圆圈来描绘轨迹:
function track!(positions, orbiter)
return function(video, object, frame)
sethue("cadetblue1")
push!(positions, pos(orbiter))
circle.(positions, 2, :fill)
end
end
我们还需要一个绘图函数,这是几乎所有Javis动画中都需要用到的,用于定义背景:
function ground(args...)
background("black")
sethue("white")
end
这个ground()定义创建了一个带黑色背景的绘图画布,并使用白色作为默认的绘图颜色。
有了每个对象的绘制函数后,我们可以创建动画:
using Javis
function epicycles(inputcycles; eccentricity=0.1, file=nothing)
box = 200
eccentricity *= -box
cycles = [(box*s, f) for (s,f) in inputcycles[1:end-1]]
R = sum(c[1] for c in cycles)
# Some encoders require a multiple of 2:
box_length = 1.5*(2box + R) ÷ 2 * 2
➊ solar_system = Video(box_length, box_length)
positions = []
➋ Background(1:500, ground)
earth = Object(planet(), Point(0, eccentricity))
origin = Object(planet(2, "white"))
inner_orbit = Object(orbit(box))
for (radius, frequency) in cycles
outer_orbit = Object(orbit(radius), Point(0, box))
box += radius
➌ act!(outer_orbit, Action(anim_rotate_around(frequency * 2π, inner_orbit)))
inner_orbit = outer_orbit
end
wanderer = Object(planet(6, "bisque"), Point(0, box))
act!(wanderer, Action(anim_rotate_around(inputcycles[end] * 2π,
inner_orbit)))
➍ Object(track!(positions, wanderer))
Object(observed_position(wanderer))
if file == nothing
➎ render(solar_system; liveview=true)
else
render(solar_system; pathname=file, framerate=30)
end
end
epicycles()函数接受一个必需的位置参数inputcycles,其形式为[(s1, f1), (s2, f2), ..., fp]。每一对(s, f)表示一个外循环的大小s,是主轨道半径的一个分数,轨道频率为f。这里的频率指的是在动画过程中完成的循环次数。最终的fp是行星的频率。
在进行一些计算后,根据视频的整体大小来缩放轨道,并根据用户输入的外循环调整大小,我们得出了所有Javis动画所需的一个语句 ➊:定义Video及其尺寸。
第一个动画命令 ➋ 为前 500 帧设置了要绘制的背景。接下来的三个动画命令是对Object()的调用;这是Javis命令,用于将图形元素放置到背景上。Object()函数接受一个帧范围作为第一个参数,但会使用最近的Background()或Object()命令提供的范围作为默认值。Javis是一个命令式系统,维护着一个动画语句适用的状态,其中包含当前的Video和帧范围。
接下来,我们有一个循环,它为每个传入的外循环添加轨道。act!()函数 ➌ 是我们在Javis中创建大多数类型运动的方式。它的第一个参数是我们想要动画化的对象,第二个参数是定义运动的函数。这个程序中使用的唯一运动是anim_rotate_around(),它接受一个角度(以弧度为单位)和一个成为旋转中心的对象。外循环模型中的复杂复合运动容易构建,因为旋转的对象本身也可以处于运动状态。
最后的两个Object()调用 ➍ 创建了追踪行星路径的轨迹和显示其大致观测位置的投影。尽管这些是动画对象,但它们不需要act!()调用,因为它们是参考其他动画对象定义的。
epicycles()函数还接受两个可选的关键字参数。eccentricity给出地球偏离主轨道中心的位移。如果提供了file,程序会创建一个视频文件并保存在指定位置;如果没有,它将在交互式查看器中显示结果。
举个例子,要制作一个动画,让一个行星在电影中转一圈,同时有两个外圈分别绕行两圈和三圈,第一个外圈的直径是轨道直径的一半,第二个外圈的直径是第一个的二分之一,可以使用以下调用:
epicycles([(0.5, 2), (0.25, 3), 1]; file="ptolemaic.mp4")
这个调用会将渲染后的动画保存为 MP4 文件。
视图类型取决于编码环境。在 REPL 中,Javis 会打开一个窗口,带有控制按钮,用于逐步或拖动动画帧。在 Pluto 笔记本中,帧以水平列表的形式出现,并带有滚动条。在使用笔记本时,修改渲染调用,将 liveview=false ➎,会将动画 GIF 直接嵌入到笔记本中。Javis 包可以将动画保存为 GIF 或 MP4 文件;选择由文件扩展名控制。由于 GIF 文件可能变得非常大,MP4 格式是一个不错的选择;然而,无论哪种格式,与 liveview 选项相比,都需要显著的渲染时间,而 liveview 非常快速。
图 7-5 显示了渲染视频中的一帧。(请访问本书的在线补充材料 https://julia.lee-phillips.org 获取完整视频。)

图 7-5:使用 Javis 创建的动画中的一帧
我们能够仅通过一种类型的运动 anim_rotate_around() 来创建这个可视化效果。要让一个对象围绕其原点旋转,调用是 anim_rotate()。
通过将它们作为参数传递给 Action(),我们可以创建以下一些其他的运动:
appear() 和 disappear() 接受 :fade、:scale 和 :fade_line_width 等任何参数,并通过改变指定的属性使对象出现或消失。使用 :draw_text 可以使文本出现,带有打字效果。
follow_path() 使一个对象沿着一系列点的路径移动。
anim_scale() 缩小或放大一个对象。
anim_translate() 沿直线移动一个对象。
change() 改变对象的任何属性。
使用 Reel 创建动画
Reel 包与 Javis 类似,也用于创建动画,但用途不同。虽然 Javis 通过编程描述对象及其运动来轻松创建动画,Reel 则让我们可以根据一个参数(通常是时间)从任何创建图像的函数生成视频。
我们使用Reel中导出的一个名为roll()的函数,将其传递一个有两个位置参数的图像创建函数:我们想要的视频时长duration和每秒帧数fps。这两个位置参数分别是时间t和时间步长dt;roll()计算这两个参数,设置dt = duration/fps,并将其传递给函数,每次调用时更新t。它返回一个视频对象,我们可以通过调用write()将其转换为 GIF 或 MP4 文件。让一个函数接受t和dt并创建所需的视频帧是我们的责任。
注意
Reel包更新不频繁,可能无法在每个计算环境中正常工作。一个替代方案是最近版本的Plots包,它只生成 GIF,但非常简单和方便。有关* @animate 和 @gif *宏的文档,请参见第 211 页中的“进一步阅读”。
清单 7-5 计算了一个轴对称模式的鼓膜振动位移,并创建了一个以热图形式可视化运动的视频。
using SpecialFunctions
using Plots
using Reel
R = 1.0 # Drum radius
z2 = 5.52008 # 2nd zero of J0
λ2 = z2/R
c = 1
A = 1; B = 1
function vibe(r; t=0)
if r > R
return 0
else
➊ return (A * cos(c*λ2*t) + B * sin(c*λ2*t)) * besselj0(λ2*r)
end
end
r = 0:R/100:R
theta = 0:2π/100:2π
➋ function drum_frame(t, dt)
heatmap(theta, r, (theta, r) ->
vibe(r; t=t); colorbar=false, clim=(-1, 1),
c=:curl, proj=:polar, ticks=[], size=(100, 100))
end
drum_video = roll(drum_frame, fps=30, duration=2)
write("drum_video.mp4", drum_video)
清单 7-5:圆形鼓膜振动动画
首先,我们导入了三个包。振动的圆形鼓膜具有由贝塞尔函数描述的径向依赖,贝塞尔函数来自SpecialFunctions包。我们使用Plots中的绘图函数来创建电影帧,然后使用Reel将动画拼接在一起。
在定义了解决方案的几个常量后,我们定义了vibe()函数,该函数接受径向坐标和一个关键字参数t,并返回该时间和坐标下的解。来自SpecialFunctions包的贝塞尔函数J[0]被命名为besselj0() ➊。
接下来的两行定义了绘图所需的坐标数组。极坐标系在这种圆形几何中最为自然:r是径向坐标,theta是角坐标。我们需要将绘图函数包装在一个接受t和dt的函数中,以便roll()能够生成动画帧。对于这个应用,我们并没有用到dt,但该函数仍然需要接受两个参数。对heatmap()的调用使用了:curl色谱,该色谱在 0 附近有一条细白色区域,帮助我们看到节点线并清晰地区分正负区域。proj参数选择了极坐标几何。
图 7-6 展示了结果动画中的一帧。(完整视频请参见在线补充材料https://julia.lee-phillips.org。)

图 7-6:振动鼓膜动画中的一帧
在使用roll()函数创建视频后,我们使用write()将结果保存到文件。文件扩展名指定视频格式;另外两种选择是 GIF 和 WEBM。
write() 函数是 Julia 用于写入数据到文件的标准函数。Reel 包定义了它的一个版本,当第二个参数是一个 Reel 视频时,它会将视频转换为请求的格式。第八章 解释了这是如何实现的,以及如何通过参数类型来激活自定义版本的函数。
Pluto 中的交互式可视化
Pluto 笔记本(参见 第 17 页 的“Pluto:一个更好的笔记本”)通过其 @bind 宏提供了一种创建交互式动画的简便方法。这个宏将任何标准 HTML 输入控件的输出绑定到 Julia 变量。当我们执行包含 @bind 宏调用的单元格时,Pluto 会在该单元格的输出区域创建控件。当用户操作该控件时,Pluto 会即时更新绑定到该控件的变量值。由于笔记本的反应式特性,任何依赖该值的单元格都会自动重新执行。如果这些单元格生成图表或其他可视化内容,图形会根据用户的交互进行更新。HTML 中的输入控件包括滑块、数值或文本输入框、文件选择器、颜色选择器、复选框、选择菜单等。
我们不需要实际编写任何 HTML(或了解它的知识),感谢 PlutoUI 包,它为 HTML 输入控件提供了一个方便的 Julia 接口。然而,对于 HTML 专家,仍然支持直接使用网页的标记语言。甚至可以使用 JavaScript 创建自定义控件。有关更多信息和 PlutoUI 文档,请参见 第 211 页的“进一步阅读”部分。
让我们看几个例子,展示如何使用 @bind 宏与 PlutoUI 控件。以下示例使用了浏览器的日期选择小部件:
@bind some_date DateField()
这个命令将用户选择的日期分配给 some_date,它将具有 Julia 的 Dates.DateTime 数据类型。
这个示例使用了 HTML 的复选框:
@bind a_setting CheckBox()
这里,a_setting 变成了一个 Boolean:如果用户点击了框框,则为true,否则为false。
以下示例使用了 HTML 文本框:
@bind label TextField()
这个调用会将用户在文本框中输入的内容作为 String 类型分配给变量 label。
还有许多其他的函数。所有这些函数都接受一个 default 关键字参数,有些还接受其他参数。例如,TextField() 接受一个可选的元组参数;如果提供该参数,它会根据元组的第一个和第二个元素创建一个多行 textarea,指定列数和行数。
作为交互式可视化的一个示例,让我们回到 列表 7-5 中的振动鼓面问题。目标是创建一个包含鼓面图形的笔记本,用户可以通过操作滑块在时间轴上进行移动。
我们将对列表 7-5 中的代码做一些小的修改。首先,我们需要一个额外的导入,using PlutoUI,以便能够使用 HTML 小部件,而不需要导入 Reel。
vibe() 函数无需修改,但我们将改变绘图函数,改为绘制曲面图而不是热图,并在标题中显示时间。Plots 中的 surface() 函数不支持极坐标,因此我们需要使用 x 和 y 并手动进行转换:
function drum_frame(t)
surface(x, y, (x, y) ->
vibe(sqrt(x² + y²); t=t); colorbar=false, clim=(-1, 1),
c=:curl, zrange=(-1.2, 1.2), title="t = $t")
end
我们希望交互性能够响应迅速,因此我们将通过使用更粗的网格来牺牲一些绘图的流畅度:
x = -1:0.05:1
y = -1:0.05:1
我们并不是在制作电影,因此不需要列表 7-5 中的最后两行。为了创建笔记本,我们在 REPL 中导入 Pluto 并执行 Pluto.run(),这将打开默认浏览器中的新标签页,显示 Pluto 起始页面。点击链接创建一个新笔记本后,我们可以将所有这些变量和函数定义输入到单元格中。最后一个单元格将包含以下代码:
@bind t Slider(0:0.01:1.1382)
作为参数提供的范围将成为滑块的起始值、步长和结束值。结束值是一个完整振动周期的时间。
书中的图片无法传达使用笔记本的体验。要体验这一点,最好的方法就是亲自尝试。不过,图 7-7 展示了绘图和滑块的屏幕截图。

图 7-7:在 Pluto 笔记本中的振动鼓面
交互式 Pluto 笔记本是创建说明文档和教育材料的强大工具。由于它们以文本文件的形式存储,因此至少可以轻松与其他 Julia 用户分享。
结论
本章中我们探索的包使得创建各种各样的图表和动画变得简单。这些工具对 Julia 用户社区大有裨益,因为很多科学家和工程师(他们是 Julia 受众的一个重要群体)也是教师、会议演讲者和在线教育材料的创作者。所有这些活动都因手头有构建说明性可视化的工具而得到提升。能够在 Julia 中创建复杂的可视化非常重要:因为这些可视化往往需要我们已经在 Julia 程序中执行的计算。实际上,它们通常是这些计算结果传播的一部分。像 Luxor 和 Javis 这样的包让我们不必依赖外部程序,也避免了陷入另一个“二语言问题”。
在下一章中,我们将回到语言本身,学习类型系统。这是掌握 Julia 所需的最后一块拼图;掌握它将解锁新的编程能力。
进一步阅读
-
视频和彩图可在在线补充材料中查看,网址是 https://julia.lee-phillips.org。
-
Luxor文档可以在 http://juliagraphics.github.io/Luxor.jl/stable/ 查阅。 -
Javis文档可以在 https://juliaanimators.github.io/Javis.jl/stable/ 查阅。 -
关于
GraphRecipes属性,包括完整的布局算法列表,请访问 https://docs.juliaplots.org/stable/generated/graph_attributes/。 -
提供不同图形布局算法的
NetworkLayout包文档,可以看到各种布局策略的有趣动画,文档地址为 https://juliagraphs.org/NetworkLayout.jl/stable/。 -
关于如何使用
@gif和@animate宏的详细信息,请访问 https://docs.juliaplots.org/latest/animations/。 -
有关如何使用 JavaScript 为 Pluto 创建自定义界面组件的教程,请访问 https://cotangent.dev/how-to-make-custom-pluto-ui-components/。
-
Pluto 的创始人在这个视频中讲解了如何使用 JavaScript 创建自定义交互:https://www.youtube.com/watch?v=SAC_RCjyRRs。
-
有关
PlutoUI中可用函数的文档,请访问 https://docs.juliahub.com/PlutoUI/。
第九章:类型系统**
面向对象编程是一个极其糟糕的想法,只有在加利福尼亚才能诞生。
—埃兹格·迪克斯特拉

到目前为止,我们已经使用并创建了很多函数。我们可以将函数看作是 Julia 语言中的动词。正如在自然语言中,动词作用于名词一样,Julia 中的名词包括数字、集合、字符串以及其他类型的实例。
到目前为止,我们在学习过程中遇到了许多数据类型:不同种类的数字、字符串、字符,以及像数组和映射这样的集合。尽管我们的重点并不是类型,但在谈论 Julia 编程时,无法不提及它们。Julia 的特别之处在于,它允许我们在不指定变量类型的情况下创建非常快速的代码,这与其他快速语言(如 Fortran(其中类型规格可以是隐式的)和 C)不同。然而,要有效地编写 Julia 程序,还是需要了解它的类型系统。这样做的主要原因是,Julia 程序是围绕函数和方法组织的,依赖于其分发系统,而该系统依赖于参数类型。其次,了解类型有助于我们编写更高效的程序。本章将涵盖这两个方面的问题。
实践中的类型
与其深入探讨类型系统的抽象理论,不如从实际的角度来看待类型。
要找出任何值的类型,Julia 提供了 typeof() 函数:
julia> typeof(17)
Int64
julia> typeof(17.0)
Float64
julia> typeof(17//1)
➊ Rational{Int64}
julia> typeof("7")
String
julia> typeof('7')
Char
我们已经讨论过字符串和字符之间的区别,以及单引号和双引号的相关区别;然而,理解各种数值类型同样很重要。例如,17、17//1 和 17.0 具有相同的值,但它们是不同类型的对象,它们的行为可能不同。它们类型的差异反映了这一现实。
在报告有理数类型时使用的花括号 ➊ 表明这是一个 参数化类型,我们将在“参数化类型”一节中(第 248 页)回到这个话题。现在,理解这是由 Int64 组成的 Rational 类型就足够了。
浮动小数点字面量的类型是 Float64,这意味着它是一个浮动小数,或者说是带有小数点的数字,并且它被存储在一个 64 位的内存段中。这 64 位被分配如下:1 位表示符号,11 位表示指数,52 位表示“分数”。Float64 的最大绝对值大约是 10³⁰⁰,并且它具有 17 位有效数字,或者说是小数点后 16 位的精度。(这与观察结果一致,即表示一个十进制数字需要三个二进制数字。)我们可以通过 Printf 包提供的 @printf 宏来看到这一点:
julia> using Printf
julia> @printf "%.16f" 1/3
0.3333333333333333
julia> @printf "%.17f" 1/3
0.33333333333333331
julia> @printf "%.18f" 1/3
0.333333333333333315
如果我们请求超过 16 位的数字,将会显示错误的数字。
如果我们使用精度较低的浮点类型,将会看到更多不正确的数字:
julia> @printf "%.16f" Float32(1/3)
0.3333333432674408
julia> @printf "%.16f" Float16(1/3)
0.3332519531250000
这里我们将类型名称作为函数来将它们的参数转换为命名类型。如果不进行类型转换,像1/3这样的表达式在大多数系统上默认是Float64类型。
在典型系统上,默认的整数类型Int64的范围是−2⁶³到 2⁶³ − 1,其中一个位用于符号位。
Julia 提供了内建函数,用于查找每种数值类型所能表示的最大值和最小值:
julia> typemax(Int32)
2147483647
julia> typemin(Int32)
-2147483648
julia> typemax(Int16)
32767
julia> typemin(Int16)
-32768
但如果我们询问浮点数,typemax()和typemin()并不太有用:
julia> typemax(Float64)
Inf
julia> typemax(Float16)
Inf16
julia> Inf64 === Inf
true
显然,无穷大是一个浮点数,而 Julia 为每种浮点数大小提供了无穷大。这是自洽的:因为没有什么比无穷大更大,所以如果Inf16是Float16,它必须是最大可能的Float16。
Julia 还有另一个函数可以在这里提供帮助:
julia> floatmax(Float64)
1.7976931348623157e308
julia> floatmin(Float64)
2.2250738585072014e-308
julia> floatmax(Float16)
Float16(6.55e4)
julia> floatmin(Float16)
Float16(6.104e-5)
函数floatmax()和floatmin()分别返回所请求类型的最大有限浮点数和最小正浮点数。
通常,我们应该在程序中使用这些原生类型进行算术运算,因为它们是最有效的选择。如果需要,而且可能的话,我们可以使用更小的数字来节省空间——例如Int16——并且我们可以使用Int128来获取更大的整数。然而,如果原生类型不能满足我们的需求,通常是因为我们需要大量的精度——换句话说,很多位数字——来进行计算。这是下一节的主题。
要检查某个值是否具有特定类型,可以使用isa()函数。我们可以将其用作普通函数或在中缀位置使用:
julia> isa(17, Int64)
true
julia> 17 isa Number
true
julia> 17 isa String
false
这个函数返回一个布尔值。前两个调用返回true,因为17既是Int64也是Number。前者意味着后者(参见第 222 页的“类型层次结构”)。
“大”与无理类型
Julia 使得使用精度根据需要增长的类型进行任意精度算术运算变得简单:这些类型的数字位数可以无限增加。使用这些类型的算术运算比使用原生类型的正常计算要慢,但对于某些任务,它是唯一的选择。
任意精度
作为需要任意精度类型的一个简单示例,假设我们想要绘制阶乘函数。这就是通常用感叹号表示的函数:

对应的 Julia 函数是factorial(n)。该函数增长得非常快:
julia> factorial(20)
2432902008176640000
julia> factorial(21)
ERROR: OverflowError: 21 is too large to look up in the table;
consider using `factorial(big(21))` instead
这显示了 20 是可以适应Int64的最大阶乘。如果我们使用Int128,可以达到 33!,但如果我们想要更大呢?
错误信息提供了一个线索。big()函数将其参数转换为具有无限大小和精度的对应类型。对于整数,这叫做BigInt,而对于浮点数则是BigFloat。
让我们使用BigInt来绘制阶乘函数:
julia> plot(factorial.(big.(1:50)), yscale=:log10,
legend=:topleft, label="Factorial")
这里我们绘制的是最大到 50!,这远远超出了本地整数能表示的范围。图 8-1 展示了结果。

图 8-1:使用 BigInt 计算的阶乘函数
我们将在“阶乘”一节中重新讨论阶乘,参考 第 312 页,其中它作为排列 n 个对象的方式数量出现。
BigFloat 类型也提供无限的大小。它的默认精度是 256,约为 80 位有效数字。我们可以使用 setprecision() 函数将 BigFloat 的精度设置为任何需要的值:
julia> big(1.0)/3
0.333333333333333333333333333333333333333333333333
3333333333333333333333333333348
julia> setprecision(512);
julia> big(1.0)/3
0.3333333333333333333333333333333333333333333333333
3333333333333333333333333333333333333333333333333
3333333333333333333333333333333333333333333333333
333333346
要检索精度,我们可以使用 precision() 函数,传入我们要查询的类型:
julia> precision(big(1.0))
512
julia> precision(float(1.0))
53
BigInt 类型的数字所使用的位数会根据需要增加,因此它没有像浮点数那样固定的精度概念。
无理数
Julia 的一个独特属性是存在 无理数类型:
julia> π
π = 3.1415926535897...
julia> typeof(π)
Irrational{:π}
希腊字母 π 所表示的数字打印时附带三个点,提示我们这只是故事的一部分。虽然它看起来像一个浮点数,但它的类型并非 Float64,而是一个新的类型:Irrational。这是因为在 Julia 中,π 表示的不是浮点数,而是圆周率与直径的比值的精确值。这三个点提醒我们,展示的数字仅仅是这个无限、不重复序列的前几位。
Julia 会根据需要计算并展示更多数字:
julia> big(π)
3.1415926535897932384626433832795028841971693993751
05820974944592307816406286198
该数字不会以尾部点的形式显示,因为它不再是精确值的表示,而是对精确值的近似。
还有其他几个无理数被内建到 Julia 中;其中最重要的无理数是 e,自然对数的底数。要插入该字符,可以输入 Unicode 码点 212F(Script Small E),在 REPL 中输入 \euler 并按 TAB 键:
julia> e
e = 2.7182818284590...
julia> big(e)
➊ 2.7182818284590452353602874713526624977572470936999
59574966967627724076630353555
julia> log(e)
➋ 1
julia> log(2.71828182845904)
0.9999999999999981
julia> log(2.718281828459045)
1.0
就像π一样,Julia 用三个点显示 e 的值,表示它正在展示一个精确值的部分数字。
我们可以通过将 e 转换为 BigFloat 来查看它的任何所需精度的近似值 ➊。根据定义,e 的自然对数的值恰好是整数 1 ➋,但如果我们取 e 的近似值的对数,我们将得到一个近似值,或者说是浮点数结果。
类型提升
在对不同数值类型的混合进行算术运算时,Julia 会根据需要默默地 提升 类型:
julia> 1 + 1
2
julia> 1 + 1.0
2.0
两个整数相加时没有理由离开整数领域,因此结果也是一个 Int64。但是,如果其中一个数字是 Float64,另一个数字会被提升到该类型,结果的类型也将是该类型。
Julia 不会将非数值类型提升为数字:
julia> 1 + "1"
ERROR: MethodError: no method matching +(::Int64, ::String)
它对类型和提升的处理方式因此类似于 Python,而不同于 JavaScript。
promote()函数可以接受任意数量的数字参数,并返回一个元组(可能)将其中的一些参数提升为必要的共同类型,以便它们可以在后续的计算中使用,而无需再次提升。它执行的提升操作与进行算术运算时自动执行的提升操作相同:
julia> promote(big(2.0), 3.5, 3.4)
(2.0, 3.5, 3.3999999999999999111821580299874767661
09466552734375)
julia> typeof(promote(big(2.0), 3.5, 3.4))
Tuple{BigFloat, BigFloat, BigFloat}
julia> typeof(promote(2, 3.5, 3.4))
Tuple{Float64, Float64, Float64}
第一行中的提升展示了某些数字(如 2.0、3.5)具有精确的二进制表示,而其他一些数字(如 3.4)则没有。接下来的两个命令示例展示了promote()如何将其参数转换为共同类型。
集合
Julia 在 REPL 中打印集合的类型时,比打印简单的数字类型更频繁,因此我们已经看到更多前者:
julia> [1 2]
1×2 Matrix{Int64}:
1 2
julia> [1.0; 2]
2-element Vector{Float64}:
1.0
2.0
julia> [[1 2];;; [3 4]]
➊ 1×2×2 Array{Int64, 3}:
[:, :, 1] =
1 2
[:, :, 2] =
3 4
Julia 打印集合的类型(Matrix、Vector或Array)及其维度。Vector是一维的,Matrix是二维的。对于更通用的Array类型,Julia 会打印一个整数,显示维度数:这里是一个三维数组➊。
它还会显示集合元素的类型,并在花括号内显示。我们可以使用eltype()函数单独提取这些信息:
julia> eltype([1 2])
Int64
julia> eltype([1.0 2])
Float64
julia> eltype([1.0 "2"])
➊ Any
julia> [1.0 "2"]
1×2 Matrix{Any}:
1.0 "2"
在第一个示例中,结果Int64是数组中两个元素的类型。第二个示例展示了 Julia 如何在可能的情况下提升数字类型,以创建同质数组,这样可以更高效地进行计算。然而,当遇到无法进行提升的类型时➊,元素类型会变成Any:这个类型字面意思是任何类型。
这些结果遵循promote()函数的行为:
julia> promote(1.0, 2)
(1.0, 2.0)
julia> promote(1.0, "2")
ERROR: promotion of types Float64 and String failed to change any arguments
如果元素可以提升为一个共同类型,则该类型会被用作集合的eltype;否则,使用Any类型。
集合类型Vector、Matrix和Array有一些共同的行为:例如,它们都可以被索引。然而,并非所有集合类型都有这种特性。Set类型没有顺序,因此无法进行索引。这三个集合类型之所以共享某些行为,是因为它们是更通用类型的特殊情况,这一概念我们将在下一节中探讨。
类型层次结构
Julia 中的所有类型都是子类型,它们的超类型则是某个类型的上级。唯一没有严格超类型的类型是Any类型,它是自己的超类型。超类型和子类型的概念与行为的继承相关,类型层次结构的配置在应用于特定情况时通常是直观的。例如,我们期望任何种类的数字都会支持某种加法运算。尽管不同种类的数字的加法含义可能有所不同——例如复数加法是实数加法的一种推广——但当我们遇到Number类型的子类型时,我们可以确信,至少+运算符是为它定义的。
如清单 8-1 所示,supertype() 函数在提供一个类型时,返回它的超类型。
julia> typeof(17)
Int64
julia> supertype(Int64)
Signed
julia> supertype(Signed)
Integer
julia> supertype(Integer)
Real
julia> supertype(Real)
Number
julia> supertype(Number)
Any
julia> supertype(Any)
Any
清单 8-1:向上遍历类型层次结构
typeof() 函数返回字面值或变量的类型。我们实际进行计算的类型,如 Float64 和 Int64,被称为具体类型。具体类型是类型树的叶子节点;它们不能互相作为子类型。
清单 8-1 展示了一系列对 supertype() 的调用,用于找出默认整数类型 Int64 在类型层次结构中的位置。像 Int64 这样的具体类型所继承的所有类型都是抽象类型。抽象类型,如 Number,的作用仅仅是创建类型树中的节点,以便定义方法。这些抽象类型及其构成的类型层次结构,并不是为了使事情更加复杂,而是为了让 Julia 程序员的工作更加轻松。由于类型树的存在,我们可以在理想的抽象层次上定义函数和方法,正如我们将在《函数和方法:多重分发》一章的第 229 页中看到的那样。
清单 8-1 的最后两行显示,Number 位于数值类型层次结构的顶部,而它的超类型 Any 是整个层次结构的根,正如最后一行所示,Any 还是它自己的超类型。
通过多次调用 supertype(),我们可以探索更多的类型树。清单 8-2 展示了对清单 7-3 程序的修改,用于可视化它的一部分。
using Plots
using Graphs
using GraphRecipes
sometypes = [Any, Complex, Float64, Int64, Number, Signed,
Irrational, AbstractFloat, Real,
AbstractIrrational, Integer, String, Char,
AbstractString, AbstractChar, Rational,
Int32, Vector, DenseVector, AbstractVector,
Array, DenseArray, AbstractArray]
type_tree = SimpleDiGraph(length(sometypes))
for t in sometypes[2:end]
➊ add_edge!(type_tree, indexin([supertype(t)], sometypes)[1],
indexin([t], sometypes)[1])
end
graphplot(type_tree; names=[string(t) for t in sometypes], nodeshape=:rect,
fontsize=4, nodesize=0.17, nodecolor=:white, method=:buchheim)
清单 8-2:可视化部分类型层次结构
我们已经在 sometypes 向量中收集了一些主要的数值类型。这些是 Julia 及其标准库提供的类型的子集,更多类型定义在各种包中。
清单 8-2 使用 supertype() 函数来创建树图的边 ➊,将每个类型与其超类型连接。图 8-2 展示了结果。

图 8-2:几种类型之间的关系
图 8-2 清晰地显示了 Any 是树的根,并提醒我们,例如,字符和字符串是不同的类型。但它也模糊了一些关系,比如某些类型是其他类型的别名。这是我们将在本章后面(参见“类型别名”章节,位于第 247 页)进一步探讨的内容。
另外两个方便探索类型层次结构的函数是 subtypes(),它返回作为参数传入的类型的所有直接子类型的向量,以及 supertypes():
julia> supertypes(Irrational)
(Irrational, AbstractIrrational, Real, Number, Any)
这个例子展示了 supertypes() 返回一个元组,包含传入的类型及其所有的超类型。
类型断言和声明
现在我们知道如何发现任何变量的类型以及任何类型的超类型。有时,我们还需要告诉 Julia 一个变量是某种特定类型(类型声明),或者一个表达式的值应该具有指定的类型(类型断言)。:: 操作符根据其所在的位置执行其中的任一操作。
类型断言
有时在我们的程序中,我们会遇到一个需要确保某个特定表达式的值具有某种类型的情况。如果不是,我们希望生成一个错误,这个错误可以被处理或者允许程序终止。
Julia 中最简单的表达式是字面值。让我们以 17 作为第一个示例:
julia> 17::Number
17
julia> 17::Integer
17
julia> 17::Int64
17
julia> 17::String
ERROR: TypeError: in typeassert, expected String, got a value of type Int64
第一行是对 17 具有 Number 类型的断言,显然它是正确的。带有类型断言的表达式如果断言为真,则返回该表达式的值,因此这里 Julia 只是返回 17。接下来的两行也是正确的断言。如果类型断言指定了表达式类型的任何超类型,那么这个断言就为真。
最后的类型断言返回错误,因为 17 既不是 String 类型,也不是 String 类型的子类型。
这里有一个示例,展示了我们如何在程序中使用类型断言:
function greetings()
println("Who are you?")
yourname = readline();
greeting = ("Hello, " * yourname * ".")
➊ return greeting::String
end
程序向用户提问,使用 readline() 接收回复,并将其与其他两个字符串连接以构造问候语,然后返回结果。我们使用了类型断言 ➊ 来确保函数返回的类型符合预期。
类型声明
我们也使用 :: 操作符进行类型声明。它的含义取决于它在语句中的位置。
我们可以通过两种方式声明一个变量具有特定类型。第一种方式是通过声明来补充常规的赋值语句,如下所示:
julia> a::Int16 = 17
17
julia> typeof(a)
Int16
这里赋值和类型声明是同时发生的。
注意
Julia v1.8 是第一个允许全局变量类型声明的版本;这使得在 REPL 中工作更加方便。在早期版本中,所有类型声明必须出现在局部作用域中。
一旦我们声明了变量的类型,就已经确定了:
julia> a = "Paris"
ERROR: MethodError: Cannot `convert` an object
of type String to an object of type Int16
julia> a::Int32 = 17
ERROR: cannot set type for global a. It already
has a value or is already set to a different type.
正如这个示例所示,尝试将错误类型的值赋给已声明的变量,或显式地改变其类型,将导致错误。
赋值给 a 的任何值必须可以转换为 a 的类型 Int16:
julia> a = 32767
32767
julia> a = 32768
ERROR: InexactError: trunc(Int16, 32768)
第二次赋值失败是因为 32,768 大于 Int16 所能容纳的最大值,即 2¹⁵−1 = 32,767,这个值由 typemax(Int16) 返回。
Listing 8-3 显示了声明类型的另一种方式:作为 local 或 global 定义的一部分。
julia> global gf::Float64
julia> gf = 17
17
julia> gf
17.0 ➊
julia> typeof(gf)
Float64
julia> gf = "London"
ERROR: MethodError: Cannot `convert` an object
of type String to an object of type Float64 ➋
julia> function weather_report(raining)
if !(raining isa Bool) ➌
println("Please tell us if it's raining with \"true\" or \"false\".")
return
else
if raining
n = ""
else
n = "not "
end
local gf::String ➍
gf = "London"
return("It is $(n)raining in $gf today.")
end
end
weather_report (generic function with 1 method)
Listing 8-3:类型声明
我们定义 gf 为全局变量,并且类型为 Float64。Julia 似乎允许我们将一个字面量整数赋值给它,但它已经在赋值过程中将该值转换为 Float64 ➊。因为没有办法将字面量字符串转换为 Float64,我们尝试将一个字符串赋值给变量时失败了 ➋。
我们可以在函数内使用相同名称的局部变量,并声明为局部变量➍;这个局部变量与全局变量 gf 没有任何关系。函数 weather_report() 期望从用户那里得到一个 Bool 类型的值(true 或 false),并用它来构建一个关于天气的句子。它使用 isa 操作符来检查是否收到了正确的类型 ➌。
以下这个简短的程序演示了类型声明的一个重要行为:
function type_dec_demo()
a = 17
println("a = $a and has the type $(typeof(a)).")
local a::Int16
end
运行这个函数会产生以下输出:
a = 17 and has the type Int16.
打印 a 类型的那一行出现在类型声明的之前;那么为什么 a 已经是一个 Int16 类型了呢?毕竟,在 REPL 中会发生这样的情况:
julia> a = 17
17
julia> typeof(a)
Int64
这个输出是我们预期的,因为 Int64 类型是 64 位机器上原生的整数类型,而 64 位架构是最常见的架构。解释是,作用域块中的类型声明(在本例中是函数定义)强制整个块内类型不可更改。声明可以在块的任何位置出现。
如果没有声明,一个变量可以在块内通过算术运算改变类型:
function changing_type_demo()
a = 17
println("a = $a and has the type $(typeof(a)).")
a = a + 1.0
println("a = $a and has the type $(typeof(a)).")
end
这个函数会产生如下输出:
a = 17 and has the type Int64.
a = 18.0 and has the type Float64.
允许这种情况发生可能会影响性能,关于这一点我们将在“消除类型不稳定性”一节中讨论,详见 page 242。
:: 操作符还可以声明函数返回值的类型。例如,我们可以像这样修改 Listing 8-3 中 weather_report() 定义的第一行:
function weather_report(raining)::String
这条语句声明函数必须返回一个 String 类型的值。
这种声明的目的与变量的类型声明相同:它们从不强制要求,通常也不需要,但在某些情况下,它们可以为编译器提供额外的信息,从而帮助提高性能。我们将在“性能优化”一节中看到一些例子,详见 page 242。当我们使用函数构造表达式时,知道每个函数调用返回的类型是很有帮助的;在函数定义中使用类型声明有助于编写正确且高效的程序。
函数和方法:多重分派
当我们在 REPL 中定义一个函数时,如果没有错误,我们会看到类似我们在 Listing 8-3 中看到的消息:
weather_report (generic function with 1 method)
一个通用函数由它的名称定义,在本例中是 weather_report()。每个通用函数可以有任意数量的方法与之关联,这些方法通过它们的方法签名来区分。签名是定义方法时,放在括号内的部分。到目前为止,这些签名包含了位置参数和关键字参数的名称及其默认值(如果有的话)。如果我们使用不同的参数集合重新定义 weather_report(),我们就创建了一个第二个方法。
::运算符的另一个用法是在方法签名中,用来指定方法参数应具有的类型。如果两个方法的定义具有相同的参数,但其中任何一个类型规范不同,那么即便签名其他部分相同,它们也定义了不同的方法。
当编译器看到函数调用时,它会调用最匹配传入参数的最具体的定义。这里我们看到我们在《类型层次结构》一章中学到的抽象类型的真正用途,在第 222 页中提到过。其他条件相同的情况下,为某个参数定义了特定类型的方法,比为该参数的超类型定义的方法更具体。
为了确定调用哪个方法,编译器会检查所有的参数。这个方法选择过程,或称为分派,因此被称为多重分派。它是编程语言中一个不常见但并非独特的特性,也是 Julia 强大和成功的一个主要原因。
相比之下,面向对象语言仅根据方法的第一个参数进行分派,这个参数通常隐式地作为方法所属的对象提供,并在程序中通过像this或self这样的变量表示。
函数式语言根本没有真正的分派机制。所有的特殊化都必须以在一个大函数中的替代代码路径的形式出现。
Julia 的多重分派范式意味着它既不是面向对象语言,也不是函数式语言,而是比它们更通用、更灵活的一种语言。
创建多个方法
我们对weather_report()的定义包括了一个检查,确保传入的参数是正确类型,并在参数不符时采取措施,这一检查通过if语句块实现。我们可以通过重新启动 REPL 并将weather_report()的定义替换为两个具有不同签名的其他方法来消除这个检查。
julia> function weather_report(raining::Bool)
if raining
n = ""
else
n = "not "
end
gf = "London"
println("It is $(n)raining in $gf today.")
end
weather_report (generic function with 1 method)
julia> function weather_report(raining)
println("Please tell us if it's raining with \"true\" or \"false\".")
return
end
weather_report (generic function with 2 methods)
在第一次定义之后,REPL 会回复与之前相同的消息,但在第二次定义后,我们会被告知weather_report()现在有两个方法。这两个方法的唯一区别在于,第一个方法的签名为单个参数raining指定了类型,而第二个没有。没有类型指定意味着编译器会接受任何类型的参数,或者换句话说,会接受Any类型。规则是编译器会始终选择最具体的方法来匹配提供的参数。如果我们传递一个Bool值(true或false),第一个方法会被选择,因为它比第二个方法更具体,因为Bool是Any的子类型。任何其他类型都会调用第二个方法,并要求传递true或false。
让我们验证这两个方法是否按预期工作:
julia> weather_report(true)
It is raining in London today.
julia> weather_report(17)
"Please tell us if it's raining with "true" or "false"."
这种创建一组方法的技术,而不是将大量类型检查代码塞进一个更大的函数,是 Julia 的更地道做法,有助于更好地组织项目,便于维护和扩展。
假设我们希望通过让程序能够评论用户提供的城市天气来扩展功能。多重分派的强大功能使我们能够简单地添加另一个方法,而无需更改我们已经编写的任何内容:
julia> function weather_report(raining::Bool, city::String)
if raining
n = ""
else
n = "not "
end
println("It is $(n)raining in $city today.")
end
weather_report (generic function with 3 methods)
julia> weather_report(true, "Tegucigalpa")
It is raining in Tegucigalpa today.
如果我们尝试使用与任何现有方法的签名不匹配的参数调用weather_report(),我们会收到一条错误消息:
julia> weather_report(true, 17)
ERROR: MethodError: no method matching weather_report(::Bool, ::Int64)
Closest candidates are:
weather_report(::Bool) at REPL[1]:1
weather_report(::Bool, ::String) at REPL[7]:1
weather_report(::Any) at REPL[4]:1
错误消息告诉我们weather_report()的所有方法都没有正确的签名,并列出了一些可用的方法,展示了可以用于其参数的类型。如果我们尝试添加两种不能相加的东西,例如1 + "1",我们会收到类似的错误,但错误消息中提到的三种或更多的方法只是+运算符定义的 200 多种方法中的一小部分。要查看为任何函数定义的所有方法的列表,可以调用methods():
julia> methods(weather_report)
# 3 methods for generic function "weather_report":
[1] weather_report(raining::Bool) in Main at REPL[1]:1
[2] weather_report(raining::Bool, city::String) in Main at REPL[7]:1
[3] weather_report(raining) in Main at REPL[4]:1
在这里,我们看到我们为weather_report()定义的方法列表及其方法签名。
通过新方法扩展内置函数
假设我们有一个程序,它从文件或用户输入中读取数字,并将它们加到现有的数字上。读取的值将是字符串,程序必须将它们转换为数字,然后才能执行加法操作。Listing 8-4 展示了这种情况,我们可能决定通过向+添加一个方法来自动完成转换,从而省略显式转换步骤。
import Base.+
function +(a::Number, b::String)
if Meta.parse(b) isa Number
return a + Meta.parse(b)
else
return a
end
end
Listing 8-4:通过新方法扩展加法
我们不允许扩展某些基本函数,例如+,除非我们首先显式地导入它们,这在第一行中完成。定义此方法后,当尝试将字符串与数字相加时,它将被分派,这通常会导致MethodError。如果String参数可以解析为Number,则该数字会与第一个参数相加,方法返回结果。如果不能,方法将简单地返回第一个参数。这个方法定义是抽象类型在签名中使用的一个示例。它适用于第一个参数的任何数字类型,而无需为Number的每个子类型编写定义。
让我们检查一下这个方法是否按预期工作:
julia> 1 + "16"
17
julia> 1 + "16.0"
17.0
julia> 1 + "sixteen"
1
julia> 1//2 + "3"
7//2
julia> π + "1"
4.141592653589793
我们通过扩展其中一个基本运算符的行为,向语言中添加了新特性。多重分派使我们能够做到这一点,而无需更改任何现有的方法。
不要做海盗
我们绝不会将像在清单 8-4 中定义的方法放入公共包中。这是因为我们对新方法定义中的“+”函数和数据类型不负责。导入我们包的某人可能会遇到冲突或意外行为。语言扩展的强大能力伴随着巨大的责任:违反这一期望被称为类型盗用。如果我们想公开我们的这个方法,我们有三个选择:给它起个别的名字,而不是“+”;让它作用于我们自己的类似字符串的数据类型;或者在 GitHub 上提交一个 Pull Request,要求将其包含到Base中。最后一个选项将为“+”增添一个新方法,除了当前的 207 个方法之外,所有 Julia 用户都将自动受益于我们的创作。
专门化方法不仅对创建新行为有用,有时它们是为了效率而创建的。例如,矩阵乘法或矩阵求逆等操作会产生数学上定义明确的结果(当结果存在时);然而,对于具有特定属性的矩阵,计算该结果的专门算法可能比通用算法更高效。SparseArrays包(参见第 196 页中的《邻接矩阵》)提供了用于这些矩阵操作的方法,当其中一个或两个参数是稀疏数组时,这些方法会更高效。多重派发会自动选择理想的方法,当矩阵操作符传递稀疏数组时,无需用户干预。
尽管我们可以创建新的方法来做任何想做的事情,但合理的做法是让这些方法的行为在概念上与它们所属的通用函数的目的或意义相关。+的 200 多个方法中的每一个都与加法的概念有关,正如我们在这里定义的新方法一样。多重派发应该被视为一种代码组织的范式,而不是混乱的许可证。语言本身并不强制执行这一原则,这取决于程序员的自律。
理解联合类型和<:运算符
有时,在构造方法时,单一的抽象类型对我们的用途来说不够通用。在这种情况下,我们可以使用Union{}声明一个参数可以是多个类型中的任何一个。这个操作符接受一个类型列表并构造一个新类型,包含所有这些类型。任何属于列表中类型的值都属于这个新联合类型。而且,任何是列表中某个类型的子类型的类型,也是该联合类型的子类型。
<: 中缀运算符是一个类型测试,如果其左侧的类型是右侧类型的子类型,则返回true。这个例子演示了联合类型的创建和<:运算符的使用:
julia> 17 isa Union{Number, String}
true
julia> Real <: Union{Number, String}
true
因为17是一个Number,所以第一个表达式返回true。
假设我们要编写一个作用于实数(而非整数)的函数:具有小数点的数字。我们可能会考虑在函数签名中使用类型声明,如n::AbstractFloat,这将包含所有具体的浮动类型,如Float64和Float32。然而,查看图 8-2 提醒我们,这个声明会排除任何作为Irrational提供的数字。如果用户将字面量π作为参数传入,结果将是一个MethodError。我们可以使用联合类型来处理这种情况:n::Union{AbstractFloat, Irrational}。根据函数的目的,我们还可以考虑将Rational添加到联合类型中。
用户自定义类型
就像我们可以为自己的目的创建动词(函数和方法)一样,我们也可以创建自己的名词(数据类型)。在 Julia 中,用户自定义类型的目的是与类型的主要目的相同:围绕方法组织项目,这些方法可以根据其参数的类型进行分发。
创建抽象类型
有时,我们不仅仅是往类型树中添加一个叶子,而是希望添加一个分支,然后创建作为叶子附加到该分支的类型。正如我们之前提到的,这些分支是抽象类型,我们可以通过abstract type声明来创建自己的抽象类型。作为示例,下面是如何创建一个从Number类型派生的新抽象类型:
julia> abstract type MyNumber <: Number end
执行此语句后,新的MyNumber类型将成为现有抽象类型Number的子类型(回想一下,具体类型不能被子类化)。
如果新的类型是全新的,并且不会与现有类型共享方法,那么它无需继承任何现有类型。然而,如果它是新的数字、字符串或其他现有类型的一种,最好将其适当地放入类型层次结构中。这样,现有方法(例如作用于Number类型的方法)将能够处理新的数字子类型。
创建组合类型
创建新抽象类型的目的是能够将新类型定义为其子类型,这些子类型实际承载值,并在计算中被操作。这些新类型可以直接从Any类型派生,也可以从我们创建的抽象类型派生。
在几乎所有情况下,这些新类型将是组合类型,在struct块中定义:
struct EarthLocation
latitude::Float64
longitude::Float64
timezone::String
end
组合类型通常有多个字段(但也可以只有一个)。新的EarthLocation类型旨在通过纬度和经度表示地球上的一个位置,并包括一个表示该位置时区的字段。字段上的类型声明是可选的;没有声明的字段将是Any类型。
以下代码创建了一个具有此类型的变量:
julia> NYC = EarthLocation(40.7128, -74.006, "ET")
julia> typeof(NYC)
EarthLocation
这个由 Julia 创建的函数,名称与类型相同,称为构造函数。如第二次交互所示,它创建了EarthLocation类型的值。
我们可以使用属性符号访问复合类型的字段值:
julia> NYC.latitude
40.7128
julia> NYC.timezone
"ET"
字段是按照它们在类型定义中出现的顺序赋值的。
由于构造函数是一个函数,我们可以为其定义多个方法。这里是一个处理调用者提供坐标但没有时区的情况的方法:
julia> EarthLocation(a, b) = EarthLocation(a, b, "Unknown")
EarthLocation
julia> someplace = EarthLocation(59.45607, -135.316681)
EarthLocation(59.45607, -135.316681, "Unknown")
julia> someplace.timezone
"Unknown"
当调用者只使用两个参数时,分派的方法调用原始方法,并将 "Unknown" 作为时区传入。这个方法本可以做任何事情,但如果将其命名为 EarthLocation 类型的构造函数,并且让它返回除该类型实例之外的东西,就会造成混淆。正如在“参数类型”章节的 第 248 页 中提到的,我们应该利用类型系统和多重分派来使代码更易于理解,而不是相反。
假设我们决定使用不同的约定来记录时区,并尝试对现有变量做一些修改:
julia> NYC.timezone = "America/New_York"
ERROR: setfield!: immutable struct of type EarthLocation cannot be changed
Julia 对于似乎是合理尝试给 NYC 的某个字段赋新值的操作。默认情况下,复合类型是不可变的,这使得编译器在某些情况下能够生成更高效的代码。如果程序需要可以改变字段值的类型,我们需要显式地使用 mutable 关键字来定义我们的类型:
mutable struct MutableEarthLocation
latitude::Float64
longitude::Float64
timezone::String
end
使用这个定义,我们可以修改 MutableEarthLocation 类型的变量:
julia> NYC = MutableEarthLocation(40.7128, -74.006, "ET")
MutableEarthLocation(40.7128, -74.006, "ET")
julia> NYC.timezone = "US/Eastern"
"US/Eastern"
julia> NYC
MutableEarthLocation(40.7128, -74.006, "US/Eastern")
我们可以随意更改可变复合类型的字段值。然而,当没有必要这样做时,比如当类型表示一个不应被修改的永久对象时,通常最好在定义时不使用 mutable 关键字。
使用复合类型
让我们探索一个简单的例子,展示创建自定义类型及其操作方法的有用性。我们的想法是定义几种表示圆形的类型。它们会有所不同,但因为它们都表示圆形,所以会有一些共性。我们计划编写一些针对这两种圆形类型的专用方法,至少有一个方法应该适用于这两种类型(如果我们将来扩展项目,可能适用于更多类型)。这种情况需要创建一个抽象类型来表示一般的圆形,从中派生每种复合圆形类型:
abstract type Circle end
如果我们不关心圆的位置,我们可以完全通过它的半径来定义它。考虑到这一点,我们定义我们的第一个复合圆形类型,只有一个字段:
struct FloatingCircle <: Circle
r::Real
end
这里 r 代表圆的半径,可以是任意 Real 数字。FloatingCircle 类型是我们抽象 Circle 类型的一个子类型:
julia> supertypes(FloatingCircle)
(FloatingCircle, Circle, Any)
我们下一个圆形类型还包含关于形状在空间中的位置的信息:
struct PositionedCircle <: Circle
x::Real
y::Real
r::Real
end
当然,PositionedCircle 也被定义为 Circle 的一个子类型。实数 x 和 y 用于表示其圆心的坐标。抽象的 Circle 类型现在有了两个子类型:
julia> subtypes(Circle)
2-element Vector{Any}:
FloatingCircle
PositionedCircle
到目前为止,我们所做的可能是一个几何计算包的开端。
假设下一步是编写一个计算圆面积的函数。这个面积与圆的位置无关,仅与其半径有关。因此,它应该接受抽象Circle类型的任何子类型,以及我们将来可能创建的任何子类型:
function circle_area(c::Circle)
return π * c.r²
end
circle_area()函数的签名要求其参数类型必须是Circle的子类型。如果是,它将具有半径,按惯例,我们在所有圆形复合类型中将其称为r:
julia> c1 = FloatingCircle(1)
FloatingCircle(1)
julia> c1.r
1
➊ julia> circle_area(c1)
3.141592653589793
julia> c2 = PositionedCircle(2, 2, 1)
PositionedCircle(2, 2, 1)
julia> c2.x, c2.y
(2, 2)
julia> c2.r
1
➋ julia> circle_area(c2)
3.141592653589793
julia> circle_area(17)
ERROR: MethodError: no method matching circle_area(::Int64)
在确认新函数正确计算了FloatingCircle类型 ➊ 和PositionedCircle类型 ➋ 的面积之后,我们忘记了circle_area()只处理Circle的子类型,并尝试传入一个数字,这导致了MethodError错误。
让我们在这个几何项目中添加一个功能:一个例程,接受两个圆,并告诉我们第二个圆是否完全位于第一个圆内。
function is_inside(c1::PositionedCircle, c2::PositionedCircle)
d = sqrt((c2.x - c1.x)² + (c2.y - c1.y)²)
return d + c2.r < c1.r # true if c2 is inside c1
end
该函数通过使用圆心的 x 坐标和 y 坐标计算两个圆之间的距离,然后检查其中一个是否完全包含在另一个圆内,通过它们的半径来判断。当然,圆形“包含”另一个圆的概念只有在我们知道圆的位置时才有意义,因此新函数只接受PositionedCircle类型,并且只会有一个方法。
让我们试试:
julia> a = PositionedCircle(2, 2, 2)
PositionedCircle(2, 2, 2)
julia> b = PositionedCircle(1, 1, 0.5)
PositionedCircle(1, 1, 0.5)
julia> is_inside(a, b)
true
julia> c = PositionedCircle(3, 3, 1)
PositionedCircle(3, 3, 1)
julia> is_inside(a, c)
false
它似乎在工作,但为了确保,我们最好画个图。我们可以使用Luxor在一个类似于示例 7-1 的程序中绘制我们的三个圆:
using Luxor
@pdf begin
origin(Point(30, 30))
➊ scale(100, 100)
fontsize(0.32)
fontface("Liberation Sans")
setdash("solid")
setcolor("black")
circle(Point(2, 2), 2, :stroke)
text("a", Point(1, 3))
setcolor("blue")
circle(Point(1, 1), 0.5, :stroke)
text("b", Point(1, 1))
setcolor("green")
circle(Point(3, 3), 1, :stroke)
text("c", Point(3, 3))
end 500 500 "circles.pdf"
Luxor包使用点作为长度单位,因此我们将尺寸 ➊ 扩展,以便制作出合理大小的插图。圆圈上的标签与我们之前为它们命名时使用的名称相同。图 8-3 展示了该程序创建的图示,我们可以看到is_inside()函数正确地计算了“包含”关系。

图 8-3:圆 b 在圆 a 内,但圆 c 不在其中。
我们知道如何强制要求用户定义类型的构造函数中使用的类型。但如果我们想限制传递给构造函数的允许值该怎么办呢?以下是如何创建一个类似我们FloatingCircle类型的类型,要求半径为正数:
struct ReasonableCircle <: Circle
r::Real
➊ ReasonableCircle(r) =
if r >= 0
new(r)
else
@error("It's not reasonable to make a circle with a negative radius.")
end
end
julia> ReasonableCircle(-12)
Error: It's not reasonable to make a circle with a negative radius.
@ Main REPL[4]:7
julia> ReasonableCircle(12).r
12
与函数类似,传递给参数的值的约束必须在函数体内进行强制。在函数体内 ➊ 的方法被称为内构造函数;我们之前使用的其他构造函数被称为外构造函数。new()函数用于创建实例,仅在内构造函数内使用。
那些有过类基础的面向对象语言(如 Python)经验的人,在尝试理解 Julia 中的用户自定义复合类型时,有时会暂时处于不利地位。当面对一个新概念时,我们通常会倾向于将其与我们熟悉的概念联系起来。Julia 中的复合类型并不是类;Julia 没有类,显然也没有类继承。在面向对象语言中,下一步通常是定义作为类一部分的方法:名词和动词被绑定在一起。而更加灵活的多重分发范式则将名词和动词解耦。Julia 程序员可以自由地编写作用于任何类型组合的方法,并且可以随意创建新类型,没有任何摩擦。
使用 Base.@kwdef 定义结构体
定义复合类型的默认方法还有些不足。其主要缺点是它创建的构造函数要求程序员记住类型字段在定义中的顺序。Base.@kwdef宏通过创建可以使用字段名的构造函数来改进这个限制。为了便于重复使用,可以导入这个宏并重新命名为:import Base.@kwdef as @kwdef。
让我们通过引入一个新的类型表示椭圆,来扩展我们的几何学包,如示例 8-5 所示。这次我们将使用@kwdef。
@kwdef struct Ellipse
axis1::Real = 1
axis2::Real = 1
end
示例 8-5:使用 @kwdef 定义一个 Ellipse 类型
这个定义展示了@kwdef的第二个便利功能:我们可以为字段提供默认值。我们还可以选择使用@kwdef mutable struct定义一个可变的结构体。
让我们创建一个椭圆并将其赋值给一个变量:
julia> oval = Ellipse(axis2=2.6)
Ellipse(1, 2.6)
julia> oval.axis1, oval.axis2
(1, 2.6)
这个示例展示了如何为类型的关键词参数提供一个子集,未提供的参数将使用默认值。与函数类似,类型定义中没有默认值的任何关键词参数在使用构造函数时必须提供。另外,与函数类似,我们不能混合使用位置参数和关键词参数:
julia> Ellipse(2, 3)
Ellipse(2, 3)
julia> Ellipse(2, axis2=3)
ERROR: MethodError: no method matching Ellipse(::Int64; axis2=3)
由于在定义复合类型时使用@kwdef没有缺点,因此通常使用它非常方便。
由于 Julia 的 JIT 编译器与类型系统的工作方式,使用用户自定义类型进行计算与使用原生类型一样快。我们可以在更高的抽象层次上工作,创建一组自然符合我们问题对象的类型,而不必在性能上做出妥协。
性能提示
在科学编程中,速度和效率通常是非常重要的关注点。虽然 Julia 通常可以在不需要极端专业知识或了解内部机制的情况下生成高效的代码,但良好的性能有时仍然依赖于对编译过程的理解。
我在本书的多个地方讨论了与性能相关的话题。在这里,我们将专门了解几个与类型相关的问题。
消除类型不稳定性
类型稳定性也许是 Julia 中与性能相关的最重要概念。它的核心原则是,函数的返回值类型应该根据传递给函数的参数类型来预测。返回类型不应依赖于参数的值。其次,函数内部使用的局部变量类型也不应发生变化。
假设我们想要编写一个除法函数,当分母为0时返回0,而不是Inf,清单 8-6 展示了一种编写此类函数的方法。
function safe_divide(a, b)
if b == 0
return 0
else
return a/b
end
end
清单 8-6:此函数需要改进。
它似乎确实按预期工作:
julia> safe_divide(1, 2)
0.5
julia> safe_divide(1, 0)
0
然而,细心的程序员可能会注意到,在第一个例子中,函数返回的是Float64,而在第二个例子中返回的是Int64:
julia> typeof(safe_divide(1, 2))
Float64
julia> typeof(safe_divide(1, 0))
Int64
两种情况下参数的类型都是整数,但结果的类型取决于它们的值。这种类型不稳定性可能并不重要。然而,一个潜在的问题在于,某一天我们可能会把safe_divide()函数提取到其他程序中使用,而它不同的返回类型可能会影响性能。
在更复杂的函数中,类型不稳定性可能不那么明显。在性能或内存消耗让我们怀疑某个函数可能存在此类问题时,Julia 提供了一个方便的工具来查找类型不稳定性:@code_warntype宏。我们可以在safe_divide()函数上使用它:
julia> @code_warntype safe_divide(1, 2)
MethodInstance for safe_divide(::Int64, ::Int64)
from safe_divide(a, b) in Main at REPL[7]:1
Arguments
#self#::Core.Const(safe_divide)
a::Int64
b::Int64
Body::Union{Float64, Int64}
1 - %1 = (b == 0)::Bool
-- goto #3 if not %1
2 - return 0
3 - %4 = (a / b)::Float64
-- return %4
这是在 REPL 中可用的多个宏和函数之一,用于显示 Julia 函数的翻译版本。@code_warntype宏打印出一个降级形式的代码:这是一种将计算表示为更小操作集的形式。它是代码转换的四个阶段之一,从我们的 Julia 源代码开始,最终生成特定于我们运行的处理器的机器代码。这种降级形式类似于发送给编译器的版本,但它包含了我们可以在调试性能问题时检查的类型信息。除此之外,它并不特别有用,也不适合日常人类使用。
当在 REPL 中打印时,表示可能存在类型稳定性问题的类型信息会以红色显示,我已将其转为粗体以便在书中打印。粗体部分表明返回类型可能是Float64或Int64:换句话说,它并不由输入参数的类型决定。这是一个类型不稳定函数的标志。
幸运的是,这种情况有一个简单的修复方法:
function safe_divide2(a, b)
if b == 0
➊ return 0.0
else
return a/b
end
end
由于a/b总是一个浮动数值,即使a和b是整数,我们可以通过将整数0替换为0.0 ➊,确保函数始终返回浮动数值。
为了确认我们是否解决了类型不稳定性问题,让我们再次使用@code_warntype:
julia> @code_warntype safe_divide2(1, 2)
MethodInstance for safe_divide2(::Int64, ::Int64)
from safe_divide2(a, b) in Main at REPL[5]:1
Arguments
#self#::Core.Const(safe_divide2)
a::Int64
b::Int64
➊ Body::Float64
1 - %1 = (b == 0)::Bool
-- goto #3 if not %1
2 - return 0.0
3 - %4 = (a / b)::Float64
-- return %4
这次没有红色(加粗)警告,宏确认➊返回类型始终是Float64。
注意
@code_warntype 的输出通常还包括与Nothing类型联合的黄色警告,Nothing 类型表示函数没有返回结果。这些通常不被认为是类型不稳定。
我们还可以通过使用类型声明定义该函数来修正这个类型稳定性问题:
function safe_divide_typed(a, b)::Float64
if b == 0
return 0
else
return a/b
end
end
这个版本在调用时,b = 0会将返回值转换为0.0。它将始终返回Float64;@code_warntype将验证其类型稳定性。
尽管@code_warntype返回的代码形式可能很难解析,但用它来扫描类型稳定性问题其实很简单。
避免更改变量类型
让我们写一个函数,使用莱布尼茨求和公式来近似π:

这种方法并不是获取π的好方法,因为它收敛得很慢,但它对我们的演示很有用。该函数的一个版本可能是:
function leibπ(N)
s = 0
for n in 1:N
s += (-1)^(n+1) * 1/(2n-1)
end
return 4.0s
end
这按预期工作;图 8-4 显示它的输出逐渐收敛到正确的π值。

图 8-4:莱布尼茨求和公式对π的近似值
这个函数显然不属于之前提到的类型不稳定的情况:无论传递什么数字作为参数,输出始终是Float64。
然而,查看@code_warntype的输出会发现一个问题:
julia> @code_warntype leibπ(100)
MethodInstance for leibπ(::Int64)
from leibπ(N) in Main at REPL[33]:1
Arguments
#self#::Core.Const(leibπ)
N::Int64
Locals
@_3::Union{Nothing, Tuple{Int64, Int64}}
s::Union{Float64, Int64}
n::Int64
Body::Float64
1 - (s = 0)
| %2 = (1:N)::Core.PartialStruct(UnitRange{Int64}, Any[Core.Const(1), Int64])
| (@_3 = Base.iterate(%2))
| %4 = (@_3 === nothing)::Bool
| %5 = Base.not_int(%4)::Bool
-- goto #4 if not %5
2 %7 = @_3::Tuple{Int64, Int64}
| (n = Core.getfield(%7, 1))
| %9 = Core.getfield(%7, 2)::Int64
| %10 = s::Union{Float64, Int64}
| %11 = (n + 1)::Int64
| %12 = ((-1) ^ %11)::Int64
| %13 = (%12 * 1)::Int64
| %14 = (2 * n)::Int64
| %15 = (%14 - 1)::Int64
| %16 = (%13 / %15)::Float64
| (s = %10 + %16)
| (@_3 = Base.iterate(%2, %9))
| %19 = (@_3 === nothing)::Bool
| %20 = Base.not_int(%19)::Bool
-- goto #4 if not %20
3 - goto #2
4 %23 = (4.0 * s)::Float64
-- return %23
再次出现的警告以加粗字体显示。它们告知我们,局部变量s是Float64和Int64类型的联合体,而不是单一的数值类型。这是因为我们将它初始化为字面整数0,但在循环中使用时,导致 Julia 将其提升为浮动类型。
更改局部变量的类型可能会导致编译器无法充分优化我们的代码。这是一个常见的错误,因为初始化变量并在for循环中使用它们的模式是日常操作。当这样做时,我们应该小心使用适合循环中算术运算的类型来初始化这些变量。
这个问题也很容易修复:
function leibπ2(N)
➊ s = 0.0
for n in 1:N
s += (-1)^(n+1) * 1/(2n-1)
end
return 4.0s
end
如之前所述,我们只需将0替换为0.0 ➊。我不会在这里重复(大多数是冗余的)输出,但使用@code_warntype检查显示警告已经消失。
类型别名
多种类型有替代名称,称为类型别名。使用别名是为了方便;它们通常是更短的名称,或者省略了机器指针大小的表示。例如,在 64 位计算机上,Int是Int64的另一个名称或别名,但在 32 位机器上,Int表示Int32:
julia> typeof(17)
Int64
julia> 17 isa Int
true
julia> Int === Int64
true
这表明,至少在我的计算机上,Int是Int64的另一个名称。
我们可以创建自己的类型别名:
julia> const F64 = Float64
Float64
julia> typeof(3.14)
Float64
julia> 3.14 isa F64
true
这里我们为默认的浮动点类型创建了一个替代名称。定义之后,我们可以将F64和Float64互换使用。
定义类型别名为const并不是必须的,但这样做是有意义的,因为它们是不会改变的某些事物的额外名称。
参数化类型
参数化类型是由多个组成部分构成的类型,这些组成部分本身可以是几种可能类型中的任何一种。参数是随着组成部分类型变化而变化的变量。
示例 8-7 展示了我们已经遇到的一个参数化类型——用于复数的类型。
julia> typeof(2 + 2im)
Complex{Int64}
julia> typeof(2.0 + 2.0im)
ComplexF64 (alias for Complex{Float64})
julia> typeof(2.0 + 2im)
➊ ComplexF64 (alias for Complex{Float64})
julia> typeof(1//2 + 1//2im)
Complex{Rational{Int64}}
示例 8-7:一些复数的类型
类型名称中的花括号({})表示我们正在处理参数化类型。在第一行中,我们请求的是一个使用整数字面量表示每个系数的复数类型。响应表明该复数是Complex类型,且参数为Int64;这个参数即系数的类型。
第二行告诉我们类似的信息,但这次复数具有浮点系数。此外,我们还了解了该类型的别名。
花括号内只有一个参数,表明两个系数必须具有相同的类型。这个确实成立;将浮点数和整数混合会导致整数系数自动转换为Float64系数 ➊。
在最后一个示例中,我们创建了一个具有Rational系数的复数。这次参数本身就是一个参数化类型。有理数可以由任何整数组成。Rational{Int64}表示分子和分母是Int64类型,而不是例如Int32类型。
集合类型,如Array,被定义为参数化类型,因为它们可以包含不同类型的元素:
julia> typeof([1,2])
Vector{Int64} (alias for Array{Int64, 1})
julia> supertype(Vector)
➊ DenseVector (alias for DenseArray{T, 1} where T)
julia> supertype(DenseVector)
AbstractVector (alias for AbstractArray{T, 1} where T)
别名的使用在集合类型中很常见,如这些示例所示。我们看到,Array是一个参数化类型,有两个参数:第一个是数组元素的类型,第二个是维度的数量。
where关键字创建了一个UnionAll类型,它是许多类型的联合,每个类型通过将特定类型分配给类型变量T来定义。一个例子是AbstractArray{T, 1},其中T表示一个抽象类型,它是AbstractArray{Int64, 1}、AbstractArray{Float64, 1}等的联合。
我们可以创建自己的参数化类型,原因与我们创建任何类型的原因相同:通过类型系统和多重分派来组织我们的方法。
让我们回顾一下示例 8-5 中的Ellipse类型,并将其转化为一个参数化版本:
@kwdef struct CEllipse{T}
axis1::T
axis2::T
end
现在,字段可以是任何类型,只要它们都是相同的类型:
julia> e1 = CEllipse(12.0, 17.0)
CEllipse{Float64}(12.0, 17.0)
julia> e2 = CEllipse(12.0, "Snails")
ERROR: MethodError: no method matching CEllipse(::Float64, ::String)
Closest candidates are:
CEllipse(::T, ::T) where T at REPL[67]:2
julia> e2 = CEllipse("Clams", "Snails")
CEllipse{String}("Clams", "Snails")
在定义了一个新的 CEllipse 后,REPL 会告诉我们类型,并将 Float64 代入参数 T。我们尝试给字段设置两种不同类型失败了,因为它们在类型定义中都是 T。T 可以是任何类型,但定义要求两条坐标轴具有相同类型,因此最终的示例被接受。但是,坐标轴是任意字符串的椭圆意味着什么呢?这由我们自己决定。我们在为自己的目的创建类型,以组织我们的项目。如果我们希望限制 CEllipse 类型的坐标轴只能是数值类型,可以使用子类型操作符:
@kwdef struct CEllipse{T<:Number}
axis1::T
axis2::T
end
在定义这个结构体之前,如果我们在 REPL 中工作,并且之前的 CEllipse 定义仍然有效,我们必须开始一个新的会话。另一种选择是给它起一个不同的名字。
现在,一个 CEllipse 可以有两条相同类型的坐标轴,而且这个类型可以是任何类型,只要它是 Number 的子类型:
julia> e2 = CEllipse("Clams", "Snails")
ERROR: MethodError: no method matching CEllipse(::String, ::String)
julia> e2 = CEllipse(1//3, 1//5)
CEllipse{Rational{Int64}}(1//3, 1//5)
由于我们将 T 定义为 Number 的子类型,而不是更具体的 Real 的子类型,因此我们允许具有复数坐标轴的椭圆的可能性。在某些情况下,我们计算椭圆属性的函数需要专门处理这种情况。举个例子,我们写一个返回椭圆偏心率的函数。这是一个衡量椭圆拉长程度的指标,其中偏心率为 0 时是圆形。如果 a 是两轴中较长的,b 是较短的,那么偏心率由以下公式给出:

下面是将此公式直接转化为 Julia 函数的例子:
function eccentricity(e::CEllipse{<:Real})
a = max(e.axis1, e.axis2)
b = min(e.axis1, e.axis2)
return sqrt(a² - b²)/a
end
这个定义适用于实数坐标轴,因此,为了确保该函数仅接受此类椭圆,它的类型参数指定了 Real 的子类型。
我们可以将具有复数坐标轴的椭圆视为位于复平面内。只要确保它们的坐标轴是垂直的,我们就可以这样定义椭圆。
让我们为我们的偏心率函数编写一个处理这些椭圆的方法:
function eccentricity(e::CEllipse{<:Complex})
a = max(abs(e.axis1), abs(e.axis2))
b = min(abs(e.axis1), abs(e.axis2))
return sqrt(abs(a)² - abs(b)²)/abs(a)
end
abs() 函数在接收一个复数时返回它的长度。我们在类型参数位置使用 <: 操作符来包含所有可能的复数类型。
我们对具有复坐标轴的椭圆了解得更多:不仅是它们的偏心率,还有它们的方向。图 8-5 显示了复平面中的椭圆。

图 8-5:复平面中的椭圆
它的坐标轴,由虚线表示,是 2 + 2i 和 −1 + i。我们将定义其方向为主轴(较长轴)与实轴之间的角度,如图中所示的 α。
这是生成图 8-5 插图的程序:
using Luxor
@pdf begin
scale(100, 100)
fontsize(0.22)
fontface("Liberation Sans")
setdash("dash") # Coordinate axes
line(Point(-2, 0), Point(2, 0), :stroke)
line(Point(0, -2), Point(0, 2), :stroke)
text("Re", Point(1.6, -0.1))
text("Im", Point(0.1, -1.8))
setdash("dot") # Ellipse axes
line(Point(0, 0), Point(sqrt(2), -sqrt(2)), :stroke)
line(Point(0, 0), Point(-1/sqrt(2), -1/sqrt(2)), :stroke)
text("α", Point(0.25, -0.08))
setdash("solid") # The ellipse
rotate(-π/4)
ellipse(0, 0, 4, 2, :stroke)
end 500 500 "ellipse.pdf"
请记住,在 Luxor 中,垂直坐标是从上到下的,方向与数学图表中的常规方向相反。
这个函数计算具有复数坐标轴的椭圆的方向:
function orientation(e::CEllipse{<:Complex})
if abs(e.axis1) > abs(e.axis2)
a = e.axis1
else
a = e.axis2
end
return angle(a)
end
由于无法为仅由实数长度给定的椭圆轴定义方向,orientation()函数将只有这一种方法。angle()函数返回一个复数的相位角;它等价于atan(imag(a)/real(a))。
让我们定义一个具有复数轴的椭圆,并计算其偏心率和方向:
julia> e45 = CEllipse(2 + 2im, -1 + im)
CEllipse{Complex{Int64}}(2 + 2im, -1 + 1im)
julia> eccentricity(e45)
0.8660254037844387
julia> orientation(e45)
0.7853981633974483
julia> orientation(e45) |> rad2deg
45.0
这个椭圆对应于图 8-5。orientation()函数以弧度为单位返回结果,因此为了更加直观,我们在最终表达式中将其转换为度数。
参数化类型使得 Julia 的强大类型系统更加灵活和富有表现力。像类型系统的其他部分一样,我们在自己的程序中不必使用任何参数化类型,但稍微使用一些可以大大帮助代码的组织、重用和效率提升。最后,了解基本的参数化类型对于理解 Julia 向我们传递的消息和信息,以及阅读语言和包的文档至关重要。
绘图配方
作为程序、模块甚至包的作者,我们应该期望定期创建自己的数据类型。在 Julia 中使用自定义数据类型不会产生性能惩罚,而且它们对于编写简洁、结构良好的代码以及充分利用多重分派至关重要。
在本书的第二部分中,我们将探索来自 Julia 科学生态系统的各种包。这些包中的许多定义了一种或多种数据类型,用来描述它们所操作的对象。这些对象包括音频信号、微分方程的解、图像、带有不确定性的测量、包含互动生物的完整环境、生物本身等等。我们将发现,使用第四章中的绘图命令,我们可以直接可视化这些数据结构,而无需做任何预处理。那么,Plots是如何知道如何处理这些不同的数据类型的呢?
可视化是科学计算的重要组成部分。绘图配方系统是我们将数据类型与 Julia 的绘图系统连接起来的方式,也就是我们教它如何处理和显示我们自定义的对象。在我们第二部分中使用的科学包的作者们并不需要修改Plots包中的代码,而Plots包也无需了解这些新的数据类型。绘图配方将数据转换插入到绘图管道中,使得现有的绘图函数能够像处理常见的数字数组一样处理我们的数据类型。
结果是,我们的程序用户只需对新数据类型调用plot()、scatter()或其他绘图函数,就能得到合理的可视化表示。我们还可以为更复杂的可视化定义全新的绘图函数。
我们需要一个具体的应用场景来清楚地说明绘图食谱的操作。假设我们正在创建一个与天气相关的程序,并为表示每日气温和降雨数据创建一些简单的数据类型:
import Base.@kwdef as @kwdef
using Dates
@kwdef struct TempExtremes
tempunit::String = "°C"
➊ temps::Vector{Tuple{Float64, Float64}}
end
@kwdef struct WeatherData
temps::TempExtremes
rainfall::Vector{Float64}
end
@kwdef struct WeatherReport
notes::String
location::Tuple{Float64, Float64}
data::WeatherData
start::Dates.Date
end
假设我们的气温数据以每天两个测量值的形式提供,分别表示当天的最低和最高气温。我们将把这些测量值存储在一个包含元组的向量➊中,每个元组代表一天,包含气温极值。这个元组的向量以及一个保存气温单位的字符串将被打包在TempExtremes数据类型中。
该数据类型与另一种名为WeatherData的数据类型中的降雨量测量向量一起处理。
第三种数据类型,WeatherReport,包含WeatherData以及一些备注、用于标记测量位置的经纬度对和记录测量序列开始日期的日期。
接下来,我们创建这三种数据类型的实例,以便绘制图表:
tmin = randn(60) .+ 15.0
tmax = tmin .+ abs.(randn(60) .+ 3.0)
td = TempExtremes(temps=collect(zip(tmin, tmax)))
wd = WeatherData(rainfall=abs.(randn(60) .* 5.0 .+ 4), temps=td)
wr = WeatherReport(notes="Rainfall and temperature extremes",
location=(-72.03, 45.47),
data=wd, start=Date(1856, 12, 31))
randn()函数生成正态分布的(参见第 323 页的“正态分布”)虚假随机气温和降雨数据。我们之前导入了Date模块,这样我们就可以使用其数据类型来定义一个起始日期。
绘图管道
食谱系统由一系列四种食谱种类组成,这些种类会按顺序在绘图管道中处理,如列表 8-8 所示。
user recipes:
user types => user types, numerical arrays
type recipes:
user types => numerical arrays
plot recipes:
numerical arrays => series
and
series => series
series recipes:
numerical arrays => series
and
series => series
列表 8-8:绘图管道
每种食谱类型都会转换其输入,并将结果传递到管道中的下一阶段;这些转换在食谱名称后面进行标识。内置的绘图函数通常知道如何绘制数字数组,因此绘图食谱必须将我们的自定义类型转换为普通数组。前两种食谱类型,用户食谱和类型食谱,可以执行这一操作。最后两种食谱类型则接收数字数组并生成系列,这是表示单个向量的绘图组件,这些向量可能来自矩阵的列(在一维情况下)。
用户和绘图食谱还可以创建布局并设置整体绘图属性。我们不需要定义每一个食谱,通常也不会在任何特定的绘图任务中使用所有食谱。对于我们已经定义的食谱,可以单独使用它们,或者作为管道的一部分用于不同的目的。在本讨论中,我们将从管道的末尾开始,一步步向前推进,定义食谱。在这个过程中,每个示例食谱都会在我们直接调用时执行某些操作,将信息传递给先前定义的食谱,以生成图表。
系列食谱
我们使用 @recipe 宏定义配方,该宏由 RecipesBase 包导出。该宏装饰一个函数定义,其中函数的名称是任意的。函数的签名决定了创建的配方类型。在以下列出的例子中,我们创建了两个系列配方。签名由类型以及三个额外的位置参数 x、y 和 z 组成,这告诉管道这些是系列配方。与往常一样,关键字参数不是用于分派的函数签名的一部分。参考 Listing 8-8,我们可以看到这些配方将接受数值数组并创建系列:
using RecipesBase
@recipe function f(::Type{Val{:ebxbox}}, x, y, z; cycle=7)
if cycle <= 2; cycle = 7; end
ymin = similar(y)
ymax = similar(y)
yave = similar(y)
➊ seriestype := :line
for m = 1:cycle:length(y)
nxt = min(m+cycle-1, length(y))
ymin[m] = ymax[m] = yave[m] = NaN
ymin[m+1:nxt] .= minimum(y[m:nxt])
ymax[m+1:nxt] .= maximum(y[m:nxt])
yave[m+1:nxt] .= sum(y[m:nxt]) / (nxt - m + 1)
end
➋ @series begin
y := ymax
➌ linecolor --> "#ff000049"
linewidth --> 6
end
@series begin
y := ymin
linecolor --> "#0000ff49"
linewidth --> 6
end
@series begin
y := yave
linecolor --> "#66666649"
linewidth --> 6
end
end
@recipe function f(::Type{Val{:temprange}}, x, y, z)
seriestype := :line
legend := false
if plotattributes[:series_plotindex] == 1
➍ merge!(plotattributes[:extra_kwargs], Dict(:nextfr => y[:]))
linecolor := :blue
linewidth := 3
elseif plotattributes[:series_plotindex] == 2
fillrange := plotattributes[:extra_kwargs][:nextfr]
linecolor := :red
linewidth := 3
fillcolor := "#45f19655"
else
x := []
y := []
end
()
end
要定义配方,我们只需要导入 RecipesBase。这非常重要,因为它意味着包可以在不依赖庞大的 Plots 包的情况下定义绘图行为。RecipesBase 很小,仅包含大约 400 行 Julia 代码。
使用 @recipe 宏定义的绘图配方使用了几种特殊的语法便利。:= 操作符 ➊ 在 plotattributes 字典中进行设置,该字典包含如线条颜色等属性—即所有绘图选项。在这里,我们将属性字典中的 seriestype 设置为 :line。这是默认的系列类型,它在绘制的点之间创建一条连续的线。另一个选项是 :scatter,用于绘制单个标记。实际上,熟悉的 scatter() 函数是 plot(; seriestype=:scatter) 的简写。
--> 操作符 ➌ 也在 plotattributes 字典中进行设置,但在这种情况下,它会推迟到管道中先前通过关键字参数做出的设置。从某种意义上说,这些设置是可选的,而我们使用 := 做的设置对构建中的系列来说是重要的。
接下来是一个 for 循环,它将输入的 y 向量分成 cycle 元素的段,并计算每个段的极值和平均值。它在每个段后插入 NaN 来分隔这些段,以便在图中显示。
接下来是三个由 @series 宏 ➋ 前缀的代码块。每个 @series 块都会为图形创建一个新的系列。在本例中,由于我们在块外设置了 seriestype 为 :line,每个系列都将是一个 :line 系列,但通常它们可以是不同的类型。它们还可以创建一个 Plots 不认识的系列类型,在这种情况下,管道会将数据传递给定义新系列的配方。如果有多个系列配方,数据会依次通过每个配方,直到某个配方创建出一个后端能够识别的系列类型。
下一个配方设计用于接受N×2 矩阵。它将绘制两个列,每列作为线条,第一列为蓝色,第二列为红色。它将使用fillrange属性填充这两条线之间的区域。这提出了一个小问题,因为我们需要引用第一列来定义绘制第二列时的fillrange,但是管道对于输入数据中的每一列都会重新开始。然而,我们可以通过引用属性字典中的:series_plotindex键来知道当前处理的是哪一列。传递不同列之间信息的一种方法是将其塞入属性字典中的:extra_kwargs条目➍。我们将新属性命名为:nextfr。
虽然我们心里有之前定义的天气数据类型,但这些配方并不知晓这些数据类型。像所有系列配方一样,它们可以绘制任何数字数组。对于实际绘图,我们需要导入Plots:
using Plots
@shorthands temprange
@shorthands ebxbox
tl = [t[1] for t in wd.temps.temps]
th = [t[2] for t in wd.temps.temps]
temprange([tl th])
ebxbox(wd.rainfall)
plot!(wd.rainfall)
@shorthands宏由RecipesBase提供,它获取配方函数签名中的名称,并生成可以直接调用的函数名称,用于绘制图表。对于每一个,它都会生成两个函数,一个用于创建新图表,另一个用于向现有图表添加内容,就像plot()和plot!()一样。
在将wd中的温度数据转换为矩阵后,我们可以直接对其使用简写,创建图 8-6。

图 8-6:由系列配方创建的填充范围图
对于图 8-7,我们对降水量向量调用ebxbox()。它只绘制极值和均值条,因此我们使用plot!()添加该向量的常规图表。

图 8-7:使用ebxbox系列配方的图表
我们可以在其他程序中使用这些系列配方,也可以作为其他管道中的组件。
绘图配方
一种称为绘图配方的配方(不要与通用概念混淆)也会将系列转换为其他系列或将数值数据转换为系列,和系列配方一样,但它可以创建包含子图和其他元素的完整可视化。像所有配方一样,它由其特定的函数签名来标识:
@recipe function f(::Type{Val{:weatherplot}}, plt::AbstractPlot; cycle=7)
frames = get(plotattributes, :frames, 1)
if frames > 1 layout := (2, 1) end
➊ cycle := cycle
legend := false
@series begin
➋ if frames > 1
subplot := 1
xguide := ""
ylabel := "Temperature (°C)"
end
➌ seriestype := :temprange
end
if plotattributes[:series_plotindex] == 3
@series begin
if frames > 1 subplot := 2 end
seriestype := :ebxbox
end
@series begin
if frames > 1
subplot := 2
title := ""
ylabel := "Rainfall (mm)"
else
ylabel := "Rainfall (mm) / Temperature (°C)"
end
seriestype := :line
linecolor := :aqua
linewidth := 3
linestyle := :dot
end
end
end
该配方接收N×3 矩阵形式的输入数据。它使用我们为此目的发明的frames属性来决定是将所有系列放置在一个图中,还是使用两个子图➋,一个显示温度,另一个显示降水量。(与系列配方的情况一样,这个配方对我们的天气相关数据类型一无所知,因此我们也可以将其重新用于绘制其他类型的数据。)
cycle变量设置用于计算输入数据中第三列的极值和平均值的段长度,我们打算将其用于降水量数据。我们为此关键字参数使用 7 作为默认值,表示每周汇总。然而,如果我们在直接调用配方时或在管道的上游提供该参数,我们会通过从plotattributes字典中读取其值来覆盖默认值 ➊。
三个@series块处理前两列,包含最低和最高温度,以及第三列中的降水量。温度的@series块将系列类型设置为temprange ➌,除非我们已经为其定义了系列配方,否则该设置不起作用,正如我们之前所做的那样。
因此,这个配方的目的是使用我们在系列配方中定义的可视化效果,创建一个带有一个或两个子图的图表,并且根据情况设置适当的标签。我们也可以直接调用它,如清单 8-9 所示。
@shorthands weatherplot
weatherplot([tl th wd.rainfall])
清单 8-9:使用数组数据调用绘图配方
但我们现在先搁置这个问题。
类型配方
回到清单 8-8,我们可以看到类型配方是管道中第一个可以接受用户自定义类型的配方。它们是最简单的一类配方。它们的任务很简单:将用户类型转换为可以直接由Plots中的函数绘制的数值数组,或者可以输入到管道中的后续步骤。
以下清单定义了两个类型配方;它们通过其特定的函数签名被识别为类型配方:
@recipe function f(::Type{TempExtremes}, v::TempExtremes)
tmin = [t[1] for t in v.temps]
tmax = [t[2] for t in v.temps]
[tmin tmax]
end
@recipe function f(::Type{WeatherData}, wdt::WeatherData)
tmin = [t[1] for t in wdt.temps.temps]
tmax = [t[2] for t in wdt.temps.temps]
[tmin tmax wdt.rainfall]
end
第一个配方获取先前定义的*TempExtremes*类型的实例,并返回一个包含两列的矩阵;第二个配方将WeatherData转换为一个三列矩阵。
定义了这些配方后,我们现在可以直接通过调用plot(td)或plot(wd)来绘制这两种类型中的任意一种。如果这样做,我们将得到简单的折线图:第一次调用得到两列数据,第二次调用得到三列数据,如图 8-8 所示。

图 8-8:直接从类型配方绘图
我们调用plot(wd)来生成图 8-8。前两条线是温度极值,底部的线是降水量。
如果我们改为调用weatherplot(wd),我们将得到与清单 8-9 中调用的结果完全相同的图,因为类型配方将wd转换为一个三列矩阵。图 8-9 显示了结果。

图 8-9:在由类型配方转换的用户数据上调用的绘图配方
在这里,绘图配方将两种类型的可视化效果(在系列配方中定义)组装到一个图表中,并在垂直轴上添加标签。由于我们没有定义frames,因此会得到默认的单帧。
用户配方
现在我们已经登上了管道的顶端。用户配方不仅接受单一的用户类型,还可以接受任何类型的组合,每种不同的签名都会创建一种新的分发方法。它们可以发出数组数据或其他类型的数据,但如果它们发出的是数组数据以外的类型,我们必须定义一个类型配方来转换它们。
以下是一个用户配方的例子:
@recipe function f(wr::WeatherReport; frames=1)
title := wr.notes
frames := frames
xlabel --> "Days from $(wr.start)"
@series begin
seriestype := :weatherplot
wr.data
end
end
管道会将其视为一个用户配方,因为它的签名。它接受一个 WeatherReport 数据类型的实例,从其 notes 字段创建标题,并通过引用 start 字段构造 x 轴的有用标签。它有一个单独的 @series 块,向其中传递 data 字段。被调用的系列是绘图配方 weatherplot,但 data 字段不是数组,它是 WeatherData。管道中的下一个步骤是类型配方,它处理任何类型的转换。这里,WeatherData 实例被转换成一个三列矩阵,并传递给 weatherplot 配方,后者可选地设置子图并将矩阵列传递给系列配方。调用 plot(wr; frames=2) 会调用这个配方并创建图 8-10。

图 8-10:调用用户配方的结果
定义用户配方教会了 plot() 函数如何处理新数据类型。正如我们在本节中所看到的,我们可以在管道的任何点进入,得到不同的结果,或者将这些配方作为不同管道的一部分,重复使用来处理不同类型的数据。
@userplot 宏
RecipesBase 包还导出了 @userplot 宏,这对于定义可视化非常方便,无需定义新的数据类型:
using SpecialFunctions
@userplot Risep
@recipe function f(carray::Risep)
seriestype := :line
➊ x, y = carray.args
@series begin
label := "Real part"
linestyle := :solid
x, real.(y)
end
@series begin
label := "Imaginary part"
linestyle := :dot
x, imag.(y)
end
end
xc = 0.01:0.001:0.1
risep(xc, expint.(1im, xc); lw=2)
导入后的第一行创建了一个新类型,并使用其小写名称作为简写。我们使用该类型的名称定义的用户配方通过简写名称来调用。在配方内部,我们可以通过 args 属性 ➊ 访问绘图数据。当我们需要为现有类型的特定可视化定义一个简写名称时,@userplot 宏非常有用。在这种情况下,我们希望通过分离复数的实部和虚部来绘制复数,这可能比 plot() 默认的处理方式更有用。在定义了配方之后,我们可以直接使用其名称来调用它,如最后一行所示。expint() 函数是来自 SpecialFunctions 包的指数积分,按其第一个参数进行参数化。此处的参数将实数映射到复数。结果显示在图 8-11 中。

图 8-11:使用 @userplot 渲染复数向量
我们还可以使用 @userplot 宏,通过使用类型别名或子类型,为用户定义的类型创建替代的可视化。
结论
通过对类型系统最重要实践方面的概述,我们对 Julia 语言的介绍已告一段落。本章以及前面的章节中的思想将在第二部分的章节中得到具体应用,我们将在那里利用 Julia 来解决各个领域中的实际问题。
然而,本书将语言学习与应用部分的划分并不是严格的。在前面的章节中,我们已经看到了一些有用的应用,而在第二部分的章节中,我们将介绍各种编程技巧和 Julia 特性,并在实际应用和解决问题的背景下更好地理解它们。
进一步阅读
-
关于一种类型不稳定形式对性能影响的详细信息,请访问https://docs.julialang.org/en/v1/manual/performance-tips/#Avoid-changing-the-type-of-a-variable。
-
Dr. Chris Rackauckas 讲解了动态分派在以下情况下如何带来净收益: https://discourse.julialang.org/t/why-type-instability/4013/8。这是一个类型不稳定性有益的案例。
-
关于 π 在 Julia 中的有趣信息,请访问https://julialang.org/blog/2017/03/piday/。
-
我尝试通过扩展的食谱类比来解释多重分派的文章可以在https://arstechnica.com/science/2020/10/the-unreasonable-effectiveness-of-the-julia-programming-language/找到。
-
关于优化和类型系统的详细教程,请访问https://huijzer.xyz/posts/inference/。
-
这里有一个用于类型层次结构可视化的包: https://github.com/claytonpbarrows/D3TypeTrees.jl。
-
另一种查找和修复类型不稳定性的方法是由
Cthulhu包提供的: https://docs.juliahub.com/Cthulhu/Dqimq/2.7.5/。
第二部分
应用
第十章:**9
物理学**
物理学不是宗教。如果它是宗教,我们就更容易筹集资金了。
—利昂·M·莱德曼

Julia 是进行各种物理计算的出色平台。它的语法特性,例如使用数学符号的能力和简洁的数组操作,使其成为编程物理算法的自然选择。Julia 的执行速度使其成为仅有的几种用于最具挑战性的大规模模拟的语言之一(其他的都是低级的、静态编译的语言)。Julia 的物理生态系统包括一些最先进的软件包。最后,Julia 独特的能力是将来自不同软件包的函数和数据类型混合搭配,创造出新的功能,这在物理计算中尤其强大,正如我们将在本章中详细看到的。
我们从介绍两个通用软件包开始,这些软件包用于处理单位和误差。这两个软件包在任何物理项目中都可能是有用的。在第一部分,我们将花一些时间研究如何生成包含轴标签中的排版单位的出版质量图表的各种选项。然后我们将转向具体的计算,首先使用一个流体动力学软件包,然后使用一个通用的微分方程求解器。有关每个主要软件包的 URL,请参见第 304 页中的“进一步阅读”。
通过 Unitful 将物理单位引入计算机
在计算机上执行物理计算的传统方法是将物理量表示为浮点数,对这些数字进行一系列算术运算,然后再将结果解释为物理量。由于物理量通常不仅仅是数字,而是具有维度的,我们需要手动跟踪与这些量相关联的单位,通常通过代码注释来提醒我们这些单位是什么。
注意
维度是一个基本的物理概念,涵盖了可以被测量的东西,比如质量或时间。单位是衡量一个维度的具体方式。维度是普遍存在的,但有不同的单位系统。例如,对于长度维度,一些常见的单位是厘米(cm)、米(m),或者如果我们生活在美国,可能是英寸或美式足球场。
换句话说,程序中出现的数字的物理意义并不是这些量本身的一部分,而是隐含的。这可能并不令人惊讶,因为这可能导致混淆和错误。1999 年,NASA 因为两个不同的承包商分别参与设计,并且他们的工程程序使用了不同的单位系统,导致失去了一个航天器。
在传统的物理学语言中,如 Fortran,通常无法直接解决这个问题。但在 Julia 中,由于其先进的类型系统,我们不局限于无量纲数值的集合;我们可以与包含单位的更丰富的对象进行计算。
导入 Unitful 包后,我们可以使用非标准字符串文字(见 第 128 页 中的“非标准字符串文字”)并以 u 前缀引用许多常见的物理单位:
julia> using Unitful
julia> u"1m" + u"1cm"
101//100 m
julia> u"1.0m" + u"1cm"
1.01 m
julia> u"1.0m/1s"
1.0 m s^-1
在这里,我们将米和厘米相加,得到的结果是一个表示米数的有理数。该包在可能的情况下返回有理数结果,以保持执行精确转换的能力。但是,正如第二个例子所示,我们可以通过提供浮点系数来强制转换为浮点结果。第三个例子展示了我们如何在字符串文字中构造表达式。
你可以在源代码中的 GitHub 仓库的 src/pkgdefaults.jl 文件中找到完整的单位列表,但大多数单位遵循常见的物理学约定。每次引用单位时使用字符串文字语法可能会显得繁琐,因此我们可以将单位分配给自己的变量,以便减少输入并使代码更易读:
julia> m = u"m";
julia> 1m + u"1km"
1001 m
我们将一米加到一公里,展示了如何将自定义变量与字符串文字结合使用。结果是 1,001 米。
我们可以通过包提供的另一个函数将字符串解析为 Unitful 表达式(在写作时没有文档):
julia> earth_accel = "9.8m/s²";
julia> kg_weight_earth = uparse("kg * " * earth_accel)
9.8 kg m s^-2
在这里,我们使用 uparse() 将一个由表示质量的字符串和另一个表示地球表面重力加速度的字符串连接起来,转换为一个表示质量重量的单位表达式。单位表达式在 REPL 中的形式本身不是可以通过 uconvert() 转换的合法字符串。例如,我们需要在第二行的字符串中包含乘法运算符。
使用 Unitful 类型
我们可以通过导入 DefaultSymbols 子模块来访问大量标准 SI 单位,而不是一个个地定义它们。然而,这种做法会将大量名称添加到我们的命名空间中,因此如果我们只使用少数单位,可能不是一个好主意:
julia> using Unitful.DefaultSymbols
julia> minute = u"minute"
julia> 2s + 1minute
62 s
这里我们将 2 秒加到 1 分钟,结果是 62 秒。DefaultSymbols 子模块提供了 s 单位,但我们需要定义 minute,因为它不是 SI 单位。我们通过并列的方式使用 Julia 的乘法语法;这个表达式与 2 * s + 1minute 是相同的。然而,这些变量必须附加到算术表达式中的数值系数上;2 * s + minute 会导致 MethodError。
我们可以通过这两个表达式的类型找到该错误的原因:
julia> typeof(1minute)
Quantity{Int64, T, Unitful.FreeUnits{(minute,), T, nothing}}
julia> typeof(minute)
Unitful.FreeUnits{(minute,), T, nothing}
1minute的类型(与1 * minute的类型相同)是Quantity,而minute的类型是FreeUnits。这两种类型都在包中定义。Unitful包定义了接受Quantity类型参数的加法和其他算术运算方法,但不接受FreeUnits类型的参数。
这些类型包含作为粗体 Unicode 字符出现的参数。Unitful包使用这些字符来表示维度,因此这些类型规范告诉我们,minute单位具有时间维度,用**T**表示。
minute类型和其他单位是抽象类型(见第 222 页的“类型层次结构”),而诸如1minute这样的量化单位类型是具体类型。为了获得更好的性能,我们应该使用具体类型进行计算,并定义只包含具体类型字段的自定义类型。
剥离和转换单位
有时我们需要从计算结果中去除单位——例如,当将结果传递给一个无法理解单位的函数时。我们可以通过convert()函数来做到这一点:
julia> convert(Float64, u"1m/100cm")
1.0
结果的类型是Float64。Unitful计算返回的结果可能并不总是我们预期的,因此当我们需要一个简单数字时,应使用convert():
julia> u"1m / 100cm"
0.01 m cm^-1
julia> typeof(u"1m/100cm")
Quantity{Float64, NoDims, Unitful.FreeUnits{(cm^-1, m), NoDims, nothing}}
这里我们将一个长度除以另一个长度,因此结果应该是简单的数字 1.0(因为长度相等),且没有维度。实际结果等同于此,但它以一个模糊的形式表达。检查结果的类型,我们发现它是具体的Unitful类型Quantity,其类型参数表示它没有维度。
如果我们在分子和分母中使用相同的字面单位,我们会得到一个更接近我们预期的结果:
julia> u"1m / 2m"
0.5
julia> typeof(u"1m / 2m")
Float64
进一步的例子表明,Unitful在保留我们在表达式中使用的单位时是一致的,而不是进行物理学家可能认为显而易见的转换:
julia> u"1m * 1m"
1 m²
julia> u"1m * 100cm"
100 cm m
这两个输入表达式意味着相同的事情,但会产生以不同方式表达的等效结果。
Unitful中的upreferred()函数将表达式转换为使用标准单位集的形式。用户可以建立首选的单位系统,但默认行为是使用常规的 SI 单位:
julia> u"1m * 100cm" |> upreferred
1//1 m²
除了使用convert()转换为数字外,我们还可以使用Unitful中的uconvert()来进行单位之间的转换:
julia> uconvert(u"J", u"1erg")
1//10000000 J
julia> uconvert(u"kg", u"2slug")
29.187805874412728 kg
该函数的第一个参数是要转换到的单位,第二个参数是要转换的表达式。在第一个示例中,我们将能量从 ergs 转换为 joules。由于这两者是由精确比例关系相关的公制单位,uconvert()使用有理数系数提供答案。第二个示例是将美国的质量单位 slug 转换为千克,后者是物理学中使用的标准 SI 单位。转换因子是一个浮动的浮点数。
列表 9-1 展示了另一种方法,通过 ustrip() 提取 Unitful 表达式中的纯数字部分。
julia> vi = 17u"m/s"
17 m s^-1
julia> vf = 17.0u"m/s"
17.0 m s^-1
julia> ustrip(v), ustrip(vf)
(17, 17.0)
列表 9-1:使用 ustrip() 去除单位
ustrip() 函数在表达式中保留数值类型。
为了从 Unitful 表达式中提取单位,包提供了 unit() 函数,如 列表 9-2 所示。
julia> unit(vi)
m s^-1
列表 9-2:使用 unit() 提取单位
我们将在“带单位的绘图”部分的 第 276 页 中找到 ustrip() 和 unit() 的应用。
排版单位
使用 UnitfulLatexify 包,我们可以将 Unitful 表达式转化为 LaTeX 排版的数学公式:可以是可以直接放入研究论文中的 LaTeX 源代码,也可以是渲染后的图像。以下是一个简单的例子:
julia> using Unitful, Latexify, UnitfulLatexify
julia> 9.8u"m/s²" |> latexify
L"$9.8\;\mathrm{m}\,\mathrm{s}^{-2}$"
latexify() 函数将地球重力加速度的 Unitful 表达式转换为 LaTeX 字符串。我们在 列表 4-1 中遇到过 LaTeX 字符串,当时我们用它来为图表生成标题。UnitfulLatexify 包结合了 Latexify 中的 LaTeX 能力与 Unitful,这就是为什么我们需要导入这三个包,就像在本示例开始时那样。
在 REPL 或其他非图形化环境中使用时,latexify() 生成准备好可以复制并粘贴到文档中的 LaTeX 标记。我们也可以通过将结果传递给 render() 函数,创建一个 PDF 图像。为了做到这一点,你需要安装外部程序 LuaLaTeX,它是标准 LaTeX 安装的一部分。如果该程序可用,render() 会使用它来排版 LaTeX 字符串,并立即通过默认的 PDF 查看器显示它。render() 过程会在你的临时目录中留下每个渲染表达式的文件,这是需要注意的地方。
在图形化环境中使用 UnitfulLatexify 时,比如在 Pluto 笔记本中,输出会以 LaTeX 的形式进行渲染,而不是 LaTeX 源代码。在大多数环境中,排版使用的是内建引擎,而不是外部程序,因此不需要额外的安装。例如,Pluto 使用 MathJax,这是一个用于 LaTeX 数学排版的 JavaScript 库。
图 9-1 显示了一个包含牛顿第二定律的 Pluto 会话。

图 9-1:在 Pluto 中使用 UnitfulLatexify
在 图 9-1 中的最后一个单元格,我们将加速度转换为更常见的单位组合,并将结果传递给 latexify()。排版版本作为结果出现。MathJax 提供了一个上下文菜单,当右键单击结果时,可以访问 LaTeX 源代码。
如果不喜欢在单位表达式中使用负指数,我们可以传递 permode 关键字,告诉 latexify() 使用其他样式。以下是一个例子,展示了默认选项和 permode 的两个选项:
julia> a = 0.0571u"m/s²"
julia> """
a = $(latexify(a))
or
$(latexify(a; permode=:frac))
or
$(latexify(a; permode=:slash))
""" |> println
a = $0.0571\;\mathrm{m}\,\mathrm{s}^{-2}$
or
$0.0571\;\frac{\mathrm{m}}{\mathrm{s}^{2}}$
or
$0.0571\;\mathrm{m}\,/\,\mathrm{s}^{2}$
这个例子使用了现有的a定义。:frac选项使用 LaTeX 分数,而不是负指数,:slash选项使用斜杠,通常对于内联数学更为合适。
将上一个列表中的输出粘贴到本书的 LaTeX 源代码中会显示渲染结果:
a = 0.0571 m s^(−2)
或者
0.0571 
或者
0.0571 m/s²
我们可以使用set_default(permode=:slash)命令更改渲染单位的默认模式。
带单位的绘图
列表 9-3 展示了Plots如何处理Unitful量。
julia> using Plots, Unitful
julia> mass = 6.3u"kg";
julia> velocity = (0:0.05:1)u"m/s";
julia> KE = mass .* velocity.² ./ 2;
julia> plot(velocity, KE; xlabel="Velocity", ylabel="KE",
lw=3, legend=:topleft, label="Kinetic Energy")
列表 9-3:带单位的 Unitful 数组绘图
在这里,我们导入Plots,这是我们绘图所需的库,以及Unitful,用来处理单位。在定义了质量(以千克为单位)和一系列速度(以米每秒为单位)之后,我们根据动能公式(动能 = 1/2 质量 × 速度²)创建了一个动能数组KE。这个新包赋予Plots中的绘图函数处理带单位的量的能力,并自动将单位附加到轴标签上。图 9-2 展示了plot()语句的结果。

图 9-2: 列表 9-3 生成的图表
我在这个例子中没有更改能量单位,但更常见的物理学用法是使用uconvert()将其转换为焦耳,这可以在绘图调用之前或在plot()内部进行。
我们能够使用相同的plot()调用创建这个图表,正如我们可能用来绘制存储在没有单位的数值数组中的相同量。Plots中的所有绘图函数,如scatter()和surface(),都可以处理Unitful数组,并生成类似的轴标签。
为出版制作图表
然而,在尝试制作高质量的出版图表时,我们会遇到一些不足之处。虽然Plots旨在为各种后端创建统一的接口,但每个绘图引擎的工作方式有所不同,每个引擎都有独特的功能和局限性。
当我们进行最终调整以准备图表用于出版时,这些后端之间的差异变得更加明显。例如,标签和注释中的排版细节在这一阶段变得非常重要。图 9-2 是使用GR后端创建的,正如在“有用的后端”部分所提到的第 115 页,该后端在写作时是默认后端,且快速且功能强大。
图 9-2 可能已经可以接受,但为了出版,我们可能需要改进其图表标签的外观,特别是让单位符号看起来像常规的数学符号。正如我们在“基于数据的 LaTeX 标题和标签定位”一节中,在 第 103 页看到的那样,我们可以在图表注释中使用带有数学内容的 LaTeX 符号。这同样适用于我们已经导入的包中的单位自动标签:
julia> using Plots, Unitful, Latexify, UnitfulLatexify
julia> plot(velocity, KE; xlabel="\\textrm{Velocity}",
ylabel="\\textrm{KE}", unitformat=latexroundunitlabel)
这个示例重复了 清单 9-3 中的绘图命令,但做了一些修改,创建了用于绘图标签的 LaTeX 字符串。unitformat 关键字通过 latexify() 处理单位注释,值 latexroundunitlabel 保留了单位周围的括号。由于这会将整个标签放入 LaTeX 字符串中,因此我们还需要将标签中的非数学部分用 LaTeX 命令包裹起来,以将它们设置为常规文本,而非数学公式。
GR 后端
这种方法的结果在很大程度上依赖于我们使用的后端。显然,只有在后端能够处理 LaTeX 字符串时,使用 LaTeX 字符串才有意义。尽管默认的 GR 后端可以解释 LaTeX,但其结果并不总是令人满意。这个引擎包含了自己的 LaTeX 处理版本,通常会产生质量较差的排版,且字间距不准确。不过,GR 中的 LaTeX 引擎目前正在开发中,因此其性能可能会有所提高。
大多数情况下,标签的高质量排版需要通过外部 TeX 引擎处理,这通常涉及如 TeXLive 之类的 TeX 安装。由于许多物理学家和其他科学家已经安装了这样的 TeX 系统,我们将继续考虑那些能够利用该安装的选项。
Gaston 后端
Gnuplot 可以选择性地编译支持 tikz 终端,这会将图形保存为包含 TikZ 命令的文本文件。(TikZ 是一种图形语言,通常包含在大多数完整的 TeX 安装中。)这些文件通过 LaTeX 处理,并且可以包含 TeX 或 LaTeX 标记,用于图形上的注释。结果的质量非常高,字体和样式与图形所在的文档一致。不幸的是,在写作时,使用 gnuplot 的 Gaston 后端并不完全支持 tikz 终端,因此这个选项目前不可用。不过,它正在被开发中,一旦我们能够将 Gaston 与 tikz 一起使用,它将成为用于复杂图形出版或需要最佳排版质量时的最佳选择。
PGFPlotsX 后端
另一个可以利用 LaTeX 字符串的后端是 PGFPlotsX,它通过 pgfplotsx() 函数调用。该后端通过调用 LuaLaTeX TeX 引擎来创建图形,而 LuaLaTeX 是大多数 TeX 安装包中都包含的,包括 TeXLive。由于 LuaLaTeX 负责排版,因此标签的质量达到 TeX 级别。因此,这个后端是发布高质量图表的绝佳选择。如果图表包含大量元素(例如在大型散点图中),Gaston 可能仍然是未来处理复杂图形的最佳选择,因为通过 LuaLaTeX 处理的速度可能比通过 gnuplot 要慢得多。
手动处理单位
不幸的是,PGFPlotsX 与 Unitful 兼容性不佳,没有考虑到 TeX 处理。这一限制提供了一个展示不同方式绘制 Unitful 数量并用单位标签轴的机会——这种方法让我们完全控制细节。
以下代码包含了一个函数的定义,该函数接受两个 Unitful 数组进行绘图,并带有用于标签的关键字参数:
using Plots, LaTeXStrings, Latexify, UnitfulLatexify
function plot_with_units(ux, uy; xl="", yl="", label="",
legend=:topleft, plotfile="plotfile")
set_default(permode=:slash)
x = ustrip(ux); y = ustrip(uy)
➊ xlabel = L"$\textrm{%$xl}$ (%$(latexify(unit(eltype(ux)))))"
ylabel = L"$\textrm{%$yl}$ (%$(latexify(unit(eltype(uy)))))"
plot(x, y; xlabel, ylabel, lw=2, label, legend)
➋ savefig(plotfile * ".tex")
savefig(plotfile * ".pdf")
end
使用 ustrip() 和 unit() 函数(见清单 9-1 和 9-2),这段代码将数组与其关联的单位分离,绘制数值部分,并使用单位部分通过 LaTeXStrings 包构建标签。
为了将值插入到 LaTeXStrings 字符串中,我们需要使用两个字符 %$ 而不是简单的 $ ➊。在从数组中提取单位时,我们需要数组元素的单位,这就是为什么在标签分配中出现了 eltype() 的原因。该函数会保存图形的独立 PDF 版本以及其 TeX 版本 ➋,以便将其包含在 LaTeX 文档中。
选择所需的后端后,我们调用该函数以默认名称创建 .pdf 和 .tex 文件:
pgfplotsx()
plot_with_units(velocity, KE; xl="Velocity", yl="K. E.")
图 9-3 显示了结果。

图 9-3:A PGFPlotsX 绘图与排版单位标签
LuaTeX 排版提供了图 9-3 中标签的优异质量。
测量中的误差传播
在前一节中,我们探讨了一个扩展数字概念以包括物理单位的包。在这里,我们将遇到 Measurements,这是另一个定义类似数字对象的包,适用于物理学或几乎所有经验科学中的计算。
Measurements 包允许我们为数字附加不确定性。所讨论的数字必须能够转换为浮点数,因此我们可以直接为 Float64 数字、整数和 Irrational 数量附加不确定性。(如果我们真的需要,还可以通过为其实部和虚部附加误差来创建带有不确定性的复数。)Measurements 包定义了一种新的数据类型,叫做 Measurement{T},其中 T 可以是任何大小的浮点数。我们可以对 Measurement 类型执行浮点数允许的任何算术运算,误差或不确定性将使用标准线性误差传播理论传播到结果中。
以下是创建 Measurement 类型实例的一些例子:
julia> using Measurements
julia> 92 ± 3
92.0 ± 3.0
julia> typeof(ans)
Measurement{Float64}
➊ julia> 92.0f0 ± 3
92.0 ± 3.0
julia> typeof(ans)
Measurement{Float64}
julia> 92.0f0 ± 3f0
92.0 ± 3.0
julia> typeof(ans)
Measurement{Float32}
julia> big(1227.0) ± 2
1227.0 ± 2.0
julia> typeof(ans)
Measurement{BigFloat}
我们使用科学家熟悉的符号表示法来创建 Measurement 对象。我们可以在 REPL 中输入 \pm 操作符并按下 TAB 键,或者使用操作系统的特殊字符输入法来输入 ± 符号。
在 REPL 中,ans 变量保存最近返回的结果。由于 Measurement 对象只有一个类型参数,因此基本数字和误差必须是相同的类型。正如 typeof() 调用所示,Measurements 根据需要提升较小的类型;f0 后缀是输入 32 位浮点字面量的一种方式 ➊。
该包智能地处理有效数字:
julia> π ± 0.001
3.1416 ± 0.001
julia> π ± 0.01
3.142 ± 0.01
被误差影响而变得不重要的数字不会被打印出来。
在 REPL 中打印结果时,包仅显示误差的两个有效数字,以保持整洁:
julia> m1 = 2.20394232 ± 0.00343
2.2039 ± 0.0034
julia> Measurements.value(m1)
2.20394232
julia> Measurements.uncertainty(m1)
0.00343
然而,它在内部仍然保留完整的值用于计算。我们可以使用 value() 和 uncertainty() 函数访问这些组件,正如这里所示,由于这些函数没有被导出,我们需要用包的命名空间来限定。
科学家们常常使用另一种方便的表示法,通过在最终有效数字后面加上括号中的误差来表示不确定性。Measurements 包也理解这种表示法:
julia> emass = measurement("9.1093837015(28)e-31")
9.1093837015e-31 ± 2.8e-40
为了使用这种表示法,我们需要使用 measurement() 函数并将参数作为字符串提供。我们还可以将 measurement() 用作 ± 操作符的替代方法:
julia> m1 = measurement(20394232, 0.00343)
2.0394232e7 ± 0.0034
算术运算会正确传播误差:
julia> emass
9.1093837015e-31 ± 2.8e-40
julia> 2emass
1.8218767403e-30 ± 5.6e-40
julia> emass + emass
1.8218767403e-30 ± 5.6e-40
julia> emass/2
4.5546918508e-31 ± 1.4e-40
julia> emass/2emass
0.5 ± 0.0
所有这些例子都按照预期对量和它们的误差执行算术运算。更有趣的是最后一个例子,其中 Measurements 识别了一个没有误差的比率。该包保持了相关和独立测量的概念,这在其文档中有详细说明。请参阅 第 304 页的“进一步阅读”获取网址。
回到 示例 9-3 的例子,我们可以通过两种方式为 Unitful 中的质量值添加不确定性:
julia> using Measurements, Unitful
julia> mass = 6.3u"kg" ± 0.5u"kg"
6.3 ± 0.5 kg
julia> mass = 6.3u"kg"; mass = (1 ± 0.5/6.3) * mass
6.3 ± 0.5 kg
这个例子展示了 Measurements 和 Unitful 包如何协同工作,创建同时带有单位和不确定性的量。
让我们继续使用来自 示例 9-3 的例子,使用这个新的 mass 值:
julia> using Plots
julia> velocity = (0:0.05:1)u"m/s";
julia> KE = mass .* velocity.² ./ 2;
julia> plot(velocity, uconvert.(u"J", KE); xlabel="Velocity", ylabel="K.E.",
lw=2, legend=:topleft, label="Kinetic energy")
尽管与之前一样,velocity 没有附带不确定性,但 mass 有;因此,KE 也应该包含不确定性。
图 9-4 显示了结果。

图 9-4:带单位和误差的绘图
图 9-4 显示了如前所述的带单位的 Unitful 数组图,其中坐标轴已标明单位。图中还显示了误差条,展示了动能增加时误差如何增加。我们不需要修改 plot() 函数的调用。某种程度上,绘图的量类型触发了绘图函数同时使用单位标签和误差条。我们在 Plots 中的其他绘图函数(如 scatter() 或 surface())也会表现出相同的行为。
流体动力学与 Oceananigans
Oceananigans 流体动力学仿真包,顾名思义,特别适用于海洋物理学。它提供了一个仿真构建工具包,可以包含温度和盐度变化、地球自转、风力等的影响。其默认设置通常表现良好,但足够灵活,用户可以指定几种可用的求解方法。它内置了各种物理模型,包括线性状态方程,但也很容易替换为用户自定义的其他模型。
物理系统
我们打算模拟地球引力场中的二维流体层。流体层底部的温度高于顶部。这种来自底部的加热会产生对流运动,正如云层或炉子上的锅中所见。
注意
Oceananigans 依赖于标准库中的一些已编译的二进制文件。如果 Oceananigans 的预编译失败,并且你正在使用最近或测试版的 Julia,可以尝试使用之前的 Julia 版本(前一个主版本号)。
底部和顶部的仿真边界是不可穿透且自由滑移的,这意味着流体可以在其上滑动。在水平方向上,我们施加了周期性边界条件,要求解答在左右边界之间进行环绕并保持一致。水平方向是 x 轴,垂直方向是 z 轴。我们将流体初始设为静止,并且对温度差异所产生的运动模式感兴趣。
图 9-5 显示了仿真系统的设置。灰色区域代表流体,粗黑色水平线表示恒温边界。

图 9-5:仿真框
创建此图表的 Luxor 程序(见 第 190 页的“使用 Luxor 绘图”)可以在在线补充资料的物理学部分找到,网址为 https://julia.lee-phillips.org。
一个流体动力学模拟包含许多部分,我们需要分别构建它们,然后才能开始计算。在接下来的小节中,我们将定义计算网格、边界条件、扩散模型和状态方程,并按此顺序建立边界条件和流体动力学模型。所有部分就绪后,我们将运行Oceananigans模拟并可视化结果。
网格
为了构建一个Oceananigans模拟,我们将使用该软件包导出的函数来定义其各个组件,然后使用model()函数定义一个模型,并将组件作为参数传递。对于这个例子,我们将使用一个grid,一个指定流体状态方程的buoyancy模型,一组边界条件,粘度和热扩散率的系数(流体的物理属性),以及流体内部的温度初始条件。我们不会包括地球自转、盐度或风的影响,但这些因素可以在其他Oceananigans模型中使用。
网格由其计算size(每个方向上有多少网格点)、extent(这些方向所表示的物理长度)以及其topology(Oceananigans用于表示每个方向上的边界条件的术语)定义。对于我们的问题,我们这样定义网格:
julia> using Oceananigans
julia> grid = RectilinearGrid(size=(256, 32);
topology=(Periodic, Flat, Bounded),
extent=(256, 32))
256×1×32 RectilinearGrid{Float64, Periodic, Flat, Bounded} on CPU with 3×0×3 halo
|-- Periodic x ∈ [0.0, 256.0) regularly spaced with Δx=1.0
|-- Flat y
-- Bounded z ∈ [-32.0, 0.0] regularly spaced with Δz=1.0
Oceananigans提供的RectilinearGrid()函数构建网格,这是该软件包中定义的多种数据类型之一。我们将网格赋值给我们自己的变量grid,以便在创建模型时使用。我们本可以为这个变量选择任何名称,但grid是模型构建函数接受的相关关键字参数的名称;为我们自己的变量使用相同的名称会使一切保持整洁。
在topology关键字参数中,我们列出了在x、y和z方向上的边界条件,其中z方向指向上方。边界条件Flat意味着我们没有使用(在这种情况下)y方向。这个调用定义了一个二维的x–z网格,其中x方向是周期性边界,z方向是不可穿透的边界。Oceananigans使用千克-米-秒单位制。因为我们将extent设置为等于size,所以网格间距在每个方向上为一个单位长度,给我们提供了一个宽 256 米、高 32 米的流体层。
如示例所示,Oceananigans在 REPL 中有用于表示其数据类型的有用形式,总结了我们检查时需要的关键信息。这里的输出为我们提供了网格参数和边界条件的摘要。
边界条件
我们将任何物理变量的边界条件定义为单独的组件,最终也会传递给model()。我们希望在上下边界上施加常数温度值;Oceananigans使用FieldBoundaryConditions()函数设置这种类型的边界条件,因为它设置了在这种情况下温度场的边界条件。我们可以使用Oceananigans提供的top和bottom的便捷定义,它们具有直观的含义(还有north、south、east和west,但在这个问题中我们不需要用到它们):
julia> bc = FieldBoundaryConditions(
top=ValueBoundaryCondition(1.0),
bottom=ValueBoundaryCondition(20.0))
Oceananigans.FieldBoundaryConditions, with boundary conditions
|-- west: DefaultBoundaryCondition (FluxBoundaryCondition: Nothing)
|-- east: DefaultBoundaryCondition (FluxBoundaryCondition: Nothing)
|-- south: DefaultBoundaryCondition (FluxBoundaryCondition: Nothing)
|-- north: DefaultBoundaryCondition (FluxBoundaryCondition: Nothing)
|-- bottom: ValueBoundaryCondition: 20.0
|-- top: ValueBoundaryCondition: 1.0
-- immersed: DefaultBoundaryCondition (FluxBoundaryCondition: Nothing)
immersed边界指的是存在于流体体积内部的边界,但我们不使用这个边界,也不使用其他任何复杂的选项,比如定义的梯度或通量。我们使用的ValueBoundaryCondition设置了在指定边界上某个变量的常数值。
扩散率
我们需要为描述流体材料属性的两个常数赋值,这是问题定义的一部分。粘度系数(ν)决定了流体的“稠密”程度,热扩散率(κ)决定了它传导热量的速率。这些值通过closure关键词传递给模型,并可以通过ScalarDiffusivity()函数进行设置:
julia> closure = ScalarDiffusivity(ν=0.05, κ=0.01)
粘度的符号是希腊字母nu,而热扩散率的符号是kappa。像所有希腊字母一样,我们可以在它们的名称前加上反斜杠,然后按 TAB 键在 REPL 中输入它们。
状态方程
状态方程是一个函数,用于描述流体在任何点的密度如何依赖于该点的温度和盐度(在Oceananigans模型中,通常假设不可压缩性意味着密度不依赖于压力)。我们的模型是无盐的,但当流体变热时,它会变得更轻。这就是流体会运动的原因,较轻的部分会上升,而较重的部分则会下沉,受到重力的驱动。
model()函数期望关键词buoyancy,所以我们也会使用它:
julia> buoyancy = SeawaterBuoyancy(equation_of_state=
LinearEquationOfState(thermal_expansion=0.01,
haline_contraction=0))
SeawaterBuoyancy{Float64}:
|-- gravitational_acceleration: 9.80665
-- equation of state: LinearEquationOfState(thermal_expansion=0.01, haline_contraction=0.0)
Oceananigans提供了许多其他选项,包括定义我们自己的状态方程的能力,但我们会保持模型的简单性。SeawaterBuoyancy组件通过将重力(此处给出的默认地球值)与密度变化相结合来处理浮力。由于我们不关心盐度效应,我们将haline_contraction设置为 0(“haline”实际上是海洋学家用来表示盐度的同义词)。
模型与初始条件
现在我们已经设置好了所有的组成部分,我们可以将它们结合成一个模型,这是Oceananigans定义计算问题的术语,包括所有的物理内容、网格和边界条件:
julia> model = NonhydrostaticModel(;
grid, buoyancy, closure,
boundary_conditions=(T=bc,), tracers=(:T, :S))
NonhydrostaticModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0)
|-- grid: 256×1×32 RectilinearGrid{Float64, Periodic, Flat, Bounded}
➊ on CPU with 3×0×3 halo
|-- timestepper: QuasiAdamsBashforth2TimeStepper
|-- tracers: (T, S)
|-- closure: ScalarDiffusivity{ExplicitTimeDiscretization}
(ν=0.05, κ=(T=0.01, S=0.01))
|-- buoyancy: SeawaterBuoyancy with g=9.80665 and
LinearEquationOfState(thermal_expansion=0.01, haline_contraction=0.0)
with -ĝ = ZDirection
-- coriolis: Nothing
该包打印了结果的简洁摘要,包括提醒一些我们未使用的特性(但并非所有),比如地球自转产生的科里奥利力。
NonhydrostaticModel() 函数使用适合我们问题的近似方法来创建模型。Oceananigans 提供了其他几种选择,包括模拟表面波的静力学模型。
我们使用了“关键字参数简洁语法”中解释的简化形式,详见第 154 页。
我们的边界条件 bc 并不指向任何特定的物理变量;它仅仅是在边界上定义一个常数场值。分配给 boundary_conditions 的命名元组会在 T 上强制执行这些条件,T 是 Oceananigans 中用于表示温度的变量。
打印结果提到的 CPU ➊,意味着该模型适用于“普通”机器架构。另一种选择是使用 GPU(图形处理单元)进行计算。halo 指的是数值算法用来强制执行边界条件或其他约束的物理网格外的几个点。
最后的关键字参数 tracers 告诉模型在流体中跟踪温度和盐度等标量场的输送。尽管我们的状态方程意味着它不会产生任何影响,但我们仍然需要包括 :S。
我们模型中由下方加热的流体层在物理上是不稳定的,这意味着对其初始静止状态的一个小扰动将被放大,并发展成一种具有持续运动的状态,这种运动由温度差和引力场驱动。我们要研究的正是这种不稳定性的发展。我们需要加入小的扰动,否则,即使系统不稳定,它也永远不会移动。
set!() 函数让我们可以在任何字段上创建所需的初始条件。我们将使用它在整个流体体积中对温度场添加一个小的随机扰动:
julia> tper(x, y, z) = 0.1 * rand()
tper (generic function with 1 method)
julia> set!(model; T = tper)
该函数的拼写中有一个感叹号,提醒我们它会修改其参数:它会就地更改 T 字段,也会更改模型。
仿真
接下来,我们需要使用 Simulation() 函数创建一个仿真。这个对象将接收模型作为位置参数,并接收时间步长和停止计算时间的关键字参数。它会跟踪仿真时间和墙钟时间的经过情况,以及所有物理场的状态。这使得我们可以在请求的开始时间之后继续仿真,保存仿真进度到文件,并检索字段进行检查和绘图。
julia> simulation = Simulation(model; Δt=0.01, stop_time=1800)
Simulation of NonhydrostaticModel{CPU, RectilinearGrid}(time = 0 seconds, iteration = 0)
|-- Next time step: 10 ms
|-- Elapsed wall time: 0 seconds
|-- Wall time per iteration: NaN years
|-- Stop time: 30 minutes
|-- Stop iteration : Inf
|-- Wall time limit: Inf
|-- Callbacks: OrderedDict with 4 entries:
| |-- stop_time_exceeded => Callback of stop_time_exceeded on IterationInterval(1)
| |-- stop_iteration_exceeded => Callback of stop_iteration_exceeded on IterationInterval(1)
| |-- wall_time_limit_exceeded => Callback of wall_time_limit_exceeded on IterationInterval(1)
| -- nan_checker => Callback of NaNChecker for u on IterationInterval(100)
|-- Output writers: OrderedDict with no entries
-- Diagnostics: OrderedDict with no entries
这是一个简单的调用,因为 model 已经包含了问题的所有细节。我们会得到仿真各种选项的摘要,大部分我们并没有使用。如果你想在 REPL 中使用时间间隔的增量,输入 \Delta 并按 TAB 键。
在运行模拟之前,让我们安排在定期的时间间隔内将速度和温度字段存储到磁盘上,这样我们就可以查看它们随时间的发展(如果不这样做,我们只能看到模拟的最终状态),如列表 9-4 所示。
julia> simulation.output_writers[:velocities] =
JLD2OutputWriter(model, model.velocities,
filename="conv4.jld2", schedule=TimeInterval(1))
JLD2OutputWriter scheduled on TimeInterval(1 second):
|-- filepath: ./conv4.jld2
|-- 3 outputs: (u, v, w)
|-- array type: Array{Float32}
|-- including: [:grid, :coriolis, :buoyancy, :closure]
-- max filesize: Inf YiB
julia> simulation.output_writers[:tracers] =
JLD2OutputWriter(model, model.tracers,
filename="conv4T.jld2", schedule=TimeInterval(1))
JLD2OutputWriter scheduled on TimeInterval(1 second):
|-- filepath: ./conv4T.jld2
|-- 2 outputs: (T, S)
|-- array type: Array{Float32}
|-- including: [:grid, :coriolis, :buoyancy, :closure]
-- max filesize: Inf YiB
列表 9-4:设置输出写入器
将元素添加到 simulation 的 output_writers 属性中会导致它定期存储结果。JLD2OutputWriter 使用 JLD2 文件格式,这是将多个 Julia 数据结构存储在一个文件中的紧凑方式。它是广泛应用于计算科学中的 HDF5 格式的一个版本。schedule 每秒进行一次数据转储,使用我们的时间步长,这将是每 100 步一次。结果中的信息显示将保存哪些量:T 和 S 分别是温度和盐度。
这样,我们就准备好运行计算了:
julia> run!(simulation)
Info: Initializing simulation...
[ Info: ... simulation initialization complete (6.850 ms)
[ Info: Executing initial time step...
[ Info: ... initial time step complete (80.507 ms).
REPL 在计算达到最终时间步之前不会有任何输出,在此情况下,通常需要几个小时才能在个人计算机上完成。计算完成后,它会指示计算结束并返回交互式提示。[第十五章探讨了通过并行处理加速此类计算的方法。
结果
当一个 Oceananigans 模拟结束时,字段的最终状态(此例中为速度分量和温度)作为 model 的属性可用。列表 9-5 展示了如何检索它们。
julia> using Plots
julia> uF = model.velocities.u;
julia> TF = model.tracers.T;
julia> heatmap(interior(TF, 1:grid.Nx, 1, 1:grid.Nz)';
aspect_ratio=1, yrange=(0, 1.5grid.Nz))
列表 9-5:检查模拟结果
速度和温度字段是模型的属性。heatmap() 调用将绘制二维温度场,但首先我们需要使用 interior() 函数将其转换为数组。此函数将 Oceananigans 字段转换为数值数组,并去除 halo 点。它的参数,在要转换的字段之后,是三个方向上网格的范围;我们输入 1 来表示未使用的坐标。在设置 yrange 时,我们访问了字段的另一个属性,即网格形状。数组后面的撇号会将其转置,以便它呈现出自然的方向,带有垂直的重力。
我们通常会先运行几个时间步的模拟,并以这种方式检查字段,然后再进行长时间的计算,以确保我们正确设置了模拟。如果我们想在经过更多时间步后再查看一次,可以这样做:
julia> simulation.stop_time+=10;
julia> run!(simulation);
这些命令将模拟推进额外的 10 个时间步,之后我们可以重复列表 9-5 中的步骤,看看进展如何。
现在返回到存储在文件中的量,如列表 9-4 所示,列表 9-6 展示了如何检索字段的整个历史。
julia> uF = FieldTimeSeries("conv4.jld2", "u")
256×1×32×1030 FieldTimeSeries{InMemory} located at
(Face, Center, Center) on CPU
|-- grid: 256×1×32 RectilinearGrid{Float64, Periodic, Flat, Bounded}
on CPU with 3×0×3 halo
|-- indices: (1:256, 1:1, 1:32)
-- data: 256×1×32×1030 OffsetArray(::Array{Float64, 4},
1:256, 1:1, 1:32, 1:1030) with eltype Float64 with
indices 1:256×1:1×1:32×1:1030
-- max=7.66057, min=-7.88889, mean=2.79295e-11
列表 9-6:从 JLD2 文件中检索字段
结果的摘要显示,FieldTimeSeries 的维度为 256×1×32×1,030,这意味着它定义在一个 2D 的 256×32 网格上,并在 1,030 个时间步长中演化。
在这个调用之后,整个 x 速度场及其各种属性都可以方便地访问。数据结构 uF 本身几乎不占用任何空间:
julia> sizeof(uF)
544
sizeof() 函数返回其参数所占用的存储空间(以字节为单位)。实际数据占用的空间为 256 × 32 × 1,030 × 8 = 67,502,080 字节。
我们可以绘制任何时间步长的水平速度场:
julia> using Printf
julia> i = 50;
julia> h50 = heatmap(interior(uF[i], 1:grid.Nx, 1, 1:grid.Nz)';
aspect_ratio=1, yrange=(0, 1.5grid.Nz),
colorbar=:false, ylabel="z",
annotations=[
(0, uF.grid.Nz+15,
text("Horizontal velocity at timestep $i", 12, :left)),
(0, uF.grid.Nz+5,
text((@sprintf "Max = %.3g" maximum(uF[i])), 8, :left)),
(100, uF.grid.Nz+5,
text((@sprintf "Min = %.3g" minimum(uF[i])), 8, :left))],
grid=false, axis=false)
我们在清单 9-5 中的版本上添加了一些标注,使用从字段中读取的属性对图形进行注解。为时间步长 100 和 500 创建类似的图形,给最后一个添加 xlabel,然后使用 plot(h50, h100, h500; layout=(3, 1)) 将它们组合起来,生成图 9-6 中的图形。

图 9-6:Oceananigans 仿真结果
系统展现了所谓的湍流对流状态;观察从随机性中出现的大尺度秩序及其与湍流流动的持久共存非常有趣。
为了制作仿真的动画,我们需要在相等的时间间隔生成图形,并将它们拼接成一个视频文件。我们的仿真使用了常量时间步长,因此在这种情况下,相等的时间间隔意味着相等的时间步长数量。然而,这并不总是如此。Oceananigans 提供了自动调整时间步长的选项,我们可能会在不同的阶段进行仿真,使用不同大小的 Δt。因此,拥有一个根据 时间 创建图形的函数非常方便。由于给定的时间可能不对应于任何特定的存储字段,可能会落在两个连续数据存储之间,我们需要一个函数来确定最接近请求时间的存储字段。清单 9-7 中的 Julia 程序获取仿真输出并生成指定持续时间的动画。
using Oceananigans, Reel, Plots
function heatmap_at_time(F, time, fmin, fmax, duration)
ts = F.times
time = time * ts[end]/duration
i = indexin(minimum(abs.(ts .- time)), abs.(ts .- time))[1] ➊
xr = yr = zr = 1
if F.grid.Nx > 1
xr = 1:F.grid.Nx
end
if F.grid.Ny > 1
yr = 1:F.grid.Ny
end
if F.grid.Nz > 1
zr = 1:F.grid.Nz
end
heatmap(interior(F[i], xr, yr, zr)'; aspect_ratio=1, yrange=(0, 1.5F.grid.Nz),
clim=(fmin, fmax)) ➋
end
uF = FieldTimeSeries("conv4.jld2", "u")
const fmin = 0.5minimum(uF) ➌
const fmax = 0.5maximum(uF)
const duration = 30
function plotframe(t, dt)
heatmap_at_time(uF, t, fmin, fmax, duration)
end
uMovie = roll(plotframe; fps=30, duration)
write("uMovie.mp4", uMovie)
清单 9-7:创建一个 Oceananigans 仿真动画
heatmap_at_time() 函数完成了所需的操作,在其参数中最接近的时间点创建了热力图。在这个函数中,F 是通过调用 FieldTimeSeries() 获取的字段,如清单 9-6 所示。它利用了这些对象的 times 属性,该属性是一个数组,包含了该字段被保存的所有时间点。索引 i 保存了与提供的 time ➊ 最接近的时间点对应的数据。当制作热力图动画时,我们希望每一帧中的值到颜色的映射保持一致,因此我们对 heatmap() 的调用使用了 clim 关键字 ➋。
有了这个功能,我们可以使用在第 206 页的“使用 Reel 制作动画”中介绍的Reel包来创建动画。为了使用该包,我们需要定义一个关于时间t的函数,并使用(未使用的)dt,该函数返回与t相对应的绘图:plotframe()函数。脚本中的三个常数 ➌ 设置了基于数据和动画总时长的调色板限制。调色板限制经过缩放,使得在运行的初期可以看到更多细节,但我们可以根据感兴趣的特征进行调整。
注意
查看在线补充内容,了解生成的动画,以及图表的全彩图版,链接见 julia.lee-phillips.org 。
最后的调用将动画保存为 MP4 文件。Reel支持的其他选项包括gif和webm。要创建这些文件类型,我们只需使用适当的文件扩展名。
使用 DifferentialEquations 求解微分方程
自 18 世纪以来,微分方程一直是物理科学和工程学,以及其他科学定量分析的语言。Julia 的DifferentialEquations包是一个强大、先进的工具,用于使用多种方法求解许多类型的微分方程。它还结合了关于机器学习的最新研究,以选择解决给定方程的最佳方法。
本节通过解决一个示例问题介绍了如何使用DifferentialEquations。有兴趣的读者可以深入查阅其详细文档获取更多信息(请参阅第 304 页的“进一步阅读”)。
定义物理问题及其微分方程
作为示例,我们来研究摆锤问题。图 9-7 展示了问题的图示,并定义了弦长(L)和角度(θ)。

图 9-7:摆锤系统
我们从竖直参考线开始逆时针测量θ,该线在图中为虚线,重力加速度指向下方。
注意
生成该图表的 Luxor 程序可在在线补充内容的物理章节代码部分找到,链接见 julia.lee-phillips.org。
通过对摆锤摆球(图中的黑色圆圈)上的力进行简单分析,并应用牛顿第二定律,得到微分方程

这是在任何基础通用物理教材中得出的。在这里,t是时间,g是重力加速度。通常的下一步是将问题限制在小角度(≲ 5°)范围内,此时 sin(θ) ≈ θ,并求解得到简谐运动的差分方程。我们将使用DifferentialEquations包数值求解“精确的”摆动方程。我们将能够检查任何初始θ的解,直到π弧度。
该包处理一阶方程组,这意味着差分方程仅限于未知函数的一阶导数。因此,为了处理摆动方程,我们首先需要将其转化为两个耦合的一阶方程的形式。这一步也是许多解析解法的一部分。我们可以通过定义一个新变量轻松进行:

现在我们要求解两个随时间变化的函数,角度θ(t)和角速度ω(t)。
设置问题
将数学问题转化为DifferentialEquations能处理的形式的第一步是定义一个有四个位置参数的 Julia 函数:
du 解的导数数组
u 解函数的数组
p 参数数组
t 时间
列表 9-8 是摆动问题的版本。
function pendulum!(du, u, p, t)
L, g = p
θ, ω = u
du[1] = ω
du[2] = -g/L * sin(θ)
end
列表 9-8:摆动方程的 Julia 版本
这是一个变异函数,如感叹号所示,因为随着计算的进行,解算引擎会变异u和du数组来存储结果。这里通过解构数组p来设置L和g,而θ和ω则从数组u中读取。DifferentialEquations中的求解器会在构建解的过程中反复调用pendulum!(),并传入p、t以及正在发展的解数组。
求解方程组
为了计算解,我们首先定义计算问题,然后将该问题传递给solve()函数。计算问题的组成部分包括参数数组、初始条件、我们希望得到解的时间范围,以及定义待求解差分方程的函数,在这个例子中是pendulum!()。其他选项包括要使用的数值方法,但在这个简单的例子中我们将不指定这些选项。该包通常会很好地选择最适合我们所提供方程性质的解法。列表 9-9 显示了问题的设置和初始化。
using DifferentialEquations
p = [1.0, 9.8]
# L g <- Parameters
u0 = [deg2rad(5), 0]
# θ ω <- Initial conditions
tspan = (0, 20)
prob = ODEProblem(pendulum!, u0, tspan, p)
sol5d = solve(prob)
列表 9-9:使用 DifferentialEquations 求解差分方程
本节中来自DifferentialEquations包的唯一两个函数是ODEProblem()和solve()。ODEProblem()接受四个位置参数:定义方程系统的函数、初始条件数组、时间跨度和参数数组。我们在清单 9-8 中定义了该函数,这里定义了其他三个参数。允许求解器将参数作为参数传递,使得生成具有不同参数范围的解集变得方便。
ODEProblem()返回的结果包含了所有函数的完整解(在这个例子中是两个),并将它们打包成包中定义的数据类型。这个数据类型设计用于便于检查和绘制解,它包含了计算的函数,以及关于问题和计算的其他信息。
检查解
对于小角度,摆动问题的解析解为:

其中θ[0]是初始角度。清单 9-9 中的初始条件设置了摆动处于静止状态,起始角度为 5°,因此小角度近似应该有效。
由于我们知道解析解,我们可以将数值结果与之进行对比。清单 9-10 展示了如何将两者绘制在一起。
using Plots
plot(sol5d; idxs=1, lw=4, lc=:lightgrey, label="Numeric",
legend=:outerright, title="Pendulum at θ0 = 5°")
L, g = p
plot!(t -> u0[1]*cos(sqrt(g/L)*t); xrange=(0, 20),
ls=:dash, lc=:black, label="Analytic")
清单 9-10:求解小角度情况
第一个plot()调用只使用一个数据参数,即解本身,在清单 9-9 中被赋值给sol5d。这既不是数组,也不是函数,但plot()似乎知道如何显示它。第一个关键字参数idxs请求绘制(在本例中)第一个函数θ。idxs在Plots包的文档中没有出现,实际上在该包中并没有定义。因此,除非我们首先导入DifferentialEquations,否则它没有任何效果。
如图 9-8 所示,该图使我们确信我们已经正确设置了问题,且数值解法是有效的。

图 9-8:检查摆动方程的小角度解
像我们这里所做的那样绘制解,并不仅仅是直接绘制解数组。它还会在计算值之间进行插值,以生成平滑的图形。在这个例子中,解只包含 83 个数据点,如果直接绘制,将生成一个粗糙的图表。
尽管解对象不是数组,但该包定义了索引方法,方便提取数据。如果我们确实需要访问未插值的解数据,可以通过索引获取。在这里,sol5d[1, :]返回第一个变量θ的 83 个数据点,而sol5d[2, :]返回第二个变量ω的 83 个数据点。要获取这些值定义的时间,我们可以使用一个属性:sol5d.t。
将解对象作为函数使用时,会返回插值结果,插值时间作为参数传入。(我们在本节中使用的是时间,但在其他问题中,独立变量可能是其他内容。)sol5d(1.3)函数调用返回一个包含两个元素的Vector,每个元素对应一个变量,插值到时间 1.3。 这些函数也接受范围和数组,因此sol5d(0:0.1:1)返回在 0 到 1 之间 11 个时刻的插值解数据。要提取这些时刻的角度变量,我们可以调用sol5d(0:0.1:1)[1, :]。通过使用解对象的函数形式来控制插值的密度,在绘制例如散点图时非常有帮助,因为我们需要控制绘制点的密度。
解的结果如何依赖于初始角度?通过重新定义u0以尝试两个较大的初始角度,并按照清单 9-10 的方式生成两个新的解,我们得到了如图 9-9 所示的结果。

图 9-9:具有较大初始角度的摆
当初始角度为 90°时,摆绳最初水平,解的形状近似为正弦波,但其频率比小角度情况下低大约 25%。当初始角度为 175°时,周期几乎是小角度周期的三倍,解明显远离正弦波形。在生成图 9-9 时,我们通过传递另一个由DifferentialEquations定义的关键字tspan=(0, 10)给plot()来限制自变量的范围。
定义时间依赖参数
通过将p数组中的一个或多个常数参数替换为时间的函数,我们可以研究系统对时间依赖参数的响应。通过这种方式,我们可以在微分方程中包含非齐次项、强迫函数和时间变化的参数。
让我们来看看如果在摆动时稳定地拉动绳子会发生什么。我们将从 45°开始,计算 10 秒内的解,将常数L替换为一个线性递减的时间函数:
tspan = (0, 10)
u0 = [π/4, 0]
Lt(t) = 1 - 0.999t/10
我们需要创建一个稍微不同版本的pendulum()函数,如清单 9-11 所示,该版本可以使用时间变化的摆绳长度。
function pendulum2!(du, u, p, t)
L, g = p
θ, ω = u
du[1] = ω
➊ du[2] = -g/L(t) * sin(θ)
end
清单 9-11:具有时间依赖的 L 的摆函数
我们对之前的函数所做的唯一更改是将L替换为L(t) ➊。接下来的步骤和之前一样。ODEProblem()函数需要一个新的参数数组,见清单 9-12,用于传递给pendulum2()。
p = [Lt, 9.8]
prob = ODEProblem(pendulum2!, u0, tspan, p)
solLt = solve(prob)
清单 9-12:使用时间变化的 L 获得数值解
将问题推广到包括时变参数的方式简化了微分方程中参数传递方法的优势。结果,在图 9-10 中,显示了随着角速度(ω)的增加,周期和幅度稳步减小。

图 9-10:摆钟上拉绳子的动作
我们通过以下调用生成图 9-10:
plot(solLt; idxs=1, label="θ", legend=:topleft, ylabel="θ",
➊ right_margin=13mm)
plot!(twinx(), solLt; idxs=2, label="ω", legend=:topright,
ylabel="ω", ls=:dot)
在plot!()的调用中,第一个参数twinx()创建了一个子图重叠,分享第一个图的水平轴并绘制一个新的垂直轴;我们使用它是为了让两条曲线不必共享同一个比例尺。右侧需要额外的空间➊来为第二个垂直轴上的标签留出位置。这个边距设置需要导入Plots.PlotMeasures,正如在第 101 页“与图表设置一起工作”中解释的那样。
参数不稳定性
一个孩子在操场上“推动”秋千使其摆动,实际上是在利用参数不稳定性。这种不稳定性的驱动因素是摆钟绳子有效长度的周期性变化。线性理论(我们在本节中正在攻克的微分方程的小角度版本)告诉我们,当驱动力频率是摆钟固有频率的两倍时,就会发生共振,利用我们的L = 1,可以得到
。如果绳子的长度在这个频率下做正弦扰动,小幅度振荡的幅度会指数级增长。
由于我们知道如何将任何时变函数L(t)插入到数值解中,因此我们可以研究摆钟对超出小角度近似的参数激励的响应。我们将从一个小的初始角度开始,跟踪其演化一段较长的时间,并定义一个新的时间函数来表示绳子的长度:
const g = 9.8
tspan = (0, 400)
u0 = [π/32, 0]
Lt(t) = 1.0 + 0.1*cos(2*sqrt(g)*t)
Lt(t)将按照参数共振的频率,将名义上 1 米的长度扰动 10%。
我们的工作与之前完全相同,只是做了一些调整。我们使用pendulum2(),它在清单 9-11 中定义,并像清单 9-12 中那样设置问题。调整之处在于,我们需要向求解函数提供一个关键字参数:
solLt = solve(prob; reltol=1e-5)
reltol参数根据需要调整自适应时间步进,以限制局部误差达到我们提供的值。它的默认值为 0.001,这导致一个看起来有些可疑的解,因为它并不完全是周期性的。我生成了reltol = 1e–4、1e–5 和 1e–6 的解。1e–4 的解看起来合理,但 1e–5 的解略有不同。由于reltol = 1e–6 的解与 1e–5 的解几乎相同,因此它们可能是准确的。图 9-11 显示了θ与时间的关系图。

图 9-11:有限角度摆钟的参数不稳定性
最初,振幅呈指数增长,符合线性理论的预测。但我们从之前的解中知道,摆的频率随着振幅的增大而减小;因此,它会持续地与强迫函数失去共振,振幅回落到接近初始值。到那个时候,它会更接近共振,振幅再次呈指数增长。正如解所示,这个过程会重复发生。
结合微分方程与测量
假设我们想通过实验验证我们的摆动解决方案的预测。在设置初始角度时会存在一些误差。如果我们估计这个不确定度为一度,我们可能会考虑这样陈述初始条件(请参见第 280 页的“测量误差传播”):
using Measurements
u0 = [π/2 ± deg2rad(1), 0]
函数 deg2rad() 将角度从度转换为弧度。
我们可以像之前一样继续操作,重复列表 9-8 和 9-9 中展示的过程。现在,θ(t)的解的图像如图 9-12 所示。

图 9-12:结合 微分方程 与 测量
尽管我们没有告诉 plot() 函数任何有关绘制误差条的内容,但它们还是出现在了图表中。图表显示了角位置的误差如何随着时间的推移平均增长。然而,误差并不是单调增长的。当精确解和误差范围的极限解恰好同相时,误差会减小。
我们生成解并在图 9-12 中绘制它,具体过程如下:
prob = ODEProblem(pendulum!, u0, tspan, p)
solM = solve(prob)
plot(solM(0:0.1:5)[1, :]; legend=false, lw=2, ylabel="θ", xlabel="t")
由于 DifferentialEquations 在解的每个点上都附加了误差,包括在绘制图形时插值的点,我们必须使用在第 297 页的“检查解”部分描述的技术来限制绘制的点的数量;否则,图表会因为误差条过于密集而无法解释。
结论
尽管我们在本章中详细探讨了几个物理包,但实际上我们只是略微触及了它们的表面。然而,我希望这里的介绍足以帮助你评估本章中探讨的任何包是否适合你的项目,并向你展示如何开始使用它们。
本章的另一个目的是作为 Julia 及其生态系统强大功能的介绍。在多个示例中,我们能够在不做任何特别安排的情况下,将两个或三个包的功能结合起来。我们制作了包含单位的图表和排版表达式,看到它们被合理地处理。我们将一个常微分方程求解器的输出传递给另一个包中的绘图函数,它提取了相关数据并进行了绘制。我们在初始条件中带有误差估计的情况下求解常微分方程,并且误差被正确地传播到解中。我们绘制了这个结果,仿佛是魔法一样,解中显示了误差条。
我们编写了脚本和程序,结合了五个包的功能,以不同的组合方式,将它们赋予了作者未曾设想或计划的能力。这些包中的大多数是没有任何关于其他包知识的情况下编写的。作者们以通用的方式编写了这些代码,使得 Julia 的类型系统和多重分派方法能够使其功能与其他包中定义的数据类型兼容。
Julia 最初吸引了人们的注意,因为它是一种像高级解释型语言一样容易上手并能高效工作的语言,同时也足够快速,能满足最严苛的科学工作需求:“像 Python 一样简单,像 Fortran 一样快速。” Julia 在科学领域日益受到青睐的第二个原因是,它能够将不同包的功能无缝结合,而不需要应用程序员额外的工作。Julia 的创建者和包的作者们将这一特性称为包的 可组合性,类似于函数组合的概念。
进一步阅读
-
GitHub 社区 “Julia 的物理生态系统” (https://juliaphysics.github.io/latest/ecosystem/) 维护了一个便捷的与所有物理领域相关的包列表,并包括与数学和绘图相关的包。
-
Unitful包可在 https://github.com/PainterQubits/Unitful.jl 找到。 -
详情请见 https://www.simscale.com/blog/2017/12/nasa-mars-climate-orbiter-metric/,了解单位混淆如何摧毁了火星气候轨道器。
-
UnitfulLatexify的文档位于 https://gustaphe.github.io/UnitfulLatexify.jl/dev/。 -
Measurements包位于 https://github.com/JuliaPhysics/Measurements.jl。 -
若要开始使用
Oceananigans,请参见 https://clima.github.io/OceananigansDocumentation/stable/quick_start/。 -
DifferentialEquations.jl的文档可以在https://diffeq.sciml.ai/stable/查阅。 -
本章节的动画、彩色图像和补充代码可在https://julia.lee-phillips.org找到。
-
你可以在https://lwn.net/Articles/835930/和https://lwn.net/Articles/834571/找到
DifferentialEquations.jl使用的简单示例。 -
摆的参数不稳定性在视频https://www.youtube.com/watch?v=dGE_LQXy6c0中展示。
-
一般谐振子参数共振的理论在https://www.lehman.edu/faculty/dgaranin/Mechanics/Parametric_resonance.pdf中进行了讨论。
第十一章:统计学**
这个世界的真正逻辑是概率微积分。
—詹姆斯·克拉克·麦克斯韦

本书的许多读者可能会跳过第二部分中的一个或多个章节。例如,一位生物学家可能对物理应用不感兴趣。但是这一特定章节对每个人都有帮助,因为迟早所有科学家都必须面对统计学这一主题。
任何进行实验的人都知道,实验数据的处理和分析是统计方法和概念的直接应用。每台科学计算器都配有用于计算一组数字的均值和标准差的按钮。在本章中,你将学习如何应用 Julia 及其统计库来操作、绘制和分析各种数据。与统计学领域的标准语言 R 相比,Julia 通常更快、更灵活、更可扩展且更强大。但如果你已经在使用 R 程序,我会解释如何在 Julia 环境中使用它们。
概率和分布的概念在物理学中无处不在,从经典的统计力学理论到量子理论,其中概率扮演着基础性角色。而统计学及其在概率语言中的基础,在科学中几乎无处不在,即使在实验和观察之外也是如此。本章中的一个详细例子涉及生物学中的概率建模:这些思想在分析实验和物理学之外的应用。
概率
我们这里没有足够的篇幅讲解完整的概率和统计课程,但幸运的是,我们可以在没有详细数学推导的情况下做我们需要做的一切。几乎所有科学家都对这一学科的基本概念和方法有所了解,但我不会假设读者有任何特别的知识。
要理解和使用统计学,我们首先需要清楚地掌握概率。在我们的应用中,我们可以把概率理解为一个介于 0 和 1 之间的数(包括 0 和 1),它表示某事件发生的可能性。概率为 0 意味着该事件不可能发生,而概率为 1 意味着该事件必然发生。其他任何概率可以被解释为在大量实验中该事件发生的频率或比例。例如,如果我们说抛硬币时正面朝上的概率是 1/2,这意味着如果你抛硬币很多次,硬币正面朝上的次数与总抛掷次数的比例将接近 0.5。
大量的“多次”到底是多少次?我们真正的意思是这里有一个极限

这只是说,当我们做更多实验时,观察到事件x的次数n[x],除以实验总次数N,将越来越接近某个特定比例。我们称这个比例为概率。在概率论中,实验指的是一个过程,例如掷硬币或掷骰子。
前面一段描述了概率的一种特定观点,叫做频率解释。当然也有其他方式来看待概率及其含义,但从某种意义上来说,它们都是等价的。频率解释是实用的,能够很好地服务于我们的目的,而且是大多数人在需要明确理解概率实际含义时所想到的方式。有关该主题的更正式的探讨,请参见 359 页中的“进一步阅读”。
我们通常希望在计算机程序中模拟某些事件,这些事件应当以某些概率发生。这可能是系统模拟的一部分,比如模拟气体分子在盒子里碰撞,我们可能希望用随机的位置和速度来初始化这些分子,或者它可能是统计检验的一部分。但这就提出了一个问题:如果概率代表机会,即某种随机过程的结果,而我们计算机内部的过程(我们当然希望如此)是确定性的,那么我们如何利用计算机生成随机事件呢?
就本书中的例子而言,我们实际上并不希望我们的随机事件是随机的,因为我们可能希望重复模拟或者检查在改变计算方法后是否会得到相同的结果。我们需要能够重复特定的“随机”事件序列。这看起来似乎是一个矛盾。如果我们知道结果会是什么,它就不可能是随机的。
我们在程序中生成的随机数被称为pseudorandom(伪随机)数。它们看起来像是随机数的序列,满足某些随机性测试,并且遵循给定的分布(接下来会解释)。然而,本质上它们并不真正是随机的。再次强调,我们并不是真的希望它们是完全随机的。
除非我们确实需要。在某些密码学应用中,我们确实需要真正的、不可预测的随机数。因为不法分子知道各种生成伪随机数的算法,能够预测这些序列可能会导致破解密码系统。为了这样的用途,计算机安全系统利用计算机上任何可用的真实不可预测性来源(称为熵源)。这些来源可以是键盘上按键时间生成的存储数据。例如,熵的寻找促使了一些创意的解决方案,比如对着熔岩灯墙拍照。
Julia 实际上提供了一种方法,可以利用操作系统提供的熵。然而,在本书中,我们并不关心密码学,而是关注科学,所以我们希望我们的随机数并不那么随机,我们将使用 Julia 的伪随机数生成器。接下来的章节中,我会遵循常见做法,将这些伪随机数称为“随机数”。
Julia 中的随机数
Julia 有用于生成各种数值类型的随机数的函数,甚至包括复数。基本的随机数生成器是Base的一部分,因此你可以在不需要import语句的情况下直接使用它们。
注意
我之前提到过,使用伪随机数的一个原因是我们在开发代码时可以重复一个随机数序列。然而,这种序列的可重复性并不能保证永远有效。特定函数返回的随机序列可能在升级 Julia 后发生变化,因此你不能长期依赖它进行代码开发。如果你需要长期可复现的数字序列,请参见第 359 页的“进一步阅读”。
最简单的用法是调用rand(),它会返回一个在区间 0, 1)内均匀分布的随机Float64。这意味着数字可能等于 0,但会小于 1,并且区间内的所有数字都是等可能的。
我们可以通过生成一堆随机数并用散点图绘制它们来检查rand()函数是否按预期工作。我们可以通过多次调用rand(),将其返回的值存储在数组中,并绘制该数组来实现。但rand()使这一过程变得更简单:如果我们传递一个整数参数,它会按要求返回一个随机值数组,其长度由参数决定。如果我们传递多个参数,它会返回一个更高维的数组。[列表 10-1 中的小程序将一个长度为 10⁵的数组填充随机浮点数,并通过散点图可视化它们的分布。
using Plots
ra = rand(100000)
scatter(ra, markersize=1, label=nothing)
列表 10-1:测试随机数生成
在结果图中,如图 10-1 所示,每个 10⁵个数字由一个小点表示。所有数字都位于正确的区间内,并且它们似乎是随机且均匀分布的,正如它们应该的那样。像这样的图表是一个有用的视觉检查,用来确保伪随机数生成器行为正常,并且没有在数值分布中引入任何不需要的模式。

图 10-1:均匀分布的随机浮点数
要获取随机整数或其他类型(而不是浮点数),只需将类型作为参数传递即可。调用rand(Int)(与rand(Int64)相同)将返回一个在该类型所定义的最小和最大整数范围内的随机整数。然而,这在应用程序中很少是你想要的。你可能希望得到一个在某个特定范围内的随机整数,这个范围与你的问题相关。在这种情况下,只需将范围作为参数传递:例如,rand(1:6)表示掷骰子。
事实上,该参数也可以是元组或列表,rand()会从中随机选择一个元素,且每个元素的选择概率相等。你甚至可以像这样做:rand([1, 3, "abc"]),并随机得到1、3或字符串"abc",每个的概率都是 1/3。如果你传入一个字符串,它会被视为字符集合,返回一个随机字符。
简单的rand()调用在模拟中非常有用,特别是当你希望某些事件按特定概率发生时。如果事件发生的概率是P,那么在代码中你将看到类似以下的内容,这是一种让某件事以指定概率发生的方式:
if P > rand()
event()
end
调用rand()有效,因为它会生成在区间[0, 1)内均匀分布的随机数。想象一下,反复把飞镖投向一个边长为一米的正方形飞镖板(假设它会随机落在板上的某个位置)。从长远来看,飞镖将有 90%的时间落在最右边的 90 厘米范围内。rand()函数就像是那个飞镖。
请记住,从长远来看,你无法依赖 Julia 的随机数函数生成的特定序列能够重复,因此在调试代码或开发算法时,你需要知道如何在短期内做到这一点。当你更改某些你认为不应该改变结果的部分时,你通常会希望重新运行程序。如果程序使用了随机数,而且序列是真正不可预测的,这种测试就变得不可能了。
通过将一个种子传递给随机数生成器,你可以生成一系列高质量的伪随机数,并且在程序的后续运行中重复相同的序列。为了做到这一点,你需要导入Random包,因为你将需要使用至少一个不在Base中的函数。但Random还有一些其他的有用功能,稍后你会看到。
以下是展示基本过程的三行代码:
using Random
rgen = MersenneTwister(7654);
rand(rgen)
导入 Random 后,MersenneTwister() 函数,即一个随机数生成算法,就可以使用了。这个名字来自于该函数所在的数学库。它的参数,在这个例子中是 7654,被称为 种子。种子的目的是生成一个特定的序列,如果需要,我们可以重复使用该序列。rand() 函数,以及 Julia 中的所有其他随机数函数,都接受一个可选的第一个参数,用于指定使用的生成器实例。如前所述,每次调用 rand() 时,我们都会得到一个 0 到 1 之间的随机数。但现在,我们可以通过重新初始化 rgen 并使用相同的种子,随时重新启动序列。我们可以通过简单地更改种子来生成一个不同的、不可预测的序列。除了最简单的使用情况外,你应该始终指定一个生成器,并为其提供一个种子,而不是像我们在前一个示例中那样使用更简单的 rand() 形式。
Monty Hall 问题
生成随机数的能力为有趣的模拟开辟了广阔的可能性。首先,让我们考虑一下 Monty Hall 问题,它以长期主持游戏节目 Let's Make a Deal 的 Monty 为名。这个问题在统计学课堂上常常引发激烈的辩论,甚至经验丰富的数学家,甚至统计学家,常常也会犯错——或者他们曾经会犯错,直到这个问题变得有名。对我们来说,这将作为一个例子,展示如何通过概率计算机模拟验证我们认为已经通过分析方法计算出的结果。模拟可以为那些难度较大的概率问题提供额外的信心,因为在这些问题中,分析计算很容易出现偏差。
想象一下有三扇门。 behind one is a prize, say, a fancy car, and behind the other two are joke prizes. Monty often used goats for these “loser” prizes. You want the car. Monty asks you to choose a door. He knows where everything is, but you know nothing.
假设你选择了门 #1。 在揭示门后面是什么之前,Monty 打开了另一个门,比如门 #3,露出了一个山羊。他给你机会,如果你愿意的话,可以换到门 #2。
问题来了:你应该坚持原来的选择,还是换到门 #2?这有关系吗?
正确答案是你应该换门。然而,许多人最初的直觉是,换不换都没有区别。毕竟,现在有两扇门可以选择:门 #1 和门 #2。它们应该有相等的机会通向奖品,所以这就像抛硬币:正面或反面是一样可能的。
然而,这种思维是错误的。最初,你的选择是赢家的概率是 1/3。大家对此都同意。这意味着奖品在其他门之一的概率是 2/3。由于奖品一定在某个地方,所以总概率必须加起来等于 1。这些初始概率仍然成立。门#1 是赢家的概率仍然是 1/3。而其他门成为赢家的概率依然是 2/3。但是现在,“其他门”这一组只包含门#2,因为蒙提已经排除了门#3。你应该切换选择,将你的获胜概率从 1/3 提高到 2/3。
这种分析只是解决问题的众多方法之一,但它们都(如果做得正确)会得出相同的结论。然而,在这个时候,许多人仍然持怀疑态度。有时,实际上进行实验可以说服那些不相信数学的人。
以下程序就执行了这样的实验——一个使用随机过程的简单模拟示例:
N = 3000
stay = zeros(Int32, N)
switch = zeros(Int32, N)
for game in 1:N
prize = rand(1:3)
choice = rand(1:3)
if choice == prize
stay[game] = 1
end
end
for game in 1:N
prize = rand(1:3)
choice = rand(1:3)
if choice != prize
switch[game] = 1
end
end
➊ stayra = [sum(stay[1:i]) / i for i in 1:N]
switchra = [sum(switch[1:i]) / i for i in 1:N]
using Plots
plot(1:N, [stayra, switchra, ones(N)*1/3, ones(N)*2/3],
label=["Stay" "Switch" "" ""])
annotate!(2700, 1/3 + 0.05, "1/3")
annotate!(2700, 2/3 + 0.05, "2/3")
这个程序玩N次游戏,其中N设为 3,000。它将胜负记录存储在两个数组中,一个是玩家保持初始选择的 3,000 次游戏记录,另一个是玩家决定切换的回合记录。数组初始化为全 0。如果玩家在第game局获胜,则该数组元素会变为 1。
两个数组➊保存了每种策略的运行平均值,这是通过列表推导式定义的。这些就是我们要查看的数组。
图 10-2 中的图表显示,从长远来看,切换策略获胜的概率为 2/3,而固执地坚持初始选择的玩家仅获胜 1/3 的时间。

图 10-2:两种蒙提霍尔策略
如果我们记住概率的频率解释的含义,这些比例与论点一致:从长远来看,事件(在本例中是获胜)的比率与总实验次数的比率应该接近概率。请注意,如果你自己运行这段代码,图表可能会略有不同,因为你会得到不同的随机数序列,但长期行为应该是相同的。
计数
在概率之后,统计学中下一个最重要的概念是计数,也叫做组合数学。计数与回答一个事件有多少种发生方式相关。如果你投掷一对骰子,骰子上的两个数字加起来等于六的方式有多少种?如果队伍中有 30 人,那么有多少种可能的九人棒球队?
在计算机上模拟涉及概率的系统时,为了正确计算各种事件的概率,我们通常会计数某个事件发生的方式数,并除以所有可能性中的总数。如果所有方式的可能性相等,这就给出了概率。
在掷骰子的例子中,得到和为六的方式有 10 种,所以概率是 10/36。
在处理概率问题时,另外两个计数概念经常出现,通常在其他地方也会用到:排列(通过阶乘计算)和组合(涉及二项式系数)。
阶乘
第一个计数概念是排列:排列一组物体的不同方式的数量。如果你有八个拼字游戏字母牌,每个字母都不同,那么你能从中组成多少种不同的八个字母的字符串呢?
答案是 8 × 7 × 6 × 5 × 4 × 3 × 2 × 1 = 40, 320。
这是一个快速的论证,展示为什么这个是正确的:选择第一个字母牌有八种方式;一旦选定了第一个字母牌,选择下一个字母牌有七种方式;依此类推。这个模式出现得非常频繁,以至于我们为它赋予了一个特殊的名称和数学符号,它叫做阶乘,在这种情况下写作 8!。Julia 也有一个内置函数处理它,但由于 ! 被用于其他目的,我们需要把它写为:factorial(8)。
阶乘函数增长得非常快,因此在 factorial(20) 以上,你需要将参数作为 BigInt 提供,并且返回值也是 BigInt。阶乘增长的速度有多快?标准的 52 张扑克牌的排列方式的数量远大于宇宙中的星星数量。它大到一个程度,以至于洗牌后,你手中的扑克牌排列几乎不可能在世界历史上曾经出现过。
二项式系数
我们将要使用的第二个组合概念是二项式系数。这个概念在许多数学场合都会出现,Julia 中有一个内置函数 binomial() 用于处理它。在计数的上下文中,二项式系数回答的是前面提到的棒球队问题。如果有 30 个可用的球员,那么组成九人小队的方式数量可以写作:

棒球问题通过 binomial(30, 9) 计算。涉及二项式系数的这些问题的组合学术语是组合。
请参阅第 359 页的“进一步阅读”,了解更多关于二项式系数的细节:为什么它们这样命名,如何通过阶乘计算它们,以及它们与其他数学领域的联系。
流行病建模
现在我们有足够的工具来进行一个重要的计算。清单 10-2 是一个模拟,模拟了感染在人群中的传播。这类似于流行病学家用来进行计算实验的模型,研究不同的 COVID-19 传播情景。这个模型相较于那些稍显简化,因为我的目的是展示目前章节中的工具和思想的应用。有关现在研究中使用的类似模型,请参阅“进一步阅读”。
using Plots
using Printf
using JLD
worldgrad = cgrad([:blue, :red, :black, :green], [0.25, 0.50, 0.75],
categorical=true)
n = 16
➊ initial = Dict("infected"=>0.5, "isolated"=>0.15)
transition = Dict("infected"=>0.05, "dead"=>0.1, "dud"=>7)
include("plotworld.jl")
"""Simulate pandemic growth.
n: length of side of world array;
initial: starting proportions of infected and isolated subpopulations;
transition: probabilities of infection and of death after dud days of
infection;
days: number of days before stopping;
seeding: selects spatially random or centered initial distribution of
infected individuals;
plotmode: display or save plots of simulation while running, or save
only the final state.
"""
function pandemic(n::Int, initial, transition, days::Int; seeding=:normal,
plotmode=:display)
noi = [] # Number of infected people
nod = [] # Number of dead people
function finish()
if plotmode == :last
plotfilename = @sprintf "%d.png" days
savefig(plotworld(world, noi, nod, worldgrad), plotfilename);
end
@save "pandata.jld" world noi nod
end
function nif(I, J) # Number of infected neighbors of an uninfected cell
return sum(world[I-1:I+1,J-1:J+1] .== infected)
end
tpi = zeros(8)
➋ for N in 1:8
tp = 0
for i in 1:N
tp += (-1)^(i-1)*binomial(N, i)*transition["infected"]^i
end
tpi[N] = tp # The total probability of infection with N infected neighbors
end
ok::Int32 = 1
infected::Int32 = 2
dead::Int32 = 3
isolated::Int32 = 4
world = fill(ok, n, n)
if seeding == :normal
world[rand(n, n) .< initial["infected"]] .= infected
end
world[rand(n, n) .< initial["isolated"]] .= isolated
if seeding == :center
world[n ÷ 2, n ÷ 2] = infected
end
➌ next = copy(world)
➍ aoi = fill(0, n, n) # Age of infection
dud = transition["dud"]
for day in 1:days
for j in 2:n-1 for i in 2:n-1
if world[i, j] == ok
if nif(i, j) > 0
if tpi[nif(i, j)] >= rand()
next[i, j] = infected
aoi[i, j] = day
end
end
end
if (world[i, j] == infected) && ((day - aoi[i, j]) == dud)
if rand() < transition["dead"]
next[i, j] = dead
end
end
end; end
world = copy(next)
➎ push!(noi, sum(world[2:n-1, 2:n-1] .== infected))
push!(nod, sum(world[2:n-1, 2:n-1] .== dead))
➏ if day > 4dud
if noi[end] == noi[end - dud] && nod[end] == nod[end - dud]
return finish()
end
end
if plotmode == :save
plotfilename = @sprintf "%05d.png" day
savefig(plotworld(world, noi, nod, worldgrad), plotfilename);
elseif plotmode == :display
display(plotworld(world, noi, nod, worldgrad))
end
end
finish()
end
days = 2000
pandemic(n, initial, transition, days; seeding=:normal, plotmode=:display)
列表 10-2:一场大流行模拟
该策略是将人口表示为一个方阵。每个单元格代表一个人,并且可以处于四种可能的状态之一:infected、dead、isolated 或 ok。一个 isolated 的人无法变为 infected。一个 ok 的人没有被感染,但可能会感染。一个 infected 的人在经过一定天数(或迭代次数)后可能会死亡,这个天数由 dud 参数赋值;如果此人活过了这一阶段,则达到了不死之身。一个 dead 的人不再具有传染性。因此,如果一个 ok 的人被 dead 或 isolated 的人包围,那么他将永远不会被感染(处于“保护”状态)。死亡和隔离能防止疾病的传播。
模拟是通过概率初始化的,用以建立起起始状态和其演变过程。day = 1 时的状态是通过 initial 字典中的概率来设置的 ➊。在每一次迭代中,根据 transition 字典中的概率以及字典中的 dud 值(它表示在疾病可能致命之前,个体需要感染多少天)来更新每个人的状态。
人口矩阵被称为 world,其边长存储在 n 中。不要太字面理解矩阵几何结构。它并不假设人们在某一地点静止不动,直到疾病发展完毕。矩阵 world 代表的是接触网络,而不是空间排列。
在导入一些你之前见过的库并包含一个绘图函数文件后(我们稍后会介绍),pandemic() 函数被定义出来,这个函数执行实际的计算。该函数接收两个关键字参数:seeding 应该是 :normal 或 :center。在前者的情况下,感染是随机播种的,根据 initial["infected"] 来进行;但如果 seeding 设置为 :center,则会在世界的中心放置一个感染个体。
第二个关键字 plotmode 控制是否创建每日的图表,如果创建,图表是显示出来还是保存到文件中。在计算结束时,调用 finish() 函数,如果 plotmode = :last,该函数会保存最终状态的图表。此函数还使用 @save 宏将 world 以及感染和死亡历史保存到一个 .jld 文件中(该功能在第九章中介绍)。
在每次迭代中,程序必须决定对于每个 ok 的人,是否将其状态更改为 infected。这个决定是随机的,基于每天每个感染邻居的感染概率(在 transition["infected"] 中给出)以及感染邻居的数量。
但是,我们需要小心。两个感染者邻居的感染概率并不是单个感染邻居概率的两倍。我们需要减去同时被两个邻居感染的概率。这里我们不会全面讨论概率论中事件组合的内容,但你很可能能轻松理解为什么我们不能简单地将这些概率相加。
假设你在掷两个硬币,并想要找出至少有一个正面的概率。你知道,单个硬币正面的概率是 1/2。如果你将这两个概率相加,你得到的总概率是 1。但这显然不对,因为这意味着正面一定会出现,而你知道两枚硬币出现两个反面的可能性也很大。正确的计算应该减去两个正面同时出现的概率:1/2 + 1/2 – 1/4 = 3/4。在长时间内,你至少会得到一个正面的概率是四分之三。此时,如果你有任何疑问,你已经处于一个良好的位置,可以编写一个简单的 Julia 程序来验证这一点。
硬币问题正好对应于你与两个感染者接触,感染的概率 = 1/2。在网格上,每个人最多可以有八个邻居。这比两个邻居的情况要复杂一些,但思想是一样的。对于每一个新邻居,你需要增加该邻居导致的感染概率,但要减去它与其他邻居之间所有可能的组合。组合这个词暗示着我们可能需要使用二项式系数,事实上,确实需要。若一个邻居的感染概率为p,n个邻居的总感染概率公式为:

请参见第 359 页的“进一步阅读”,了解有关此公式及相关问题的更多信息。这个概率已经为所有可能的邻居数量1:8进行了预计算,结果存储在tpi➋数组中。
在每次迭代的计算开始之前,有必要先复制world数组,在程序中将其命名为next ➌。我们更新next中的单元格,然后将其复制回world。如果直接在world上更新,单元格将根据邻居单元格中部分更新的信息进行过渡,这将导致不一致。因此,需要进行copy,正如我们在前面的章节中遇到的那样,因为简单地使用next = world会创建对数组的第二个引用,而不是实际的复制。
数组aoi初始化为 0➍;它将记录每个人感染的day,以便在适当的时候应用生存概率。
在考虑了前述内容后,随后的循环将在天数的循环内进行人员遍历,应该不言自明。在矩阵扫描后,我们将push!() ➎ 当前感染和死亡人数的新值分别推送到向量noi和nod中。Julia 简洁明了的语法通过对二进制数组求和来计算这些总数。
在这里以及前面的 world 循环中,程序仅处理2:n-1范围内的元素,而不是整个数组,以实现边界条件。通过将边界上的一行或一列“冻结”,更新逻辑简化了,例如,计算每个人感染邻居数量的表达式对于每个非冻结的人来说是相同的。
像物理问题一样,边界条件还有其他可能性。边缘上的人们可以根据邻居数量的减少来更新,但这样做可能会引入伪影。周期性边界条件是另一种可能性,在这种条件下,邻居关系会绕过矩阵并连接到对面一侧。任何选择在某种程度上都是任意的。
条件块 ➏ 会检查计算是否已经达到了稳态。如果已经达到了稳态,就没有必要继续计算,随后会调用清理操作。程序的最后一行通过调用pandemic()来启动计算。
这个简单的算法可以产生有趣的行为,并且可以用来探索诸如封锁遵守对感染传播的影响,以及更高的致死率如何减缓疫情增长等问题。
图 10-3 展示了一个 512×512 的模拟输出,初始和过渡概率如下:
initial = Dict("infected"=>0.001, "isolated"=>0.5)
transition = Dict("infected"=>0.08, "dead"=>0.25, "dud"=>5)
模拟在进行 1,064 次迭代后停止,达到了稳态。图中的标注意味着由于他人的隔离和死亡率的作用,有 5.48% 的人群免受感染。

图 10-3:疫情模拟中的稳态
请访问本书的补充网站(https://julia.lee-phillips.org),查看彩色版本的图表和类似模拟的动画。在打印的灰度版中,热图中最深的颜色代表死亡或感染的人,白色代表那些保持免疫的人群,中间的颜色表示被隔离的人。
清单 10-3 展示了一个简单的函数,该函数计算保护百分比并绘制如图 10-3 所示的图表。
using Plots
using Printf
"""Plot a heatmap of the current state of the pandemic with the histories
of the number of infected and dead people; calculate and display the
proportion of people protected from infection."""
function plotworld(world, noi, nod, worldgrad)
ok::Int32 = 1
day = length(noi)
protected = sum(world[2:n-1, 2:n-1] .== ok) / n² * 100
➊ prot = @sprintf("%.2f%% protected", protected)
p1 = heatmap(1:n, 1:n, world, c=worldgrad, clims=(1, 4), legend=nothing);
p2 = plot(1:day, noi, label=nothing, yformatter=y -> @sprintf("%.1e", y),
titlefontsize=10);
p3 = plot(1:day, nod, label=nothing, annotate=
(0.7day, 0.1nod[end], text(prot, :blue, 7)), yformatter=
y -> @sprintf("%.1e", y), titlefontsize=10);
➋ layout=@layout [a{0.6w} grid(2, 1)];
return plot(p1, p2, p3, layout=layout,
title=["" "Number infected." "Number dead."]);
end
清单 10-3:疫情可视化
plotworld()函数使用@sprintf宏 ➊,该宏在“字符串格式化宏”一节中介绍,第 177 页,用于格式化变量protected和 y 轴标签进行显示。在创建了三个图并将其存储在p1、p2和p3中后,@layout宏,在“使用@layout 创建复杂布局”一节中介绍,第 118 页,将它们 ➋ 安排成一个模拟结果的汇总展示。
常见统计函数
Julia 提供了计算所有常见统计参数的函数,以及用于数据统计可视化的特殊绘图函数。
Julia 统计包正在进行一些重组,因此可能有些内容不在你预期的位置。本节描述了在写作时这些包的位置,但当你实际尝试时,可能会发现某些函数已经移动。
如果你在分析任何类型的数据,你将大量使用本节描述的至少部分函数,其中大多数位于Statistics包中,该包是 Julia 标准库的一部分。在本章的其余部分,我假设你已经使用using Statistics命令导入了该包。
该包提供了总结数据集的基本函数,利用统计参数来进行描述。对于均值,或算术平均值,使用mean(data),其中这里和下面的数据是一些观察值的向量。
对于中位数,即数据中的中间值,使用median(data)。如果数据点数目为偶数,则没有一个数据点是中间值。在这种情况下,median()返回两个中间值的均值:
julia> median([1, 2, 3])
2.0
julia> median([1, 2, 3, 4])
2.5
在写作时,Statistics包不包含众数函数。众数是最常见的值,或者是连续分布的最大值(如果存在)。从这个概念出发,产生了bimodal和multimodal这两个术语,用来描述具有多个局部最大值的分布。图 10-4 中的身高分布就是一个双峰分布的例子。
如果你需要一个众数函数,你可以从另一个名为StatsBase的包中导入它,你需要先add该包。StatsBase包含一些不常用的统计函数,这些函数不在标准的Statistics包中,但你可以只导入计划使用的那些。如果你只需要将众数函数添加到工具箱中,可以输入import StatsBase.mode。
下面是几个示例,展示了mode()函数的行为:
julia> mode([1, 3, 2, 9, 9])
9
julia> mode([1, 3, 2, 9, 9, 4, 4])
9
julia> mode([1, 3, 2, 9])
1
如果有多个众数,函数返回第一个众数。因此,如果每个值仅出现一次,它们都是众数,所以函数返回第一个值。
标准Statistics包包含大多数其他基本统计函数,包括以下内容:
std 标准差
stdm 具有指定均值的标准差
var 方差
varm 具有指定均值的方差
cor 皮尔逊相关系数
cov 协方差
中位数 (最大值 + 最小值) / 2
分位数 分位数
这些命令适用于向量或数据对,操作方式与预期一致。此外,cor()函数可以接受一个矩阵并返回一个相关矩阵,cov()函数也可以以类似方式工作。
mean()函数接受一个可选的第一个参数,它可以是一个一元运算符或一个单一数值变量的函数。然后该函数会将运算符或函数映射到数据向量上,再计算均值。如果需要对数据进行缩放或其他处理,这样做会很方便,但对于简单向量的情况,它给出的结果与将函数广播到数组上的结果相同。
julia> mean([1, 2, 3])
2.0
julia> mean(x -> 2x, [1, 2, 3])
4.0
julia> mean(2 .* [1, 2, 3])
4.0
统计学中使用标准差和方差有两个版本。var()函数默认使用的公式是

其中μ是均值,x[n] 是单个数据点,N是数据点的总数,σ²是样本方差,即应用了贝塞尔修正的方差。标准差std()只是其正平方根。
为了计算总体方差和总体标准差,需要将关键字参数corrected设置为false,然后传递给这两个函数中的任意一个。这将把公式中的 1/(N – 1) 项替换为 1/N。解释这个修正的来源会让我们进入统计理论的深奥领域,但对于大多数用途而言,默认值是你想要的,而且无论如何对于合理大的N来说几乎没有区别。
无论是哪种情况,标准差都是一个衡量观察值或理论分布的平均距离的度量。它告诉我们分布的“分散程度”。
分布
我们已经看过了几个例子,展示了如何利用简单的、均匀分布的随机数做很多事情。然而,并非所有随机现象都是均匀分布的。大多数自然现象显示的是其他类型的分布。
假设我们考虑某个特定城市成年人的身高。显然,你不会期望发现一个身高 7 英尺的成年人和发现一个身高接近平均值的成年人有相同的概率:身高并不是均匀分布的。如果你绘制一个图表,将横坐标划分为例如每 2 英寸为一个区间,并收集一部分居民的身高数据,你就可以绘制出每个区间内的身高数量。收集了大量测量数据后,这个图表将开始看起来像一条平滑的曲线,类似于图 10-4。它有两个峰值,因为男性的平均身高略高于女性,并且它显示出身高接近平均值的人比非常高或非常矮的人要多。

图 10-4:成年人口身高的可能直方图
这种类型的图叫做直方图;它是表示分布的一种方式。概率分布是统计学中最核心的数学对象,就像概率在概率论中自然地形成了核心概念一样。分布简单地告诉你数据中有多少,或者数据中有多少比例落在不同的区间内。作为实际数据的描述,它被称为经验分布,而如果它来自一个模型,则是理论分布。
你可以这样理解统计学:概率告诉我们某件事情发生的可能性,而概率论的数学让我们进一步阐明这一点,告诉我们事件组合发生的可能性,并回答相关问题。统计学则恰恰相反:它从观察开始,系统地推断出导致这些观察结果的概率。有了这些概率,我们可以对未来的观察做出预测。
Julia 提供了多个包和大量函数来帮助进行统计分析,包括用于统计绘图的函数。要生成如图 10-4 所示的直方图,只需在using Plots之后调用histogram(data, bins = 100)。此调用中的data是实际的观测数据系列;bins告诉程序使用该数量的区间来构建直方图。对于每个区间,它会统计data中的观测值数量,并在适当的高度绘制矩形。每个矩形的面积表示其覆盖的水平轴区间中的观测值数量。请注意,选择不同的区间数可能会生成非常不同的图形;有些选择会比其他选择更好地反映底层分布。如果你省略了bins参数,histogram()函数会尝试选择“最佳”值,使用来自统计理论的公式,这些公式旨在最准确地表示数据。但这个公式并不总是完美有效,因此谨慎的科学家或统计学家会始终关注绘制的数据的性质,并在必要时手动干预。
正态分布
考虑本章前面提到的rand()函数。由于它生成的浮点数在 0 到 1 的区间内等概率地出现,因此它返回的数字的平均值应该是 0.5。这个数字大于 0.5 的概率和小于 0.5 的概率是一样的。
这意味着,如果你多次调用rand()并计算结果的平均值,你应该得到一个接近 0.5 的值:mean(rand(1000))应该大约是 0.5。我刚才做了一次,得到的结果是 0.49869515604579906。直观上,你可能会预期,如果使用小于 1000 的数字,平均值更可能远离 0.5,这种预期是正确的。
但即使使用 1,000 个数字,均值也很少能恰好为 0.5。因为(除非你重置了种子)每次你都会得到一组不同的随机数,所以每次的均值也会不同。你知道,这些数字在 0, 1)之间均匀分布。如果你多次调用mean(rand(1000)),这些均值将如何分布呢?
你知道它们不可能均匀分布,因为它们更可能接近 0.5 而不是远离它。那么,均值的分布究竟是什么样的呢?
让我们写一个小程序来找出答案。即使是那些学过统计学并知道预期结果的人,也可能会发现[列表 10-4 中的数值实验很有趣。
using Plots
using Statistics
N = 10000
averages = zeros(N)
for i in 1:N
averages[i] = mean(rand(1000))
end
histogram(averages, label="Empirical")
列表 10-4:探索均值的分布
这个程序是对 1,000 个随机数的 N 个均值的直接计算。为了查看这些均值如何分布,我们使用本章早些时候介绍的histogram()绘图函数。这个函数的目的正是展示分布。"Empirical"标签表示该直方图是数值实验的结果。图 10-5 展示了结果。

图 10-5:均匀随机数的均值分布
显然,均值的分布不是均匀的。正如我们预期的那样,均值接近 0.5 的情况更为频繁。
事实上,基于概率论中的一个核心结果,我们可以预测这个分布的精确数学形式。它应该是

其中 x 是我们正在描述分布的随机变量,σ 是标准差,μ 是均值。
这是著名的正态分布方程,也叫高斯分布。它描述了程序中的经验分布吗?我们不需要将方程转换为代码来找出答案。这个分布如此关键,以至于它被包含在了统计工作中第二重要的 Julia 包 Distributions 中。
一旦你将这个包导入到命名空间中,Normal(μ, σ)函数将创建一个均值为 μ、标准差为 σ 的正态分布。你可以通过使用rand()函数从中进行采样来与分布交互。例如,如果你创建一个均值为 10、标准差为 2 的正态分布 d = Normal(10, 2),你可以用rand(d, 10)从中抽取 10 个样本。如果没有显式提供分布,如我们之前所做的那样,调用rand()默认使用均匀分布。
查看图示的经验分布是否符合正态分布的预测的一种方法是,从正态分布中抽取一个较大的样本,并将其直方图与之前的直方图进行比较。为了使图表更易于查看,我们可以通过在正态plot()命令中使用不同类型的直方图显示,例如提供:scatterhist系列类型,而不是试图在同一图表中绘制两个histogram()图形。将清单 10-5 中的四行代码添加到清单 10-4 中的程序中,能实现我们想要的图形对比。
using Distributions
σ = std(averages)
nd = Normal(0.5, σ)
plot!(rand(nd, 10000), seriestype=:scatterhist, label="Normal sample")
清单 10-5:从正态分布中抽样
图 10-6 表明这两个分布非常接近,正如理论预测的那样。

图 10-6:比较经验分布与理论分布
请注意,为了直接比较两个直方图,它们必须具有相同的箱宽,或者都进行归一化处理。在这些示例中,我允许程序自动计算箱宽,知道对于相似的分布,宽度应该是相同的。
Distributions 包提供了许多概率分布,除了正态分布外,还包括许多用于使用这些分布的函数以及其他统计工具。
概率密度函数
其中一个工具是 pdf(),即概率密度函数。该函数通过以下方式描述分布:如果你对某个区间内的概率密度函数进行积分,结果就是观察值落在该区间内的概率。换句话说,观察值落在a和b之间的概率,就是分布曲线在a和b之间的面积。
通常,当我们提到分布的图形时,我们指的是其概率密度函数的图形。整个分布的积分必须存在,并且等于 1,因为任何观察值必定会落在可能值的范围内。
所有的 histogram() 绘图类型都有一个可选的 normalize 关键字参数,设置为 true 后,直方图将显示概率而非原始计数——例如:
histogram(averages, label="Empirical", normalize=true)
plot!(rand(nd, 10000), seriestype=:scatterhist, label="Normal sample",
normalize=true)
➊ plot!(0.46:0.001:0.54, pdf.(nd, 0.46:0.001:0.54), lw=5, label="Normal PDF")
那三行代码重复了在图 10-6 中绘制的两个直方图的图形,但进行了归一化处理。现在,直方图矩形的面积,如在图 10-7 中所示,代表的是概率而不是原始计数。新的曲线是正态分布的概率密度函数的图像➊,其均值和标准差与样本相同。这是ϕ方程的图形,显示在图 10-5 之后。图 10-7 展示了它如何准确预测清单 10-4 中的数值实验结果。

图 10-7:添加概率密度函数
由于正态分布的重要性,Julia 提供了另一个类似于 rand() 的函数,它返回的是正态分布的随机数,而不是均匀分布的随机数。randn() 函数是 Base 的一部分,因此你无需进行 import。它返回单个数字或数组,数据服从均值为 0,标准差为 1 的正态分布。
让我们使用 randn() 重复 清单 10-1 中的绘图:
using Plots
ra = randn(100000)
scatter(ra, markersize=1, label=nothing)
唯一的区别是使用 randn() 代替 rand()。 图 10-8 显示了结果。如同 图 10-1,每个 10⁵ 个数字都由一个小点表示,但现在这些点并不是均匀分布的。

图 10-8:正态分布的随机浮动数
相反,它们在纵轴上的值集中在 0 附近,随着距离 0(其分布的均值)越来越远,它们的密度变得越来越稀疏。
处理数据
到目前为止,本章中的所有“数据”要么是虚构的,要么是通过数值伪随机过程收集的结果。如果你正在使用 Julia 进行统计分析,很有可能你已经有了一些实际的、真实的数据需要分析。
在本节中,我们将探讨 Julia 处理中实际数据的最重要方法。我们将查看一种在处理实际数据时非常有用的数据类型,学习如何读取最常见的数据文件类型,如何使用数据框来查看和分析这些数据,以及如何利用 Julia 的统计包来理解和可视化数值信息。
缺失值
有一个不常见的数据类型我在 第八章 中没有提到,因为我打算在这一章介绍。它是一个叫做 Missing 的单例类型,用来表示缺失值。
想象你有一个传感器,应该定期记录水箱内的温度。不幸的是,它偶尔会未能记录一个测量值。这些失败的记录被记录为 0,但这个数字远远超出了可能的测量范围,因此这些失败的记录不能被误认为是实际温度。在实验结束时,你会有两个向量,或者说是一个矩阵的两列,一个是测量时间,另一个是温度。在分析这些数据时,你不希望将虚假的零温度包含在分析中,因为那样会扭曲你的计算。你希望有一个比简单删除失败读取值更好的解决方案,因为那样会创建一个错误的记录,显示实验中实际上发生的情况,并且为了保持时间和温度向量的长度一致,可能需要删除时间向量中的相应条目,从而导致时间序列中出现间隙。
Missing类型为这类问题及其他问题提供了一个解决方案——例如,在数据科学中,缺失值的概念就来源于此。它具有一些看似奇特的特性,这些特性在清单 10-6 中得到了体现,这是一个在 REPL 中探索Missing类型算术运算的会话。
julia> m = missing
missing
julia> 3m
missing
julia> 3 + m
missing
julia> missing/3
missing
julia> missing/0
missing
julia> missing + missing
missing
julia> typeof(m)
Missing
清单 10-6:缺失值的算术属性
从清单 10-6 中我们看到,对missing值进行算术运算会得到missing结果,即使是除以 0 时也是如此。
通常,missing值并不是单独存在的,而是数据集合的一部分。清单 10-7 是一个小函数,它创建一个数组,将其中的一些值替换为missing值,并绘制结果。
using Plots
function plotmissing()
a::Vector{Union{Missing, Float64}} = sin.(0:0.03:2π) .+ rand(210)/4
a[49:54] .= missing
plot(a, legend=nothing, linewidth=3)
end
清单 10-7:为绘图创建缺失数据
我们需要声明数组,以便能够接受missing值以及浮动点数。如果我们省略此声明,当我们尝试将missing赋值给数组中的任何位置时,编译器会报错,因为它已经将数组定义为Vector{Float64}。
图 10-9 中的图表显示,Plots知道如何处理缺失数据。

图 10-9:处理缺失数据的绘图
默认情况下,它会在缺失值处留下一个空白。
处理缺失值的函数
Julia 提供了几个方便处理missing值的函数。为了说明它们的作用,假设我们有一个数组a,其中包含一些数字和一些missing元素:
a = [1, missing, 2, 3, missing, 4]
如果你想得到数组中数字的总和,你可能会尝试sum(a),但是如果你参考清单 10-6,你会看到,由于将数字加到missing值上会得到missing,因此sum()操作的最终结果也会是missing。在这里,Julia 的skipmissing()函数恰好解决了这个问题,它的作用正如其名:
julia> sum(skipmissing(a))
10
skipmissing()函数是内置于Base中的,它返回一个迭代器:
for i in skipmissing(a)
println(i)
end
如果你运行那个循环,你会看到这个结果:
1
2
3
4
如果你需要创建一个去除missing值的新数组,可以使用collect(skipmissing(a))。
如果你想要创建一个数组,将某个特定的值替换掉原数组中的missing值,那么可以使用coalesce()函数:
julia> coalesce.(a, NaN)
6-element Vector{Real}:
1
NaN
2
3
NaN
4
注意我们如何需要使用点操作符将coalesce()应用于向量的所有元素,以及返回的数组类型不再是带有missing的Union类型。
如果你有一个分析数据的程序,并且希望使其具有处理包含missing元素的数据集合的能力,那么skipmissing()函数会让这个任务相对简单。你可能只需要用skipmissing()替换你数据数组中的相关部分。
然而,你可能更喜欢一种不让代码充斥着大量skipmissing()调用的方法。你可以利用 Julia 的多重派发定义你自己的sum()方法,或者对任何其他处理数据数组的函数,按照自己的喜好处理missing元素。如果你在每次sum()数据数组时(并且记住第八章关于类型窃取的警告),都希望忽略missing值并将数值加在一起,你可以这样定义一个方法:
import Base.sum
function sum(a::AbstractArray{Union{Missing, Int64}})
return sum(skipmissing(a))
end
这个例子适用于整数,但很容易修改为适用于其他数值类型。
ismissing()函数返回true,如果其参数是missing,否则返回false。它通常比在数据表达式中与Missing类型进行比较更具表现力。
Missings包提供了一些用于处理这种数据类型的便捷函数。这个包不在标准库中,因此你需要使用add和import来导入它。
任何使用missing值的人可能会感激这个包中的两个函数。如 Listing 10-7 所示,定义一个既能容纳所需数值类型又能容纳可选值的向量有点繁琐——更重要的是,你可能会有一个数值数组,需要将其转换为一种允许你添加missing值的类型。以下是一个小的 REPL 会话,展示如何使用Missings包中的allowmissing()函数,它解决了这两个问题:
julia> import Missings
julia> a = rand(4)
julia> a = Missings.allowmissing(a)
julia> a[3] = missing;
julia> a
4-element Vector{Union{Missing, Float64}}:
0.6225362617934931
0.4473340385496267
missing
0.5062746637386624
你可以使用Missings.disallowmissing()将Vector{Union{Missing, Float64}}类型转换回纯浮点数类型,但首先你必须将其中的missing值去除。
带有 Missing 值的逻辑
在离开 Julia 的Missing数据类型话题之前,我们来看看它在逻辑表达式中的表现。我们通常认为逻辑值操作遵循二值(布尔)逻辑,其中唯一可能的值是true和false,这是在《逻辑》一节中回顾的内容,见第 31 页。missing值扩展了布尔逻辑的世界,加入了第三种真值状态,这既不是true也不是false,而是未确定的。在 Julia 中,missing类型与按位与(&)、按位或(|)、按位异或(xor)、相等(==)和取反(!)一起,形成了三值逻辑系统。
逻辑表达式的结果可以是true、false或missing。以下列表展示了系统的工作方式,经过思考后,条目应该是直观易懂的。例如,true | missing的结果是true,因为结果将是true,无论第二个操作数的真值如何。而true & missing的结果必须是missing,因为它将依赖第二个操作数的真值,而第二个操作数的真值是未确定的。
true | missing true
true & missing true
false | missing missing
false & missing false
xor(true, missing) missing
xor(false, missing) missing
!missing missing
missing == missing missing
missing === missing true
由于missing == missing的真值取决于缺失项的值,它本身也是missing。然而,由于missing是一个单例类型,因此它的所有实例都是相同的对象;因此,missing === missing必须为true。
CSV 文件
中等大小的数据通常以逗号分隔值(CSV)文件的形式出现。这些是由逗号分隔的文本文件,且可选择包含描述性标题。它们有一个显著的优点,即人类可读,并且可以使用所有 Linux 命令行工具进行处理。但是它们也有缺点,通常占用比必要更多的空间,比二进制表示效率低,而且在转换为文本后可能无法忠实地表示原始值。由于这些原因,这种格式可能不是存储物理模拟输出的最佳选择。然而,CSV 可能是分发通常称为“统计数据”的最常见格式,例如我们稍后将探讨的人口数据或疫情数据。
你可能会想编写自己的程序来读取 CSV 文件、解析它们并将其转换为某种 Julia 数据结构。如果你已经读到这本书的这一部分,你肯定能够做到这一点。然而,除了作为练习,最好抗拒这种诱惑。
对于实际工作,使用CSV包是一个更好的选择,我们需要在包管理器中添加这个包。这个包可以处理任何分隔符,除了逗号外,还包括流行的制表符分隔文件格式以及任何你可能遇到的自定义格式。它甚至能够在许多情况下自动识别文件使用的分隔符。这个分隔符不必仅限于单个字符;它也可以是一个字符串。CSV包可以处理与数据混合的注释、列标题以及你可能遇到的其他内容。它可以从磁盘读取文件,或者在给定 URL 的情况下从互联网上获取文件。它可以处理任何格式的日期,并将标签转换为更适合编程的形式。也许最重要的是,它将文本信息转换为 Julia 数据类型,并能进一步转换为几种不同的类似表格的数据格式,这些格式专为统计工作而设计,便于操作。
数据框
这些类似表格的数据结构中最重要的是dataframe,它由DataFrames包提供,我们同样需要添加这个包。事实上,由 CSV 读取文件后返回的数据结构并不是最方便进行探索的,因此通常的做法是立即将其转换为dataframe。
dataframe 是一个值的表格,类似于矩阵,但具有为数据探索设计的附加功能。除了 dataframe 数据类型外,DataFrames 包还导出了几个用于操作数据框的函数。此外,许多你已经熟悉的 Julia 函数也有方法扩展其功能,以适应 dataframe。
最有用的理解方式是将 dataframe 看作是一个由列拼接而成的集合。每一列都有一个唯一的名称。可以通过整数索引、字符串形式的名称或符号形式的名称来引用某一列。在你检查、绘制或操作数据时,实际上是在处理 dataframe 的列。
注意
我们将数据框视为用于数据分析和可视化的一组列。然而,大多数在集合上操作的 Julia 函数将数据框视为一组行。有关此主题的详细内容,请参阅 第 359 页的“进一步阅读”部分。
让我们通过一个实际的、通常杂乱无章的数据示例来深入理解。通过对这些数据的处理,我们将使之前关于数据框的讨论更具实际意义,并介绍从野外来源中整理数据的重要函数。
让我们来看一组来自约翰霍普金斯大学系统科学与工程中心(CSSE)维护的 COVID-19 数据库的数据(https://github.com/CSSEGISandData/COVID-19)。这些数据以 CSV 文件的形式提供,使用逗号作为分隔符。第一行包含描述每一列数据的标题,但这些标题的格式会使得后续在 Julia 中的操作变得不便。第一个问题是,一些标题是包含空格的国家或地区名称。第二个问题是,一些标题是日期,但这些日期的格式需要我们特别注意,以确保它们能够正确解析。
注意
本示例中使用的数据文件可以在在线资源区找到,文件名为 time_series_covid19_confirmed_global.csv。CSSE 数据随着时间的推移而增长,因此,本节中展示的某些图表在使用约翰霍普金斯大学未来版本的文件时可能会变得难以处理。
幸运的是,CSV 包中的文件读取功能能够处理这两种常见问题。列表 10-8 展示了读取 CSV 文件并立即将其转换为 dataframe 的操作指令。
using CSV, DataFrames
covdat = CSV.File("time_series_covid19_confirmed_global.csv";
normalizenames=true) |> DataFrame
列表 10-8:读取 CSV 文件
normalizenames 选项会将列名中的空格和其他难以处理的字符替换为下划线,并执行任何其他必要的转换,使标题文本成为合法的 Julia 标识符。dateformat 关键字参数应该是显而易见的。
CSV.File()的第一个参数是磁盘上文件的名称,我之前已经下载并保存了该文件。另一种选择是传递文件的 URL。CSV.File()将识别这一点,并自动通过互联网下载数据。日期格式是通过检查文件来确定的,文件的第一行包含列标题,内容如下所示:
Province/State,Country/Region,Lat,Long,1/22/20,1/23/20,1/24/20,...
总共有 432 列。在清单 10-8 中的第二个命令的结尾部分,将CSV.File()对象转换为DataFrame对象,并将其存储在变量covdat中。如果在 REPL 中执行此操作,Julia 会打印出数据框的截断表示形式。图 10-10 展示了这种情况。特定情况下,我将 REPL 窗口缩小,以便更好地适应页面。

图 10-10:REPL 中数据框的表示
显示内容表明省略了多少信息,显示的列名称以及它们包含的数据类型。数据类型后面的问号表示某些值可能是missing。以下是missing数据类型的典型用法:文件中的大多数国家没有列出省份,但少数有。缺失的数据在原始 CSV 文件中通过缺失的数字表示……缺失。
数据框在 REPL 中的精美显示是通过show()实现的,通常是隐式进行的。对数据框进行print()操作会将整个数据框打印出来,没有漂亮的格式或类型信息,通常这不是你想要的。除此之外,show()还可以创建 HTML 和 LaTeX 版本,并控制数据框显示的其他方面。请参考 REPL 帮助以了解详细信息。
@df 宏
在本章余下的部分,我们将广泛使用在StatsPlots包中找到的一个宏,名为@df。它之所以是StatsPlots的一部分,是因为它特别有效于简化从数据框生成绘图命令,但它的使用并不限于plot()命令。从现在开始,假定使用以下命令:
using StatsPlots, Statistics
@df宏做的是宏最擅长的事情:它重写代码,使我们的程序更容易编写和阅读。这个宏的唯一作用就是:它将表达式中的符号替换为作为第一个参数出现的数据框的列。这个简单的表达式重写足以使这个宏非常受欢迎,因为它让程序员不必在表达式中多次重复数据框的名称。考虑以下示例:
julia> @df covdat print((minimum(:_1_1_21), maximum(:_1_1_21), mean(:_1_1_21)))
(0, 20252310, 306902.8576642336)
在这个表达式中,符号:_1_1_21每次出现时都会被转换为covdat._1_1_21。宏后面的参数必须是一个块或函数调用,因此,如果不将结果包裹在print()函数中,上面的代码会失败。
由于在使用@df宏时,Symbol会转换为数据框的列,因此我们需要一些语法来指示何时应该保留Symbol不变——例如,如果列名和用于其他目的的符号之间发生冲突。该宏提供了“^()”包装器来处理这些冲突。例如,如果数据框中恰好有一个名为“topleft”的列,您需要在绘图命令中使用语法legend=^(:topleft)将图例放置在西北方向。
索引和筛选数据框
数据框可以使用我们应用于矩阵的相同方法进行索引和筛选。然而,数据框提供了一些额外的索引方法,让我们能够利用其命名列。
本章仅包括我认为在大多数情况下最有用的索引和筛选方法。除此之外,还有几个包提供宏和函数,提供更多选择和转换数据框信息的方式。它们的目的是为某些常见任务提供更简化的语法,这些包可以非常方便。然而,大多数包仍处于某种变化之中。与本书中的大多数章节一样,我尽量将自己限制在那些已经稳定的方法上——这些方法你可以学会一次,并永远使用。
数据框中的项目可以使用熟悉的整数索引形式提取。以下是一些示例:
➊ julia> covdat[3, 2]
"Algeria"
➋ julia> covdat[3:6, 2]
4-element Vector{String}:
"Algeria"
"Andorra"
"Angola"
"Antigua and Barbuda"
➌ julia> covdat[1, 2:4]
DataFrameRow
Row | Country_Region Lat Long
| String Float64? Float64?
------------------------------------------
1 | Afghanistan 33.9391 67.71
注意结果的数据类型如何取决于我们如何索引数据框。如果我们请求一个元素➊,我们会返回一个单一值,在这种情况下是一个字符串。如果我们请求一个范围的行在单一列中➋,我们会得到一个Vector。最后,如果我们按水平方向提取数据,通过索引一个单独的行和一系列列➌,我们会得到一个我们之前未见过的数据类型:DataFrameRow。
让我们请求 Julia 提供一个行范围和列范围:
julia> covdat[266:268, 2:4]
3×3 DataFrame
Row | Country_Region Lat Long
| String Float64? Float64?
------------------------------------------
1 | Uruguay -32.5228 -55.7658
2 | Uzbekistan 41.3775 64.5853
3 | Vanuatu -15.3767 166.959
我们得到的是一个更小的数据框。还有什么可能呢?
我们不必计算索引来引用列,而可以使用它们的名称,如示例 10-9 所示。
julia> covdat[272:end, [:Country_Region, :Lat, :Long, :_1_22_21]]
3×4 DataFrame
Row | Country_Region Lat Long _1_22_21
| String Float64? Float64? Int64
----------------------------------------------------
1 | Yemen 15.5527 48.5164 2118
2 | Zambia -13.1339 27.8493 43333
3 | Zimbabwe -19.0154 29.1549 30523
示例 10-9:按名称选择列
我们使用Symbol来索引数据框的列。对于每个列标题,会创建一个具有相同名称的Symbol以便于索引。我们也可以使用列名的字符串版本,如示例 10-9 所示,但使用Symbol更加高效。这也是在读取数据时使用normalizenames的原因之一:包含空格的标题将无法成为有效的Symbol名称,我们将不得不使用字符串版本。示例 10-9 展示了最后三个国家、它们的纬度和经度,以及 2021 年 1 月 22 日的 COVID 病例数。
纬度和经度列的标题旁边打印了数据类型,并且带有问号。这意味着该表格中某个国家或省份的纬度或经度值缺失。为了查看这些国家或省份,我们需要找到数据表中:Lat或:Long值为missing的行。要从数据框中选择符合某个条件的行,我们可以使用filter()函数(详见第 163 页的《filter()操作符》)。DataFrame包扩展了filter()函数,使其可以作用于数据框,过滤行并返回一个新的数据框。以下代码行过滤了我们的 COVID 数据框,寻找缺失纬度或经度的行:
filter(r -> (r.Lat === missing) || (r.Long === missing), covdat)
1×432 DataFrame
Row | Province_State Country_Region Lat Long ...
| String? String Float64? Float64? ...
-------------------------------------------------------------------
1 | Repatriated Travellers Canada missing missing ...
428 columns omitted
结果是一个包含单行的 dataframe,其中 Repatriated Travellers 代替了省份名,显示了一个奇怪的符号。
与其使用filter()函数,你也可以使用位掩码索引或任何其他适用于普通数组的技术,得到相同的结果。
请注意,在刚才展示的例子中,我们是如何通过直接使用列名作为纯文本来指定过滤的列的。这是另一种索引形式,适用于过滤表达式。我们还可以使用这种语法从数据框中选择列,并将其转换为Vector:
julia> covdat.Country_Region
274-element Vector{String}:
"Afghanistan"
"Albania"
"Algeria"
"Andorra"
:
"Yemen"
"Zambia"
"Zimbabwe"
由于选择列会返回Vector,我们可以使用这种索引形式来绘制图表:
using Plots
plot(covdat.Country_Region, covdat._1_1_21; xrotation=40,
label="Cases on 1JAN2021", legend=:topleft)
这里没有什么神秘的。我们只是从数据框中提取了两个向量,并按常规方式绘制了它们,得到的结果如图 10-11 所示。

图 10-11: 病例与国家
然而,这个图并不理想。它展示了某个特定日期的病例分布情况,但横轴几乎没有意义,因为没有足够的空间容纳数百个国家标签。也许与其试图一次性绘制所有数据,不如绘制一些有意义的子集。我们可以通过刚刚学到的过滤机制,限制可视化仅显示病例较多的国家。此外,我们还可以切换到柱状图,这对于这种数据类型是更合适的可视化方式:
covhc = filter(r -> r._1_1_21 > 2*10⁶, covdat)
@df covhc bar(:Country_Region, :_1_1_21; xrotation=40,
label="Cases on 1JAN2021", legend=:topleft)
现在我们得到了一些有用的东西:2021 年元旦时,病例超过两百万的国家的图表,见图 10-12。

图 10-12: 超过两百万病例的国家
在之前的索引命令中,我们使用了整数索引来选择列,虽然有效,但需要我们计算到感兴趣的第一列。而且,这样做之所以方便,是因为我们知道我们想要的列一直延伸到最后,这简化了索引表达式。
允许我们直接使用列名的另一种方法是Between()函数。选择日期列的等效表达式为:
covdat[1, Between(:_1_22_20, end)]
这个操作可以很容易地修改,以选择任何闭区间的列。
另一种选择是Not()函数。这里是一个返回与之前相同DataFrameRow的选择:
covdat[1, Not([:Country_Region, :Province_State, :Lat, :Long])]
排除掉已列出的列后,剩下的就是我们想要的:日期列。
我们还可以使用正则表达式来选择列,应用于它们的标题名称。这里是另一种做相同选择的方法,返回相同的DataFrameRow:
covdat[1, r"_2"]
有时,这是选择数据最方便的方式。例如,如果我们只想提取 2021 年 2 月的阿富汗数据,可以直接写covdat[1, r"_2_\d*_21"]。
那么,如果我们想要创建一个包含所有日期列并且例如包含Country_Region列(但不包括其他列)的DataFrameRow呢?我们目前看到的索引方法并不方便实现这一点,尽管你可能能通过某些技巧来获得想要的结果。不过,其实无需做复杂的处理,因为我们可以使用Cols()函数。以下几行展示了四种不同的使用该函数的方法,以获得一个类似于我们之前使用多种技术创建的DataFrameRow,但加入了Country_Region列:
covdat[1, Cols(:Country_Region, r"_1")]
covdat[1, Cols("Country_Region", r"_1")]
covdat[1, Cols(2, r"_1")]
covdat[1, Cols(2, 5:end)]
如我们所见,Cols()函数允许你使用数字索引、正则表达式或列名(可以是符号或字符串)来选择单个列或列范围。它还可以重新排列列。以下示例将covdat数据框中的纬度和经度列移到最后:
covdat[:, Cols(1:2, r"_", :Lat, :Long)]
有了这些,我们就拥有了一个足够强大的工具箱,能够处理我们在工作中可能遇到的大多数数据框的索引、选择和重排操作。
修改数据框
索引表达式covdat[:, :Country_Region]和covdat.Country_Region似乎都返回一个与名为covdat的数据框的Country_Region列内容相同的Vector。然而,它们并不完全相同:
julia> covdat[:, :Country_Region] == covdat.Country_Region
true
julia> covdat[:, :Country_Region] === covdat.Country_Region
false
这告诉我们,尽管左右两边包含相同的值,但它们不是同一个对象。语法dataframe[:, :col]会创建列的副本并将其作为Vector返回。但covdat.Country_Region是对列的引用。如果你有选择的话,避免做不必要的副本,因为这会变慢并消耗内存。而且,如果你想通过赋值给单个元素来修改某列,你必须使用引用而非副本,正如示例 10-10 中所示。
julia> covdat.Country_Region[1] = "Disneyworld"
"Disneyworld"
julia> covdat
274×432 DataFrame
Row | Province_State Country_Region Lat Long ...
| String? String Float64? Float64 ...
--------------------------------------------------------------
1 | missing Disneyworld 33.9391 67.71 ...
2 | missing Albania 41.1533 20.168
: | : : : :
274 | missing Zimbabwe -19.0154 29.154
429 columns and 271 rows omitted
示例 10-10:修改数据框
这里使用的直接点符号语法仅在使用字面量列名时有效,而不是用持有列名的变量。如果你使用变量来存储列名,必须使用方括号。不过,这并不意味着你必须创建列的副本。另一种语法允许你使用方括号通过变量引用列,而无需创建副本:dataframe[!, var]与dataframe.columnname的效果相同(如果 var 被设置为"columnname")。
例如,命令covdat[:, c][1] = "Disneyworld"对原始数据框没有影响。然而,示例 10-10 中的赋值也可以写成
covdat[!, :Country_Region][1] = "Disneyworld"
这将改变数据框。感叹号的含义可以通过其在“变更参数的函数”一章中的使用得到暗示,参见第 56 页。
转置数据框
数据框使得绘制或操作数据列变得方便。但假设,使用covdat数据框中的数据,你想绘制多个国家的病例时间历史。对于每个国家,其时间序列是从第五列开始的该国对应的行部分。我们从之前的索引部分知道,我们可以从数据框中提取行,而且提取的结果不是Vector,而是DataFrameRow。这意味着,对于绘图,我们需要将结果转换为Vector。下面是将这些内容整合起来,用于绘制美国 COVID 病例时间历史的一个方法:
using Chain
@chain covdat begin
filter(r -> r.Country_Region == "US", _)[1, 5:end]
Vector()
plot(names(covdat)[5:end], _, xrotation=45, legend=:topleft,
label="US cases", lw=3)
end
我偷偷加了一个你之前没有见过的函数:names()返回数据框中列的名称,形式是一个包含字符串的Vector,因此它正是我们用来生成有意义的 x 轴刻度标签所需的。
该列表使用了在《@chain 宏》一章中介绍的@chain宏,参见第 174 页。管道语法在处理数据框中的数据时非常流行,因为这一过程本质上涉及一系列转换。这个代码片段将生成所需的时间线图,见图 10-13。

图 10-13:美国病例与日期的关系
现在,为了比较不同的国家,我只需要重复绘图管道,使用plot!()来添加新的曲线,并替换感兴趣的国家名称。
你可能会想,仅仅为了绘制一行数据,需要输入这么多内容,可能会让交互式工作变得有点繁琐。再次强调,所有这些输入是必要的,因为数据框的设计初衷是将其作为列集来处理,因此绘制行数据违背了这一设计思路。如果先将数据框翻转一下,使得行变成列,代码会更容易编写和理解。选择要绘制的数据将更直接,并且会以Vector的形式呈现,可以立即绘制,避免了转换的需要。
我们希望得到的是一系列不同国家的列,每列包含该国的病例数数据。如果我们有这样的数据框架,我们可以直接绘制任何国家的病例数历史图。我们还希望有一个包含日期标签的列,用于绘图。其他列可以省略。我们不打算在这些图表或后续分析中使用纬度和经度信息,但它们将保留在原始的covdat数据框架中,如果需要的话。我们只是创建一个新的数据框架作为工具,以便更方便地探索数据。
然而,在继续之前,我们需要处理一些国家名称出现多次的问题,因为其中一些与多个Province_State条目一起列出。如果这些国家名称要成为列标题,它们必须是唯一的。稍后我们会学习如何整合这些数据,但现在,我们可以简单地删除带有省份的行,仅保留主要国家条目:
covmc = covdat[ismissing.(covdat.Province_State), :]
删除了麻烦的行后,我们现在可以安全地交换行和列了。这听起来像是我们需要对数据框架进行转置;然而,transpose()函数(我们在处理矩阵时熟悉并喜爱的那个)在这里无法使用。幸运的是,DataFrame包提供了一个专门用于此目的的函数。我们在《伴随矩阵与转置》一章中(见第 144 页)了解了permutedims()函数,它是一种广义的转置操作。DataFrames包扩展了此函数以处理DataFrame数据框架;下面是如何使用它的方式:
covmc = covmc[:, Not([:Province_State, :Lat, :Long])]
cdcn = permutedims(covmc, 1, "d")
在第一行,我们删除了不需要的列。转置操作发生在第二行,其中permutedims()的第一个参数是需要转置的数据框架,第二个参数选择原数据框架中用作转置后数据框架列名的列,第三个参数是新列的名称,列内容将由原数据框架的列名组成。由于我们删除了Province_State列,covmc的第一列现在是Country_Region,因此该列中的国家名称被用作新的列标题。我们可以使用任何类型的选择器来指定旋转的列,因此我们也可以这样写:
cdcn = permutedims(covmc, :Country_Region, "d")
我们的新数据框架cdcn如图 10-14 所示。

图 10-14:REPL 中的 cdcn 数据框架
我们刚转置的数据框架有一个问题:一些列标题现在包含了空格。你在图 10-14 中看到的小片段里看不出这些空格,但我们知道它们存在:
julia> [c for c in covdat.Country_Region if contains(c, " ")]
46-element Vector{String}:
"Antigua and Barbuda"
"Bosnia and Herzegovina"
"Burkina Faso"
"Cabo Verde"
:
"United Kingdom"
"United Kingdom"
"West Bank and Gaza"
这不是一个严重的问题,但正如你现在所知道的,合法的符号名称更加方便,有助于编写更简洁、更高效的代码。
rename!()函数会原地修改数据框的列名(因此会有变更警告)。它有多个方法;我们将使用的方法是将函数作为第一个参数,数据框作为第二个参数。提供的函数会分别应用到每个列上。清单 10-11 中的命令将cdcn数据框的列名中的空格替换为下划线。
rename!(x -> replace(x, " " => "_"), cdcn)
清单 10-11:重命名数据框的列
成功了吗?让我们看一眼数据框架中的相关部分:
julia> cdcn[:, r"^Un"]
428×2 DataFrame
Row | United_Arab_Emirates United_Kingdom
| Int64 Int64
--------------------------------------------
1 | 0 0
2 | 0 0
: : :
428 | 446594 4312908
现在,我们可以轻松地绘制选定国家的时间依赖性病例数:
@df cdcn plot(:d, [:Zambia :Albania :Afghanistan]; xrotation=35,
legend=:topleft, lw=3, ls=[:solid :dash :dot])
来自StatsPlots的@df宏在这里非常有用,因为命令通过Symbol引用了多个列;如果没有它,我们每次都需要提及数据框架的名称。此plot()命令生成了图 10-15 中的图形。

图 10-15:三个国家病例数的时间线
在@df宏调用中的plot()命令里,cols()函数(注意是小写)可以用来选择一个数值范围的列,使用cols(a:b),选择所有列使用cols(),或者选择存储在变量中的Symbol名称的列,使用c = :thecol和cols(c)。
注意
记住, Cols(大写 C)用于在方括号内选择列,是 DataFrames.jl的一部分,而 cols(小写)是一个用于@df宏的工具函数,由 StatsPlots.jl*提供。
有了我们现在掌握的所有工具,我们可以做的不仅仅是绘制随机选择的国家。一个可能有趣的任务是绘制那些在数据集中任何一天的病例数超过某个特定水平的国家。下面是使用@df宏和cols()函数来实现这一目标的一种方法:
sc = [Symbol(c) for c in names(cdcn)[2:end] if maximum(cdcn[:, c]) > 3*10⁶]
@df cdcn plot(:d, cols(sc); xrotation=35, lw=2, legend=:topleft, ls=:auto)
策略是将相关列收集为一个Symbol数组,这样我们可以在plot()语句中使用cols()来选择它们。图 10-16 显示了结果。

图 10-16:病例数大的国家
StatsPlots将用于绘图的标识列的符号转化为字符串,提供了一个有用的图例。
汇总数据框
DataFrames提供的另一个有用功能是combine函数。它允许我们将一个函数映射到一组列上,创建一个新的数据框,作为现有数据框的摘要。例如,假设我们想要一个表格,包含每个国家看到的最大病例数。combine()函数使得这一任务变得简单:
julia> combine(cdcn, 2:190 .=> maximum)
1×189 DataFrame
Row | Afghanistan_maximum Albania_maximum Algeria_maximum ...
| Int64 Int64 Int64 ...
--------------------------------------------------------------
1 | 56192 122295 116438 ...
186 columns omitted
对于第二个参数中定义的列范围内的每个列,combine()都会对其内容应用maximum()函数。
combine()函数通过附加函数的名称来创建新的列名。如果你希望保留原始列名,可以将renamecols = false传递给它。
这些数据很适合制作另一个柱状图,但如果将其转置成一个国家列和一个最大值列会更方便。我们现在知道如何做到这一点,但缺少了一步:我们需要添加一列来存放新的列名。清单 10-12 结合了我们已经学到的方法,首先制作了一个名为cdmp的排列数据框,然后在最后一行中,仅将具有最大工作量的行复制到另一个数据框cdmpc中。
cdmax = combine(cdcn, 2:190 .=> maximum, renamecols=false)
cdmax[!, :Country] = ["Maximum"]
cdmp = permutedims(cdmax, :Country)
cdmpc = cdmp[cdmp.Maximum .> 2*10⁶, :]
清单 10-12:绘制最大工作量图
执行完清单 10-12 中的代码后,cdmpc看起来是这样的:
14×2 DataFrame
Row | Country Maximum
| String Int64
--------------------------------
1 | Argentina 2269877
2 | Brazil 12220011
: | : :
13 | US 30010928
14 | United_Kingdom 4312908
10 rows omitted
你会看到,在本数据集中覆盖的时间段内,只有 14 个国家的工作量超过了两百万。现在我们可以通过这个简单的命令制作柱状图:
bar(cdmpc.Country, cdmpc.Maximum, xrotation=45, label=nothing,
title="Countries with highest maximum caseloads")
这会生成图表图 10-17。

图 10-17:最高的最大工作量
对数据框中的数据进行汇总统计是如此常见,以至于有一个函数可以为我们执行上述工作,但了解如何“手动”操作也是很有用的,以防你需要一些它没有提供的功能。这个函数叫做describe(),它是这样工作的:
julia> describe(cdcn, :max; cols=Not(:d))
189×2 DataFrame
Row | variable max
| Symbol Int64
------------------------------------
1 | Afghanistan 56192
2 | Albania 122295
3 | Algeria 116438
4 | Andorra 11638
5 | Angola 21836
6 | Antigua_and_Barbuda 1080
: | : :
184 | Venezuela 153315
185 | Vietnam 2576
186 | West_Bank_and_Gaza 230076
187 | Yemen 3703
188 | Zambia 86993
189 | Zimbabwe 36749
177 rows omitted
这样确实更容易!默认情况下,describe()会返回一个包含均值和中位数的DataFrame,但这些对于这些时间线来说没有意义,所以我们通过传递一个符号:max来限制计算的统计量,选择我们想要的那个。该函数还可以计算其他汇总统计信息,如标准差,并自动跳过missing值。如果需要,它甚至可以报告每列中missing值的数量。
分组数据框
之前我们丢弃了一些数据,即对于存在此类条目的几个国家,删除了附加的省份数据。正如之前所承诺的,我们现在将找到一种方法来包含这些信息。
假设我们不关心查看各个省份的数据,而是想要将属于每个国家的所有省份的数据加起来,只看总病例数。这样比直接删除这些数据更有意义。做这种事情最方便的方法是使用分组数据框的概念,以及一个新的数据类型GroupedDataFrame。
GroupedDataFrame类似于一个数据框的向量。向量中的每个数据框都是通过将具有相同值的行合并在一起从源数据框中创建的。在我们的例子中,我们将按Country_Region进行分组。大多数GroupedDataFrame的成员将只有一行,因为大多数国家仅出现一次。但那些多次出现的国家(因为它们有Province_State值)将产生GroupedDataFrame成员,并且每个成员有一行对应每个Province_State。
一个小问题是,GroupedDataFrame的成员实际上不是数据框,而是具有一种叫做SubDataFrame的新数据类型;然而,这种区别通常并不重要。
以下将按照国家对covdat数据框进行分组:
cvgp = groupby(covdat, :Country_Region)
现在,cvgp是一个GroupedDataFrame。让我们在 REPL 中检查它:
➊ julia> length(cvgp)
192
➋ julia> length(covdat.Country_Region) - length(cvgp)
82
➌ julia> cvgp[1]
1×432 SubDataFrame
Row | Province_State Country_Region Lat Long _ ...
| String? String Float64? Float64? I ...
--------------------------------------------------------------
1 | missing Afghanistan 33.9391 67.71 ...
428 columns omitted
➍ julia> cvgp[183]
12×432 SubDataFrame
Row | Province_State Country_Region La ...
| String? String Fl ...
--------------------------------------------------------------
1 | Anguilla United Kingdom 1 ...
2 | Bermuda United Kingdom 3
: | : :
12 | missing United Kingdom 5
430 columns and 9 rows omitted
该分组数据框有 192 个成员 ➊,这告诉我们数据中包含了多少个不同的国家(记住,其中一个是回国旅客)。
从总行数中减去该值➋,我们可以得知有 82 个国家列出了省份。
查看cvgp的个别成员 ➌ ➍ 确认这些是专门针对单个国家的数据框。下一步是将每个日期所有省份的病例数汇总起来,这样每个国家的数字将包含所有其省份的病例数。这就是combine()函数的作用。当我介绍combine()时,我们将它用于数据框,但当它应用于分组数据框时,它正好满足我们的需求,针对每个组成员分别在选定列上应用指定函数,然后返回一个普通的DataFrame作为结果。
首先,我们需要一个数组来保存要汇总的列,即日期列,然后我们可以使用combine()将它们合并。我们将结果存储在一个新变量中:
dcols = cdcn.d
cvsm = combine(cvgp, dcols .=> sum, renamecols=false)
现在,cvsm具有与原始covdat相同的结构,但只有 192 行,每行代表一个国家。和以前一样,准备好这个数据框的转置将非常方便:
cvsp = permutedims(cvsm, :Country_Region, "d")
和以前一样,最好对列名进行规范化(去除空格)。在对cvsp执行列表 10-11 中的程序之后,我们得到了一个适合绘图的数据框。
现在比较法国的时间线变得容易了,既有包括领土的,也有不包括领土的:
@df cvsp plot(:d, :France; xrotation=35, label="France with territories", legend=:topleft)
@df cdcn plot!(:d, :France; xrotation=35, label="France minus territories", legend=:topleft,
ls=:dash)
图 10-18 展示了结果。

图 10-18:法国病例数量的时间历史
在大多数情况下,包含Province_State列几乎不会影响图表。
多变量数据
之前的示例都处理了时间线:在不同国家中,单一数量(此处为感染人数)作为日期的函数。另一种数据形式涉及多个事件在不同地方或不同人群中发生的频率。图 10-4 展示了这种数据形式的一个简单例子,其中事件是身高观察,人口群体为男性和女性。
当你拥有多个变量的数据时,可以使用统计方法来寻找它们之间的关联,但始终记住“相关性不代表因果关系”。但是,关联可以提示你值得进一步探讨,而缺乏关联可能有助于排除某些假设。
在(虚构的)男性和女性身高的例子中,如果我们还从相同的受试者那里获得了收入水平或年龄的数据,我们可以寻找它们之间的关联。富裕的人是否更高?随着年龄增长,身高的增加何时会趋于平稳?Julia 的 DataFrame 结合其便捷的统计功能和 StatsPlots 提供的可视化,使得这种数据探索变得相对简单且愉快。
我从美国人口普查局维护的数据中编译了我们的第二个数据文件(https://www.census.gov)。该数据文件可以在补充网站 https://julia.lee-phillips.org 上获取,文件名为 census.dat。该文件是制表符分隔值格式,第一行是列标题,注释行以井号(#)开头。数据包括 2011 年美国各县在多个类别中报告的犯罪绝对数字,另外还有每个县的总人口和未完成高中学业的未成年人的百分比列。注释行给出了各州和全国的总数。以下是该文件 3,143 行中的前九行:
Areaname Larceny Murder MVTheft Robbery MinorsNHI EstimatedPop
##UNITED STATES 6384687 16107 1196608 405471 10.8 295753151
##ALABAMA 97640 308 10796 5636 7.8 4545049
Autauga, AL 1149 0 112 28 8 47870
Baldwin, AL 1973 5 137 37 11.3 162564
Barbour, AL 64 0 7 1 7.8 29452
Bibb, AL 144 0 18 3 8\. 21375
Blount, AL 558 0 134 6 11.8 55035
Bullock, AL 54 0 0 3 7.9 10975
显然,我们首先需要做的是使用 CSV 包来读取该文件并将其存储在数据框中。CSV.File 函数会自动检测到使用制表符作为分隔符,并且识别出第一行为标题行,但我们仍然需要告诉它注释行的情况:
cbc = CSV.File("census.dat", comment="#") |> DataFrame
cbc = cbc[cbc.EstimatedPop .!= 0, :]
第二行删除了任何人口为零的行(共有三行)。由于我们计划将绝对数除以人口以转换为比率,因此需要删除这些行。下面是转换的过程:
for c in 2:5
cbc[!, c] = cbc[!, c] ./ cbc[!, 7]
end
到此为止,我们的数据框看起来是这样的:
julia> cbc
3143×7 DataFrame
Row | Areaname Larceny Murder MVTheft Robber ...
| String Float64 Float64 Float64 Float6 ...
----------------------------------------------------------------------
1 | Autauga, AL 0.0240025 0.0 0.00233967 0.0005 ...
2 | Baldwin, AL 0.0121368 3.07571e-5 0.000842745 0.0002
3 | Barbour, AL 0.00217303 0.0 0.000237675 3.3953
4 | Bibb, AL 0.00673684 0.0 0.000842105 0.0001
5 | Blount, AL 0.010139 0.0 0.00243481 0.0001 ...
6 | Bullock, AL 0.00492027 0.0 0.0 0.0002
7 | Butler, AL 0.0227653 9.83381e-5 0.00108172 0.0007
8 | Calhoun, AL 0.0256511 4.46106e-5 0.00215915 0.0014
: | : : : : :
3137 | Sheridan, WY 0.0167767 0.0 0.000921795 3.6871 ...
3138 | Sublette, WY 0.0387191 0.0 0.00262009 0.0
3139 | Sweetwater, WY 0.0296249 2.68341e-5 0.00262974 0.0001
3140 | Teton, WY 0.0197487 0.0 0.00149925 0.0001
3141 | Uinta, WY 0.0283567 0.0 0.00190417 0.0002 ...
3142 | Washakie, WY 0.00425093 0.0 0.000128816 0.0
3143 | Weston, WY 0.0122008 0.0 0.0 0.0001
3 columns and 3128 rows omitted
某一特定犯罪类别,比如盗窃,在各个县的分布情况如何?它们都是一样的吗?一个县的盗窃率异常高的可能性有多大?我们可以通过直方图来回答这些问题,下面的命令可以帮助我们生成直方图:
@df cbc histogram(:Larceny; legend=nothing)
在许多从数据框中提取数据的命令中,@df 宏可以节省一些输入时间并使代码更易于阅读。直方图,如 图 10-19 所示,表明大约 400 个县在报告年份内没有任何盗窃案件,而大多数县的比率(总数除以人口)低于 2%。超过该比率后,分布稳步下降且相对迅速。

图 10-19:盗窃案的直方图
设置好数据框后,在 REPL 中探索这些数据变得非常简单(以下假设 Statistics 已经被导入):
julia> mean(cbc.Larceny)
0.014305068778810368
julia> @df cbc cor(:Murder, :Larceny)
0.29993876295850447
julia> @df cbc cor(:MVTheft, :Larceny)
0.6528140798664165
平均盗窃率大约是 1.4%。这种犯罪与其他犯罪的关联如何?与谋杀的相关性较弱,意味着知道一个县的盗窃率高并不能告诉你该县的谋杀率如何。然而,与车辆盗窃的相关性显著:盗窃率高的县通常是更容易发生汽车被盗的地方。这可能并不令人惊讶,但在我们认真对待之前,应该记住,Statistics包的cor()函数计算出来的相关系数是皮尔逊系数,假设这两个变量之间存在线性关系。那么,这两个犯罪类别之间真有这种线性关系吗?回答这个问题的方式是通过散点图:
@df cbc scatter(:MVTheft, :Larceny; legend=nothing, markersize=2,
opacity=0.3, xlabel="Motor vehicle theft", ylabel="Larceny",
xrange=[0, 0.015])
从图 10-20 看,似乎这两个比率之间至少存在一种大致的线性关系,因此相关系数是有意义的。

图 10-20:盗窃与机动车盗窃散点图
在绘制包含大量数据点的散点图时,使用较小的标记大小并结合较低的不透明度是有效的。其理念是,可能会存在重叠较多的区域。使用小且透明的点可以使任何位置的点密度在图像密度上得以体现。如果使用不透明或较大的点,一旦标记开始相互遮挡,就无法区分适中和高密度区域。
这一思路通过StatsPlots包中的histogram2d()绘图函数变得更加系统化。顾名思义,它接受两个变量并创建一个二维直方图。结果类似于散点图,但平面被分成多个单元格,单元格的颜色根据其包含的点数来确定。以下是其工作原理:
@df cbc histogram2d(:MVTheft, :Larceny; xlabel="Motor vehicle theft",
ylabel="Larceny", xrange=[0, 0.015])
和普通的直方图一样,如果自动计算的结果不理想,我们可以调整箱子的数量,但在这种情况下,算法已经做得很好。图 10-21 中展示的结果传达了与图 10-20 中散点图相似的信息,但现在我们可以通过颜色图读取案例数量。

图 10-21:两类犯罪的二维直方图
我们之前遇到的describe()函数对于快速了解这种类型的数据非常有用。通过删除不感兴趣的部分,结果可以更加简洁:
julia> describe(cbc, :mean, :max, :nmissing)[2:end,:]
6×4 DataFrame
Row | variable mean max nmissing
| Symbol ...Union Any Int64
--------------------------------------------------------
1 | Larceny 0.0143051 0.0925926 0
2 | Murder 3.01897e-5 0.000539374 0
3 | MVTheft 0.00156298 0.0231045 0
4 | Robbery 0.000357696 0.00987096 0
5 | MinorsNHI 11.5316 42.9 0
6 | EstimatedPop 94099.0 9803912 0
描述表中的最后一列告诉我们没有缺失值。使用复合数据类型的原因是,摘要数据框中包含了一行县名,我们通过索引表达式将其去除,因此这些列实际上包含了数字和字符串的混合。
你可以将图 10-21 中的二维直方图与每个变量的常规一维直方图结合起来,使用StatsPlots中的marginalhist()方法:
@df cbc marginalhist(:MVTheft, :Larceny; xlabel="Motor vehicle theft",
ylabel="Larceny")
结果如图 10-22 所示,是两个分布的同时可视化效果。

图 10-22:说明边际直方图绘图方法
StatsPlots包还有一个技巧。它可以将我们已经看到的部分图表组合成一个复合可视化,几乎一眼就能从中识别出变量之间的关联和模式。通过corrplot()方法可以实现这一点,如下所示:
@df cbc corrplot([:MinorsNHI :MVTheft :Robbery]; fillcolor=cgrad(),
xrotation=40)
我们选择了三个变量进行分析;你可以一次性查看所有内容,或者选择任何其他包含超过两个类别的子集。需要包含fillcolor参数是一个 bug,可能在你阅读本文时已经修复,因此你可以尝试省略它。它控制二维直方图中使用的调色板,正如你之前看到的,常规的histogram2d图不需要它来获取默认的颜色。
图 10-23 展示了结果。

图 10-23:相关性图
该方法生成一个图表矩阵,比较第一个参数中提供的数组向量中所有可能的变量对组合。该图表矩阵的对角线上(两个变量相同的地方)是常规的、一维直方图;对角线以上,我们看到所有三种可能的二维直方图;对角线以下,我们有所有的散点图,使用透明点显示。作为额外功能,散点图还包括通过点绘制的回归(最佳拟合)线,标记颜色反映了相关类型:正相关用蓝色表示,缺乏相关性用黄色表示,负相关用红色表示。这是一个强大的可视化工具,承载了丰富的信息。快速浏览可以告诉我们,未完成中学教育与车辆盗窃或抢劫率无关,但这两种犯罪类型是彼此相关的。
其他包
本节简要介绍了几种统计学相关的工具,读者如果对统计学感兴趣,可以了解这些工具。更多资源请参见第 359 页中的“进一步阅读”部分。
JuliaDB 用于核心外数据集
数据框(Dataframe)是强大的数据类型,但它们适用于适合存储在内存中的数据结构。对于无法完全装入内存的数据,更好的选择是JuliaDB,它专门设计用于高效处理这种“核心外”数据集。
RCall 用于与 R 交互
R 编程语言是一个历史悠久的统计分析语言和系统。像 Julia 一样,R 是自由软件,并且拥有大量忠实用户。然而,它并不是一个好的通用编程语言,对于某些类型的计算,它的速度可能会比较慢。如果你正在启动一个新项目,并且没有一个多年来自己开发的 R 代码库,我建议你使用 Julia 来满足你的统计需求。Julia 已经有一个庞大且强大的统计包生态系统,而且每天都有更多的包被添加进来。如果你的分析程序需要在大数据上快速运行,Julia 不会让你失望。它能够在 GPU 和其他多处理器硬件上运行,并且其编译后的代码效率高,这意味着你不需要重写程序以便扩展。
然而,如果你已经投入了时间和精力编写 R 例程,并希望继续使用它们,你不需要重写它们。你可以在 Julia 中直接或与之结合使用它们。RCall 包提供了多个宏,用于与 R 例程和数据结构互操作,还提供了一个特殊的 REPL 模式,允许在 Julia 会话中直接与 R 交互。事实上,一旦你键入 using RCall,一个 R 进程便会在后台启动。它会定位你的 R 安装,并且甚至可以为你安装 R。
P-hacking
对于计算 p 值和进行其他分析,从而助长科学中的复制危机,HypothesisTests 包在 https://github.com/JuliaStats/HypothesisTests.jl 是无价的。
结论
统计学的概念和技术跨越了所有科学学科。Julia 通过其统计包,将大量的探索和分析能力呈现在我们手中。与 Plots 包的良好集成使得可视化变得快速且简单。尽管像 R 这样的系统已经成为统计分析的标准,并提供一些 Julia 包尚未构建的功能,但后者发展迅速。Julia 相对于这些久经考验的工作马有一些优势:语言的开发简便性使得添加缺失功能变得更容易,Julia 的高效性使得你无需在面对大数据或计算密集型分析时将代码重写为更快的语言。
我们将在下一章中,模拟进化的章节里,再次回顾本章介绍的一些概念,在 第十三章 中,我们将探索使用概率编程技术来对模型进行推断。
进一步阅读
-
有关熔岩灯熵项目的详细信息,请参见 https://blog.cloudflare.com/randomness-101-lavarand-in-production/。
-
本章中的流行病模拟实现了一个简化的模型,类似于广泛使用的 COVID-19 模型,该模型由https://github.com/mrc-ide/covid-sim开发。
-
本章中流行病模拟所使用的事件组合公式来源于 William Feller 的概率论经典著作《An Introduction to Probability Theory and Its Applications》第一卷(Wiley 1968)的第四章。
-
旨在长期稳定性的替代随机数生成器,可以在https://github.com/JuliaRandom/StableRNGs.jl获取。如果你希望你的程序在未来的 Julia 版本及其包中使用相同的伪随机序列,你可能会想使用它。
-
RCall包的主页位于https://github.com/JuliaInterop/RCall.jl。 -
一个经常更新的 Julia 统计学和机器学习包列表,附带简短描述,可以在https://github.com/JuliaStats找到。
-
请观看 Juan Klopper 的这段 20 分钟教程视频,了解如何在 Julia 中进行统计学介绍:https://www.youtube.com/watch?v=xbsr46Dw8hg。
-
Yoni Nazarathy 和 Hayden Klok 合著的教科书,介绍如何使用 Julia 做统计学、数据科学和机器学习,可以在https://statisticswithjulia.org找到。
-
JuliaDB包的主页位于https://juliadb.juliadata.org/latest/out_of_core/。 -
关于数据框作为行集合的更多信息,请参考https://bkamins.github.io/julialang/2023/02/24/dfrows.html。
第十二章:生物学**
现代生物学正越来越成为信息技术的一个分支。
—理查德·道金斯

正如道金斯教授所指出的,计算已经成为生物学许多领域中的核心工具。这或许是不可避免的,因为进化是生物学的核心组织原则,而进化通过被称为 DNA 的数字存储设备的形式传递信息。
Julia 周围的生物学生态系统非常复杂,涵盖面广,且增长迅速。该语言及其包在生物学和医学研究的许多领域中得到了应用,涵盖了工业界和学术界。
本章首先简要概述了 Julia 生物学领域的概况,然后直接进入模拟进化的详细案例研究。
Julia 生物学生态系统
生物信息学已成为生物学的一个主要子领域,其特点是使用计算机。它主要处理蛋白质序列的分析和操作,因此具有很强的计算语言学特征。BioJulia GitHub 组织为浏览这一大规模包集提供了一个起点。它包括了许多其他模块,用于处理生物信息学家多年来开发的各种文件类型。
要发现 bioinformatics 组织之外的其他 Julia 包,我们可以转向“如何查找公共包”一节中描述的常规 GitHub 搜索方法(参见第 80 页)。这些包中的许多并没有包含像“biology”这样的通用标签,因此你可以使用像 系统发育学 或 生态学 这样的关键词进行集中搜索,从而更轻松地找到它们。
Pumas 药物建模与仿真工具包作为 Julia 在医学和生物学领域的一个重大成功值得特别提及。Pumas 被大公司和研究团队用来开发和测试药物。其 GitHub 页面包含了丰富的文档和教程链接。
许多 Julia 生物学包被创建来与统计学、方程求解或其他在数学生物学中有用的领域的其他包一起使用。例如,EvolutionaryModelingTools 就与 DifferentialEquations 包协作(参见第 302 页中的“将 DifferentialEquations 与测量结合”),提供宏来帮助设置使用 Gillespie 算法(攻击随机微分方程的一种方法)进行传染病传播模型和类似结构问题的仿真。
使用基于代理的建模模拟进化
基于代理的建模 (ABM) 是一种模拟技术,通过一群计算实体——代理——彼此与环境进行交互,遵循一组规则。这些代理可以是生命形式、车辆或更抽象的事物,如信息。规则可以依赖于时间、代理之间的距离、它们的运动、代理附近环境的状态,或者我们能想象的几乎任何其他因素。代理可能会移动、存储数据、死亡并诞生。环境本身也可能发生变化。
研究人员已使用 ABM 模拟交通流、传染病的进展、社会动物的集体行为、舆论的传播等等。请参见第 380 页中的“进一步阅读”,获取一些关于这种方法的背景信息链接,以及本节所使用的主要包的文档。
我们的项目将模拟自然选择过程中的进化,模拟一个由两种简单生物组成的种群,这些生物代表捕食者和猎物。我们将看到,当猎物生物允许从父母那里继承它们的“基因”时,它们如何进化得更擅长躲避捕食者。这种进化源于遗传特征的随机突变,加上捕食者从吃掉不善于躲避的猎物开始施加的选择压力,迫使它们在繁殖前被捕食。
Agents包提供了一个框架,用于各种 ABM 计算。它处理低级别的细节,如计算代理的运动、执行边界条件和查找邻居,从而使我们能够专注于在较高层次上编程代理交互的规则。
代理所处的空间可以是一个连续的物理空间(我们在这里使用的空间);一个网格空间,代理只能占据离散的位置;一个更抽象的树形空间,其中代理并不物理存在,而是存在于树状数据结构中;甚至是一个基于实际道路地图定义的空间,使用 OpenStreetMap 数据。这个空间可以变成一个环境,包含空间和时间变化的条件,影响代理的行为。
这些代理具有位置、速度属性以及唯一的 ID。我们还可以赋予它们任何适合我们模拟的数据结构。我们可以基于代理之间的接近程度、时间或环境条件来创建或销毁代理,或者更改它们的任何属性。代理的接近性——最近的邻居或在给定半径内的邻居——可以通过简单的函数调用返回。
任何特定项目通常只会使用Agents的一个小子集功能,本节的项目也不例外。
模拟问题概述
我们的宇宙将包含两种类型的生物:捕食者和猎物。每种类型都有简单的行为。捕食者追赶猎物。如果捕食者成功接近目标,它会从模拟中消失,被追捕者吞噬。捕食者从所有处于其探测范围内的猎物中选择目标,但它们很有礼貌:如果同伴已经追捕某个猎物,它们不会再追赶那个猎物。捕食者只有一个速度,稍微快于猎物的速度。它们在追赶猎物时会转向目标,但它们的高速度被有限的灵活性所抵消:它们每次模拟步骤只能转动一定的最大角度。像某些实际的捕食者物种一样,我们的模拟捕食者会调整它们的繁殖率,以保持捕食者种群与猎物种群的比例。
猎物按照一组角度在固定的时间间隔内转弯;每个猎物有自己的一组角度。当它到达列表的末尾时,它会回到列表的顶部。猎物不会对捕食者做出反应,它们只是按照预定的角度转弯四处奔跑。可以想象,它们的环境充满了均匀分布的食物,因为一个属性(在程序中称为mojo)在每一步都会增加一个固定的量。如果猎物在不被吃掉的情况下成功地积累了一定数量的mojo,它就会繁殖。繁殖是致命的;这个生物会被两个后代所取代。每个后代继承其父母的角度列表,并带有一些随机突变。
区分不同猎物个体的唯一特性,除了它们的位置和速度,就是角度列表。我们以一个随机列表初始化这些代理,列表中的角度均匀分布在–π到π之间。某些转弯列表由于偶然的原因,可能比其他列表更适合让代理生存得更久,因为它们会使捕食者由于有限的灵活性而更难以抓住猎物。这些代理更有可能繁殖,正如它们的后代一样。通过突变,这些后代中的一些可能比其他后代更有可能生存下来并繁殖。我们希望观察到猎物种群中角度分布的演化,以及由于这种选择压力,避免被捕食的能力的平均提高。
上述内容是该项目的结构和目标概述。在接下来的几个部分,我们将按照在完整程序中出现的顺序,将所有的模拟组件拼接在一起,完整程序方便地汇总在本章节的网页补充材料中,网址为https://julia.lee-phillips.org。在将这些思想转化为程序时,我们需要将一切具体化。例如,我们决定让角度列表包含八个元素。这些细节在一定范围内是任意的,读者可以尝试更改其中的全部或部分,甚至可能改进此处描述的实验。
捕食者和猎物代理
Agents 包提供了一个便捷的宏来定义我们的代理:
using Agents, StatsBase, JLD2, Random
@agent Prey ContinuousAgent{2} begin
mojo::Float64
moves::Vector{Float64}
end
@agent Predator ContinuousAgent{2} begin
victim::Int64
end
首先,我们导入所需的包。除了Agents,我们还需要StatsBase来创建角度分布的直方图,JLD2来保存和加载模拟数据(参见列表 9-4,第 289 页),以及Random用于生成随机数(参见《Julia 中的随机数》第 307 页)。
@agent宏将代理定义为复合类型。在执行列表中的宏后,我们有了一种名为Prey的代理类型,和另一种名为Predator的代理类型。ContinuousAgent{2}表示这些代理将在一个连续的二维空间中生活,其中它们的位置由一个包含两个浮点数的元组定义。
每个Prey实例都拥有两个属性:mojo,它是一个浮动值,用来决定猎物何时准备好繁殖;以及moves,它是一个角度向量,决定猎物在环境中盲目徘徊时的路径。
捕食者只有一个属性:victim,它是捕食者正在追逐的猎物的 ID。如果该值为 0,则捕食者静止不动,等待潜在猎物进入其范围。
定义模型行为的常量
决定模型行为的某些参数在一系列常量中定义,如列表 11-1 所示。我们可以修改这些常量,以便在不同条件下进行进化实验,而无需更改程序。这些常量被声明为const,我们应当对所有全局变量应用此声明,以提高性能。一般来说,程序不应使用非const的全局变量。
const NPrey = 16 # Number of Prey agents
const NPred = 8 # Number of Predator agents
const PPR = 0.5 # Predator/prey ratio
const M = 8 # Number of turns
const SBT = 100 # Steps between turns
const TAD = 0.2 # Target acquisition distance
const KD = 0.01 # Kill distance
const LS = 2 # Litter size
const MIPS = 0.1 # Mojo increase per step
const MNFR = 50.0 # Mojo needed for reproduction
const SPEEDR = 1.5 # Ratio (predator speed)/(prey speed)
const LAA = π/128 # Limit of angular agility
const dt = 0.001
const SEED = 43
const rng = Random.MersenneTwister(SEED)
const LF = open("logfile", "a+") # Logfile
const LI = 100 # Log interval
列表 11-1:定义模型的常量
捕食者种群会在每一步进行调整,以保持PPR,根据需要增加捕食者,若比例超过 5%则会消除部分捕食者。
参数M是代表猎物基因组的角度向量的长度。猎物将在直线前进SBT步,然后转向角度向量中的下一个角度。
如果捕食者与猎物的距离小于TAD,它就能“看到”猎物。它开始追赶它所看到的第一个猎物,且该猎物尚未被其他捕食者追赶。如果捕食者成功地将与目标的距离缩小到KD以内,则目标被消灭。
当一个猎物生殖时,它会用LS后代替代自己。
猎物在跑步时不断进食,每一步都增加MIPS的mojo值。mojo实际上只是衡量一个生物存活时间的指标。当猎物的mojo值达到MNFR时,它就会繁殖。
捕食者的直线速度是猎物速度的SPEEDR倍。捕食者的转弯能力受到LAA的限制。它会朝猎物的方向转向,每一步调整一次航向,但每次转向的角度不超过LAA弧度。
Agents集成例程(一个简单的欧拉步进)使用dt作为时间步长。这个常量作为代理速度的总体尺度。
为了能够使用相同的随机数序列重复仿真,并在需要时创建仿真集,我们将使用一个可控的随机数生成器(详见“Julia 中的随机数”第 307 页)。这就是SEED和rng的用途。此外,当传递一个rng时,rand()函数的效率会更高,尽管这种问题在一些早期版本的 Julia 和Random包中比现在更为严重。
实用函数
我们希望有一些函数使得控制捕食者的方向和改变猎物方向的代码更加简洁:
function vnorm(v)
v ./ sqrt(v[1]² + v[2]²)
end
function angle_between(a, b)
atan(b[2], b[1]) - atan(a[2], a[1])
end
function turn(v, θ)
M = [cos(θ) -sin(θ); sin(θ) cos(θ)]
M * [v...]
end
我们需要对速度向量进行归一化处理,也就是将它们的长度调整为单位长度。这正是vnorm()的作用。angle_between()函数返回两个向量之间的角度。捕食者需要这个函数来计算在追捕猎物时该怎么转向。转向,无论是捕食者还是猎物,都依赖于turn(),该函数在给定一个起始向量和角度后,会返回经过该角度旋转后的向量。
此外,我们还需要一个函数来变异moves表格。如果没有这个函数,进化过程就不会发生:
function rmutate!(moves, nms)
for ms in rand(rng, 1:M, nms) # nms random mutation sites
θ = moves[ms] + (2rand(rng) - 1) * π/4
# Keep within ±π:
if abs(θ) < 1π
moves[ms] = θ
else
moves[ms] = (θ - sign(θ) * 2.0π) % 2.0π
end
end
end
这个函数会对表格中指定数量的角度进行随机变化,变化量是一个均匀分布在–π/4 到π/4 之间的角度。
模型初始化
每个Agents仿真除了需要代理本身外,还需要三种数据结构:
arena = ContinuousSpace((1, 1); periodic=true)
properties = Dict(:stepno => 0, :total_step => 0)
model = ABM(Union{Prey, Predator}, arena; properties)
arena是代理生活和互动的空间。我们的空间将是连续的,坐标范围从 0 到 1,并且每个维度都采用周期性边界条件。这使得空间变得无限——一个代理跑到右边界时会重新出现在左边界。
properties是一个与整个仿真相关的数量字典。在我们的仿真中,我们用它来跟踪已过的步骤数。为了跟踪何时是猎物转弯的时间,我们使用stepno,并在每一步时增加total_step。前者可以从后者中推导出来,但在从保存的状态重新启动仿真时,维护两个计数器会很方便。我们将这两个计数器初始化为 0。
有了这两个对象,我们可以初始化model,它维护整个仿真状态。检查点和重启仿真仅需要将model保存到磁盘。它的构造函数的两个位置参数分别是代理类型和空间。如果我们只有一种类型的代理,调用的方式将像ABM(Prey, arena; properties),例如。
我们选择properties作为属性字典的名称,因为该名称在模型构造函数中用作关键字,这使得调用ABM更加简洁(参见“关键字参数的简洁语法”在第 154 页)。
注意
在写作时使用的 Agents 版本中,在这种方式下构建模型后,我们会收到一个警告。消息警告我们在使用代理类型的 Union 时可能会出现效率低下的问题。这是一个正在进行中的开发领域,警告可能会在未来版本中消失。除非我们使用超过三种代理类型,否则这个效率问题实际上并不会成为问题。
定义了model后,我们可以通过添加代理来初始化它:
for i in 1:NPrey # Initialize Prey agents
vel = vnorm(Tuple(rand(model.rng, 2).-0.5))
➊ moves = π*(2rand(model.rng, M) .- 1)
add_agent!(Prey, model, vel, 0.0, moves)
end
for i in 1:NPred # Initialize Predator agents
add_agent!(Predator, model, (0.0, 0.0), 0)
end
add_agent!()函数的命名使用了感叹号,提醒我们它会修改其中一个参数:它通过向model中添加代理来改变它。此函数在arena中随机位置创建一个代理。它期望第一个参数为代理类型,第二个为模型,第三个参数为一个元组,指定代理的初始x和y速度。第三个位置之后的其他位置参数将传递给代理构造函数。因此,在第一次循环中,每个add_agent!()调用都会使用Prey(0.0, moves)创建一个Prey实例。初始的mojo设置为 0,起始角度向量随机设置 ➊。
从模型中提取信息的函数
让我们看一些接受model作为参数并返回其当前状态的短小实用函数。我们将在计算中使用其中一些函数,并用其他的来提取数据,之后在分析结果时存储并使用它们。
首先,我们需要一些函数来返回系统中所有猎物或捕食者的向量:
function preys(model)
[a for a in allagents(model) if a isa Prey]
end
function predators(model)
[a for a in allagents(model) if a isa Predator]
end
在列表推导式内部,我们使用了allagents()函数,它创建了一个迭代器,用于遍历模型中的代理。
以下这些富有提示性的函数只是调用了前面展示的函数,并返回代理向量的长度:
function number_of_predators(model)
length(predators(model))
end
function number_of_preys(model)
length(preys(model))
end
由于捕食者之间不会争夺猎物,它们需要知道潜在的猎物是否已经被追捕:
function being_chased(model)
[a.victim for a in predators(model)]
end
该函数返回一个包含所有被某些捕食者标记为受害者的猎物生物 ID 的向量。为了判断潜在的猎物是否已经被追捕,捕食者会检查其 ID 是否在该列表中。
如前所述,我们期望角度向量会发生变化。了解这一过程的一个方法是观察群体中角度分布的演变(有关分布概念的概述,请参见 第 321 页的“分布”)。
以下函数收集所有捕食者的 moves 向量中的所有角度,并返回一个 Histogram 数据结构,表示将分布划分为 40 个相等的桶。然后,我们可以在不同的时间步对结果进行归一化并绘制图表,以分析仿真的一个方面:
function moves_dist_data(model)
moves_data = [m.moves for m in preys(model)]
all_angles = [i for a in moves_data for i in a]
fit(Histogram, all_angles, -π:2π/40:π)
end
这个函数及其对 fit() 和 Histogram 数据结构的使用,是我们导入 StatsBase 包的原因。第二行中的推导式模式,带有两个 for 循环,是扁平化一组集合的常见方法。
通过仿真步进
agent_step!() 和 model_step!() 函数是任何 Agents 仿真中的核心。在每个时间步,agent_step!() 函数会在 调度器 选中代理时更新该代理。这个更新可以包括移动代理、改变其速度、修改其属性值,或者对单个代理应用其他任何合理的变化。调度器是计算的一个组成部分,负责选择哪些代理需要更新以及更新的顺序。在大多数 Agents 仿真中,我们可以不指定顺序;允许调度器以任意顺序更新代理是最快的选择。
在 agent_step!() 函数之后(默认情况下)是 model_step!() 函数,该函数会执行对整个模型的更新。这包括需要访问整个代理群体的更新内容,例如那些寻找邻近代理的更新。
agent_step!() 函数是必需的,但 model_step!() 是可选的;我们的计算同时使用了这两者。此外,如果计算需要,也可以在 agent_step!() 之前执行 model_step!()。
代理步进
以下是更新捕食者和 猎物 代理的整个函数:
function agent_step!(agent, model)
move_agent!(agent, model, dt)
if agent isa Predator && agent.victim > 0
if agent.victim in keys(model.agents)
if euclidean_distance(agent, model[agent.victim], model) < KD
kill_agent!(model[agent.victim], model)
agent.victim = 0
agent.vel = (0.0, 0.0) # Time to rest a bit ➊
else
θp = angle_between(agent.vel,
get_direction(agent.pos, model[agent.victim].pos, model))
θf = min(abs(θp), LAA) * sign(θp) ➋
agent.vel = Tuple(turn(agent.vel, θf))
end
else
agent.victim = 0 # Already gone
end
end
victims = being_chased(model)
if agent isa Predator && agent.victim == 0
food = [a for a in nearby_agents(agent, model, TAD)
if (a isa Prey && !(a in victims))]
if !isempty(food)
agent.victim = food[1].id
append!(victims, food[1].id)
agent.vel = SPEEDR .* vnorm(get_direction(agent.pos, food[1].pos, model)) ➌
end
end
if agent isa Prey
if agent.mojo >= MNFR # Reproduce: add LS new Preys at my position
for c in 1:LS
child = add_agent!(agent.pos, Prey, model,
vnorm(Tuple(rand(model.rng, 2).-0.5)), 0, agent.moves)
rmutate!(child.moves, 2)
end
kill_agent!(agent, model) # Reproduction is fatal ➍
end
if model.stepno == 0
vel = turn(agent.vel, agent.moves[1])
agent.vel = Tuple(vel) ➎
agent.moves = circshift(agent.moves, -1)
end
agent.mojo += MIPS # I eat as I run
end
end
agent_step!() 函数(可以命名为任何名称,但我们使用了传统名称)必须接受 agent 和 model 作为参数。调度器会依次将每个代理传递给该函数,随着它对代理的轮流操作。
第一行通过时间步 dt 确定的量来移动代理。
然后我们消除任何被捕食者追逐的Prey代理,这里的“被捕获”意味着它们之间的距离小于KD。我们使用包内的euclidean_distance()函数来衡量这个距离。
吃完饭后,捕食者静止不动➊,等待另一只猎物进入攻击范围。
如果猎物距离太远无法吃掉,我们通过转向继续追逐。第一步是找到捕食者速度向量与捕食者与猎物位置之间的向量之间的当前角度。幸运的是,Agents包提供了一个专门的函数:get_direction()。调用此函数时,我们使用了model的两个附加特性:一个代理的位置元组可以通过agent.pos获得,model[i]返回具有ID i的代理。虽然model不是数组,但Agents包定义了一个getindex()方法来支持这一点。在将转向角度限制到捕食者的灵活性,即常数LAA之后,我们更新它的速度。
然后我们检查是否有任何足够接近的可追捕猎物:任何距离TAD以内且尚未被追逐的Prey代理。如果找到了,我们将捕食者的速度向量指向猎物,再次使用get_direction() ➌。
转向猎物时,我们首先检查它们是否积累了足够的mojo以进行繁殖。那些有足够mojo的会繁殖出LS个副本,然后这些副本会发生突变。我们使用kill_agent!()函数 ➍杀死父体,这是Agents包的一部分。
当需要转向时,我们使用turn()函数旋转速度。由于该包使用元组来存储代理的速度,我们需要将结果转换为Tuple ➎。
转向后,我们使用一个首次使用的函数circshift()来旋转代理的私人转向表,这个函数可以旋转数组。这个调用将moves向量向左旋转,使其第二个元素成为第一个,第一个元素成为最后一个。结果是猎物会重复做moves中存储的M次转向(如果它足够活跃的话)。
模型步进
在调度器更新所有代理后,它调用此函数,并将模型作为参数传递:
function model_step!(model)
model.stepno = (model.stepno + 1) % SBT
model.total_step += 1
# Maintain predator/prey ratio:
predators_pop = length(predators(model))
prey_pop = length(preys(model))
if predators_pop/prey_pop < PPR
for i in 1:Int(round(PPR*prey_pop - predators_pop))
add_agent!(Predator, model, (0.0, 0.0), 0)
end
end
if predators_pop/prey_pop > 1.05PPR
for i in 1:Int(round(predators_pop - PPR*prey_pop))
➊ kill_agent!(random_agent(model, a -> a isa Predator), model)
end
end
# Logging and checkpointing:
if model.total_step % LI == 0
write(LF, "$(model.total_step), $prey_pop, $predators_pop \n")
flush(LF)
end
end
首先我们增加model_step,使用模算术来保持长度为SBT的循环;然后我们增加总步数。由于total_step及其其他属性与模型一起存储,我们可以通过使用JLD2保存并重新加载模型来进行检查点并无缝重启仿真,同时total_step将跟踪运行了多久。
我们通过根据需要添加或移除捕食者来保持指定的捕食者/猎物比(PPR)。Agents中的add_agent()函数在随机位置添加一个代理。参数列表中的元组是其初始速度,后续的参数传递给代理构造函数。在这个例子中,只有一个这样的参数:初始的victim属性被设置为 0。
我们通过将一个随机代理传递给kill_agent()来移除代理,使用random_agent()函数➊。这个Agents函数在其可选的第二个参数中接收一个函数,该函数表示潜在被删除的代理必须满足的条件。
最后,例程维护一个日志文件,每LI步写入一次记录。我们通过flush()调用日志文件,以便在仿真运行时查看它。如果没有这个调用,文件可能直到计算结束后才会写入。
运行仿真
run!()函数是Agents的基本设施,用于逐步执行模型, 如 Listing 11-2 所示。它的四个位置参数是模型、用于更新代理的函数、用于更新模型的可选函数以及步骤总数。
function evolve!(model, nruns, nsteps_per_run)
for run in 1:nruns
adf, mdf = run!(model, agent_step!, model_step!, nsteps_per_run;
adata=[:mojo],
mdata=[:total_step, number_of_predators,
number_of_preys, moves_dist_data])
jldsave("mdf$run"; mdf)
jldsave("model$run"; model)
end
end
evolve!(model, 10, 1000)
Listing 11-2:运行仿真
它返回两个数据框(参见“CSV 文件”在第 332 页):一个用于代理,另一个用于模型。adata关键字参数是一个包含代理数据的数据向量,mdata关键字参数则用于模型数据框。这些数量可以是代理或模型的属性,变成符号,或者是model的函数。在mdata的值中,我们使用了三个我们在此基础上定义的函数:我们正在跟踪两个种群规模和角度分布。
我们将run!()封装在一个函数中,该函数调用它nruns次,每次让它运行模型nsteps_per_run步,并使用JLD2中的保存函数将模型数据框和整个模型存储到磁盘上。
为了从磁盘加载模型的保存版本,我们可以输入
mode = load(filepath, "model")
其中,字符串参数指定了从文件中加载的变量。
可视化系统行为
获取模型在任何时刻的快照,或创建其进展的动画,最方便的方式是使用InteractiveDynamics包提供的两个函数,这需要单独导入:
julia> using InteractiveDynamics, CairoMakie
我们还需要导入Makie库,因为InteractiveDynamics使用它进行绘图。Makie是一个图形框架,基本上与当前的标准Plots类似。
在我们计划为包含两种代理类型的模型创建可视化时,让我们创建将代理类型映射到两种不同颜色和形状的函数:
function agent_color(agent)
if agent isa Prey
return :blue
end
if agent isa Predator && agent.victim > 0
return :red
end
return :green
end
function agent_shape(agent)
if agent isa Prey
return '•'
end
return '⊙'
end
当这些函数一起使用时,它们将使猎物以蓝色点表示,捕食者以圆圈内有点的形式表示。追逐中的捕食者为红色,而静止的捕食者则为绿色。
在使用run!()将模型演化到任意步骤后,我们可以通过以下调用创建并保存其状态的图片:
julia> fig, _ = abmplot(model; ac=agent_color, as=20, am=agent_shape)
julia> save("model_snapshot.pdf", fig)
绘图函数abmplot()返回两个值,我们只需要第一个。代理的颜色(ac)和形状(am)使用我们之前定义的函数,我们将代理的标记大小(as)设置为在可视化中效果较好的值。图 11-1 显示了第 10,000 步后的结果。

图 11-1:第 10,000 步的模型配置
在图 11-1 中所示的时刻,总共有 139 个代理。此时没有任何捕食者处于空闲状态,因此它们全部以相同的颜色渲染。
我们还可以使用abmvideo()创建模型的动画,这个函数同样由InteractiveDynamics提供。它实际上是运行模型,从第二个参数提供的初始状态开始,通过我们提供给run!()的相同步骤函数进行演化:
julia> abmvideo("arena.mp4", model, agent_step!, model_step!;
ac=agent_color, am=agent_shape,
frames=500, framerate=30)
运行将在frames关键字参数中给定的步数后停止,并将视频文件保存为第一个参数给定的名称。我们可以像在abmplot()中一样使用常量或函数来表示代理的形状和颜色。你可以在本章的在线补充中查看使用这种方法制作的动画。
动画是验证 ABM 仿真是否按预期工作的优秀工具,尤其是在动态行为有趣时,可以用来传达结果。然而,使用abmvideo()运行模型要比使用run!()慢得多,因为除了模型计算之外,该函数还会在每个步骤使用abmplot()渲染图像,并且会组装一个视频文件。因此,对于长时间运行的代理仿真,策略可能是先用run!()运行计算,然后将某些步骤渲染为动画。这种策略需要定期保存模型,就像我们在agent_step!()示例中所做的那样,以便我们可以从多个已保存的状态开始。
关于abmvideo()还需要注意两个额外的特性:它没有像run!()那样使用感叹号作为名称的一部分,尽管它会改变模型;并且它不能像run!()那样直接生成数据框。我们可以通过将数据记录放入model_step!()来绕过这个问题,就像我们在示例中使用日志记录所做的那样。无论如何,这是一种更灵活的方法,因为它让我们可以更好地控制记录的数据。例如,我们可能决定不在每一步都向数据框中添加一行。
CairoMakie 图形库适用于制作高质量的图表和动画,并保存为文件。为了更及时的反馈,我们可以导入GLMakie。如果两者都已导入,调用GLMakie.activate!()和CairoMakie.activate!()可以在它们之间切换。当GLMakie处于活动状态时,abmplot()和abmvideo()在使用 REPL 时会打开一个专用的图形窗口,或者它们可以将图形插入到计算笔记本中。
分析结果
在列表 11-2 中运行仿真时,会在每个时间步长存储捕食者角度表中的角度分布。这些角度最初是均匀分布的,所以如果分布随时间变化,我们就知道某种形式的进化正在发生。种群的角度分布并不能告诉我们其所有特征,但如果我们达到一个分布停止变化的点,这表明种群可能已经在捕食者施加的选择压力下达到了某种最优状态。
我们可以通过从模型数据框中提取moves_dist_data来绘制任意一步的分布直方图。第 20 次运行的数据框如下所示:
julia> mdf20 = load("mdf20", "mdf")
1001×5 DataFrame
Row | step total_step number_of_predators number_of_preys moves_dist_data
| Int64 Int64 Int64 Int64 ...Histogram
----------------------------------------------------------------------------------------------
1 | 0 19000 1787 3447 Histogram{Int64, 1, Tuple...
2 | 1 19001 1787 3444 Histogram{Int64, 1, Tuple...
3 | 2 19002 1787 3438 Histogram{Int64, 1, Tuple...
4 | 3 19003 1787 3434 Histogram{Int64, 1, Tuple...
: | : : : : :
999 | 998 19998 2559 5075 Histogram{Int64, 1, Tuple...
1000 | 999 19999 2559 5072 Histogram{Int64, 1, Tuple...
1001 | 1000 20000 2559 5069 Histogram{Int64, 1, Tuple...
下面是调用绘制直方图的代码,来自此数据框的最后一行:
julia> using LinearAlgebra, Plots
julia> Plots.plot(normalize(mdf20.moves_dist_data[end], mode=:pdf);
xticks=([-π:π/2:π;], ["-π", "-π/2", "0", "π/2", "π"]),
legend=false, xlabel="θ", ylabel="PDF(θ)")
我们需要LinearAlgebra包来使用normalize()函数,它将原始计数的直方图重新缩放为可以解释为概率密度函数的形式(请参见第 325 页的“概率密度函数”)。这使得我们能够直接比较不同大小种群的分布。在这一阶段,数据框中可以读取到有 5,069 个Prey代理,并且比较早期的分布显示,分布似乎已经收敛到图 11-2 所示的形态。

图 11-2:进化后的角度分布
一些反思揭示了为何猎物生物可能进化出了这样的分布。捕食者比猎物显著更快(SPEEDR = 1.5),但它们的灵活性极为有限:LAA = π/128,这意味着它们在任何一步骤中最多只能转动 1.4°。如果猎物试图沿直线奔跑,捕食者很可能会在它们有机会繁殖之前抓住它们。这个事实导致了分布在接近 0°时出现明显的下降。在接近 180°的大转弯处,猎物能争取到最多的时间,这就是我们在分布中找到峰值的地方。
如果这个想法是正确的,即猎物已经“学会”避免具有这些特定属性的捕食者,那么具有不同属性的捕食者物种应该会导致不同的角度分布。
为了验证这个观点,我们只需要改变LAA和SPEEDR常量,并重新运行模拟。在尝试了SPEEDR = 1.05和LAA = π/16后,我们观察到在 13,000 步后,得到如图 11-3 所示的分布。

图 11-3:进化后的角度分布,捕食者更慢但更灵活
这个结果与之前的明显不同,并且有一个直观的解释。这些捕食者的速度仅比猎物快 5%,因此猎物通常能够在接近直线的路径上生存足够长的时间以进行繁殖。尽管捕食者很慢,但它们比之前模拟中的捕食者要灵活得多,能够在每一步中转动 11.25°,因此做出许多大幅度转弯的猎物更容易被捕获。因此,我们看到的分布在 0°附近有一个宽阔的峰值,并且在较大角度时逐渐下降。
分布演化具有启发性,但我们需要确认一个观点:猎物是否已经进化得更擅长避开捕食者。我们通过将进化后的种群与具有均匀角度分布的未进化种群进行比较来验证这一想法,这些种群的moves表格中的角度分布是均匀的。由于我们使用了种子随机数生成器,我们可以通过多次运行模拟并调整SEED来创建不同种群的集合。
一个种群在特定类型捕食者环境中的生存能力被称为其适应度。我们模型中的捕食者类型由两个参数定义:SPEEDR和LAA,即它们的速度和灵活性。
为了测试初始未进化种群的适应度,我们从 200 个Prey(猎物)代理和 100 个Predator(捕食者)代理开始,并关闭Prey代理的繁殖能力。在这种情况下,猎物种群应该会呈现大致指数衰减,最终趋于零。我们进行这个实验 10 次。
为了测试进化后种群的适应度,我们在模拟运行 20,000 步后加载模型,并从中提取 200 个Prey代理的随机样本。我们将该样本放入竞技场,与 100 个捕食者一起观察种群衰减过程,重复此实验 10 次。每次实验使用不同的随机种子,因此每次得到的随机样本都会不同。
图 11-4 显示了结果:进化后的种群明显表现得比初始种群要好。

图 11-4:适应度比较
当然,进化后的种群也会灭绝,因为没有繁殖能力。但比较结果表明,图 11-2 所示的急转弯策略是有效的,因为这个种群衰减得较慢。
结论
在本章中,我展示了一个完整的、详细的研究问题示例:我们首先对是否可以模拟自然选择作用于种群的特定属性产生了好奇。然后,我们通过设计一个情景使问题变得具体,其中行为通过八个数字的列表来编码。接下来,我们构建了一个模拟,捕捉了我们想要研究的机制,并观察到模拟显示出种群的进化,显然趋向某个最优解。最后,我们测试了进化后的种群,发现其确实具有更强的适应性。
Agents包以及 Julia 的表现力和高效性,大大简化了从初步假设到可验证、定量且易于可视化结果的路径。在一个统一的交互环境中尝试多种情境并分析和查看结果,同时不牺牲性能,对于研究人员来说是前所未有的福音。
进一步阅读
-
有关 BioJulia(Julia 语言的生物信息学基础设施)的更多信息,请访问https://biojulia.dev。
-
文章“Julia for Biologists”概述了该语言在生物学中的应用: https://arxiv.org/abs/2109.09973。
-
Agents.jl的更多详细信息,请访问https://juliadynamics.github.io/Agents.jl/stable/。 -
在这里观看关于
Agents.jl的视频: https://youtu.be/Iaco6v6TVXk。 -
有关人工生命领域的详细调研,请访问https://www.ais.uni-bonn.de/SS09/skript_artif_life_pfeifer_unizh.pdf。
-
关于人工生命模拟的有趣轶事,请访问https://direct.mit.edu/artl/article/26/2/274/93255/The-Surprising-Creativity-of-Digital-Evolution-A。
第十三章:数学
乌尔姆的人们是数学家。
—阿尔伯特·爱因斯坦的出生地乌尔姆的座右铭

在本章中,我们将探讨几个用于符号和数值数学的 Julia 包。符号数学软件可以取代繁琐的笔算,或者替代那些需要长时间查阅积分表的夜晚,使用自动化的数学表达式运算。数值包包括线性代数、方程求解和相关领域的模块。这两类包有大量的重叠,它们对应用数学家,或者潜在地对任何在研究中使用数学的人来说,都是一种福音。
符号数学
这一类软件有时被称为计算机代数,但它包含所有类型的自动符号运算,如代数和三角简化;泰勒级数的生成;极限、导数和积分的计算;以及更专门的领域,如代数数论。
符号数学软件区别于我们更熟悉的计算机和数学的交集,它能够将数学作为数学来处理,而不仅仅是执行算术运算。我们将包含变量的表达式输入给它,它返回重新写过的表达式,或者是问题的解答,结果是以这些变量的形式给出,而不是数字。
符号-数值建模与 Symbolics
本节介绍了Symbolics,它被描述为一种符号建模语言和数值符号软件。这些描述旨在表明Symbolics强调符号计算和数值计算之间的协同作用,并且设计时考虑了效率。Symbolics并不具备完整计算机代数系统的所有功能——例如,它不能计算不定积分。但它具有其他独特的功能。例如,它可以将普通的 Julia 函数转换为符号函数,并且可以将一个 Julia Symbolics程序转换为 C 程序。Symbolics完全用 Julia 编写,这意味着在处理符号表达式时,我们可以调用语言的任何部分。Symbolics是ModelingToolkit包的关键部分,后者是一个用于自动并行化科学机器学习的框架。
要将名称设定为符号变量,如清单 12-1 所示,最方便的方法是使用Symbolics包提供的宏。
@variables a b c φ z;
5-element Vector{Num}:
a
b
c
φ
z
清单 12-1:声明 符号 变量
在调用这个宏之后,我们可以像使用数学表达式中的变量一样使用这五个提到的变量。它们的类型是Num,并且与Real类型有很多相似的行为,但它们具有额外的能力,我们接下来会探讨。
让我们像在《矩阵乘法》一节中一样创建一个旋转矩阵,参见第 146 页:
RM = [cos(φ) -sin(φ); sin(φ) cos(φ)]
由于φ是一个Symbolics变量,这个矩阵是一个Symbolics表达式。
让我们看看如果我们像在第五章中使用“常规”旋转矩阵那样,尝试用矩阵乘法旋转一个向量,会发生什么:
julia> RM * [1, 0]
2-element Vector{Num}:
cos(φ)
sin(φ)
julia> RM * [0, 1]
2-element Vector{Num}:
-sin(φ)
cos(φ)
julia> RM * [1, 1]
2-element Vector{Num}:
cos(φ) - sin(φ)
cos(φ) + sin(φ)
julia> RM * [0.5, 0]
2-element Vector{Num}:
0.5cos(φ)
0.5sin(φ)
julia> RM * [0.5, 0.6]
2-element Vector{Num}:
0.5cos(φ) - 0.6sin(φ)
0.5sin(φ) + 0.6cos(φ)
在每种情况下,矩阵乘法返回一个准确的结果,适用于任何值的φ。*操作符能够作用于Symbolics表达式,像处理数字矩阵一样执行矩阵乘法。这是 Julia 包的组合性另一个例子。大多数数组和数值操作符及函数将以我们预期的方式处理Symbolics表达式。
要计算数值结果,我们可以使用substitute()函数:
julia> substitute(RM * [1, 0], Dict(φ => π/2))
2-element Vector{Num}:
6.123233995736766e-17
1.0
结果与“矩阵乘法”中在第 146 页的结果完全相同。
substitute()函数的第一个参数是一个Symbolics表达式,第二个参数是一个替换字典。得到的表达式不总是像我们预期的那样简化:
julia> ex = a²*z² + a⁴*z⁴;
julia> substitute(ex, Dict(a => sqrt(b)))
(z²)*(sqrt(b)²) + (z⁴)*(sqrt(b)⁴)
julia> substitute(ex, Dict(a => b^(1//2)))
b*(z²) + (b^(2//1))*(z⁴)
这里我们有一个多项式,尝试通过变量替换将其写成稍微简单的形式。我们第一次尝试失败了,因为Symbolics似乎不知道例如sqrt(b)² = b。在第二次尝试时,我们运气更好。
Symbolics能够自动简化涉及变量乘法或除法的表达式,尤其是带有整数次幂的变量:
julia> z³ * z⁵
z⁸
julia> a⁵/a³
a²
它还带有一个simplify()函数,但它的功能有限——甚至不能进行文档中提到的简化。正如前面提到的,Symbolics的重点是高效的数值-符号建模。我们总是可以转向SymPy,在下一节中将进行探讨,来对表达式进行非平凡的简化,然后将其结果用于Symbolics程序中。
一个例子:贝塞尔函数
作为Symbolics的实际应用示例,假设我们需要计算各种阶数的第一类贝塞尔函数及其一些导数。这些函数在物理学和工程学中广泛出现。在第 7-5 节的第 206 页中,我们使用了贝塞尔函数来表示振动鼓面形状,并通过SpecialFunctions包访问了它。
为了实现我们自己的贝塞尔函数,记作J**m,其中m是阶数,我们可以转向其著名的级数表示法:

在示例 12-2 中展示了一个实现此表示法的 Julia 函数,它接受x、m和若干项(因为我们不能计算无限项)作为参数,我们称这些为N。
function Jm(x, m::Int, N)
s = 0
for k in N:-1:0
s += (-1)^k * x^(2k + m) / (2^(2k + m)*factorial(k)*factorial(k + m))
end
return s
end
示例 12-2:使用级数展开计算贝塞尔函数
这个函数将返回通过使用级数中的N项计算的J**m的值。因为它使用的是普通整数,而不是big整数,所以我们只能在N < 19 的情况下使用它(见第 216 页的“‘Big’ 和无理类型”)。保持九项已经足够在区间 0 ≤ x ≤ 6 内进行极其准确的近似。
我们的小函数Jm()在需要知道不同x值下的J**m的数值时非常有用,特别是如果我们不熟悉Special Functions包的话。如果我们碰巧需要不同导数的J**m的值,我们可以使用某种有限差分方案来计算它们,通过在两个或多个紧密相邻的x值处调用Jm(x, m, N)来计算导数。然而,这些方法固有的数值误差会随着导数阶数的增加而积累,并且反复评估Jm(x, m, N)也会增加计算成本。让我们看看如何通过使用Symbolics的方式巧妙地解决这两个问题。
如果我们为x、m和N提供数值并调用Jm(x, m, N),我们会得到一个数值,即在x处的m阶贝塞尔函数的近似值。清单 12-3 展示了当我们为x提供一个Symbolics变量名称时会得到什么结果,而不是一个数值。
julia> J19 = Jm(z, 1, 9)
(1//1917756584755200)*(z¹⁷) + (1//1474560)*(z⁹) +
(1//29727129600)*(z¹³) + (1//384)*(z⁵) + (1//2)*z -
(1//176947200)*(z¹¹) - (1//18432)*(z⁷) - (1//6658877030400)*(z¹⁵) -
(1//690392370511872000)*(z¹⁹) - (1//16)*(z³)
清单 12-3:一个 Symbolics 表达式近似 J1
在清单 12-1 中,我们创建了Symbolics变量z,以及其他一些变量。当我们将z传递给Jm()时,它会返回生成的级数展开式的九项,使用m = 1 和N = 9,并且顺序是随机的。我们将这个Symbolics表达式赋值给变量J19。我们可以通过替换来获取这个表达式的数值:
julia> substitute(J19, Dict(z => 1.2))
0.4982890575672154
julia> Jm(1.2, 1, 9)
0.4982890575672155
最后一位的数值差异是由于运算顺序的不同造成的。清单 12-2 中展示的策略是先将级数中的小项加总,然后再加上大项,这样应该能更加准确。
作为组合 Julia 包功能的另一个例子,我们可以使用Latexify来渲染Symbolics表达式的 LaTeX 版本:
julia> using Latexify
julia> latexify(J19)
L"\begin{equation}
\frac{1}{1917756584755200} z^{17} + \frac{1}{1474560} z^{9} - [etc.]
\end{equation}
"
将生成的 LaTeX 字符串的内容(并添加一些换行符)复制并粘贴到本书的源文件中(该书使用 LaTeX 排版),我们可以看到渲染后的表达式:

这里展示的过程,即将一个普通的 Julia 函数重新利用来生成一个Symbolics表达式,有时被称为追踪。只有那些在某种意义上是确定性的函数才能被追踪。在我们Jm()函数的例子中,这意味着我们可以为x提供一个Symbolics变量,但不能为项数N提供一个变量。对于N,我们必须提供一个整数。如果我们尝试为第三个位置参数提供一个Symbolics变量,我们会得到一个晦涩的错误信息:
julia> Jm(z, 1, a)
ERROR: TypeError: non-boolean (Num) used in boolean context
我们没有在函数签名中强制要求 N 为整数,正如我们对 m 所做的那样,是为了展示这种行为。
尝试在使用 Symbolics 变量表示项数时跟踪 Jm() 的问题在于循环限制是未知的:应该返回什么表达式?我们只能追踪那些基于输入生成完全确定表达式的函数。在此列出的特定错误信息表明我们遇到了这个问题。
贝塞尔函数的求导
由于我们已经拥有了在 Listing 12-2 中生成的J1 的解析表达式,我们可以推导出其任何阶的解析导数,从而得到 d(*p*)*J*[1]/d*z*(p),即第 p 阶导数。由于 J19 仅是一个多项式,这个过程虽然简单,但仍然繁琐且容易出错。
Symbolics 可以帮助我们减轻手动求导的负担:
julia> Differential(z)(J19) |> expand_derivatives
(1//2) + (13//29727129600)*(z¹²) + (17//1917756584755200)*(z¹⁶) +
(5//384)*(z⁴) + (1//163840)*(z⁸) - (19//690392370511872000)*(z¹⁸) -
(11//176947200)*(z¹⁰) - (3//16)*(z²) - (7//18432)*(z⁶) - (1//443925135360)*(z¹⁴)
这里我们使用 Differential() 函数。Differential(t) 返回另一个函数,该函数计算 Symbolics 表达式对 t 的导数。为了实际看到这一操作的结果,我们需要将其传递给 expand_derivatives()。结果是多项式 J19 的正确求导,其项的顺序可能是随机的。
如前所述,我们可以反复应用 Differential() 来生成任何阶数的导数,而不必担心有限差分误差的积累。让我们来看一下贝塞尔函数的前 10 个导数:
julia> using Plots, LaTeXStrings
julia> dnJ19 = [Differential(z)(J19) |> expand_derivatives];
➊ julia> for ord in 2:10
push!(dnJ19, Differential(z)(dnJ19[ord-1]) |> expand_derivatives)
end
julia> plot(J19; lw=2, xrange=(0, 6), yrange=(-0.6, 0.6), legend=false,
xlabel=L"x", ylabel=L"J_1, J_1^\prime, J_1^{\prime\prime}, ...")
➋ julia> for ord in 1:10
plot!(dnJ19[ord]; linestyle=:auto)
gui()
end
我们打算绘制导数,因此首先导入 Plots,并且为了在轴标签中显示排版数学,导入 LaTeXStrings。我们按照之前的方法计算贝塞尔函数的导数,并将结果放入一个向量中。在循环 ➊ 中,我们反复应用导数运算符来生成前 10 个导数。我们通过绘制 J1 来设置图表,使用 LaTeX 字符串作为标签,然后在循环 ➋ 中遍历导数向量的元素,将每个导数添加到可视化中。图 12-1 显示了结果。

图 12-1:J1 的前 10 个导数
粗实线表示 J1。linestyle=auto 关键字参数传递给 plot!() 会生成一系列具有不同虚线模式的线条,使用默认的线条厚度进行绘制。这些是 10 个导数。
我们能够直接绘制这些 Symbolics 表达式,而不需要设置数值变量的向量或手动进行数值替换,这是组合性的另一个例子。Plots 包在编写时并不知道(未来的)Symbolics 包,但它能以自然的方式处理 Symbolics 表达式。
使用 SymPy 和 Pluto 进行数学运算
对于更通用的符号数学,SymPy可能是目前最好的可用软件包。这个软件包是一个 Julia 封装器,基于功能强大的同名 Python 库,因此它的性能受到 Python 限制;然而,对于通常使用此类软件包的工作,原始速度通常不是一个关键因素。
注意
为了从 Julia 使用 SymPy ,在一些系统和配置下,仅需在 Julia 的包模式中执行 add SymPy ,然后执行 using SymPy* 就足够了。在其他系统中,我们需要在 Julia 之外安装 Python 的* SymPy 库(可能还需要安装 Python 本身)。例如,在 Linux 上(大多数发行版中常常自带 Python),我们可以在终端执行 pip3 install sympy 。然而,由于 Python 世界中没有官方的库安装方法或解决依赖关系的标准方式,因此无法提供适用于所有人的命令。本节的其余部分假设你已经在 Julia 环境中成功执行了 add SymPy 和 using SymPy*。
SymPy 可以在任何这样的环境中使用,并且能够很好地在终端 REPL 中渲染数学符号。然而,从 Pluto 使用时,体验更加愉悦,我们将使用来自该环境的示例。在 Pluto 中,数学会自动渲染为 LaTeX,因此结果立即以美观的排版公式形式显示,并嵌入在笔记本中。Pluto 使用 MathJax 进行数学渲染。右键点击任何显示的表达式,会弹出一个上下文菜单,提供多个选项,其中最重要的选项是将创建表达式的 LaTeX 命令复制到剪贴板。
Pluto 是 SymPy 的天然平台,另一个原因是,当使用计算机代数库时,我们通常处于发现或探索模式,或者将 Julia 与 SymPy 作为计算器使用,而不是开发一个大型程序。Pluto 的反应式特性非常适合这种交互模式(参见 第 17 页中的“Pluto: 一个更好的笔记本”)。由于 Pluto 的依赖图,我们可以知道在任何时候,笔记本中显示的所有方程式彼此一致,而 Jupyter 则无法做到这一点。
使用 Pluto 的能力是我们可能更倾向于从 Julia 中使用 SymPy 而不是直接使用 Python 的原因之一。另一个原因是 SymPy 提供的函数和数据结构封装为 Julia 程序员提供了更熟悉的接口,并简化了与其他 Julia 程序和库的互操作性。然而,从某种意义上讲,这种封装并不完全。SymPy 的用户将遇到 Python 类方法语法的残留,比如在调用 sol.rhs() 时,表示解 sol 的右侧。
由于 Pluto 是一个强大(且有趣)的SymPy使用环境,本节中的示例将采用 Pluto 会话的截图形式(参见第一章了解如何启动 Pluto 笔记本会话)。
图 12-2 展示了会话的开始。

图 12-2:在 Pluto 中启动 SymPy 会话
导入包后,我们使用@syms宏将一些变量定义为SymPy符号名称。这与在Symbolics包中使用的@variables宏有相同的目的。将其中一个名称输入为f(),就会把f定义为一个符号函数名称,我们可以在定义微分方程时作为未知数使用(我们稍后会讨论这一点)。
使用 SymPy 进行代数运算
SymPy可以进行代数简化、展开及其逆操作——因式分解,如图 12-3 所示。

图 12-3:简化、展开和因式分解
在图 12-3 中的输入单元格里,某些字符下方微妙的下划线标明了它们是SymPy符号——这是界面的一项精妙改进。
为了解决代数方程组,我们可以将方程放入一个向量中,并使用solve()函数,传入这个向量作为参数,如图 12-4 所示。

图 12-4:求解方程组
向量p包含了两个方程,右边为 0,因此,p代表以下的方程组:

调用solve()的结果是解a = –1/7, b = 3/7。
使用 SymPy 进行数值解法
我们的例子涉及线性方程,但SymPy也能处理更高阶的多项式、有理方程等,并能找到复杂和多个解。我们还可以使用它的内建数值求解器,在没有符号解的情况下也能找到解。
举个例子,假设我们感兴趣的是a的值,使得:
sin(a) + log(a) = 1
尝试将这个问题交给符号求解器只会得到一个错误消息,表示SymPy没有针对其解析解的算法。这是一个需要近似数值求解器的任务。
智能数值解法要求我们至少在寻找解的区域及其附近,理解方程的行为。一个好的第一步是查看方程的图形,如图 12-5 所示。

图 12-5:寻找数值解的第一步
这里我们绘制了方程的左边,曲线与水平线 1 的交点显示了我们可以期待解的位置。通过观察图形,可以看到有三个解,分别位于a = 1、3 和 5 附近。
SymPy的数值求解器是nsolve()函数。它的第一个参数是符号表达式,第二个参数是该表达式的根的初值。通过调用该函数三次并提供三个近似根,我们可以获得三个精确解,如图 12-6 所示。

图 12-6:数值根求解
与 SymPy 的结合
SymPy掌握微积分,它可以大幅替代复杂的积分表。我们将使用该包来计算高斯分布的无穷积分和定积分(参见第 323 页的“正态分布”)。我们可以通过integrate()函数一步完成这些积分的求解,也可以将问题分为两个阶段。第一阶段是定义未求值的积分表达式,如图 12-7 所示。

图 12-7:未求值的积分
我们使用sympy.Integral()函数创建一个未求值的积分,由于该函数没有被包裹导出,所以需要使用命名空间前缀。在这个例子中,积分符号下的表达式只有一个自变量,但如果有多个自变量,我们会将积分变量作为第二个参数传入(无论如何,这样的传入方法效果相同)。在定积分版本中,第二个参数是一个元组,包含了积分变量和上下限。这里的e是欧拉数,可以通过输入\euler 并按 TAB 键,或者直接输入 Unicode 字符来输入。我们使用双o来表示符号无穷大,使用PI来表示符号π——这与无理数 Julia 中的π不同,两者不可互换:如果我们使用π而不是PI,前者将被转换为π的近似值,且在随后的计算中,π的因子将无法相互抵消。
创建这些中间表达式而不是一步积分的原因有很多。我们可能希望将这些未求值的积分用于其他计算,或者我们可能仅仅希望检查它们的排版形式,确保我们正确地输入了它们——这种检查方法使用传统的数学符号比使用即便是极为清晰的计算机语言要更为容易。
为了求解这些积分,我们将它们传递给doit()函数,如图 12-8 所示。

图 12-8:求解积分
高斯函数的无穷积分(反导数)不能用基本初等函数的封闭形式表达。它被定义为误差函数,缩写为 erf(z)。这是大多数强大计算代数系统内置的数学知识,SymPy也不例外。积分中的
因子使结果归一化,以便整个区间的定积分为 1。通过这种归一化,积分被视为概率密度函数,定积分从a到b即为观测值落在该区间内的概率。
使用 SymPy 求解微分方程
SymPy还可以解微分方程。为了呼应我们关于贝塞尔函数的小主题,回顾一下这些应用数学中的基本函数是如何通过微分方程的解得出的。图 12-9 展示了一个特定的示例,演示了如何在SymPy中定义微分方程。

图 12-9:贝塞尔方程
图 12-9 展示了一级贝塞尔函数微分方程的构造。我们使用Eq()函数定义该方程,该函数将方程的左侧和右侧作为两个参数。在定义中,我们使用了符号微分算子:diff(f(z), z, n)是f(z)关于z的n阶导数。正因为如此,我们在图 12-2 中将f()定义为符号函数。
为了求解微分方程,我们使用SymPy的dsolve()函数,该函数将要求解的方程和要求解的函数作为前两个参数传入。但由于边界条件对于确定我们感兴趣的解至关重要,dsolve()还会将一个边界条件的字典作为关键字参数ics的值传入。我们可以在该字典中指定特定点的值或导数;在这里,我们只需要一个简单的条件来排除在原点奇异的另一个贝塞尔函数。图 12-10 展示了生成感兴趣解的调用。

图 12-10:求解微分方程
图 12-10 显示了SymPy使用贝塞尔函数的常规表示法(在 Pluto 中;在 REPL 中则直接显示名称)。带有给定边界条件的解直到乘法常数未确定,SymPy将其命名为C[1]。图 12-10 中的第二个单元格展示了如何提取解的rhs(右侧),并为常数指定一个值,在本例中为 1。我们可以使用rhs来绘制解,如图 12-11 所示。

图 12-11:绘制贝塞尔方程的解
图 12-11 中显示的曲线与图 12-1 中通过其他方法计算的贝塞尔函数一致。
线性代数
正如 L. Fox 教授在他的 1965 年教材《数值线性代数导论》中所说,约 75%的科学计算,完全或部分地涉及数值线性代数。不论当前的比例如何,线性代数始终是任何依赖计算机帮助解决科学、数学或工程问题的事业中的核心部分。其根本原因在于,数值线性代数的核心问题——解线性方程组,在建模各种系统时反复出现——这些系统不仅仅是行为真正线性的,还有那些在某些参数范围内可以线性建模的系统。例如,某个偏微分方程组常常可以在某个初始条件或控制参数的小范围内通过一个线性代数系统来近似。
视图
在使用矩阵(或其他形状的数组)进行计算时,我们常常使用视图。在 Julia 中,视图是指向数组一部分的引用,我们可以创建并操作它而无需复制任何数据;对视图的修改会影响原始数组。
我们可以使用@view或@views宏创建视图。第一种方法紧跟在我们想要转换为视图的数组表达式之前,而第二种方法则会将整个表达式或代码块中的所有切片操作转换为视图:
julia> R = rand(5, 5)
5×5 Matrix{Float64}:
0.957982 0.206423 0.00489974 0.0881235 0.708827
0.301785 0.107707 0.524776 0.83413 0.771915
0.049844 0.031097 0.22972 0.415245 0.735899
0.438108 0.57943 0.144575 0.131095 0.103629
0.473649 0.237991 0.148043 0.0351828 0.724837
julia> row1Rview = @view R[1, :]
5-element view(::Matrix{Float64}, 1, :) with eltype Float64:
0.9579822727773696
0.20642276219972644
0.004899741566674942
0.0881235008776815
0.7088267041115207
➊ julia> row1Rview .= 17;
➋ julia> R
5×5 Matrix{Float64}:
17.0 17.0 17.0 17.0 17.0
0.301785 0.107707 0.524776 0.83413 0.771915
0.049844 0.031097 0.22972 0.415245 0.735899
0.438108 0.57943 0.144575 0.131095 0.103629
0.473649 0.237991 0.148043 0.0351828 0.724837
julia> @views row1RviewAgain = R[1, :];
julia> row1RviewAgain === row1Rview
true
在创建了随机矩阵R的第一行视图后,我们将其中所有元素设置为 17 ➊。由于修改视图会修改原始数组,因此R的第一行被修改了 ➋。我们使用@views宏创建了相同的视图,并通过最后的表达式验证了这些视图确实是相同的。
之前使用的切片语法,如果没有@view或@views宏,会创建一个新数组,其中包含R第一行数据的副本。修改副本不会对原始数组产生任何影响。
什么时候我们应该使用副本,什么时候应该使用视图?答案取决于我们打算对数据结构进行的计算模式。在这个例子中,由于数组是按列优先存储的,操作一行数据会使用不连续的内存访问。如果在提取该行后我们反复使用它,那么创建副本所消耗的时间可能是值得的。然而,如果数组很大,副本将消耗大量内存,而使用视图可以避免这一点。副本使用更多内存,但可能会导致更快的代码。对于本段开头提出的问题,并没有普遍适用的答案。是否更好使用视图或副本,取决于涉及的数组大小以及我们如何使用这些数据。
线性代数实例
让我们来看一个简单的示例问题。考虑 方程 12.1 中展示的 2×2 系统。

在这个方程组中,x[1] 和 x[2] 是我们最终要求解的未知数;a[xx]s 是数值系数,其索引表示它们在系统中的位置。系统的右侧由两个数字 b[1] 和 b[2] 组成。
为了应用数值线性代数的相关工具,我们将遵循通用约定,将系统更紧凑地表示为:

其中 A 是矩阵

x 是向量 [x[1], x[2]],b 是向量 [b[1], b[2]]。A 和 x 的并列表示通常的矩阵乘法。
方程 12.2 的形式表明,我们可以通过某种方式除以 A 来求解 x,这确实是对的。由于这是关于 数值 线性代数的章节,在 方程 12.3 中,让我们尝试用实际数字代替 方程 12.1 中的符号:

这个方程式可能有解,也可能没有解,对于 x[1] 和 x[2]。为了尝试通过数值方法求解,我们将定义一个 Julia 矩阵和一个右侧向量,分别对应 方程 12.2 中的 A 和 b,如 清单 12-4 所示。
julia> A = [1 3; 2 4]
2×2 Matrix{Int64}:
1 3
2 4
julia> b = [1, 7]
2-element Vector{Int64}:
1
7
清单 12-4:一个小的线性系统
此时,如果我们能够理解除以矩阵的概念,那么我们就可以通过将 b 除以 A 来计算解。事实上,这将是我们解决 清单 12-4 中方程组的第一种方法。
当然,我们熟悉 / 运算符用于除法。Julia 提供了一个“反向”版本,称为 左除运算符,直到现在我们还没有机会使用它:
julia> 1 / 3 == 3 \ 1
true
Julia 的 Base 扩展了左除运算符,使其可以作用于矩阵,计算矩阵的逆并执行矩阵乘法。结果应该是包含解的列数组:
julia> A \ b
2-element Vector{Float64}:
8.5
-2.5
这确实是解,我们可以立即验证:
julia> A * [8.5, -2.5]
2-element Vector{Float64}:
1.0
7.0
结果是 b,如 清单 12-4 中所定义。
如前所述,A \ b 的含义是矩阵 A 的 逆 与 b 的矩阵乘法:
julia> inv(A) * b
2-element Vector{Float64}:
8.5
-2.5
julia> inv(A) == A^-1
true
第二个输入表达式展示了另一种表示矩阵逆的方式。
尽管这就是 \ 运算符的正式含义,但我们绝不应该使用 inv() 来求解方程组,而应该使用 A \ b 这样的表达式。这是因为左除运算符使用最有效的算法来求解系统,而这可能并不涉及计算矩阵的逆。
矩阵的逆定义为 A^(−1) A 和 AA^(−1) 都等于 单位矩阵,该矩阵与 A 形状相同,主对角线上是 1.0,其他位置是 0.0:
julia> A * inv(A)
2×2 Matrix{Float64}:
1.0 0.0
0.0 1.0
单位矩阵通常表示为 I,之所以这样称呼,是因为它在矩阵乘法下是单位元素:
julia> I22 = A * inv(A);
julia> I22 * A == A * I22 == A
true
一般来说,矩阵乘法不是交换的,但乘以单位矩阵和矩阵与其逆矩阵相乘是交换的。
LinearAlgebra 包
本节中的示例目前不需要导入任何包,因为inv()和矩阵扩展\是Base的一部分。要进一步操作,我们需要导入LinearAlgebra包,它是标准库的一部分,因此导入速度很快,无需下载。本节中的其余代码示例假设你已经执行了using LinearAlgebra。
LinearAlgebra包可以执行矩阵的所有标准操作。我们将使用我们的小矩阵A来演示。首先是迹和行列式:
julia> tr(A) # Trace of A
5
julia> det(A) # Determinant of A
-2.0
接下来,计算特征值和特征向量(Ax = λx,如果x是 A 的特征向量,λ是其特征值):
julia> eigvecs(A) # Eigenvectors
2×2 Matrix{Float64}:
-0.909377 -0.565767
0.415974 -0.824565
julia> eigvals(A) # Eigenvalues
2-element Vector{Float64}:
-0.3722813232690143
5.372281323269014
第n个特征向量/特征值对是eigvecs()返回的矩阵的第n列,以及eigvals()返回的向量的第n个元素。我们可以检查LinearAlgebra函数是否返回正确的值:
julia> evec1 = eigvecs(A)[:,1];
julia> eval1 = eigvals(A)[1];
julia> A * evec1 - evec1 * eval1
2-element Vector{Float64}:
0.0
-5.551115123125783e-17
在这里,我们已为第一个特征向量及其特征值分配了名称;我们应该看到A * evec1等于eval1 * evec1。比较最终表达式中的两个值,我们可以看到它们在浮点精度范围内是相同的。
专门的矩阵类型
线性代数例程,如eigvals()等,旨在调度一个算法,该算法利用参与矩阵的对称性或其他属性。例程会检查传递给它们的矩阵参数的相关属性,以选择最有效的求解方法。例如,eigvals()函数使用issymmetric()函数检查实矩阵的对称性,并使用ishermitian()检查复矩阵的厄米性。
在选择高效算法时,矩阵的属性非常重要,包括矩阵是否是对称的、带状的、三角形的、厄米的、稀疏的(参见第 196 页的“邻接矩阵”),或对角矩阵。每种矩阵类型都有一个相关的 Julia 类型。我们可以通过使用适当的函数创建视图,将一般矩阵转换为这些更具体的类型。例如,Symmetric(M)创建一个对称矩阵M的视图。我们可能希望这样做,以便将结果传递给线性代数函数,确保它选择最佳算法,以防它没有检测到矩阵的特征。
为了了解这一切是如何工作的,我们来看看eigvals()函数的行为。首先,我们为时间研究创建一个适中的大矩阵,如清单 12-5 所示。
julia> N = 3000;
julia> G = rand(N, N);
julia> sG = (G + G') / maximum(G + G');
清单 12-5:创建一个随机的对称矩阵
最后的赋值通过将G逐元素与其转置相加,创建一个对称矩阵。我们将以几种方式计算G的特征值,如列表 12-6 所示。我们不关心结果,但我们关心时间测量。
julia> using BenchmarkTools
julia> @btime eigvals(G);
24.044 s (20 allocations: 69.58 MiB)
julia> @btime eigvals(sG);
4.612 s (14 allocations: 69.74 MiB)
➊ julia> SsG = Symmetric(sG);
julia> SsG == sG
true
julia> typeof(SsG)
Symmetric{Float64, Matrix{Float64}}
➋ julia> @btime eigvals(SsG);
4.481 s (14 allocations: 69.74 MiB)
列表 12-6:计算特征值的时间测量
前两个时间测量展示了eigvals()函数如何利用矩阵的对称性显著减少计算时间。我们还创建了sG的Symmetric视图 ➊,它包含与原始矩阵相同的值,但类型不同。在这种情况下,使用SsG不会影响计算时间 ➋,因为eigvals()已经检测到sG是对称的。我们也可以要求eigvals()计算eigvals(Symmetric(G)),它会像计算实际对称矩阵的特征值一样快速地完成。但在这种情况下,计算出的特征值不是G的特征值,因为G不是对称的。
eigvals()和eigvecs()函数检查对称或厄米特(hermitian)参数,但不检查其他性质。我们可以通过计算上三角矩阵的特征值来演示这一点:一个在对角线下方元素为零的矩阵。首先,我们需要构造用于测试的矩阵:
julia> N = 3000;
julia> G = rand(N, N);
➊ julia> UTt = UpperTriangular(G);
julia> typeof(UTt)
UpperTriangular{Float64, Matrix{Float64}}
julia> UT = Matrix(UTt);
julia> typeof(UT)
Matrix{Float64} (alias for Array{Float64, 2})
➋ julia> UT == UTt
true
在再次创建随机矩阵G后,我们创建了该矩阵的UpperTriangular视图并将其赋值给UTt ➊。然后我们将其转换为基础Matrix类型并赋值给UT。这是创建一个恰好是上三角矩阵的完整矩阵的便捷方法。这两个对象包含相同的元素 ➋,但类型不同。UTt的类型告诉LinearAlgebra函数它是上三角矩阵,因此如果有可用的专门化算法,它们可以利用这一点。eigvals()就是这些函数之一:
julia> @btime eigvals(UT);
119.571 ms (18 allocations: 69.53 MiB)
julia> @btime eigvals(UTt);
35.905 μs (2 allocations: 23.48 KiB)
计算 3000 个特征值的时间远远短于一个没有结构的矩阵(列表 12-6),因为UT中有许多零。eigvals()在处理矩阵的UpperTriangular视图时所需的时间大幅减少(注意@btime返回的时间单位),内存需求也减少了。这些矩阵的元素完全相同,计算出的特征值也相同(只是返回顺序不同)。然而,UpperTriangular类型所携带的信息向eigvals()传递了矩阵的结构信息,而这些信息可以帮助它选择比通用算法更高效的算法。
这个故事的寓意是,我们应该向任何LinearAlgebra函数传递尽可能最具信息量的视图。
方程求解与 factorize()
矩阵的分解类似于数字的分解,是一系列矩阵,当这些矩阵(矩阵)相乘时,得到原始矩阵。矩阵分解通常是求解矩阵方程(线性方程组)的早期步骤,它是通过左除操作符——标准的求解此类系统的函数——来完成的。分解过程通常是计算解时最耗时的部分,而在分解完成后,计算通常会迅速进行。由于许多问题涉及使用不同的b向量反复求解方程 12.2 的形式,如果我们能一次性完成分解,将计算这一部分分离出来,就能节省大量时间。这正是LinearAlgebra函数factorize()所能实现的:
julia> N = 8000;
julia> G = rand(N, N);
julia> g = rand(N);
julia> fG = factorize(G);
julia> @btime G \ g;
10.073 s (6 allocations: 488.40 MiB)
julia> @btime fG \ g;
37.942 ms (2 allocations: 62.55 KiB)
在这里,我们看到使用预分解矩阵求解方程组的速度约为 200 倍,且所需的内存仅为使用未分解矩阵时的一小部分。然而,调用factorize()本身所需的时间与计算G \ g的时间差不多。优势在于,我们可以在后续的问题中,针对仅有右侧向量不同的情况,廉价地利用fG来获得解。
用视图告诉\关于矩阵属性并没有帮助,就像在eigvals()中那样:
julia> g = rand(3000)
julia> @btime sG \ g;
504.239 ms (6 allocations: 68.71 MiB)
julia> @btime SsG \ g;
556.492 ms (8 allocations: 70.18 MiB)
julia> fSsG = factorize(SsG);
julia> @btime fSsG \ g;
6.161 ms (2 allocations: 23.48 KiB)
这里同样,尽管Symmetric视图并没有帮助,但我们发现使用分解后的矩阵时,计算速度显著提升,所需的内存也大大减少。
结论
本章涵盖了两个重要话题,我认为这些话题对科学家、工程师和其他 Julia 技术用户普遍有用。
使用符号数学包对每个人来说都有潜在的价值,我与多位学生和研究人员的讨论使我确信,许多人并不知道计算机可以计算积分和导数、符号求解方程、进行其他真正的数学操作——不仅仅是算术。打开这扇门会带来许多可能性,特别是当符号方法和数值方法结合时,正如Symbolics包所鼓励的那样。
当然,线性代数是计算机应用中的一个广泛传统领域,我们这里只是浅尝辄止。Julia 在这个领域的计算特别方便。BLAS(基本线性代数子程序)和 LAPACK 是数值线性代数的核心 Fortran 库,大多数编程语言的线性代数能力就是对这些经过优化的例程集合的接口。Julia 在几个方面不同寻常:BLAS 和 LAPACK 正在用纯 Julia 重写,这是一个持续进行的项目,并且通过libblastrampoline包,Julia 提供了独特的能力,可以在运行时动态切换 BLAS 实现。
进一步阅读
-
有关符号数学的更多详情,请参见https://lwn.net/Articles/710537/。
-
Symbolics.jl的文档可以在https://symbolics.juliasymbolics.org/stable/找到。 -
OSCAR 是一个计算代数包,涵盖了代数、几何和数论: https://oscar.computeralgebra.de。
-
若要查看具有特殊对称性和结构的矩阵列表,请访问https://docs.julialang.org/en/v1/stdlib/LinearAlgebra/#Special-matrices。
-
libblastrampoline可以在https://github.com/JuliaLinearAlgebra/libblastrampoline找到。 -
最近开发的
LinearSolve包提供了一个统一的接口,用于选择不同的线性方程求解器:https://github.com/SciML/LinearSolve.jl。
第十四章:科学机器学习**
眼睛的困惑有两种类型,源于两种原因,要么是从光中出来,要么是进入光中。
—苏格拉底

本章的主题是通过计算解决科学问题的一种相对较新的方法。科学机器学习(SciML)领域的许多最新发展发生在 Julia 生态系统内,由使用 Julia 进行科学研究的研究人员主导。关于如何将这些新技术应用于非机器学习专家能够理解的形式的解释,公开的资料相对较少。我希望通过选择一些简单但具体的例子来填补这一空白,从而澄清其中的概念,让读者能够将其应用于各种问题。
科学机器学习并非传统的机器学习。机器学习(ML)是人工智能的一个分支,计算机通过练习大量数据(通常在人类监督下)自我训练,识别模式并进行分类。机器学习技术应用于诸如检测欺诈性金融交易或预测你下一个想看什么电影等问题。训练取代了传统的具体模型或算法编程。
SciML从机器学习中提取了几个关键技术,并将其应用于不同类别的问题。在 SciML 中,我们假设我们研究的系统由一个特定的模型描述,通常以一组微分方程表示。然而,模型中的某些参数或其他方面是未知的。如果我们有关于系统行为的数据,SciML 技术可以帮助我们高效地推断这些未知参数的值。
物理问题中的自动微分
结合统计学和概率论的概念,SciML 借用了自动微分技术,这一点对于机器学习和科学机器学习都至关重要。传统上,微分是微积分中的一个数学过程,用于找到曲线(在一维中)或曲面(在二维或更多维度中)的斜率。我们称曲面的导数为梯度,它涉及处理多个变量。如果你的厨房水槽安装得当,那么其表面的负梯度在每个点都指向排水口,这样当你拔掉塞子时,所有的水都会排出去,不会留下任何水坑。
自动微分是通过编程语言计算函数的导数或梯度,而不是使用数学符号表示。编写的函数可以是数学表达式的直接翻译。通常,当表达式很复杂时,其解析导数会涉及许多项,并且通过传统方式计算会非常昂贵。自动微分可以更快。我们甚至可以使用自动微分计算没有解析形式的梯度:被微分的函数可以包含几乎任何计算,包括那些无法用数学符号表示的计算。自动微分不是数值微分;它不是有限差分计算。它也不是符号微分,正如在第十二章中探讨的那样。它应用了微积分知识,如链式法则,以及特定函数的导数和数值技术,从而高效、准确地进行微分。
机器学习(ML)使用自动微分来引导其模型朝着正确的解的方向发展,并且在 SciML 的机制中以类似的方式使用它。我们还可以显式地使用它来高效地计算数学模型中的导数,正如在第 408 页的“从势能计算力”一节中所示。
使用 ForwardDiff 进行微分
我们可以在本章中通过ForwardDiff包中的derivative()函数来满足自动微分的需求,我假设以下示例中已导入该包。其使用方法很简单:我们提供一个函数和一个值,ForwardDiff.derivative()将返回在提供的值处求得的该函数的导数:
julia> ForwardDiff.derivative(sin, 0.0)
1.0
结果是正确的:sin(x) 的导数是 cos(x),而 cos(0) = 1。
ForwardDiff.derivative()函数也可以处理在 Julia 中定义的函数,这些函数可能包含几乎任何类型的计算:
julia> function fdst(x)
(x - floor(x))² / ceil(x)
end
fdst (generic function with 1 method)
julia> plot(fdst, 0, 5; label="fdst(x)", xlabel="x", lw=2);
julia> plot!(x -> ForwardDiff.derivative(fdst, x); label="fdst'(x)", lw=2, ls=:dash)
floor()和ceil()函数将它们的参数四舍五入到最接近的小或大的整数。示例中定义的fdst()函数不是我们能在导数表中查找的,也不能使用微积分的常规方法来处理,但 Julia 的自动微分例程能够正确地计算其导数。图 13-1 展示了结果。

图 13-1:一个奇怪函数的自动微分
在图 13-1 中,图例使用了一个撇号来表示导数。虚线表示自动微分函数的结果,它不受不连续点存在的困扰。
从势能计算力
在物理学中,作用在物体上的力是其势能的负梯度。如果势能仅依赖于一个变量,那么它就是该变量的导数的负值。让我们重新审视第九章中的有限角度摆问题。
列表 13-1 为了方便起见,将问题总结在一个地方。
using ForwardDiff
using DifferentialEquations
const L = 1.0
const g = 9.8
const m = 1.0
➊ function ppot(θ)
return m*g*L*(1-cos(θ))
end
function pendulum!(du, u, p, t)
L, g = p
θ, ω = u
du[1] = ω
➋ du[2] = -ForwardDiff.derivative(ppot, u[1])/m
end
function pendulumF!(du, u, p, t)
L, g = p
θ, ω = u
du[1] = ω
du[2] = -g/L * sin(θ)
end
p = [L, g] # <- Parameters
u0 = [deg2rad(175), 0]
# θ ω <- Initial conditions
tspan = (0, 20)
prob = ODEProblem(pendulum!, u0, tspan, p)
probF = ODEProblem(pendulumF!, u0, tspan, p)
➌ sol5d = solve(prob)
sol5dF = solve(probF)
列表 13-1:重新审视有限角度摆
列表 13-1 还包含了一些额外的内容:ppot() 函数,它给出了摆的重力势能,作为高度 ➊ 的函数。pendulum!() 函数现在使用自动微分来计算势能的(负)导数 ➋,从而推导出力,而不是直接使用力函数。第二个函数 pendulumF!() 像之前一样,使用力函数来设置问题。我们像在第九章中那样继续进行,但我们得到了两个数值解:一次使用势能 ➌,另一次使用力。
图 13-2 比较了两种解法。

图 13-2:两种方式计算的有限角度摆
这两个解完全一致。显然,解决这个问题并不需要使用 ForwardDiff 包,但我们这样做是为了验证它是否按预期工作。在应用新技术时,首先在一个相对简单并且已知解的问题上进行测试非常重要,这样可以增强我们对如何使用它的信心,并确认我们理解它是如何工作的。
物理学家通常更倾向于从势能的角度而不是力的角度进行思考,因此在进行数值实验时,我们更可能尝试不同的势能而不是直接调整力函数。拥有一个能够为我们微分势能的解法程序,比每次迭代时都推导一个新的力场更为方便。而且,我们所处理的势能函数比从中推导出的力函数要简单。这在下一个例子中也是如此。
假设我们发现了一种新粒子,其势能在短距离时强烈排斥,在特定距离处有一个势阱,且在较长距离时势能呈弱排斥。这个势能

其中 r 是粒子与势能有这些性质的距离,如图 13-3 所示。

图 13-3:一个假想粒子的势能
图 13-3 显示了 r ≈ 1.3 处的势阱。这个位置是一个交互粒子可以被困住的地方,如果它没有足够的能量逃脱。
系统将包含两个这样的粒子,固定在 r = 0 和 r = 20 处。我们将把一个运动粒子放置在它们之间,并使用单位使其质量为 1。 图 13-4 显示了这两个固定粒子的合成势能。

图 13-4:两个虚拟粒子的总势能
我们将在系统中插入一个移动粒子,位置为r = 5.0,初速度为 0.2035\。这个正向速度使得粒子在t = 0 时开始向右运动。如果初速度为零,它将在以x = 10 为中心的浅势阱中振荡,范围在x = 5 和x = 15 之间。其特定的初速度刚好提供足够的能量让粒子克服位于x = 16 附近的势能山丘。
在清单 13-2 中,我们按照在清单 13-1 中重新审视的摆问题的方法进行处理。
using DifferentialEquations
using ForwardDiff
U(r) = exp(-(exp((-0.4*(r-1)²))))/sqrt(r+1)
function particle!(du, u, p, t)
x1, x2 = p
r, v = u
du[1] = v
➊ du[2] = -ForwardDiff.derivative(U, abs(r - x1)) +
ForwardDiff.derivative(U, abs(r - x2))
end
➋ p = [0.0, 20.0]
➌ u0 = [5.0, 0.2035]
tspan = (0, 650)
prob = ODEProblem(particle!, u0, tspan, p)
sol = solve(prob)
清单 13-2:求解两个虚拟粒子之间的运动
我们通过对势能函数应用自动微分来推导力,这个势能函数是两个固定粒子贡献的总和 ➊,我们在每个粒子的位置上评估导数。p数组保存这两个粒子的位置 ➋,u0数组包含移动粒子的初始位置和初速度 ➌。在确定了解决方案的时间跨度后,我们定义了常微分方程(ODE)问题,并像之前一样将其解保存在sol中。
第一次尝试的解如图 13-5 所示,展示了移动粒子的位置与时间的关系。

图 13-5:一个不准确的解
我们按照第九章中解释的方法,从解中提取位置变量。
科学家应始终对所谓的数值解进行批判性审视。我们的第一反应应该是根据我们对问题行为的了解,检查求解器的输出。在这种情况下,我们知道解应该是周期性的,因为问题的定义中没有任何因素能增加或移除能量。图 13-5 中的结果显然不是准确的周期性。
DifferentialEquations包提供了许多解法选项,并暴露了多个参数以便调整求解器的行为。请参见第 427 页中的“进一步阅读”部分,了解文档相关部分的链接。由于在清单 13-2 中设置的微分方程并不复杂,我们可以继续使用默认的求解器。准确性问题很可能是由于势能和初始速度的性质所致,正如前面提到的,初始速度接近一个临界值,这个临界值决定了粒子是否能克服局部势能的最大值。这表明,仅仅应用误差界限可能就足够了。reltol参数作为关键字参数传递给solve()函数,用于根据需要调整自适应时间步长,以将局部误差限制在我们指定的值内,如第 300 页中“参数不稳定性”部分所述。其默认值为 0.001,这对于此问题来说可能不够严格。初始速度的微小变化对粒子的运动有很大影响。如果我们尝试使用sol = solve(prob; reltol=1e-6)重新计算,我们将得到图 13-6 中所示的解。

图 13-6:一个精确的解
新的解似乎是准确的周期性解。此外,进一步减小reltol并不会改变解,这为我们提供了一些安慰,证明解已经收敛到正确的答案。
U 的导数恰好是

直接处理起来可能会更麻烦一些。
概率编程
本节通过多个示例介绍了Turing包。这个包可以帮助我们根据观察到的效果推断可能的原因。我们假设读者已经对第十章中讨论的几个概念有一定的了解——特别是概率和概率分布。理解这些概念对于理解Turing的输出和解释其结果是必要的。
硬币公正性测试
这个简单的例子介绍了使用Turing进行概率编程的基本概念和程序。
假设我们抛一枚硬币L次,并观察到得到Nheads次正面。我们想评估我们观察到的结果是否表明这枚硬币是公平的,其中公平意味着正面朝上的概率是 1/2,或者非常接近 1/2。这正是概率编程所声称能够回答的类型的问题:给定一个效果或一组观察结果,原因是什么?在这里,效果是正面朝上的比例,原因是正面朝上的概率。
注意
我意识到前述简短分析可能并不令所有人满意,但我希望避免陷入形而上学的讨论。我们观察到的实际原因将是硬币的物理构造细节和投掷方法。正面朝上的概率代表了这些无数未知细节的累积效应的总结;将原因描述为概率反映了我们知识的局限性。
使用Turing的第一步是构建一个概率模型,描述问题中每个随机变量的概率分布。对于某些变量,这些分布是未知的,在这种情况下,我们需要假设一个合理的分布,如均匀分布或正态分布,涵盖所有可能的值,可能以我们认为最可能的值为中心。对于其他变量,问题的描述暗示了一种特定的分布,这种分布通常由观察结果或某些其他变量的值来参数化。
在这个例子中,我们有一个未知的随机变量,Pheads。我们假设它可以取 0 到 1 之间的任何值,且服从均匀分布。这个假设意味着我们对硬币的性质没有任何先验信念。如果我们有理由认为它几乎肯定是公平的,我们可以假设它服从均值为 1/2、方差较小的正态分布。
在Turing模型中,我们使用~操作符表示关于随机变量分布的假设。我们对正面朝上的概率分布的假设形式为Pheads ~ Uniform(0, 1)。Uniform()函数来自Distributions.jl,该包会自动被Turing导入(有关“Distributions”的详细信息,请参见第 321 页)。
列表 13-3 展示了完整的Turing模型。
julia> using Turing, StatsPlots
julia> @model function coin(Nheads, L)
Pheads ~ Uniform(0, 1)
➊ Nheads ~ Binomial(L, Pheads)
end;
列表 13-3:一个简单的概率程序
在导入Turing和StatsPlots(它们对可视化输出非常有用)之后,我们使用Turing中的@model宏来定义模型。我们可以对@model所作用的函数进行任何我们想要的调用;该宏理解~操作符,并将该函数转换为Turing模型。
输入是观察到的正面朝上的次数和L,即总共的投掷次数。如前所述,我们假设Pheads(我们要推断的量)服从均匀分布。当我们投掷硬币L次时,观察到的正面朝上的次数是一个随机变量,我们知道它遵循由L和Pheads ➊ 参数化的二项分布(有关简要介绍,请参见第 427 页中的“进一步阅读”)。
为了大致理解Turing如何通过归纳过程从观测值推断模型中的未知数(在本例中是Pheads),我们可以设想如何手动进行这一过程。对于这里的简单问题,我们可以从 0 到 1 选择一系列Pheads值,可以是确定性的,也可以是随机的,可能会使用rand()。对于每个Pheads值,我们可以计算其二项分布的期望值或均值。最接近观测值Nheads的期望值就是我们推断出的Pheads值。
这个推断过程会相当高效,因为我们有一个简单的公式可以计算二项分布的均值。如果我们处理的是不太容易求解的分布,包括那些依赖于多个参数、每个参数有自己分布的情况,那么提取期望值的唯一方法就是通过从分布中采样的数值实验。如《Julia 中的随机数》一文中在第 307 页所指出的,rand()函数允许我们直接从分布中采样。然而,正如我们很快会看到的,一个更现实的问题可能涉及成千上万的随机变量和分布。从每个分布中进行简单采样将花费极其漫长的时间。
这就是Turing所解决的问题。它允许我们只需告诉它概率分布是什么,然后它高效地从中采样,根据需要计算期望值,并报告结果以及它们的不确定性和误差估计。我们不会深入探讨Turing是如何实现这一壮举的,只是提到它实现了马尔可夫链蒙特卡洛(MCMC)采样技术,想要研究理论背景的读者可以以此为起点。
为了让Turing生成关于其推断的报告,我们可以使用它的sample()函数发出一个命令:
julia> flips = sample(coin(60, 100), SMC(), 1000)
这里的coin()是清单 13-3 中的模型函数。它的参数包括正面朝上的次数和投掷的总次数——在这个例子中是 100 次投掷中的 60 次正面朝上。下一个参数从Turing包中提供的几种采样策略中选择一个。SMC的首字母代表顺序蒙特卡洛(Sequential Monte Carlo),它在简单问题上表现良好。选择哪种采样器可能需要通过反复试验来决定;不同的采样器适用于不同的问题。(有关Turing采样器文档的链接,请参见第 427 页的“进一步阅读”。)最后一个参数1000是进行采样实验的次数。每次实验都会生成一个Pheads的估计值,而Turing会报告这些估计值的均值,即最可能的值,如清单 13-4 所示。
Chains MCMC chain (1000×3×1 Array{Float64, 3}):
Log evidence = -4.5014682572661195
Iterations = 1:1:1000
Number of chains = 1
Samples per chain = 1000
Wall duration = 12.73 seconds
Compute duration = 12.73 seconds
parameters = Pheads
internals = lp, weight
Summary Statistics
parameters mean std naive_se mcse ess rhat ess_per_sec
Symbol Float64 Float64 Float64 Float64 Float64 Float64 Float64
Pheads 0.6024 0.0460 0.0015 0.0023 410.5088 1.0002 32.2499
Quantiles
parameters 2.5% 25.0% 50.0% 75.0% 97.5%
Symbol Float64 Float64 Float64 Float64 Float64
Pheads 0.5058 0.5719 0.6092 0.6319 0.6862
清单 13-4:来自 Turing 的报告
该报告在我笔记本电脑上经过 12.73 秒后生成,包含了大量信息,但只有少数几个数字对我们至关重要。在Summary Statistics下,Symbol是我们想要推断值的随机变量:在这个例子中,只有Pheads。Turing对Pheads的最佳猜测是 0.6024。另一个需要注意的数字是rhat,在这个例子中为 1.0002。如果这个数字远离 1.0,则抽样过程未能正确收敛,我们需要尝试不同的抽样器或更改传递给抽样器的控制参数(如果抽样器接受参数的话)。
现在我们可以考虑解决本节标题中暗示的问题:硬币是否公平?我们可以通过查看从抽样过程中得出的 1,000 个Pheads推断值的分布来获得一些见解。histogram()函数(请参见第 321 页的“分布”)通过简单调用histogram(flips; normalize=true),借助Turing和StatsPlots包,获得了绘制此分布的能力。我们将在同一图表上绘制直方图,并叠加一个正态分布曲线,使用如下代码:
julia> histogram(flips; normalize=true)
julia> plot!(Normal(0.6024, 0.0460); lw=2)
正态分布中的参数,如第二行所示,是从列表 13-4 中的报告中提取的均值和标准差。图 13-7 展示了结果,我们可以看到,从Turing获得的抽样分布是对正态分布的良好近似,并且与其报告的参数相符。

图 13-7:Pheads 推断的分布
为什么Pheads的均值分布应该是正态分布?毕竟,我们在模型中将Pheads设置为均匀分布。答案是,图 13-7 中的分布是随机变量Pheads的均值分布。正如在第 323 页的“正态分布”中所展示的(使用相同的均匀分布),均值的分布将是正态分布(高斯分布)。无论变量本身的底层分布是什么,这都成立,这是概率论中的一个重要定理,也是正态分布普遍存在的根本原因。
在检查了抽样结果之后,我们可以应用任何标准来决定硬币是否公平。尽管硬币正面朝上的概率最可能值非常接近 0.6,这强烈暗示我们有一枚偏的硬币,但也有可能这枚硬币是公平的。我们可以直接从归一化的直方图中估算Pheads为 1/2 的概率。横轴上围绕 0.5 的两个条形的面积大约是(0.52 - 0.48) × 0.8 = 0.32,这意味着硬币公平的概率为 3.2%。0.8 的值是通过目测估计这两个相关区间的平均高度得到的。我们还可以从正态分布中计算出这一点:
julia> cdf(Normal(0.6024, 0.0460), 0.52) - cdf(Normal(0.6024, 0.0460), 0.48)
0.032725277247186525
cdf()函数,表示累积分布函数,返回从负无穷大到第二个参数指定值的第一个参数所提供分布的积分。因此,要提取一个随机变量在分布控制下位于两个值之间的概率,我们只需从两次调用cdf()的结果中相减即可。3.3%的值与我们从直方图中得到的同一区间的估计结果非常一致。
这枚硬币公平的概率只有 3.3%。这是否足够强的证据来证明它存在偏差?那由我们来决定。
将硬币投掷 100 次为它的不公平特性提供了相当强的证据。直观上我们理解,如果我们只投掷了 10 次,并且恰好观察到 6 次正面朝上,那并不足以作为硬币不公平的有力证据。同样,若在投掷 1,000 次硬币后观察到 600 次正面朝上,那就是一个相当具有说服力的结论。
我们可以通过调用sample()两次,并将结果直接传递给histogram()来查看这两种情况的结果:
julia> histogram(sample(coin(6, 10), SMC(), 1000); normalize=:probability, fc=:lightgray)
julia> histogram!(sample(coin(600, 1000), SMC(), 1000); normalize=:probability, fc=:gray)
这是一个快速比较分布的方法,当我们不关心详细报告时,可以使用这个方法。
注意
因为 sample() 返回的结果部分通过随机采样生成,所以每次的细节都会有所不同。运行本节代码的每个人都将观察到略有不同的分布和均值,尽管总体结论应该保持不变。在一个重要的问题中,一个好的做法是进行多次采样实验,尝试不同的采样器,并可能在模型中某些关于假设分布的细节上做些许变化。
图 13-8 展示了结果。

图 13-8:弱证据与强证据
更浅的直方图,显示了 10 次投掷的推断结果,明显表明我们没有证据表明硬币存在偏差。Pheads为 1/2 的可能性和为 6/10 的可能性几乎相等。然而,使用 1,000 次投掷的观察结果则没有歧义:在那次实验中,600 次正面朝上使得硬币公平的可能性几乎为零。第二个直方图上较深的灰色覆盖层显示了围绕Pheads = 0.6 的窄分布。
从系列观察推断模型参数
在大多数概率编程的应用中,科学家们关注的是推断一系列随时间变化的观察结果背后的原因,而不仅仅是一个单一的数字。我们可以通过将前一部分的方法扩展到处理时间序列,将每个时刻收集的数据视为围绕某个预测值的独立测量,并考虑每个时刻的分布。只要我们能够将其表达为 Julia 函数,这些值可以是几乎任何类型模型的预测结果。
一个简单的数学模型
为了展示这种方法,我们首先考虑拟合一对参数的问题,这一参数对我们假设为一系列观测的原因。模型是一个正弦函数,两个未知参数分别是其振幅 A 和频率 f,如 Listing 13-5 所示。
const t = 0:π/50:4π;
A0 = 3.4; f0 = 2.7;
data = A0*sin.(f0*t) + 0.5 .* randn(length(t));
@model function wave(data)
f ~ Uniform(0, 3)
A ~ Uniform(0, 4)
➊ prediction = A*sin.(f*t)
for i in eachindex(t)
➋ data[i] ~ Normal(prediction[i], 0.5)
end
end;
Listing 13-5:作为模型的未知频率和振幅的正弦函数
在定义一系列时间后,我们为 A(振幅)和 f(频率)参数选择值。我们使用这些值生成一些包含正态分布误差的模拟观测数据,并将其存储在 data 中。我们的计划是假装我们不知道 A 和 f 的值,并使用数据以及假设的正弦波依赖关系来推断它们的值。
在模型中,我们预先假设频率和振幅的分布是均匀的,这些分布为它们的可能值设定了限制。对于每一组可能的值,我们有一个预测 ➊,即由此得到的时间序列。我们认为传递给模型的数据是一组物理测量值,因此我们假设每个时间点的观测值是围绕该时刻的“真实”(预测)值正态分布的,标准差为 0.5 ➋。
通过采样进行推断的过程与前一节相同。然而,SMC 采样器似乎在这一类问题上效果不佳。MH 采样器(即梅特罗波利斯-海斯廷斯采样器)在此类问题上表现得更加可靠,而且速度也相当快,但在其他问题中表现较差。(如前所述,我们可能需要尝试多种采样算法及其输入参数。)Listing 13-6 显示了采样命令及其截断输出。
julia> wavesample = sample(wave(data), MH(), 1000)
Chains MCMC chain (1000×3×1 Array{Float64, 3}):
Iterations = 1:1:1000
Number of chains = 1
Samples per chain = 1000
Wall duration = 0.92 seconds
Compute duration = 0.92 seconds
parameters = f, A
internals = lp
Summary Statistics
parameters mean std naive_se mcse ess rhat ess_per_sec
Symbol Float64 Float64 Float64 Float64 Float64 Float64 Float64
f 2.6876 0.0247 0.0008 0.0039 8.9062 1.1077 9.7230
A 3.4323 0.3867 0.0122 0.0681 2.5378 2.1700 2.7706
Listing 13-6:推断参数值
采样器在不到一秒的时间内返回了合理的结果。这一点令人印象深刻,因为算法在采样两个参数 1,000 次并使用 200 个数据点(每个数据点都有其自身的分布)时,能够推断出 A 和 f 的最终分布及其期望值。
让我们通过将返回的 A 和 f 的均值与模拟数据以及我们知道的真实解进行叠加,来可视化推断出的解:
julia> plot(t, A0*sin.(f0*t); lw=2, legend=false, ylabel="A(t)", xlabel="t")
julia> plot!(t, data)
julia> A1 = 3.4323; f1 = 2.6876;
julia> plot!(t, A1*sin.(f1*t); ls=:dot)
在前两行中,我们用粗线绘制了模型,细线绘制了带噪声的数据。最后的绘图命令使用 A 和 f 的推断值绘制了一个正弦波,线条为虚线。Figure 13-9 显示了合并后的图表。

Figure 13-9:从带噪声的数据中恢复的模型参数
Figure 13-9 显示了如何从噪声观测中恢复正确的信号。模型的周期性特征意味着,推断的频率中的微小误差会导致曲线在后续时间进一步发散。
常微分方程模型
用于生成预测的模型不必是已知的函数;它可以是一组微分方程。这是可能的,因为Turing和DifferentialEquations是可组合的,这是 Julia 类型系统的另一个优势。两者的结合极其强大,为研究开辟了新的领域。在科学中,我们的模型通常以微分方程的形式出现,这些方程大致编码了我们关于系统如何运作的假设。系统的某些细节可能仍然作为参数存在,其值可能是未知的,或者部分已知的。使用“从系列观察中推断模型参数”中概述的一般程序,进行概率编程(见第 419 页),允许我们推断这些参数最可能的值,并定量检查我们假定的模型表现如何。
例如,我们可能测量了一颗炮弹的轨迹,并认为其轨迹受牛顿运动定律、重力和空气阻力的作用。但我们可能不知道地球上的重力加速度或炮弹在大气中的阻力系数。假设我们的微分方程是正确的,我们可以使用Turing和DifferentialEquations从观察到的轨迹中推断出这两个数值的值,然后将它们代回模型中,看看我们是否能够重现数据。这种方法消除了大量的试错过程,并且使我们能够在模型的变体之间流畅地迭代。
回到第九章中的参数不稳定性问题,我们来反向推理:假设我们知道有一个钟摆处于重力场中,且绳长在变化,我们知道重力、钟摆质量和绳子的平均长度,但频率和振幅是未知的。我们假设定义这种振荡的函数是 sin(t),其中,像之前一样,t表示时间。
这个例子将展示如何通过反向推理从关于钟摆行为的数据出发,估算驱动频率和振幅,前提是我们假设数据背后有一个基础的物理模型。天真地,我们可能通过多次求解微分方程,使用第九章中的技术,并尝试不同的未知参数值,直到找到一个足够接近数据的解。但这个过程在计算上非常昂贵,并且可能无法系统地了解最终结果的不确定性。
清单 13-7 展示了通过Differential Equations包求解的问题设置,便于参考,这里组合自清单 9-8 和 9-9。
using DifferentialEquations
function pendulum!(du, u, p, t)
L, g = p
θ, ω = u
du[1] = ω
du[2] = -g/L(t) * sin(θ)
end
➊ g = 9.8; A = 0.2; f = 0.97
L(t) = 1.0 + A * cos(f*2*sqrt(g)*t)
p = [L, g]
u0 = [deg2rad(5), 0]
# θ ω <- Initial conditions
tspan = (0, 80)
sol = solve(ODEProblem(pendulum!, u0, tspan, p); saveat=0.1)
清单 13-7:参数驱动的钟摆
在这个例子中,驱动频率被设定为比参数共振频率小 3 个百分点➊。
在我们继续使用Turing处理这个问题之前,先来看一下变化f和A值如何影响结果。首先,我们将绘制在共振状态和稍微“失谐”的 0.95 共振频率下的解:
g = 9.8; A = 0.2; f = 1.0
L(t) = 1.0 + A * cos(f*2*sqrt(g)*t)
p = [L, g]
plot(solve(ODEProblem(pendulum!, u0, tspan, p)); idxs=1,
legend=false, ylabel="A(t)")
f = 0.95
L(t) = 1.0 + A * cos(f*2*sqrt(g)*t)
p = [L, g]
plot!(solve(ODEProblem(pendulum!, u0, tspan, p)); idxs=1, lw=2)
annotate!(40, 1, ("Thin line:\nparametric forcing at resonance", 8))
annotate!(40, -0.5, ("Thick line:\n5% detuning", 8))
图 13-10 展示了这两个解。

图 13-10:在两个驱动频率下的参数驱动摆
如图 13-10 所示,解对驱动频率非常敏感。
改变驱动振幅也会对解产生较大的影响。图 13-11 展示了在相同频率下,两个不同驱动振幅的效果。

图 13-11:在两个驱动振幅下的参数驱动摆
仅仅改变驱动振幅就会改变包络振幅、包络时间尺度和响应频率。
当我们比较这些解时,可以看到振幅和频率是相互依赖的。从响应中推断任一驱动参数并非一件简单的事。让我们看看使用Turing的概率编程能在这个问题上做得怎样。首先,我们将定义一个模型,其中A和f在合理区间内均匀分布:
using Turing
@model function pdpen(observation)
A ~ Uniform(0.0, 0.3)
f ~ Uniform(0.9, 1.1)
g = 9.8
L(t) = 1.0 + A * cos(2*f*sqrt(g)*t)
p = [L, g]
prediction = Array(solve(ODEProblem(pendulum!, u0, tspan, p); saveat=0.1))[1, :]
mstd = 0.1 * maximum(abs.(prediction))
for i in eachindex(prediction)
observation[i] ~ Normal(prediction[i], mstd)
end
end
如同简单的正弦波模型一样,我们将根据给定的A和f值,从DifferentialEquations返回的解中生成一些带噪声的模拟数据,然后使用Turing模型尝试从数据中推断出这些数值。以下列出的程序展示了这个过程,针对一小组A和f的值进行计算,并绘制推断的数值与已知值的对比图:
plot(; xrange=(0, 0.3), yrange=(0.9, 1.1), legend=false,
xlabel="A", ylabel="f")
for A in range(0.02, 0.25; length=3)
for f in range(0.95, 1.05; length=3)
➊ L(t) = 1.0 + A * cos(2*f*sqrt(g)*t)
p = [L, g]
➋ sol = solve(ODEProblem(pendulum!, u0, tspan, p); saveat=0.1)
mstd = 0.1 * maximum(abs.(Array(sol)[1, :]))
observation = Array(sol)[1, :] + mstd * randn(length(sol))
➌ psamples = sample(pdpen(observation), MH(), 3000)
scatter!([A], [f]; mc=:lightgray, ms=9)
scatter!([mean(psamples[:A])], [mean(psamples[:f])];
xerror=std(psamples[:A]), yerror=std(psamples[:f]),
mc=:black, shape=:hexagon, ms=9)
end
end
plot!()
对于每一对A,f值,程序定义了一个驱动力函数➊,并从微分方程中生成了解➋。我们告诉求解器在定期的间隔点保存解的结果,使用saveat关键字参数,并将模拟噪声的幅度调整到解的幅度。这个解的目的是生成模拟的带噪声观察数据,然后我们将这些数据提供给采样器➌。接下来的命令将在图中的A-f平面上标出对应真实A和f值的标记。然后,我们会为推断出的值加上误差条,误差条的值取自sample()返回的分布的标准差。
我们可以通过索引参数名称来访问单独参数的采样结果,psamples[:A]是采样器生成的分布中所有 3,000 个A值的数组。这个数组的均值就是其期望值(也是在 REPL 中打印的报告中的值)。std()函数计算数组的标准差,返回的数值与报告中std下的数值一致。
图 13-12 展示了结果。

图 13-12:在参数摆中的强迫参数推断
使用 3,000 个样本时,实验运行得很顺利;然而,使用 1,000 个样本时,同一程序的表现明显较差。图 13-12 显示,每个推断值都在其报告的标准偏差范围内是正确的,而且这些误差大部分都很小。尽管这个问题复杂且敏感,Turing和DifferentialEquations还是能够协同工作,验证模型的忠实性并准确地推导出正确的模型参数。毫无疑问,经过进一步调整采样方法,我们可以进一步改善结果。
结论
科学机器学习领域正在快速发展,并取得令人瞩目的进展。正如我所写的,Julia 用户完全可以利用该领域的最新研究,因为这些研究已在 SciML 生态系统的软件包中得到了应用。科学机器学习选取了机器学习中一些可以有效应用于科学和工程问题的技术。对整个领域的概述本身就是一本书。在本章中,我们探讨了一些核心思想,并将其应用于一些尽管本身具有趣味性,但足够简单的问题,以免过多的附带细节掩盖 SciML 机制的工作。这些思想和技术可以应用于所有定量科学领域。这是一个值得关注的激动人心的领域。无论它走向何方,都会不可避免地成为计算科学的支柱。
进一步阅读
-
请参阅 Christopher Rackauckas 的《科学机器学习(Scientific ML)的基本工具》一书,了解现有开源工具的介绍:http://www.stochasticlifestyle.com/the-essential-tools-of-scientific-machine-learning-scientific-ml/。
-
关于自动微分的扎实数学介绍可以在http://www.ams.org/publicoutreach/feature-column/fc-2017-12找到。
-
这里是 Julia SciML 文档的汇总:https://docs.sciml.ai/。
-
要了解
DifferentialEquations.jl包的各种求解器选项,请访问https://docs.sciml.ai/DiffEqDocs/stable/basics/common_solver_opts/。 -
关于二项分布的详细信息可以在https://www.itl.nist.gov/div898/handbook/eda/section3/eda366i.htm找到。
-
Turing包的文档可以在https://turinglang.org/dev/docs/using-turing/get-started找到。 -
要了解如何使用
Turing,请访问https://turinglang.org/dev/docs/using-turing/guide。
第十五章:信号与图像处理
我在高中学习了拉丁语,并且我当时在阅读西塞罗的作品。这个信号花了几千年才传到我这里。不过,我仍然对他所说的内容感兴趣。
—Seth Shostak

本章包含了来自信号和图像处理问题的示例。这两个学科通常被认为与不相关的研究领域相关:信号处理吸引音频或电子工程师,而图像处理则与生物学家和天文学家相关。然而,它们是紧密相连的,因为它们使用许多相同的技术,并且相关的工具有着相同的数学基础。对于许多用途,我们可以把图像看作只是一个二维信号,并应用类似的算法来进行转换、平滑、滤波等,将单一的时间维度扩展到两个(或三个)空间维度。
我们首先将研究一维信号,考虑独立坐标表示时间的常见情况。之后,我们将探索 Julia 的图像处理包。
时间中的信号
声音以时变的气压形式传入我们的耳朵,我们将其存储为幅度与时间的记录,其中幅度可能代表压力的直接测量,或是通过我们的测量设备将其转换为电压或其他某种量。我们将通过在 Julia 中处理来自自然的声音来探讨信号处理。
探索声音样本
我们现实生活中的声音是濒危的铁锈小型仙人掌猫头鹰(Glaucidium brasilianum cactorum)的叫声,它是亚利桑那州的本土物种。我在http://www.naturesongs.com/falcstri.html#cobo 找到了这个声音样本,并将其保存为文件名为 cfpo1.wav 的磁盘文件。这个样本是一个 WAV 文件:一种常见的音频文件格式,几乎任何音乐播放或声音编辑软件、在任何操作系统下都能播放。听这个样本可以听到一个叫声,由一个简短的、中等偏高音的发声组成,大约每秒重复三次,总共持续约 12 秒。
注意
WAV 文件常被错误地描述为“无压缩”的音频。它们包含的音频数据几乎总是使用少数几种无损压缩算法进行压缩(类似于 ZIP 文件压缩工具所使用的压缩)。它们占用的空间比使用感知编码器(例如 MP3 文件所用的压缩方式)压缩的同一声音要大得多,但这种文件对于科学信号处理和分析没有用处。
在 Linux 终端中,我们可以使用 file 命令获取有关文件的一些信息:
$ file cfpo1.wav
cfpo1.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 8 bit, mono 8000 Hz
输出反映了最常见的文件格式;数据采用小端格式,因为 WAV 格式是在微软发明的。第三个子句指定了压缩算法;Microsoft PCM 是最常见的格式。其余输出表示样本以 8 位精度保存,每个点提供 2⁸ = 256 个可用的幅度级别,并且我们有一个以每秒 8,000 次采样的通道。
回到 Julia REPL,我们来读取样本,将其赋值给 cfpo,并绘制波形:
julia> using SignalAnalysis, SignalAnalysis.Units, Plots
julia> cfpo = signal("cfpo1.wav");
julia> plot(cfpo)
首先,我们导入两个便捷的信号分析包。本节的所有其他示例都假设使用了这一 using 语句。SignalAnalysis.Units 包提供了时间和频率单位的缩写,并提供了一种便捷的基于时间的索引形式,我们稍后将使用它。
signal() 函数有很多方法。当传入一个字符串时,它会加载指定的文件,并将数据打包成包中定义的类型。SignalAnalysis 包还扩展了 Plots,使其能够直接绘制信号。图 14-1 显示了猫头鹰叫声的波形。

图 14-1:仙人掌铁锈小猫头鹰的叫声
由于声音样本包含 100,558 个元素,绘图不是即时的。图表配方使用采样率信息来创建正确的时间轴,并对轴进行标注。signal() 函数将 8 位样本重新缩放为 Float64 数字,范围从 -1.0 到 1.0。
SignalAnalysis 包提供了几个用于提取信号信息的函数。以下是其中最重要的几个:
julia> framerate(cfpo)
8000.0f0
julia> nframes(cfpo)
100558
julia> duration(cfpo)
12.56975f0
nframes 这个术语指的是样本数,而 duration() 则报告信号的时长(单位为秒)。
图 14-1 清晰地展示了猫头鹰叫声的每秒三声啼叫结构,但我们无法判断猫头鹰在唱什么音符。让我们放大一下:
julia> one_chirp = plot(cfpo[2.05:2.25s]);
julia> chirp_zoomed = plot(cfpo[2.1:2.11s]);
julia> plot(one_chirp, chirp_zoomed; layout=(2, 1))
前两个绘图语句利用了 SignalAnalysis 包所提供的便捷基于时间的索引功能。它让我们无需在信号数据的时间和索引号之间进行转换。该索引仅适用于秒,并且仅适用于浮动范围。要访问两秒时的单帧数据,我们可以写作 cfpo[2.0:2.0s]。
图 14-2 显示了组合图:信号的两个不同尺度的两个片段。图表配方总是从 t = 0 开始标记图表,但我们始终可以定义 xticks 来引用原始时间区间(如果需要)。

图 14-2:猫头鹰叫声的两个放大片段
图 14-2 中的底部图位于其中一个啁啾信号的中间,并且被足够放大,以便我们轻松数出周期。在 2.5 毫秒的时间内大约有 3.25 个周期(从 t = 5.0 毫秒开始数,波形的峰值恰好与网格线对齐,到 t = 7.5 毫秒),这对应的频率是 3.25/2.5e–3 = 1,300.0 Hz,非常接近音乐音符 E[6]。
频率分析
分析 这个词的一个含义是将某物分解为组成部分。我们将对信号进行两种频率分析。第一种类型将信号(振幅对时间的函数)转化为振幅对频率的函数。这就是傅里叶变换的目的,它假设信号是周期性的,并将其分析为周期函数的和(正弦波和余弦波的不同振幅,或不同相位和振幅的正弦余弦,或者是复指数函数——这些都是等效的表示)。作为频率之和的表示就是信号的频谱。第二种类型将时间和频率信息结合成频谱图。在这里,我们不再假设信号是周期性的。频谱图展示了信号频谱随时间变化的情况。
SignalAnalysis 包提供了几种绘图例程,可以用来可视化这两种类型的频率分析。psd() 函数绘制了信号的功率谱密度,这是基于其傅里叶变换的。它在应用于周期信号时,解释起来比较直观,这也很好地描述了猫头鹰的叫声:
julia> psd(cfpo; xticks=0:100:4000, xrot=90, lw=2)
由于 psd() 使用了 Plots 包,我们可以提供熟悉的关键字参数。图 14-3 显示了频谱。

图 14-3:猫头鹰叫声的傅里叶频谱
频谱在接近 1,300 Hz 的位置有一个峰值,这与我们通过数周期得到的估计一致。我们还可以看到接近第二和第三谐波的峰值(1,300 Hz 的两倍和三倍)。
如 图 14-3 所示,显示图像是有用的分析和诊断工具,但它们并不能完全传达所研究信号的特性。我们可以看到信号以 1,300 Hz 为主频,有两个强的泛音,但没有呈现出快速的断奏效果。
为了进行更全面的分析,我们转向频谱图。SignalAnalysis 包也提供了一个函数,可以轻松创建这些可视化图像:
julia> specgram(cfpo; c=:grayC)
图 14-4 包含了频谱图,并清楚地显示了信号中能量的频率分布:1,300 Hz 附近的强分量和两个较低振幅的高阶谐波。我们还可以看到时间结构;大约每秒三次重复的啁啾声音非常明显。

图 14-4:猫头鹰叫声的频谱图
声谱图使用傅里叶变换和窗口在信号上滑动来计算其演变过程中的频谱,从而生成一个结合了频率和时间信息的可视化图。对于除周期信号外的任何信号,它们比psd()类型的图更具信息量。实际的傅里叶变换例程,如psd()使用的那些,也使用了窗口函数,但目的是消除信号边缘不可避免的断点以及由此产生的伪高频分量的“泄漏”。
注意
本节介绍了信号分析的最快和最便捷的方法,重点是获取大多数科学家感兴趣的可视化效果。如果需要更多控制,或直接获取频谱数据,请导入 DSP.jl 包。 SignalAnalysis 包封装了许多其例程,但导入 DSP 包可访问其各种傅里叶变换窗口的定义,以及其他可以通过关键字参数调用的细节,这些都能在更高级别的 SignalAnalysis 例程(如 psd() )中使用。
现在我们已经介绍了两种检查信号频谱的方法,在下一节中,我们将探讨通过改变频谱来转换信号的方法。
滤波
在信号处理的上下文中,滤波器是一个电路、设备,或在我们的情况下是一个计算,能够衰减信号中某些频率。最常见的例子可能是扬声器中的分频电路,它将高频信号传送给高音扬声器,将低频信号传送给低音扬声器。
滤波器在经验科学中也很重要——例如,用于减少测量中的噪声。假设有一个传感器记录水道深度的变化。我们可能有兴趣测量潮汐的影响,检测任何长期的平均深度变化。这些变化发生在小时或更长的时间尺度上。然而,测量将被风、天气和经过的船只造成的更快速变化污染。通过使用滤波器,我们可以通过去除比每小时一次的周期更快的频率,来消除信号中的无关数据。
上一段建议的策略称为低通滤波器,因为它衰减高于指定截止频率的频率,并允许低于截止频率的频率通过。高通滤波器的一个例子就是指向扬声器高音扬声器的分频电路。
另一种在科学仪器中常见的滤波类型是陷波滤波器:它衰减接近目标频率的信号。陷波滤波器对于消除通过信号通过的仪器中 60 Hz 或 50 Hz 电源噪声非常有用(但只有在信号不包含接近电源频率的信息时才有效)。
带通滤波器会衰减目标频率周围狭窄频带以外的信号。
使用 fir() 创建滤波器
SignalAnalysis包使得构建这些类型的滤波器并将其应用于信号变得容易。在每种情况下,我们从fir()函数开始构建滤波器。其基本用法包括三个位置参数和一个名为fs的可选关键字参数,表示信号的采样频率。
第一个参数是一个整数抽头的数量,与描述滤波器的多项式中保留的项数相关。基本上,更多的抽头使得滤波器更加选择性,响应更加平滑。第二和第三个参数是未滤波频率范围的下限和上限。如果我们提供fs关键字,我们将这些参数以Hz、kHz或来自SignalAnalysis.Units的其他单位提供。例如,清单 14-1 展示了如何制作一个过滤掉 2,000 Hz 以上频率的低通滤波器。
lpf = fir(127, 0, 2kHz; fs=8kHz);
清单 14-1:构造低通滤波器
该示例创建了一个 127 抽头的滤波器,这是一个典型值。
低通滤波器的下限为 0,如本示例所示。要创建一个高通滤波器,我们将nothing作为上限传递。
SignalAnalysis包提供了一个绘图函数,用于可视化通过fir()创建的滤波器。要查看先前定义的lpf滤波器的频率响应图,我们只需输入:
julia> plotfreqresp(lpf; fs=8000)
这将生成图 14-5 中所示的图形。

图 14-5:低通滤波器的频率响应
图 14-5 中的顶部图表显示了在将滤波器应用于信号时,水平轴上给定的频率分量会被降低的程度。单位为 dB(分贝),这是信号处理中常用的单位。图 14-5 显示,直到接近 2,000 Hz 时,频率没有变化,信号迅速衰减。对于普通声音,20 dB 的减少会有效地使其应用到的频率分量静音;因此,滤波器响应中低于–50 dB 的振荡对声音没有可听的影响。
底部图表显示了滤波器所产生的相位变化。这些变化通常是听不见的,但根据对滤波信号的使用计划,它们可能相关,也可能无关。
注意
为了更详细地控制滤波器特性,我们可以导入 DSP.jl 并使用 method 关键字传递给 fir() ,并使用 docs.juliadsp.org/stable/filters/ 中描述的滤波器构造方法之一。
频率响应图中的 dB 值直接加到信号的psd()图中显示的频率分量峰值,这些峰值也以 dB 显示。为了计算信号本身的幅度变化,我们使用公式:

其中,V是输入信号中成分的振幅,V[f]是滤波后的振幅。因此,6 dB 的减少意味着振幅减半:

为了观察更大的抽头值的效果,我们可以制作两个额外的低通滤波器,具有相同的频率范围,但有更多的抽头:
lpf_255 = fir(255, 0, 2kHz; fs=8kHz);
lpf_1027 = fir(1027, 0, 2kHz; fs=8kHz);
更高的抽头数会产生更接近理想响应的滤波器,如 Figure 14-6 所示。

Figure 14-6: 使用不同抽头数的低通滤波器
尽管使用更高的抽头数会产生具有更锐利截止的更干净滤波器,但它会导致计算开销增加。对于我们的示例(使用中等长度的存储信号)来说,增加的计算时间没有影响,但在实时滤波的情况下,这可能是一个需要考虑的问题。
应用滤波器
要对信号进行滤波,我们可以使用sfilt()函数:
julia> cfpo_lp = sfilt(lpf, cfpo);
这将 Listing 14-1 中定义的低通滤波器应用于猫头鹰样本,并将结果(一个新的信号)赋值给cfpo_lp。使用psd()绘制滤波信号的功率谱,展示了滤波效果(见 Figure 14-7)。

Figure 14-7: 滤波后的猫头鹰叫声
此图使用虚线显示原始的未滤波谱图,使用较粗的实线显示滤波后的谱图。低通截止频率 2 kHz 以下的频谱未受到影响,而所有高于此频率的部分已被消除。
我们使用以下命令创建 Figure 14-7:
julia> using Plots.PlotMeasures
julia> psd(cfpo_lp; lw=2, label="Filtered signal", legend=true)
julia> psd!(cfpo; ls=:dot, ticks=0:200:4000, xrot=90, label="Original signal",
legend=true, margin=5mm)
在向psd()绘图时需要重复某些关键字参数,因为绘图公式会重置它们。
Figure 14-8 中的滤波信号的频谱图还显示了第二和第三谐波的消除,同时保持了信号的其他部分。

Figure 14-8: 滤波后猫头鹰叫声的频谱图
合成信号
为了确保我们定量地理解信号分析和滤波,我们从一个由已知频率成分合成的信号开始。signal()的另一种方法是通过一个正常的向量创建一个包含采样率信息的信号。在 Listing 14-2 中,我们创建了一个由两个正弦波叠加而成的向量,表示采样率为 8 kHz 的 1,000 Hz 和 2,050 Hz 两种频率成分的数据。然后,我们将数据打包成一个信号。
julia> sin1000_2050 = signal(sin.((0.0:1.0/8000:1.0)*2π*1000) .+
0.5 .* sin.((0.0:1.0/8000:1.0)*2π*2050), 8000);
Listing 14-2: 创建合成信号
我们将结果赋值给sin1000_2050。signal()的第二个参数指定采样率。2,050 Hz 处的成分的振幅是 1,000 Hz 处成分的一半。功率谱应该显示两个峰值,较高频率的峰值比低频率的峰值低 6 dB。Figure 14-9 展示了 Listing 14-3 的结果。
julia> psd(sin1000_2050; xrange=(500, 2500), xticks=600:100:2500,
xminorticks=2, yticks=-61:3:-02, xrot=45, margin=5mm)
Listing 14-3: 合成信号的频谱
因为信号包含嵌入的采样率信息,psd()能够正确地缩放图谱。

图 14-9:合成信号的频谱,具有两个频率分量
图 14-9 显示了功率谱,其中两个窄峰分别出现在我们指定的位置,并且它们的幅度差为正确的 6 dB。
现在让我们来测量滤波的效果。我们将使用在清单 14-1 中定义的lpf滤波器,但首先我们需要在其截止频率附近更仔细地观察它:
julia> plotfreqresp(lpf; fs=8000, xrange=(1800, 2100), yrange=(-50, 1),
yticks=0:-4:-50, xticks=1800:50:2100, right_margin=5mm)
图 14-10 中的滤波器响应扩展图(省略了相位响应)显示,滤波器应该将 2,050 Hz 分量减少 16 dB。

图 14-10:低通滤波器的截止区域
我们可以通过将滤波后的信号的功率谱叠加到清单 14-3 中创建的图谱上,检查滤波器是否按预期工作:
julia> psd!(sfilt(lpf, sin1000_2050), xrange=(500, 2500), xticks=600:100:2500,
xminorticks=2, yticks=-61:3:-02, xrot=45, margin=5mm)
图 14-11 显示,高频峰值被减少了 16 dB,而低频峰值保持不变。

图 14-11:滤波后的合成信号的功率谱
这个小练习展示了滤波器具有可预测的效果,改变了频谱而没有引入伪影。
保存信号
我们可以使用signal()函数从磁盘读取 WAV 文件到信号中,但将信号保存为 WAV 文件需要导入WAV.jl包:
julia> using WAV
julia> wavwrite(cfpo_lp, "cfpo_lp.wav"; compression=WAVE_FORMAT_PCM, nbits=8)
关键字参数选择一个压缩格式和词大小,这些与各种软件兼容。在调用wavwrite()后,磁盘上将生成一个名为cfpo_lp.wav的 WAV 文件。
如果我们想将sin1000_2050信号保存为 WAV 文件,首先需要将其缩放到单位幅度:
julia> scaled = sin1000_2050 ./ maximum(sin1000_2050)
然后我们像之前一样使用wavwrite()保存它,并使用任何音频软件播放它。
图像处理
让我们考虑一个在医学和实验室生物学中常见的图像解释任务:在显微镜下拍摄的血液样本照片中,有多少个血细胞?传统的“血细胞计数”方法是手动列举细胞,这是一项繁琐且容易出错的过程。我们将看看如何使用 Julia 中的各种图像处理技术来自动化这个过程。结果将是一个更快速、更准确的计数,不需要繁重的体力劳动。然而,我们在这里研究的技术不仅限于血细胞计数。我们可以将其应用于从细菌计数到分析卫星侦察的所有任务。
加载与转换图像
命令using Images导入了文件和图像输入输出函数,包括对大多数图像类型的优化例程:
julia> using Images
julia> frog_blood = load("frogBloodoriginal.jpg");
导入后,简单的load()命令将文件读取为图像,在 Julia 中,图像是一个像素数组。
在使用笔记本,如 Pluto 时,图像操作的结果以图像的形式显示;在终端 REPL 中,它们以类似其他数组的方式显示。对于 REPL 中的图形图像显示,ImageView包提供了imshow()函数。imshow()打开的窗口具有一些 GUI 功能,其中最有用的是在鼠标指针移动到图像上时显示像素地址和颜色值。
图像可以是数字矩阵或像素类型。有几种像素类型,但我们将使用的是RGB和Gray像素。由于我们从彩色图片中加载了frog_blood图像,它是一个RGB(红-绿-蓝)像素的数组:
julia> eltype(frog_blood)
RGB{N0f8}
这显然是一个参数化类型(参见第 248 页的“参数化类型”)。参数N0f8是另一个(参数化)类型,它将无符号 8 位整数映射到范围为[0.0, 1.0]的浮点数。frog_blood的一个元素如下所示:
julia> frog_blood[1, 1]
RGB{N0f8}(0.361,0.008,0.384)
这将是紫色:几乎相等的红色和蓝色量,几乎没有绿色。
如果我们想用纯绿色替换极左上角的像素,可以执行以下操作:
frog_blood[1, 1] = RGB{N0f8}(0.0, 1.0, 0.0)
然而,我们不会这么做。
我们可以通过将Gray()作为转换函数广播到图像数组,将彩色图像转换为灰度版本:
frog_blood_gs = Gray.(frog_blood);
save("frog_blood_gs.jpg", frog_blood_gs)
save("frog_blood_gs.png", frog_blood_gs)
该代码片段还展示了如何将图像保存到文件中。save()函数将图像数据转换为由文件名扩展名指定的文件格式。这里我们保存了两种版本的相同图像,一种是.jpg文件,另一种是.png文件。
图 14-12 展示了灰度化图像。

图 14-12:青蛙血液图像转为灰度图像。原始图像由 Wayne Large 提供(CC BY-ND 2.0)。可从 flic.kr/p/cBDUEG 获取。
其他有用的颜色转换函数包括red()、green()和blue(),它们从RGB像素中提取指定的颜色通道,当然也可以广播到整个图像,将其分离为各个颜色通道。
为了比较两张或多张图像的不同版本,可能是为了直观查看某个转换或处理步骤的效果,mosaicview()函数非常有用:
julia> imshow(mosaicview(red.(frog_blood), green.(frog_blood),
blue.(frog_blood), frog_blood_gs; ncol=2, npad=6))
该命令创建四个图像,显示原始frog_blood图像的三个颜色通道和合成的灰度版本,将它们按网格排列并显示。如果在笔记本中工作,我们不需要imshow()调用。ncol参数指定图像网格中的列数(也有nrows可用),npad参数在图像之间添加指定数量的像素边框。
图 14-13 展示了mosaicview()的输出。

图 14-13:青蛙血液图像的红色、绿色、蓝色和所有通道(从上到下,左到右)
原始图像,包含所有颜色通道,位于右下角。
使用面积分数计数细胞
我们第一次尝试自动计数血细胞将使用ImageBinarization包。这个包包含了一些用于将图像分为“前景”和“背景”的算法,将前景染成纯黑色,背景染成纯白色。换句话说,原始图像中的每个像素都会根据调用的算法结果被分配为 0.0 或 1.0。该包的文档展示了各种算法在不同类型图像上的结果示例。
目标是生成一张尽可能将血细胞与其他所有物体分离的图像。这个二值图像将成为进一步分析的良好起点。通过图 14-13 所示的颜色分离,我们已经在这方面取得了一些进展。底部的蓝色通道似乎增强了(较大的)红细胞与其他颗粒之间的对比度。我们不会直接对原始彩色图像进行二值化,而是从蓝色通道开始:
julia> using ImageBinarization
julia> frog_blood_blue = blue.(frog_blood);
julia> frog_blood_b1 = binarize(frog_blood_blue, Intermodes())
binarize()函数将图像作为第一个参数,二值化算法的名称作为第二个参数,并返回二值化后的图像。文档描述了Intermodes算法的详细信息。就我们的目的而言,它能够很好地检测出与背景对比明显的离散结构,如细胞。
图 14-14 展示了二值化后的图像。

图 14-14:青蛙血液幻灯片经过二值化后的蓝色通道
我们将使用这张图像作为血细胞计数的基础。
如果我们知道图像中血细胞的平均面积,就可以将其除以所有血细胞所占的总面积,从而估算细胞的数量。细胞似乎大致呈椭圆形(在这个二维图像中)。
在imshow()窗口中使用 GUI 时,我使用像素读数来测量四个典型细胞的长轴和短轴长度,长轴为 26.8 像素,短轴为 24.5 像素。使用A = πr[1]r[2]计算椭圆面积,其中r[1]和r[2]是椭圆的半径,四个面积的平均值为 511.3 平方像素。
使用二值化图像来计算总的血细胞比例非常简单。在frog_blood_b1中,细胞是黑色的,像素值为 0,背景是白色的,像素值为 1。因此,细胞的总数为sum(1 .- frog_blood_b1),其值为 255,029.0。将此结果除以平均细胞面积得到 499 个细胞。
通过识别特征计数细胞
我们可以通过利用搜索图像中特定形状特征的算法,改进上一节中的估计。霍夫变换(有关背景知识,请参见第 465 页的“进一步阅读”部分)就是这样一类可以专门用于各种形状的算法。我们假设以下示例中已经导入了ImageFeatures包,它提供了检测直线和圆形的实现。由于我们需要检测的特征类似于圆形,因此我们将使用hough_circle_gradient()函数,这是霍夫变换在圆形上的一种实现。
在应用算法之前,我们将处理图像,以使其任务更容易,并产生更准确的结果。图像中的一个问题是,我们想要计数的细胞不是圆形的,而是拉长的。虽然霍夫变换有针对椭圆形的实现,但ImageFeatures包中尚未提供此功能。另一个问题是,许多细胞是接触的,还有一些是重叠的。霍夫变换可以处理接触和重叠的圆形,但它对清晰分开的形状效果更好。
大自然在第二个问题上提供了一些帮助:每个细胞都有一个核,在图像中清晰可见。即使血细胞接触或重叠,它们的细胞核依然分开。如果我们能从图像中去除除了细胞核以外的大部分内容,我们就可以通过计数细胞核来得到血细胞数量。
我们很幸运:细胞核的颜色使它们在图像中与其他部分容易区分。这可能不容易通过肉眼察觉,但通过将鼠标光标放在imshow()窗口中的细胞核上,并与其他位置进行比较,我们可以看到,细胞核的绿色值接近 0,同时红色值大于 0.2。我们可以通过其他方式确认这一点——例如,通过沿图像中的直线绘制三个颜色分量。
以下的数组推导式通过逐个像素地从原始图像创建新图像,它保留细胞核颜色范围内的像素不变,同时将其他像素变为白色:
julia> nuclei = Gray.([(green(e) < 0.1) & (red(e) > 0.2) ? e :
RGB{N0f8}(1.0, 1.0, 1.0) for e in frog_blood]);
我们还将结果转换为灰度图像,以便进一步处理和打印。图 14-15 显示了结果。

图 14-15:通过颜色隔离的青蛙血细胞核
我们已经很好地隔离了细胞核,并去除了部分不是血细胞的颗粒。
图 14-15 是圆形检测算法的一个很好的候选者,但我们首先必须完成两个初步步骤。hough_circle_gradient()函数并不作用于实际图像,而是作用于边缘和相位的映射。边缘映射是边缘检测算法的输出,将图像转换为基本上是描绘其形状的线条。相位映射是从边缘映射计算出的角度矩阵,给出每个梯度点的方向,角度范围从–π到π。
canny()函数是一个出色的边缘检测器:
julia> edges = canny(nuclei, (0.15, 0.0))
它的第二个参数是一个阈值元组,用于定义从输入图像(必须是灰度图像)中检测到的边缘。我通过反复试验得到了这些值,目标是捕获细胞核的边缘,同时忽略大多数白细胞和其他颗粒的散射。图 14-16 显示了canny()函数的输出。

图 14-16:细胞核图像的边缘检测
这是一个相当干净的结果,正是我们所追求的。
相位计算本身需要两步——首先是计算梯度图,然后从中推导出相位:
julia> dx, dy = imgradients(edges, KernelFactors.ando5);
julia> phases = phase(dx, dy);
在计算了边缘和相位之后,我们可以运行霍夫变换:
julia> centers, radii = hough_circle_gradient(edges, phases, 1:5; min_dist=20);
在这个调用之后,centers包含一个索引向量,给出每个圆形的位置,radii包含它们对应的半径向量。任何一个向量的长度都表示检测到的圆形数量。在这种情况下,长度为 534,与我们之前得出的 499 个血细胞的估计值相吻合。
hough_circle_gradient()的第三个参数给出了圆形半径允许的范围,单位是像素。min_dist关键字参数是圆心之间的最小允许距离。
为了查看圆形拟合的效果,以及我们应该对 534 个血细胞估计的信心程度如何,我们可以使用centers数组直接在原始图像上绘制出hough_circle_gradient()函数认为应该出现的圆形位置:
julia> using ImageDraw
julia> for p in centers
draw!(frog_blood, CirclePointRadius(p, 15; thickness=8, fill=false))
end
draw!()函数由ImageDraw提供,通过在其第一个参数上绘制形状来修改该参数,默认颜色为白色。第二个参数中的CirclePointRadius()在点p处创建一个半径为 15 的圆;fill=false会创建一个开放的圆,其边界厚度由thickness关键字控制。
图 14-17 显示了将圆形绘制在(灰度版本的)原始图像上的结果。

图 14-17:霍夫变换检测到的圆形
图 14-17 显示了霍夫变换的良好效果。几乎每个血细胞都被标记为一个圆形,其他大多数物体被忽略。虽然有少量漏检和误检,但整体上 534 个血细胞的数量相当准确。
本节描述的图像处理管道对于自动化血液计数非常实用,尽管需要根据不同类型的样本、不同的染色方式等调整具体参数。与手动计数相比,这种方法要快得多,可能也更准确。
应用高级数组概念
由于图像本质上是一个数组,Julia 提供的各种高级数组概念可以使其操作更加方便和简洁。本节探讨了处理数组的技巧,虽然我们在多个包中看到了它们的应用,但我们直到现在才直接使用它们。在图像处理的上下文中,使用这些技巧变得更容易理解。
视图
视图 是对另一个数组或另一个数组部分的引用。另一个数组被称为父数组。视图是一种虚拟数组,几乎不占用内存:它与父数组共享内存,因此修改一个会同时修改另一个。
注意
在创建视图后,修改父数组的形状是危险的。对视图的后续操作可能会导致越界内存访问或段错误。
为了了解视图的工作原理,我们将创建一个中灰色值的小网格,并创建一个视图指向网格中的每个其他元素:
julia> rgi = rand(Float64, (10, 10)) .* 0.2 .+ 0.4;
julia> checkers = @view rgi[1:2:end, 1:2:end];
julia> size(checkers)
(5, 5)
➊ julia> checkers .= 0.0;
julia> black_squares = heatmap(rgi; c=:grays, clim=(0.0, 1.0), colorbar=false);
julia> checkers .= 1.0;
julia> white_squares = heatmap(rgi; c=:grays, clim=(0.0, 1.0), colorbar=false);
julia> plot(black_squares, white_squares)
第二行展示了如何使用 @view 宏创建视图。通过选择父数组的交替方格来定义 checkers 视图,形成一个棋盘模式。它的大小是父数组的一半。在将所有元素设置为 0.0 ➊ 后,父数组中的相应元素也被修改。我们可以反复更改视图中的元素值,这些更新会反映在父数组中。图 14-18 显示了结果。

图 14-18:使用视图创建的模式
这个例子展示了视图如何简化某些表达式。它们也作为内存节约的工具非常有用。如果计算过程中使用了数组的部分内容作为中间容器,而我们在最终结果中不需要这些容器,我们可以通过使用视图来避免为这些临时结构分配内存。
举个例子,这里有两个版本的小函数,它们返回数组中交替元素和的差值:
function odd_even_difference(a::AbstractArray)
return sum(a[begin:2:end]) - sum(a[begin+1:2:end])
end
function odd_even_difference2(a::AbstractArray)
➊ return @views sum(a[begin:2:end]) - sum(a[begin+1:2:end])
end
julia> using BenchmarkTools
julia> @btime odd_even_difference(rand(Int(1e7)));
96.716 ms (6 allocations: 152.59 MiB)
julia> @btime odd_even_difference2(rand(Int(1e7)));
62.116 ms (2 allocations: 76.29 MiB)
@views 宏 ➊ 将表达式右侧的所有切片操作转换为视图操作。程序的第一个版本创建了两个数组,并计算了奇数和偶数索引元素的和。第二个版本执行相同的计算,但通过创建视图而不是新数组来完成。计时结果显示,使用视图将内存消耗减少了一半,同时运行时间也减少了三分之一。通过在可能的情况下使用视图来避免不必要的数组复制,是一种简单的优化方法。
轴数组
使用AxisArrays包,我们可以为数组维度和轴命名,给数组加上单位,并享受更灵活的索引方式。数据框(见“数据框”章节中的第 333 页)也允许我们为行和列命名,但仅限于二维。
以下例子展示了如何为矩阵的行和列命名:
julia> using AxisArrays
julia> ae = AxisArray(reshape(1:100, 10, 10); row='a':'j', col='A':'J')
2-dimensional AxisArray{Int64,2,...} with axes:
:row, 'a':1:'j'
:col, 'A':1:'J'
And data, a 10×10 reshape(::UnitRange{Int64}, 10, 10) with eltype Int64:
1 11 21 31 41 51 61 71 81 91
2 12 22 32 42 52 62 72 82 92
3 13 23 33 43 53 63 73 83 93
4 14 24 34 44 54 64 74 84 94
5 15 25 35 45 55 65 75 85 95
6 16 26 36 46 56 66 76 86 96
7 17 27 37 47 57 67 77 87 97
8 18 28 38 48 58 68 78 88 98
9 19 29 39 49 59 69 79 89 99
10 20 30 40 50 60 70 80 90 100
使用这个定义,我们可以使用我们习惯的数字索引,或者使用我们为轴命名的名称,或者两者混合:
julia> ae['a', 'B']
11
julia> ae[1, 2] == ae['a', 2] == ae[1, 'B']
true
➊ julia> ae['a':'c', 'B':'D']
2-dimensional AxisArray{Int64,2,...} with axes:
:row, ['a', 'b', 'c']
:col, ['B', 'C', 'D']
And data, a 3×3 Matrix{Int64}:
11 21 31
12 22 32
13 23 33
➋ julia> ae[col=2, row=1]
11
这个例子展示了我们可以像使用数字索引一样,使用我们自定义的名称进行切片 ➊,并且如果我们使用维度的名称,我们可以以任意顺序提供索引 ➋。我们在这里使用row和col,它们在索引表达式内定义;它们在括号外并不存在作为变量。
下一个例子展示了如何将单位纳入数组定义中:
julia> using Unitful
julia> mm = u"mm";
julia> cm = u"cm";
julia> rgin = AxisArray(rand(Float64, (10, 10)) .* 0.2 .+ 0.4,
Axis{:y}(0mm:1mm:9mm), Axis{:x}(0cm:1cm:9cm));
julia> rgin[x=3, y=2] == rgin[1mm, 2cm] == rgin[2, 3] == rgin[x=2cm, y=1mm] ==
rgin[2, 2cm]
true
这展示了Axis{}()构造函数的使用,并且在最后一行展示了我们可以以不同的方式对数组进行索引,包括混合使用数字索引和单位索引。
我们可以使用省略号,它来自自动导入的EllipsisNotation包,来表示单位范围:
julia> rgin[1mm .. 2mm, 1cm .. 3cm] == rgin[1mm .. 2.3mm, 10mm .. 30mm]
true
这展示了维度范围的两个特性。我们可以使用等效的单位,这里使用 10 毫米=1 厘米,并且区间的端点不需要恰好位于数组的某个元素上。需要注意的是,索引是向下取整,而不是取最接近的元素。
让我们通过定义一个长度范围的矩形,绘制它并将其涂成白色,然后绘制结果数组:
julia> rgin[2mm .. 7.2mm, 3cm .. 4.9cm] .= 1.0;
julia> heatmap(rgin; c=:grays, clim=(0.0, 1.0), colorbar=false, ratio=1,
xticks=(1:10, ["$(i)mm" for i in 0:9]),
yticks=(1:10, ["$(i)cm" for i in 0:9]),
xrange=(0, 11))
绘图命令是自定义标签刻度的一个例子。图 14-19 展示了rgin的新状态。

图 14-19:我们通过指定物理长度来绘制这个白色矩形。
直接使用物理尺寸来索引数组,让我们不再需要不断地在整数索引和它们在模型中代表的量之间进行转换,无论是通过思维还是编程方式。
偏移数组(OffsetArrays)
有经验的 Python 或 C 开发者在第一次接触 Julia 时,常常会抱怨其基于 1 的索引,而老一辈的 Fortran 开发者知道这其实是更好的选择。前者可能会高兴地发现,在 Julia 中,和 Fortran 一样,我们可以使数组从任何位置开始。
不要假设 1 基索引
假设传递给函数的数组是基于 1 的索引,这会是公共包中的偶发错误源。OffsetArrays的存在就是我们之前提醒不要通过以下方式迭代数组的原因:
for i = 1:length(A) # Do not do this.
# ...expressions with A[i]...
代替使用eachindex(A)或其他生成合法索引的构造。不过还有一个原因:使用eachindex()会为某些类型的数组生成更高效的内存访问。
OffsetArrays包提供了多种创建OffsetArray的方法。我们可以使用OffsetArray()函数,传入源数组和每个维度的偏移量作为位置参数。一个维度的偏移量是其索引从正常位置的偏移程度。偏移量为 0 表示没有偏移,偏移量为-2 表示该维度的索引从-1 开始,到比其长度少 2 的位置结束。为了说明OffsetArray的工作原理,我们将再次使用我们的随机灰度矩阵:
julia> using OffsetArrays, Random
julia> rgen = MersenneTwister(7654);
julia> rgi = rand(rgen, Float64, (10, 10)) .* 0.2 .+ 0.4;
julia> rgi_offset = OffsetArray(rgi, -3, 2);
julia> rgi[1, 1]
0.5447560977385423
julia> rgi_offset[-2, 3]
0.5447560977385423
在这个示例中,我们使用了一个带有种子的随机数生成器(详见 第 307 页的《Julia 中的随机数》),这样读者在尝试这些命令时,结果将是相同的。rgi_offset的(-2, 3)位置对应于rgi的(1, 1)位置。
使用OffsetArray()创建的这是一个视图,而不是原始数组的副本,正如代码清单 14-4 中所示。
julia> rgi_offset[-2, 3] = 0.0
0.0
julia> rgi[1, 1]
0.0
代码清单 14-4: OffsetArrays 是视图。
由于这两个数组共享内存,修改rgi_offset会同时修改rgi。当然,如果需要,我们可以使用copy()创建一个新数组:
julia> rgi_offset_copy = copy(OffsetArray(rgi, -3, 2));
julia> rgi_offset_copy[-2, 3] = 1.0
1.0
julia> rgi[1, 1]
0.0
将数组的部分区域涂成白色,说明范围与之前一样有效,考虑了偏移量:
julia> rgi_offset[0:5, 8:11] .= 1.0;
图 14-20 显示了数组的图像,其中黑色元素设置在代码清单 14-4 中,白色矩形设置在本示例中。热图的绘制网格以元素为中心,因此我们可以检查哪些元素发生了变化,以验证我们是否理解了索引范围。

图 14-20:白色矩形被定义为一个 OffsetArray。
使用heatmap()绘图时,除非我们明确提供坐标向量,否则它与OffsetArrays无法正常工作。换句话说,我们可以调用heatmap(rgi),但必须使用heatmap(1:10, 1:10, rgi_offset)来防止绘图例程混淆。在这种情况下,这两个调用会产生相同的图像,因为这两个数组共享内存。
OffsetArray()提供了另一种语法,使用索引范围而不是单一偏移量。当从现有数组中提取子集时,这种方法很方便:
julia> passage = Float64.(Gray.(load("titanPassage.jpg")));
julia> passage = reverse(passage; dims=1);
julia> middle_passage = OffsetArray(passage[300:600, 400:700], 300:600, 400:700); ➊
julia> passage[300:600, 400:700] .= 0.0;
julia> passage[350:550, 450:650] = middle_passage[350:550, 450:650]; ➋
首先,我们加载一张彩色照片,将其转换为灰度图像,然后转化为浮点数组,并将结果赋值给passage。由于我们计划使用heatmap()来检查这些图像,我们为方便起见将图像垂直翻转,以抵消垂直轴方向的影响。
使用OffsetArray(),我们提取图像的一个方形部分并将其赋值给middle_passage➊。这一行展示了另一种建立偏移索引的方法:我们不使用单一的整数偏移量,而是提供一个索引数组的范围。我们选择这些索引与提取子图像时使用的索引相同,以便提取部分中的像素与原始图像中相同索引的像素对应。这种技术极大地简化了我们想要保持数组与子数组之间一致性的程序,消除了不断转换索引的需求。middle_passage矩阵是一个新的数组,而不是视图,因为索引范围会创建一个副本。
下一行将我们提取的方形区域涂成黑色。
在最后一行中,我们用提取自图像的一部分替换了黑色方块的部分➋。由于两个数组中的索引范围是相同的,替换后的图像部分将与原始部分完全对应。结果是图像的一部分被一个黑色框架包围,其他部分未做任何更改,正如图 14-21 所示。

图 14-21: OffsetArrays 使许多图像操作变得更简单。原始照片由 Lee Phillips 在 Titan 导弹设施内拍摄(CC BY-ND 2.0)。
使用偏移索引使得这段代码更容易编写和阅读,也更不容易出错。使用常规数组时,我们将不得不添加执行数组运算的代码行来转换大图像和提取图像之间的像素范围,或者从各个部分构建框架。
OffsetArrays包提供了另外两种自动构建数组偏移量的方法,这两种方法在图像处理中非常方便。我们可以创建一个以数组中心为中心的OffsetArray:
julia> passage = Float64.(Gray.(load("titanPassage.jpg")));
➊ julia> OffsetArrays.center(passage)
(375, 500)
julia> passage[375, 500]
0.25098039215686274
➋ julia> passage_centered = OffsetArrays.centered(passage);
julia> passage_centered[0, 0]
0.25098039215686274
center()函数➊来自OffsetArrays,它返回数组中心的索引(如果数组在某一维度上元素数量为奇数,它会向下取整)。该包的centered()函数➋创建一个以索引[0, 0]为中心的OffsetArray。由于存在命名冲突,我们通常需要用包名来限定这些函数名。
将索引空间的中心设置在数组的中心对于常见的情况非常有帮助,在这种情况下,数组表示的是物理空间中的某个量,或是时空中的某个量,我们通常使用以中心为原点的坐标系。这里是另一个视觉示例,其中将[0, 0]点置于图像的中心简化了计算:
julia> dmax = minimum(size(passage_centered))/2
julia> for j in eachindex(passage_centered[1, :]),
i in eachindex(passage_centered[:, 1])
passage_centered[i, j] *= max(0.0, 1.0 - sqrt(i² + j²)/dmax)
end
我们将dmax设置为从中心到较短维度边缘的距离。然后,我们将每个像素乘以一个关于距离中心递减的函数。图 14-22 展示了结果,一个向边缘逐渐变暗的居中圆形框架。

图 14-22: OffsetArrays 使得引用数组的中心变得容易。
使用居中的OffsetArray简化了代码,使我们无需编写通常需要的索引运算来引用数组的中心。
笛卡尔索引
Julia 的笛卡尔索引是一个强大的工具,可以大大简化与数组相关的各种计算。Julia 内置的两个相关类型是CartesianIndex和CartesianIndices。CartesianIndex表示数组中任意大小元素的地址。CartesianIndices是跨越数组中任意维度的矩形区域的迭代器。
为了更加具体,也方便我们查看图示,我们将重点关注二维数组,如 Listing 14-5 所示。
julia> ci = CartesianIndex(1, 1)
CartesianIndex(1, 1)
julia> collect(5ci:8ci)
4×4 Matrix{CartesianIndex{2}}:
CartesianIndex(5, 5) CartesianIndex(5, 6) CartesianIndex(5, 7) CartesianIndex(5, 8)
CartesianIndex(6, 5) CartesianIndex(6, 6) CartesianIndex(6, 7) CartesianIndex(6, 8)
CartesianIndex(7, 5) CartesianIndex(7, 6) CartesianIndex(7, 7) CartesianIndex(7, 8)
CartesianIndex(8, 5) CartesianIndex(8, 6) CartesianIndex(8, 7) CartesianIndex(8, 8)
Listing 14-5: 遍历 CartesianIndices
这个例子展示了如何使用CartesianIndices简化对矩形区域的迭代。首先,我们将与索引[1, 1]对应的CartesianIndex赋值给ci。然后,我们从CartesianIndex(5, 5)(表示为5ci)迭代到8ci,并使用collect()来实例化迭代过程,以便我们检查它。关键在于,线性迭代是如何扩展成对两个维度的嵌套迭代的,覆盖了两个角落[5, 5]和[8, 8]之间的矩形区域。我们可以在任何数量的维度中使用这种类型的迭代:
julia> collect(CartesianIndex(1, 1, 1):CartesianIndex(3, 3, 3))
3×3×3 Array{CartesianIndex{3}, 3}:
[:, :, 1] =
CartesianIndex(1, 1, 1) CartesianIndex(1, 2, 1) CartesianIndex(1, 3, 1)
CartesianIndex(2, 1, 1) CartesianIndex(2, 2, 1) CartesianIndex(2, 3, 1)
CartesianIndex(3, 1, 1) CartesianIndex(3, 2, 1) CartesianIndex(3, 3, 1)
[:, :, 2] =
CartesianIndex(1, 1, 2) CartesianIndex(1, 2, 2) CartesianIndex(1, 3, 2)
CartesianIndex(2, 1, 2) CartesianIndex(2, 2, 2) CartesianIndex(2, 3, 2)
CartesianIndex(3, 1, 2) CartesianIndex(3, 2, 2) CartesianIndex(3, 3, 2)
[:, :, 3] =
CartesianIndex(1, 1, 3) CartesianIndex(1, 2, 3) CartesianIndex(1, 3, 3)
CartesianIndex(2, 1, 3) CartesianIndex(2, 2, 3) CartesianIndex(2, 3, 3)
CartesianIndex(3, 1, 3) CartesianIndex(3, 2, 3) CartesianIndex(3, 3, 3)
这里的迭代代表一个立方体。如果没有CartesianIndices,我们需要将其写成三个嵌套循环,但这里它只是一个简单的范围表达式。
事实上,CartesianIndices比这些例子展示的更为通用。它们不必代表连续的矩形区域:
julia> collect(CartesianIndex(1, 1):CartesianIndex(2, 2):CartesianIndex(5, 5))
3×3 Matrix{CartesianIndex{2}}:
CartesianIndex(1, 1) CartesianIndex(1, 3) CartesianIndex(1, 5)
CartesianIndex(3, 1) CartesianIndex(3, 3) CartesianIndex(3, 5)
CartesianIndex(5, 1) CartesianIndex(5, 3) CartesianIndex(5, 5)
它们的实用性在于紧凑地表示嵌套迭代,并构造“可移植”的索引范围,我们可以在不同的数组中使用。Listing 14-6 阐明了这个思想。
julia> by2 = CartesianIndex(1, 1):CartesianIndex(2, 2):CartesianIndex(5, 5)
CartesianIndices((1:2:5, 1:2:5))
julia> reshape(1:100, 10, 10)[by2]
3×3 Matrix{Int64}:
1 21 41
3 23 43
5 25 45
Listing 14-6: 使用 CartesianIndices 构造“可移植”索引范围
这里我们将一个CartesianIndices迭代器赋值给by2,然后用它从一个 10×10 矩阵中提取了九个不连续的元素。这个例子还展示了一种更简洁的定义迭代器的方法,这种方法是根据第一行返回结果的形式得到的:
julia> CartesianIndices((1:3, 1:3, 1:3)) ==
CartesianIndex(1, 1, 1):CartesianIndex(3, 3, 3)
true
为了帮助可视化CartesianIndices,我们将从一个 100×100 版本的随机灰度矩阵开始,并通过遍历ci的倍数,在矩阵中选择一个矩形区域,定义见 Listing 14-6:
julia> rgi = rand(rgen, Float64, (100, 100)) .* 0.2 .+ 0.4;
julia> rgi[5ci:20ci] .= 0.0;
Figure 14-23 展示了这对rgi的作用。

Figure 14-23: 用 CartesianIndices 定义矩形区域
Julia 的CartesianIndices为我们提供了一种定义矩形区域的方法,我们可以在该区域上执行直接的算术运算,例如将其移动到数组周围的不同位置。这种“移动窗口”在我们之前使用的快速傅里叶变换和声谱图函数中后台运作。这也是在网格上求解偏微分方程的一个重要部分,这是计算科学中的一项主要工作。那些有经验在传统语言如 Fortran 中编写此类模板操作的人,知道这个过程有多么棘手。在这里,我们将这一思想应用于一张照片,通过滑动一个方形窗口在图像上来创建一个模糊的、像素平均化的版本:
julia> monk = Float64.(load("monk-mintons-1947.jpg"));
➊ julia> average_monk = similar(monk);
julia> cim = CartesianIndices(monk);
julia> ws = 1; # Window size
➋ julia> c1 = CartesianIndex(ws, ws);
julia> for i in cim
n = s = 0.0
for j in max(first(cim), i - c1):min(last(cim), i + c1)
n += 1
s += monk[j]
end
average_monk[i] = s/n
end
加载图像后,我们使用similar() ➊初始化一个数组,用来保存平均化后的版本,该函数会复制一个大小和类型相同的数组。我们将使用cim变量来遍历整个原始图像。移动方形窗口的大小被赋值给ws,它用于定义窗口的范围➋。for循环遍历原图中的每个点,将其替换为以该点为中心的方形窗口内所有像素的平均值。
max()和min()调用的目的是处理边界区域,在这些区域中,移动窗口会扩展超出矩阵的边界。这之所以可行,是因为max()和min()如何处理CartesianIndex类型:
julia> max(CartesianIndex(3, 4), CartesianIndex(-2, 9))
CartesianIndex(3, 9)
julia> min(CartesianIndex(3, 4), CartesianIndex(-2, 9))
CartesianIndex(-2, 4)
这些函数返回一个新的CartesianIndex,其中每个维度的索引都被单独最大化或最小化;因此,我们只需要参考原始数组的角落,以确保没有索引分量过大或过小。
这些函数在元组(tuple)上有不同的作用:
julia> max((3, 4), (-2, 9))
(3, 4)
在这里,元组(或向量)按其第一个元素排序,返回值始终是其中一个参数。
图 14-24 展示了原图以及经过 1、4 和 8 像素平均化后的结果。

图 14-24:西奥诺留斯·蒙克,1947 年。原图和经过 1、4 和 8 像素平滑处理后的图像,从左到右、从上到下。照片由威廉·戈特利布拍摄(公有领域,hdl.loc.gov/loc.music/gottlieb.06191)。
结果是原图逐渐变得更加柔和,这是简单的低通滤波的结果。
我们可以使用类似的方法创建一个缩小的图像——例如,在每个维度上缩小一倍:
julia> smaller_monk = zeros(size(monk) .÷ 2);
julia> cism = CartesianIndices(smaller_monk);
julia> c1 = CartesianIndex(1, 1)
julia> for i in cism
n = s = 0.0
➊ for j in max(first(cim), 2i - c1):min(last(cim), 2i + c1)
n += 1
s += monk[j]
end
smaller_monk[i] = s/n
end
在初始化一个大小为原图一半的数组来存放缩小版图像后,我们创建一个覆盖该数组的CartesianIndices迭代器,并将其赋值给cism。外层循环遍历这个较小的数组,并将其每个元素设置为对应位置在原图中相邻像素的平均值。索引➊之所以如此,是因为对于缩小版图像中的位置[i, j],在原图中的对应位置是[2i, 2j]。
图 14-25 展示了原图与减少版的对比。

图 14-25:钢琴四手联弹:通过像素平均化缩减图像
当然,我们也可以使用original[1:2:dy, 1:2:dx]快速创建一个缩减图像,但像素平均化会带来更好的结果,特别是在斜线的表现上。专业的图像缩减算法通常采用更大的窗口,采样方法也比这个例子中的简单算术平均更复杂。
结论
在本章中,我们分析和处理了来自声音和图像的物理世界中的伪影。我们探索了信号和图像处理包中的各种工具,也发现,Julia 强大的数组处理功能使得困难的任务变得简单,让我们能够编写简短而简单的程序来完成复杂的工作。
进一步阅读
-
SignalAnalysis.jl的文档可以在 https://org-arl.github.io/SignalAnalysis.jl/stable/ 查阅。 -
有关 WAV 文件格式的详细信息,请访问 https://docs.fileformat.com/audio/wav/。
-
JuliaImages 是查找各种 Julia 图像处理包及其文档的起点:https://juliaimages.org/stable。
-
关于霍夫变换的背景知识,请访问 https://homepages.inf.ed.ac.uk/rbf/HIPR2/hough.htm。
-
笛卡尔指标……它们有什么用?Tim Holy 解释道:https://julialang.org/blog/2016/02/iteration/。这篇文章启发了本章中使用的图像缩减方法。
第十六章:并行处理
如果一头牛无法完成工作,他们不会尝试养更大的一头牛,而是使用两头牛。
—格蕾丝·霍普

并行处理是一类计算策略,我们将一个问题分解成若干部分,并用不同的计算机或单台计算机上的不同处理单元来解决每个部分——或者两者的组合。本章讨论的是真正的并行处理,即不同的计算同时进行,以及并发处理,即我们要求计算机同时做几件事,但它可能需要在它们之间交替执行。
虽然编写有效的并行程序可能很棘手,但 Julia 在尽可能简化并行和并发处理方面做了大量工作。同一个程序可能在并行或仅仅并发的方式下运行,这取决于机器资源,但 Julia 的抽象使我们可以编写一个版本的程序,在不同的运行时环境中都能发挥作用。
本章将概述如何利用 Julia 内建的功能和几个便利的包来实现主要的并发范式。
并发范式
从程序员的角度来看,一个自然的区分是多线程和多进程,这是本章的主要划分。这个领域存在一些术语不一致的问题。我们使用多线程来指代旨在实现单台机器上多个 CPU 核心并行执行的编程。核心是 CPU 芯片中的处理单元。每个核心都配备有自己的资源,比如缓存和算术逻辑单元,并可以独立执行指令,尽管它可能与其他核心共享一些资源。如果某人将多线程程序运行在只有一个核心的计算机上,那么不会发生并行处理,但当我们编写程序时,这并不需要担心。如果我们正确编写代码,它将在多核机器上运行得更快。
我们使用多进程编程来指代一种编程风格,在这种风格中,我们启动可以由单台计算机上的不同进程或多台计算机(或两者)执行的任务。
这两种编程风格之间最重要的区别在于内存访问:多线程程序中的所有线程都可以访问相同的内存池,而多进程程序中的进程则有各自独立的内存区域。
多线程
本节讨论通过将工作划分为多个任务来加速单个进程中的工作。由于所有这些任务都存在于同一进程中,因此它们都可以访问相同的内存空间。任务是 Julia 并行和并发处理的基本概念。它是一个离散的工作单元,通常是一个函数调用,由调度器分配给特定的线程。任务本质上是异步的;一旦启动,它们将在分配的线程上继续执行,直到完成或通过让出给调度器而自行挂起。然而,我们可以通过多种方式同步和协调任务的生命周期。
注意
你可能已经在不知情的情况下使用 Julia 进行并行计算。许多线性代数例程,包括由运算符调度的矩阵乘法,运行多线程 BLAS(基本线性代数子程序)例程,这些例程自动利用所有 CPU 核心,对用户是透明的。你可以通过在 REPL 中执行矩阵乘法并关注你的 CPU 监控工具来验证这一点。
当我们进入 Julia REPL 或使用julia命令运行存储在磁盘上的程序时,我们有几个可用的命令行选项。除非使用-t选项,否则 Julia 只使用一个线程(因此,也只使用一个 CPU 核心),无论其运行的硬件配置如何。
要允许 Julia 使用所有可用的线程,请使用-t auto参数。在这种情况下,所有“可用”的线程将是机器上的所有逻辑线程。这通常不是最优选择。更好的选择是使用-t n,其中 n 是物理核心的数量。例如,流行的 Intel Core 处理器通过一种称为超线程的技术为每个物理核心提供两个逻辑核心。超线程可能会带来从适度加速到实际减速的效果,这取决于计算类型。
在 Linux 上,我们可以在系统命令行使用lscpu命令获取关于 CPU 的信息。例如,如果输出包含以下几行
Thread(s) per core: 2
Core(s) per socket: 2
Socket(s): 1
那么该机器总共有两个物理计算核心和通过超线程提供的四个逻辑线程。我们通常需要实验才能发现-t n(在这种情况下,-t 2)或-t auto哪个能带来更好的结果。
在程序中或 REPL 中,我们可以通过以下命令检查可用线程的数量
Threads.nthreads()
它报告了正在使用的总数,但无法区分其中多少是真正的核心。
使用多个线程,我们可以通过将任务分配到多个 CPU 核心上同时运行,从而加速程序,无论是自动的还是通过应用不同级别的控制。
使用折叠实现简单多线程
启动任务的一种自动方式是使用 Folds 包,它提供了 map()、sum()、maximum()、minimum()、reduce()、collect() 等多个函数的多线程版本。这些函数的使用非常简单,比如将 sum() 替换为 Folds.sum()。并行化函数会负责在所有可用线程之间分配工作。
作为一个例子,Listing 15-1 展示了在一个数组上并行化计算一个开销较大的函数。
julia> using BenchmarkTools, Folds
julia> f(x) = sum([exp(1/i²) for i in 1:x]);
julia> time_serial = @belapsed map(f, 100_000:105_000)
13.989536582
julia> time_parallel = @belapsed Folds.map(f, 100_000:105_000)
7.606663313
julia> time_parallel / time_serial
0.5437394776026614
julia> Threads.nthreads()
2
Listing 15-1: 使用 Folds.jl 实现简单的并行化
@belapsed 宏是 BenchmarkTools 的一部分。像我们之前使用过的 @btime 宏一样,它会反复运行任务并报告资源利用率的平均值。这个版本在我们仅需获取消耗的 CPU 时间时特别方便。
map() 的并行化版本将每个线程分配到 5,001 个数字的循环中的大致相等部分。理想情况下,总的计算时间应该是 1/N,其中 N 是线程的数量。在后台,它会创建任务,每个任务处理循环中的一部分,然后将它们分配给可用线程;它可能使用两个任务,或者更多。它还会同步计算,在所有任务完成后才返回结果。
这个 REPL 会话是通过 -t 2 标志启动的。结果显示,使用并行版本的计算时间仅略高于串行计算的一半。由于我们在两个(物理)线程上运行,结果显示了几乎理想的并行加速效果。
然而,我们并不总是这么幸运。并行化计算是否有帮助、会造成负面影响或没有效果,取决于设置和管理任务集的开销与分配工作所带来的好处之间的权衡。这种效果受到每个数组元素的计算成本、数组的大小以及内存访问模式的影响。在较小的数组上执行相同的计算时,使用串行 map() 会得到更好的结果:
julia> time_serial = @belapsed map(f, 1:41)
2.4464e-5
julia> time_parallel = @belapsed Folds.map(f, 1:41)
2.5466e-5
在这里,使用单个处理器的计算实际上比试图并行化短计算要快。成功的并行计算需要大量的测试。我们需要确保能够充分利用硬件,并且在多个核心上运行的结果与串行运行的结果是相同的,除了浮点数计算重排序可能在某些程序中导致的微小数值差异。
使用 @threads 进行手动多线程
Folds 包是本节讨论的手动多线程的高级接口。手动操作需要更多的注意,但它可以提供额外的控制,这在某些情况下是我们所需要的。
Threads.@threads
Julia 中进行多线程的主要工具是 Threads.@threads 宏,它是 Base 的一部分,因此始终可用。为了并行运行一个循环,我们只需在循环前加上这个宏。作为介绍,Listing 15-2 解决了与上一节相同的问题。
julia> f(x) = sum([exp(1/i²) for i in 1:x]);
julia> time_serial = @belapsed for x in 100_000:105_000
r = f(x)
end
13.933373843
julia> time_parallel = @belapsed Threads.@threads for x in 100_000:105_000
r = f(x)
end
7.507556971
清单 15-2:计时线程循环
显然,@threads版本的表现与Folds包中的包装器相似。
@threads宏通过将循环划分为N个段落并将每个段落分配给一个独立的任务来工作。调度器将这些任务分配给可用的线程。通常N是线程数的小倍数,所以如果我们有两个核心并且使用了-t 2标志,@threads可能会将 5001 个元素的循环分成两个或四个大致相等的循环。
@threads循环是同步的,意味着计算不会在循环结束之前继续,直到所有任务完成。循环的不同部分,因此不同的任务,可能需要不同的时间。如果这个时间差异很大,一些线程将会闲置,等待其他线程赶上。这就是为什么,如前所述,当所有迭代的计算时间大致相同时,这种多线程风格的表现最好。
与其丢弃结果,不如尝试将所有的f(x)加在一起:
function sumf_serial(n)
s = 0.0
for x in 1:n
s += f(x)
end
s
end
function sumf_parallel(n)
s = 0.0
Threads.@threads for x in 1:n
s += f(x)
end
s
end
julia> sumf_serial(1000)
502900.5422006599
julia> sumf_parallel(1000)
376606.37463883933
julia> sumf_parallel(1000)
376453.03112871706
并行结果不仅与串行结果不同,而且似乎我们在不同的并行程序运行中会得到不同的答案。我们哪里做错了?
原子理论
问题出现在我们在并行循环中更新s时。多个独立的线程尝试访问并写入相同的标量变量时,产生了竞争条件,这是一个冲突,其结果取决于操作顺序,而程序无法控制。由于时序的不同,基于操作系统在运行期间执行的其他任务等未知因素的影响,我们可能从不同的运行中得到不同的结果。当更新数组位置时没有问题,因为在多线程循环中,数组将被分配到不同的线程中,没有线程会干扰其他线程的数据。
Julia 提供了几种在多线程执行期间保护标量的策略。一种方法是使用原子变量,如清单 15-3 所示。
function sumf_parallel_locked(n)
s = Threads.Atomic{Float64}(0);
Threads.@threads for x in 1:n
Threads.atomic_add!(s, f(x))
end
s[]
end
julia> sumf_parallel_locked(1000)
502900.5422006605
清单 15-3:使用原子变量
我们已经使用内建的Threads.Atomic声明将s初始化为原子变量。它仅允许简单类型:各种浮动数、整数和Bool类型。我们通过一小组为此目的设计的函数来更新原子变量,所有函数都在Threads命名空间下。除了Threads.atomic_add!(),我们还可以使用atomic_sub!()进行减法、几个逻辑操作符、atomic_xchg!()用于将变量设置为新值,等等。我们通过程序最后一行中看起来有点奇怪的语法来访问原子变量的值。
结果接近串行结果,因此原子变量解决了这个问题。结果虽然接近,但不完全相同:它们在最后几位小数上有所不同。一系列浮点操作的结果可能取决于它们的顺序,而顺序在串行和并行运行之间以及在不同线程数的并行运行之间会有所变化。如果我们在循环中反向计数运行串行代码,我们也会得到一个稍微不同的结果:
function sumf_serial_reversed(n)
s = 0.0
for x in n:-1:1
s += f(x)
end
s
end
julia> sumf_serial_reversed(1000)
502900.5422006606
这些答案中最低有效位的细微变化是正常且可以预期的,数值分析人员在比较并行化程序在不同计算机上运行时的结果时,必须对这些变化保持警觉,因为不同的计算机可能有不同数量的核心。
我们还可以使用不同的策略得到正确的求和结果:
function sumf_parallel2(n)
s = zeros(Threads.nthreads())
Threads.@threads for x in 1:n
➊ s[Threads.threadid()] += f(x)
end
sum(s)
end
julia> sumf_parallel2(1000)
502900.5422006605
本质上,我们为每个线程提供了其私有的求和变量副本,并在最后将所有副本相加。我们使用 Threads.nthreads() 创建一个与线程数相同长度的向量。在每个线程中,Threads.threadid() 返回该线程的唯一整数标识符。我们使用这个标识符来索引求和数组 ➊,确保每个线程仅更新属于它的元素。最后一行的求和结果应该与程序的其他版本中的标量 s 相同。
使用数组代替原子变量的技术可能更快,因为在一个线程被允许读取或更新原子变量之前,它必须等待直到任何其他线程释放它。使用数组避免了这种锁定和随之而来的等待时间。然而,它为新数组使用了稍微更多的内存。
启动和同步任务
我们在前两节中描述的技巧通过将工作分配给任务并在后台启动它们来实现并行化。在这里,我们将学习如何控制任务的启动和同步。
使用线程启动任务.@spawn
我们也可以像 Listing 15-4 中展示的那样,使用 Threads.@spawn 宏手动启动任务。
function sumf_atomic(f)
s = Threads.Atomic{Float64}(0.0);
➊ @sync for x in 100_000:105_000
Threads.@spawn Threads.atomic_add!(s, f(x))
end
return s
end
julia> @belapsed s = sumf_atomic(f)
8.101242794
julia> s = sumf_atomic(f);
julia> s[]
5.126145395914207e8
Listing 15-4: 引入任务启动
由于 @belapsed 和 BenchmarkTools 中的其他基准测试工具会多次运行代码,我们将计时代码放入函数中,以确保在每次试验运行时原子变量都会被初始化。
@sync 宏 ➊ 适用于任何代码块,而不仅仅是 for 循环。它同步所有在代码块的词法作用域内启动的任务,这意味着其 end 语句后的语句会等待直到所有任务完成。在 Listing 15-4 中,@sync 确保当我们访问 s[] 时,它会有最终值,并且计时结果包括所有任务完成的时间。
Listing 15-4 中的代码块是 Listing 15-3 中函数的一个版本,使用了手动启动的任务。通常,循环
Threads.@threads for i in 1:N
something
end
与语义等效
@sync for i in 1:N
Threads.@spawn something
end
但是它们的实现是不同的,正如前面提到的,Threads .@threads是粗粒度的,将循环划分为少量的任务。手动启动的版本会为每次循环迭代创建一个新任务。
这两个例子的时序几乎相同,表明在 Julia 中启动任务几乎没有开销;我们可以启动成千上万的任务,性能损失几乎可以忽略不计。如果我们将使用任务的程序移到拥有更多核心的机器上,它应该能够更快运行,而且我们不需要做任何修改。
注意
在本章中,我们在顶层对裸循环进行了多次计时,以便比较不同并发和并行方法的效果,并尽可能少的代码行。在开发一个真正的程序时,所有的计时研究应该放在函数中,最好是在模块中。许多编译器优化仅对函数中的代码有效。
同步
使用Folds.map()或@threads可以帮助我们同步任务。但是,如果我们手动使用Threads.@spawn启动任务,我们无法知道在程序的某个特定时刻哪些任务已经完成。这就是为什么 Listing 15-4 中的程序需要@sync宏的原因。
以下示例说明了如果我们忽略同步,可能会发生什么情况:
W = zeros(5);
for i in 1:5
Threads.@spawn (sleep(1); W[i] = i)
end
println(W)
如果我们运行这个程序,我们会看到如下输出:
[0.0, 0.0, 0.0, 0.0, 0.0]
每次循环迭代都会启动一个任务,改变全局数组,向其中的某个位置写入数据。然而,在循环结束时,数组W似乎没有发生变化。
每个@spawn都会发送一个任务来执行工作,循环会立即继续到下一次迭代。虽然每个被启动的任务都会受到sleep()调用引入的内建延迟,但整个循环几乎是瞬间完成的。然后我们执行循环后面的语句,打印出W的值,此时它还没有被写入。
如果我们想要等待循环结束时所有在其中启动的任务完成,以便W得到更新,我们可以使用@sync宏:
W = zeros(5);
@sync for i in 1:5
Threads.@spawn (sleep(1); W[i] = i)
end
println(W)
当我们运行这个程序时,我们看到:
[1.0, 2.0, 3.0, 4.0, 5.0]
我们可以选择不对所有任务进行同步,而是等待某些任务完成,让其他任务继续执行:
W = zeros(5);
jobs = Vector{Any}(undef, 5);
for i in 1:5
jobs[i] = Threads.@spawn (sleep(i); W[i] = i)
end
wait(jobs[2])
println(W)
我们初始化一个jobs向量来存储每次调用@spawn返回的值。这些是Task,一种数据类型,用来存储关于异步任务的信息。wait()函数会暂停执行,直到其参数准备好。我们稍微修改循环,每次迭代等待i秒,这样每个任务所需的时间都会比上一个任务更长。第二个任务完成后,下一条指令,打印W,会被执行。
程序会输出如下内容:
[1.0, 2.0, 0.0, 0.0, 0.0]
我们可以看到,当println()语句被执行时,W的前两个元素被修改了,但其余的任务仍然在运行(处于睡眠状态)。
另一个有用的同步函数是fetch()。像wait()一样,它接收一个Task作为参数,并等待任务完成:
W = zeros(5);
jobs = Vector{Any}(undef, 5);
for i in 1:5
jobs[i] = Threads.@spawn (sleep(i); W[i] = i)
end
job2 = fetch(jobs[2])
println(W)
println(job2)
该函数打印出如下输出:
[1.0, 2.0, 0.0, 0.0, 0.0]
2
由于赋值操作返回的是被赋的值,执行 W[2] = 2 的任务返回 2,并通过调用 fetch() 将其赋值给 job2。此时 W 的状态是第二个任务完成后的状态。
让出
在调度器将任务分配到所有可用线程后,任何剩余的生成任务都会进入队列,等待它们运行的时机。它们必须等到其中一个正在运行的任务完成或让出其位置。这个系统被称为协作式多任务,它是 Julia 通常应用于任务调度的模型。一些操作会导致任务自动让出。最重要的操作包括等待 I/O 和休眠。但如果程序涉及多个执行长时间计算的任务,那么我们的任务就是手动拆分计算并插入yield(),以便为其他任务提供运行的机会,除非我们不介意每个线程等待它上面每个昂贵任务的完成(这可能是可以接受的)。
清单 15-5 包含两个函数,它们各自执行相同的繁琐任务,应用在清单 15-1 中定义的 f(x),对一系列数字进行操作。这两个函数的区别在于,第一个函数一次性完成所有工作,而第二个函数将范围分成两个部分,并在中间调用 yield()。yield() 函数告诉调度器可以挂起该任务,并从队列中运行下一个任务(如果有任务在等待)。该任务完成后,挂起的任务将恢复运行。
function task_timer(n)
push!(times, (n, time()))
map(f, 100_000:102_000)
push!(times, (n, time()))
end
function task_yield_timer(n)
push!(times, (n, time()))
map(f, 100_000:101_000)
yield()
map(f, 101_000:102_000)
push!(times, (n, time()))
end
清单 15-5:插入让出机会
这些函数假设存在一个名为 times 的全局数组。它们将调用 time() 的结果与整数 n(表示任务标识符)一起放入一个元组,并在任务开始时和返回之前将其放到该数组的末尾。time() 函数返回系统时间(以秒为单位,精度可达到微秒)。其值并不重要,但我们可以通过两次调用 time() 之间的差值,来了解两段代码之间经过了多少时间,这是一个非常准确的衡量中间计算所需时间的方法。
清单 15-6 使用第一个函数生成三个任务,并记录保存的时间,然后使用包含 yield() 调用的修改函数执行相同的操作。
times = []
@sync for n in 1:3
Threads.@spawn task_timer(n)
end
times_noyield = times[:]
times = []
@sync for n in 1:3
Threads.@spawn task_yield_timer(n)
end
times_yield = times[:]
清单 15-6:测试让出的效果
图 15-1 绘制了任务编号与每个线程生成循环开始后的经过时间的关系图,实验是在单个线程上进行的。

图 15-1:协作式和自私任务的时间
从图 15-1 中我们可以看到,每个完整的循环大约需要 5.5 秒。实验中使用让步(圆圈)表明每个任务完成一半循环后,允许队列中的下一个任务运行。直到所有后续任务完成它们的第一半并让步后,它才会恢复。没有让步的实验(六边形)中,每个任务都独占线程直到完成。
在只有一个线程活动时,任务操作的顺序是可以预测的。而且,使用一个线程时,无法通过任务让步或任何任务重排来缩短所有计算完成的时间;我们无法从中获得免费的时间。然而,在较轻任务与耗时任务混合的情况下,允许后者让步将让我们更早获得轻任务的结果,这在某些程序中是非常可取的。当有多个线程可用时,让步可以给调度器一个机会,在各线程之间迁移任务,使它们都保持忙碌,并可能增加总的吞吐量。这个任务重排的过程称为负载均衡。
多处理
如果我们决定沿用 Grace Hopper 在本章开头使用的比喻,我们可以说,前一部分讨论的多线程就像将一队牛车连在一起拉动一大车货物,而本部分讨论的多处理则可以比作将负载分成多个独立的车厢,让每头牛以自己的节奏拉动各自的车厢。
多处理和分布式计算是紧密相关的概念,这两个术语通常可以互换使用。这种计算方式将工作划分为多个拥有自己内存空间的进程。这些进程可以共享单台计算机上的资源,或者在多个联网计算机上共享资源。Julia 的抽象使得我们可以编写一次多进程程序,并在各种环境中运行它。
由于各个进程无法访问相同的内存,它们需要的数据必须被复制并发送给它们,可能通过网络传输。因此,分布式计算最适合处理小数据上的耗时任务,特别是在计算资源通过像互联网这样的慢速网络进行通信时。
在集群上运行使用多处理将工作分配到多个处理器,通常这些处理器通过更高带宽的网络进行通信,再结合前一部分中的多线程技术,以便充分利用每个节点。
多处理基于与前述多线程相同的异步任务概念。它增加了进程的概念以及在多个进程中生成任务的可能性。它允许我们自动或通过控制单个任务来完成这一操作,且程序接口与我们在多线程中探索过的接口类似。
使用 pmap 轻松实现多处理
要以多进程模式启动 Julia REPL 或运行时,请使用-p标志。与-t标志一样,通常最合理的做法是请求与可用硬件线程数相等的进程数。在一台具有两个核心的机器上,使用julia -p2启动 Julia。这将创建两个工作进程,它们可以接收任务。在这种情况下,我们将有三个进程:两个工作进程和执行进程,如果我们是交互式工作,REPL 将在其中运行。我们可以通过自动分配任务给工作进程或指定进程编号来分配任务。
使用-p2标志时,每个进程将是单线程的,每个进程将在双核机器的各自线程上运行。我们还可以使用-p2 -t2标志,这将创建两个工作进程,每个进程可以访问两个线程。这样我们就可以选择在任意进程上生成任务,并且在每个任务中运行多线程或多进程的循环。此时,可能会觉得选项太多,很难决定使用哪种策略。一种合理的方法是,按照下一节描述的机制,在每台远程机器上启动一个工作进程,并使用-t auto标志。这种策略允许每台联网机器使用其所有可用线程进行共享内存并行计算,帮助避免不必要的数据移动。
使用-p标志启动 Julia 会自动执行相当于using Distributed的操作,加载提供多进程工具的标准库包。我们可以通过Distributed提供的nworkers()来获取可用进程的数量。Distributed中一个有用的工具是pmap(),它是map()的分布式版本,如示例 15-7 所示。
➊ julia> @everywhere f(x) = sum([exp(1/i²) for i in 1:x]);
julia> time_serial = @belapsed map(f, 100_000:105_000)
13.934491874
julia> time_mp = @belapsed pmap(f, 100_000:105_000)
7.944081133
示例 15-7:分布式映射
由于每个进程都有自己的内存,我们必须将所有函数定义的副本提供给工作进程。这就是@everywhere宏的作用➊。我们还需要用@everywhere装饰模块导入、常量定义以及工作进程需要使用的其他所有内容。
一旦所有工作进程都有了f()函数的副本,我们就可以使用pmap()重新进行示例 15-1 中的计时测试。它的工作方式类似于Folds.map(),但是它不是通过在当前进程中生成多个线程来协调同步计算,而是在多个进程中生成任务。如果我们按照前面的建议,以物理核心数为工作进程数启动 Julia,通常pmap()启动的每个进程将占用其自己的硬件线程,pmap()将以一种尽量平衡负载的方式将任务分配给进程,因此也会分配给线程。
使用机器文件进行网络连接
Julia 使得在一组网络连接的计算机上进行多进程操作几乎和在单台计算机上操作一样简单。第一步是创建一个文本文件,文件中包含我们希望参与计算的机器的网络地址及其他一些细节。相关机器必须已安装 Julia,并且应包含与我们运行控制程序的路径相同的目录路径。我们需要对每台机器有免密码ssh访问权限。省略一些可选的细节后,机器文件每行包含一台机器,格式如下:
n*host:port
这里,n 是要在主机上启动的工作进程数量,主机可以是控制计算机可以解析的 IP 地址或主机名。:端口部分是可选的,仅在使用非标准ssh端口(22 以外的端口)时需要。
在这个例子中,我将两台计算机放入一个名为machines的机器文件中。以下是完整的文件内容:
2*tc
2*pluton:86
两个主机名通过我在/etc/hosts文件中的条目解析成它们的 IP 地址。我也可以直接使用 IP 地址。名为tc的计算机在我家,而pluton是一台我主要用来提供我为这次练习起草的 Pluto 笔记本的服务器,它距离约 1,200 英里。它监听端口 86 以接收ssh连接,而tc使用标准端口。机器文件指定每台机器将使用两个工作进程。
要启动一个同时使用这些远程资源以及在运行 REPL 的机器上使用两个工作进程的 REPL,我们执行
julia -p2 --machine-file=machines
省略其他选项,例如指定项目目录。
经过一段短暂的延迟后,我们得到了 REPL 提示符。此时,位于两台远程计算机上的 Julia 工作进程正在运行,并等待接收任务。让我们检查一下每台机器是否都在监听:
julia> pmap(_ -> run(`hostname`), 1:6)
From worker 4: tc
From worker 3: sp3
From worker 2: sp3
From worker 5: pluton
From worker 6: pluton
From worker 7: tc
6-element Vector{Base.Process}:
Process(`hostname`, ProcessExited(0))
Process(`hostname`, ProcessExited(0))
Process(`hostname`, ProcessExited(0))
Process(`hostname`, ProcessExited(0))
Process(`hostname`, ProcessExited(0))
Process(`hostname`, ProcessExited(0))
主机sp3是运行 REPL 的笔记本电脑。我们使用pmap()启动六个进程,要求每个进程运行系统命令hostname。并不能保证它们会均等分配,如这个例子中所示,或者每台机器都会接收到任务——但在这个例子中,六个任务已经足够。使用run()可以提供一份报告,标明哪个工作进程 ID 被分配到哪台机器。如果我们只需要来自 shell 命令的输出,可以使用readchomp()代替run()。
工作进程的编号范围从 2 到 7,因为进程 1 是 REPL 进程。我们可以随时获取工作进程的列表,方法是:
julia> workers()
6-element Vector{Int64}:
2
3
4
5
6
7
让我们在我们的三台计算机网络上重复 Listing 15-7 中的计时,如 Listing 15-8 所示。
julia> @belapsed pmap(f, 100_000:105_000)
5.255985404
Listing 15-8: 在一组计算机网络上进行分布式映射
机器pluton和tc每台都有两个 CPU 核心,因此我们已经将可用于计算的核心数增加了三倍。我们确实观察到加速效果,但比在本地机器上执行计算时只快了约 50%。通过互联网计算会产生显著的开销。监视远程机器的 CPU 使用情况时,发现tc的两个 CPU 核心在计算过程中都处于活动状态,利用率约为 70%,而pluton的两个核心几乎处于静止状态。实验中,pluton的 ping 时间约是tc的 50 倍,正如我们根据它们的相对距离所预期的那样。显然,Julia 的调度程序将更多的工作单位分配给了距离较近的计算机,同时等待从远程机器接收响应。
手动使用@spawnat
@spawnat宏与@spawn一样会启动一个异步任务,但它是在工作进程上执行的。我们可以通过使用@spawnat :any来决定哪个进程接收任务,或者通过@spawnat n来指定某个特定进程。该宏是Distributed的一部分,因此如果我们使用-p标志启动 Julia,它将始终可用。
让我们通过使用宏来检查它是否按照预期工作,要求每台机器报告其主机名:
for p in 2:7
@spawnat p @info "Process $(myid()) is running on $(readchomp(`hostname`))"
end
myid()函数返回调用它的进程的进程号。运行程序时,输出如下信息:
From worker 3: Info: Process 3 is running on sp3
From worker 2: [ Info: Process 2 is running on sp3
From worker 4: [ Info: Process 4 is running on tc
From worker 7: [ Info: Process 7 is running on tc
From worker 5: [ Info: Process 5 is running on pluton
From worker 6: [ Info: Process 6 is running on pluton
在[示例 15-8 中,我们观察到在三台计算机的网络上运行pmap()时取得了适度的加速。示例 15-9 展示了如果我们尝试使用手动生成版本的循环时会发生什么情况。
@sync for x in 100_000:105_000
@spawnat :any r = f(x)
end
示例 15-9:启动过多的分布式进程
我们会观察到非常糟糕的性能,甚至比在单个线程上执行计算还要差。这是因为,与pmap()将循环转化为粗粒度并发不同,这个手动多进程的循环会在少数几个进程上启动成千上万的任务。每个任务都需要进行进程间通信来进行管理,这远远超过了并发带来的任何收益。这个情况与示例 15-4 中的版本不同,因为在那个版本中,精细粒度的循环表现与粗粒度的循环一样好,因为在那种情况下,所有计算都在一个进程内完成。创建进程内的任务非常便宜,但进程间通信则不是;因此,@spawnat最好用于少量昂贵的任务,这些任务不需要大量的数据复制。
使用@distributed 进行多进程线程计算
与Threads.@threads宏类似的多进程类比是@distributed宏。前者将一个循环划分为在本地机器或进程的可用线程上执行的粗粒度任务集合,而后者则将一个循环划分为跨进程(可能是在网络中的多台机器)执行的粗粒度任务集合。
示例 15-10 展示了示例 15-2 中使用线程的循环的@distributed版本。
julia> @belapsed @sync @distributed for x in 100_000:105_000
r = f(x)
end
3.668112229
Listing 15-10: 使用 @distributed
我在我的三台机器的小型网络上执行了这个计时测试,每台机器有两个 CPU 核心。这是我们迄今为止在这个循环中获得的最佳时间。我们需要在使用 @distributed 时使用 @sync 宏,而不像 Threads.@threads,它始终会同步。(即使我们没有使用计算结果,省略 @sync 会使计时变得没有意义,因为在这种情况下,循环会在生成任务后立即返回。)
一个常见的模式是将循环每次迭代的结果结合起来,就像我们在 Listing 15-4 中做的那样,使用原子变量。如果我们在 @distributed 宏和 for 关键字之间插入一个函数,该宏将收集每次迭代的结果,使用该函数进行归约,并返回将每个进程的归约结果结合起来的最终结果。由于返回最终结果意味着同步,我们可以在提供归约函数时省略 @sync:
julia> @distributed (+) for x in 100_000:105_000
r = f(x)
end
5.126145395914206e8
该循环等同于
sum(pmap(f, 100_000:105_000))
它还会自动在多个进程之间执行归约操作。
为什么 Listing 15-10 中的循环比 Listing 15-8 中显示的 pmap() 版本更快?两种方法都在相同的机器上执行相同的计算。正如我们所知,当我们通过并发来提升性能时,必须分析程序中的工作负载。此处的循环是对 5,001 次函数评估的处理,这些评估既不复杂,也不非常昂贵(在本地机器上,f(105_000) 需要 2.77 毫秒来评估)。默认情况下,pmap() 为循环的每次迭代生成一个新任务。调度程序将尝试通过将这些任务分配给各种进程来进行负载平衡。通过并发带来的加速部分被调度和进程间通信的开销所抵消。考虑到这些因素,pmap() 在没有额外调优参数的情况下,最适合少量昂贵任务,而这种情况并不适用于本示例。
相比之下,@distributed 循环的粗粒度并发在这种情况下效果很好,因为它处理了大量相对轻量的任务。生成的任务大大减少,更多的计算时间被用于计算,进程间通信和调度开销较少。
在多线程示例中,粗粒度的 Threads.@threads 版本和精细粒度的 Folds.map() 版本之间几乎没有性能差异。这是因为在那种情况下没有进程间通信,生成任务非常快。
我们可以通过 batch_size 关键字参数告诉 pmap() 将循环分解成更大的块:
julia> @belapsed pmap(f, 100_000:105_000; batch_size=1000)
4.370967232
julia> @belapsed pmap(f, 100_000:105_000; batch_size=2501)
3.746921853
batch_size 的默认值为 1,意味着每次迭代会生成一个任务。若 batch_size 为 n,则将循环分割成长度 最多为 n 的片段,并将每个循环片段作为独立任务发送给工作进程。示例表明,我们可以通过将工作分成两半来使 pmap() 达到与 @distributed 循环相似的性能。
Julia 中的并发总结
任何针对大规模高性能计算的程序都可能会利用多进程和多线程的组合。前者允许程序将工作分配到超级计算机集群的节点上,而后者则利用每个节点上的多个核心。因此,Julia 程序通常会使用如 -p、-t 以及 --machine-file 的启动标志组合来运行。
Julia 的抽象使我们能够编写一个版本的程序,该程序可以在单线程上运行得很快,在多核硬件上运行得更快,甚至在计算机网络上运行得更快。然而,为了获得最佳性能,我们无法避免需要仔细考虑程序中的计算模式,并使 Julia 的调度器和操作系统能够最大限度地利用硬件。
表 15-1 是我们在本章中探讨的并行和分布式处理的主要工具的高度简化总结。
表 15-1: 多线程和分布式处理
| 模型 | 线程(共享内存) | 分布式(私有内存) |
|---|---|---|
| 启动 | julia -t n |
julia -p n |
| 循环 | Threads.@threads for |
@distributed for |
| 映射 | Folds.map() |
pmap() |
| 启动任务 | Threads.@spawn |
@spawnat (p 或 :any) |
在调整程序的并行化之前,我们应力求实现最佳的单线程性能,方法是应用前几章中讨论的优化原则。其中最重要的包括类型稳定性、正确的内存访问顺序以及对全局变量的谨慎使用。然而,比这些常见陷阱更为重要的是选择合适的算法,这个话题超出了本书的范围。
结论
Julia 中的并发主题庞大且复杂,单独就能写一本同样大小的书。在掌握本章内容之后,接下来的兴趣点可能是使用 共享数组,这允许使用共享内存进行多进程编程;GPU 编程,它使用图形处理单元作为数组处理器;以及在 Julia 中使用 消息传递接口(MPI) 库,这是 Fortran 程序中用于高性能科学计算的流行工具。“进一步阅读”部分包含了所有这些主题的起点链接。
进一步阅读
-
Folds包可以在https://github.com/JuliaFolds/Folds.jl找到。 -
《Julia 中数据并行的简要介绍》由
Folds.jl的作者 Takafumi Arakaki 撰写,尤其值得关注,因为Folds的文档较少:https://juliafolds.github.io/data-parallelism/tutorials/quick-introduction/。 -
关于 Julia 性能优化的一般技巧,请参考https://docs.julialang.org/en/v1/manual/performance-tips/。
-
有关共享数组的文档,请访问https://docs.julialang.org/en/v1/stdlib/SharedArrays/。
-
JuliaGPU GitHub 组织 (https://juliagpu.org) 为实现或能够利用图形处理单元进行并行化的 Julia 包提供支持。
-
使用 Julia 进行 GPU 编程的示例,请参考https://enccs.se/news/2022/07/julia-for-hpc。
-
JuliaParallel GitHub 组织托管了多个用于 Julia 并行计算的包,包括
MPI包 (https://github.com/JuliaParallel/MPI.jl) 和ClusterManagers包 (https://github.com/JuliaParallel/ClusterManagers.jl),用于管理高性能计算集群上的作业调度器,如 Slurm。


浙公网安备 33010602011771号