Julia 设计模式与最佳实践实用指南(二)
原文:
annas-archive.org/md5/cf7c7ac4f7ce92066baf2d230554da6f译者:飞龙
第四章:宏和元编程技术
本章将讨论 Julia 编程语言中最强大的两个功能:宏和元编程。
简而言之,元编程是一种编写生成代码的代码的技术——这就是为什么它有前缀meta。这可能听起来很神秘,但它是今天许多编程语言中相当常见的实践。例如,C 编译器使用预处理器来读取源代码并生成新的源代码,然后新的源代码被编译成二进制可执行文件。例如,你可以定义一个MAX宏,如下所示#define MAX(a,b) ((a) > (b) ? (a) : (b)),这意味着每次我们使用MAX(a,b)时,它都会被替换为((a) > (b) ? (a) : (b))。请注意,MAX(a,b)比更长的形式更容易阅读。
元编程的历史相当悠久。早在 20 世纪 70 年代,它就已经在 LISP 编程语言社区中流行起来。有趣的是,LISP 语言的设计方式使得源代码的结构类似于数据——例如,LISP 中的函数调用看起来像(sumprod x y z),其中第一个元素是函数的名称,其余的是参数。由于它实际上只是一个包含四个符号的列表——sumprod、x、y和z——我们可以以任何方式操作这段代码——例如,我们可以扩展它以计算数字的和与积,因此生成的代码变为(list (+ x y z) (* x y z))。
你可能会想知道我们是否可以只为这个目的编写一个函数。答案是,是的:在我们刚刚查看的两个例子中,没有必要使用元编程技术。这些例子只是为了说明元编程是如何工作的。一般来说,我们可以这样说,99%的时间不需要元编程;然而,仍然有那剩下的 1%的情况,元编程会非常有用。第一部分将探讨我们想要使用元编程的场景。
在本章中,我们将学习 Julia 语言中的几个元编程功能。特别是以下内容将被涵盖:
-
理解元编程的需求
-
与表达式一起工作
-
开发宏
-
使用生成的函数
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
理解元编程的需求
在本章的开头,我们大胆地宣称 99%的时间不需要元编程。这确实不是一个虚构的数字。在 2019 年的 JuliaCon 会议中,麻省理工学院的史蒂文·约翰逊教授就元编程发表了主题演讲。他对 Julia 语言的源代码进行了一些研究。从他的研究中,Julia 版本 1.1.0 包含了 37,000 个方法,138 个宏(0.4%),以及 14 个生成函数(0.04%)。因此,元编程代码仅占 Julia 自身实现的不到 1%。虽然这只是元编程在一种语言中作用的例子,但它足以说明即使是最高明的软件工程师也不会经常使用元编程。
所以下一个问题是:何时需要使用元编程技术?一般来说,使用这些技术有几个原因:
-
它们可能允许以更简洁和更易于理解的方式表达解决方案。如果不使用元编程编写代码,代码看起来会很丑陋,难以理解。
-
它可能会减少开发时间,因为源代码可以生成而不是手动编写;尤其是样板代码可以被消除。
-
它可能会提高性能,因为代码是直接编写的而不是通过其他高级编程结构(如循环)执行。
我们现在将看看一些在现实世界中如何使用元编程的例子。
使用 @time 宏来衡量性能
Julia 内置了一个有用的宏 @time,它可以测量执行代码所需的时间。例如,为了测量计算一千万个随机数之和所需的时间,我们可以这样做:
宏通过在要测量的代码周围插入代码来工作。生成的代码可能看起来像以下这样:
begin
t1 = now()
result = sum(rand(10_000_000))
t2 = now()
elapsed = t2 - t1
println("It took ", elapsed)
result
end
新代码使用 now() 函数来获取当前时间。然后,它执行用户提供的代码并捕获结果。它再次获取当前时间,计算经过的时间,将计时信息打印到控制台,然后返回结果。
这是否可以不使用元编程来完成?也许我们可以尝试一下。让我们定义一个名为 timeit 的函数,如下所示:
function timeit(func)
t1 = now()
result = func()
t2 = now()
elapsed = t2 - t1
println("It took ", elapsed)
result
end
要使用这个计时功能,我们需要将表达式包裹在一个函数中。
这个函数工作得相当好,但问题是我们在测量其性能之前必须将代码包裹在一个单独的函数中,这是一件非常不方便的事情。正因为如此,我们可以得出结论,拥有一个 @time 宏更为合适。
展开循环
宏的另一个用途是将循环展开成重复的代码片段。循环展开是一种性能优化技术。其背后的前提是执行循环代码总是需要一些开销。原因是,每次迭代完成后,循环必须检查条件并决定是否应该退出或继续下一次迭代。现在,如果我们确切知道循环需要运行多少次代码,那么我们可以通过以重复的方式编写代码来展开它。
考虑一个简单的循环如下:
for i in 1:3
println("hello: ", i)
end
我们可以将循环展开成三行代码,它们执行完全相同的工作:
println("hello: ", 1)
println("hello: ", 2)
println("hello: ", 3)
但手动展开循环将是一项相当无聊和枯燥的任务。此外,工作量会随着循环中所需的迭代次数线性增长。借助Unroll.jl,我们可以使用@unroll宏定义一个函数,如下所示:
using Unrolled
@unroll function hello(xs)
@unroll for i in xs
println("hello: ", i)
end
end
代码看起来像应该的那样干净,@unroll宏被插入到函数以及for循环之前。首先,我们应该检查代码是否正常工作:
现在,我们应该质疑@unroll宏是否真的做了什么。检查循环是否展开的一个好方法是使用@code_lowered宏:
降低后的代码明显包含三个println语句,而不是一个单独的for循环。
什么是降低后的代码?Julia 编译器在将源代码编译成二进制文件之前必须经过一系列的过程。第一步是将代码解析成抽象语法树(AST)格式,我们将在下一节中学习。之后,它通过降低过程来展开宏并将代码转换为具体的执行步骤。
现在我们已经看到了一些示例并了解了元编程的力量,我们将继续学习如何自己创建这些宏。
处理表达式
Julia 将任何可运行的程序的源代码表示为树结构。这被称为抽象语法树(AST)。它被称为抽象的,因为树只捕获代码的结构而不是真正的语法。
例如,表达式x + y可以用一个树来表示,其中父节点标识自己为函数调用,子节点包括运算符函数+和x、y参数。以下是其实现:
略微复杂一些的表达式x + 2y + 1看起来如下所示。虽然它使用了两个加法运算符,但表达式被解析为对+函数的单个函数调用,它接受三个参数——x、2y和1。因为2y本身也是一个表达式,它可以看作是主抽象语法树的子树:
Julia 编译器必须首先将源代码解析成抽象语法树,然后才能执行额外的转换和分析,例如宏展开、类型检查、类型推断,最终将代码转换成机器码。
尝试解析器
因为抽象语法树只是一个数据结构,我们可以在 Julia 的 REPL 环境中直接检查它。让我们从一个简单的表达式开始:x + y:
在 Julia 中,每个表达式都表示为一个Expr对象。我们可以通过使用Meta.parse函数解析一个字符串来创建一个Expr对象。
这里,表达式对象以类似于原始源代码的语法显示,以便更容易阅读。我们可以确认该对象具有Expr类型如下:
为了查看抽象语法树,我们可以使用dump函数来打印结构:
在 Julia 中,每个表达式都由一个头节点和参数数组表示。
在这种情况下,头节点只包含一个call符号。args数组包含+运算符和两个变量,x和y。请注意,这里的一切都是一个符号——这是可以的,因为我们正在检查源代码本身,它本质上只是一个符号的树。
由于我们在这里玩得很开心,让我们尝试几个其他的表达式。
单变量表达式
其中一个最简单的表达式只是一个变量的引用。你可以尝试解析一个数字或字符串字面量,看看它返回什么:
带关键字参数的函数调用
让我们尝试一个稍微复杂一些的例子。我们将检查一个函数调用,它接受一个位置参数和两个关键字参数。在这里,我们使用三引号包围代码,以便正确处理其中的双引号:
注意,函数调用以call符号作为表达式的头节点。此外,关键字参数表示为子表达式,每个子表达式都有一个头节点kw和一个包含参数名称和值的两个元素数组。
嵌套函数
我们可能会好奇当函数嵌套时,Julia 是如何解析代码的。这里我们可以选择一个简单的例子,它计算x+1的正弦值,然后计算结果的余弦值。抽象语法树如下所示:
这里,我们可以清楚地看到树结构。最外层的函数cos包含一个参数,它是一个调用sin函数的表达式节点。这个表达式反过来包含一个参数,它是一个调用带有两个参数的+运算符函数的表达式节点——x变量和值为1。现在,让我们继续我们的表达式工作。
手动构建表达式对象
由于表达式只是一个数据结构,我们可以很容易地通过编程方式构建它们。理解如何做到这一点对于元编程至关重要,元编程涉及在运行时创建新的代码结构。
Expr构造函数有以下签名:
Expr(head::Symbol, args...)
头部节点总是携带一个符号。参数只包含头部节点期望的内容——例如,简单的表达式x + y可以创建如下:
当然,如果我们想的话,我们总是可以创建一个嵌套表达式:
到这个时候,你可能想知道是否有更简单的方法来创建表达式,而无需手动构建Expr对象。当然,可以像下面这样做到:
基本上,我们可以用左边的:(和右边的)将任何表达式包裹起来。代码块内的代码将不会被评估,而是被解析为一个表达式对象;然而,这种引用方式只适用于单个表达式——如果你尝试用多个表达式这样做,将会显示错误,如下面的代码所示:
这是不行的,因为多个表达式应该用begin和end关键字包裹。所以如果我们输入以下代码块会更好:
结果有点有趣。正如你所见,代码现在被包裹在一个quote/end块中,而不是begin/end块中。这实际上是有道理的,因为显示的是引用的表达式而不是原始源代码。记住,这是抽象语法树而不是原始代码。
结果表明quote/end可以直接用来创建表达式:
我们现在已经学会了如何将源代码解析为表达式对象。接下来,我们将探讨更复杂的表达式,以便我们更熟悉 Julia 程序的基本代码结构。
玩转更复杂的表达式
正如我们之前所说的,任何有效的 Julia 程序都可以表示为一个抽象语法树。现在我们已经有了创建表达式对象的构建块,让我们考察一些更多的结构,看看更复杂程序的表达式对象是什么样的。
赋值
我们首先看看赋值是如何工作的。考虑以下代码:
从前面的代码中,我们可以看到变量赋值有一个=的头部节点和两个参数——要赋值的变量(在这个例子中是x)和另一个表达式对象。
代码块
代码块由begin和end关键字包围。让我们看看抽象语法树是什么样子的。
头节点只包含一个 block 符号。当块中有多行时,抽象语法树也包括行号节点。在这个例子中,有一个 LineNumberNode 在 println 的第一次调用之前,行号为 2。同样,还有一个 LineNumberNode 在 println 的第二次调用之前,行号为 3。LineNumberNode 节点不做任何事情,但它们对于堆栈跟踪和调试很有用。
条件
接下来,我们将探索条件结构,如 if-else-end。参考以下代码:
头节点包含 if 符号。有三个参数——一个表示条件的表达式,一个当条件满足时的块表达式,以及一个当条件不满足时的另一个块表达式。
循环
我们现在将转向循环结构。考虑一个简单的 for 循环,如下所示:
头节点包含 for 符号。有两个参数:第一个包含关于循环的表达式,第二个包含一个块表达式。
函数定义
接下来,我们将看到函数定义的结构。考虑以下代码:
头节点包含 function 符号。然后,第一个参数包含一个带有参数的 call 表达式。第二个参数只是一个块表达式。
调用表达式可能看起来有点奇怪,因为我们之前在函数被调用时见过类似的表达式对象。这是正常的,因为我们目前处于语法层面。函数定义的语法确实与函数调用本身非常相似。
到现在为止,我们已经看到了足够的例子。显然,还有许多我们没有探索的代码结构。我们鼓励您使用相同的技巧来检查其他代码结构。理解抽象语法树的结构对于编写良好的元编程代码至关重要。接下来,我们将看到如何评估这些表达式。
评估表达式
我们已经详细地探讨了创建表达式对象的过程。但它们有什么用呢?记住,表达式对象只是 Julia 程序的抽象语法树表示。在这个阶段,我们可以要求编译器继续将表达式转换为可执行代码,然后运行程序。
表达式对象可以通过调用 eval 函数来评估。本质上,Julia 编译器将完成剩余的编译过程并运行程序。现在,让我们启动一个新的、全新的 REPL 并运行以下代码:
显然,这只是一个简单的赋值操作。我们可以看到,x 变量现在在当前环境中被定义了:
注意,表达式的评估实际上是在全局范围内进行的。我们可以通过在函数内部运行 eval 来证明这一点:
这不是一项无关紧要的观察!乍一看,我们可能预计 y 变量将在 foo 函数内部被分配;然而,变量分配实际上是在全局范围内发生的,因此 y 变量作为副作用在当前环境中被定义。
更确切地说,表达式是在当前模块中评估的。由于我们在 REPL 中进行测试,评估是在名为 Main 的当前模块中完成的。表达式被设计成这样,因为 eval 通常用于代码生成,这在定义模块内的变量或函数时可能很有用。
接下来,我们将学习如何更轻松地创建表达式对象。
表达式中的变量插值
从引号块中构造表达式非常简单。但如果我们想动态创建表达式怎么办?这可以通过 插值 实现,它允许我们使用简单的语法将变量值插入到表达式对象中。表达式中的插值与变量可以在字符串中插值的方式非常相似。下面的截图显示了示例:
如预期的那样,2 的值在表达式中被正确替换。请注意,splatting 也得到了支持,如下所示:
我们必须确保包含散列操作符的变量在这种情况下被插值。如果我们忘记在 v... 周围放置括号,那么我们会得到一个非常不同的结果:
在这里,散列操作实际上并没有在表达式插值过程中发生。相反,散列操作符现在成为表达式的一部分,因此散列操作将不会发生,直到表达式被评估。
在如 $v... 这样的表达式中,优先级顺序有些不清楚。v 变量是在散列操作之前还是之后绑定到插值操作的?在这种情况下,最好在我们想要插值的内容周围使用括号。因为我们希望插值完全发生,语法应该是 $(v...)。在需要运行时进行散列操作的情况下,我们可以写成 $(v)...。
插值是编写宏的重要概念。我们将在本章后面看到更多关于它的用法。接下来,我们将看到如何处理具有符号值的表达式。
使用 QuoteNode 为符号构造表达式
符号在表达式中出现时非常特殊。它们可能出现在表达式对象的头部节点中——例如,变量赋值表达式中的 = 符号。它们也可能出现在表达式对象的参数中,在这种情况下,它们将代表一个变量:
由于符号已经用来表示变量,我们如何将一个实际的符号赋给一个变量呢?为了弄清楚这是如何工作的,我们可以使用我们迄今为止学到的一个技巧——使用 dump 函数来检查表达式对象中的此类语句:
正如我们所见,一个实际的符号必须被包含在 QuoteNode 对象中。现在我们知道了需要什么,我们应该尝试将一个实际的符号插值到表达式对象中。实现这一目标的方法是手动创建一个 QuoteNode 对象,并像往常一样使用插值技术:
一个常见的错误是忘记创建 QuoteNode。在这种情况下,表达式对象将错误地解释符号,并将其视为变量引用。显然,结果非常不同,并且它将无法正常工作:
不使用 QuoteNode 会生成将一个变量的值赋给另一个变量的代码。在这种情况下,变量 x 将被赋予来自变量 hello 的一个值。
理解 QuoteNode 的工作原理对于动态创建表达式至关重要。程序员将符号插值到现有表达式中是很常见的。因此,接下来我们将探讨如何处理嵌套表达式。
在嵌套表达式中进行插值
有可能存在一个包含另一个引用表达式的引用表达式。除非程序员需要编写元元程序,否则这不是一个常见的做法。尽管如此,我们仍然应该了解如何在这样的情况下进行插值。
首先,让我们回顾一下单层表达式的样子:
我们可以通过将引用表达式包裹在另一个引用块中来查看嵌套表达式的结构:
现在,让我们尝试在这样的表达式中进行插值:
正如我们所见,2 值并没有进入表达式。表达式的结构也完全不同于我们预期的。解决方案是只需通过使用两个 $ 符号将变量插值两次:
通常,插值超过一层深度可能不是很有趣,因为逻辑变得难以处理。然而,如果你需要为宏生成代码,这可能是有用的。我绝对不建议你超过两层深度并编写元元元程序!
到现在为止,你应该对表达式更加熟悉,并且能够舒适地与之工作。从 Julia 的 REPL 中,很容易看到表达式是如何作为Expr对象来表示的结构的。你应该能够构造新的表达式并在其中插值值;这些是进行元编程所必需的基本技能。
在下一节中,我们将探讨 Julia 中一个强大的元编程特性——宏。
开发宏
现在我们已经理解了源代码是如何表示为抽象语法树的,我们可以通过编写宏来开始做一些更有趣的事情。在本节中,我们将学习宏是什么以及如何与之工作。
宏是什么?
宏是接受表达式、操作它们并返回新表达式的函数。这最好通过一个图表来理解:
如我们所知,表达式只是源代码的抽象语法树表示。因此,Julia 中的宏功能允许你取任何源代码并生成新的源代码。然后,生成的表达式就像源代码直接在原地编写一样被执行。
在这一点上,你可能想知道为什么我们不能使用常规函数来实现相同的事情。为什么我们不能编写一个接受表达式、生成新表达式然后执行结果的函数?
有两个主要原因:
-
宏扩展发生在编译期间。这意味着宏只从它被使用的地方执行一次——例如,当宏从一个函数中调用时,宏是在函数定义时执行的,以便函数可以被编译。
-
宏生成的表达式可以在当前作用域内执行。在运行时,由于函数本身已经编译,没有其他方法可以在函数内部执行任何动态代码。所以,评估任何表达式的唯一方法是在全局作用域内进行。
到本章结束时,你应该对宏的工作原理以及它们与函数的不同之处有更好的理解。
既然我们现在已经理解了宏是什么,我们将继续我们的旅程,编写我们的第一个宏。
编写我们的第一个宏
宏的定义方式与函数的定义方式类似,只是使用macro关键字而不是function关键字。
我们还应该记住,一个宏必须返回表达式。让我们创建我们的第一个宏。这个宏返回一个包含for循环的表达式对象,如下所示:
macro hello()
return :(
for i in 1:3
println("hello world")
end
)
end
调用宏就像用@前缀调用它一样简单。请参考以下代码:
与函数不同,宏可以在不使用括号的情况下调用。所以我们可以这样做:
太棒了! 我们现在已经编写了我们的第一个宏。虽然它看起来并不非常令人兴奋,因为生成的代码只是一段静态的代码,但我们已经学会了如何定义宏并运行它们。
接下来,我们将学习如何向宏传递参数。
传递字面量参数
就像函数一样,宏也可以接受参数。实际上,接受参数是宏最常见的用法。最简单的参数类型是字面量,例如数字、符号和字符串。
为了在返回的表达式中利用这些参数,我们可以使用我们在上一节中学到的插值技术。考虑以下代码:
macro hello(n)
return :(
for i in 1:$n
println("hello world")
end
)
end
hello宏接受一个参数,n,当宏运行时,这个参数会被插入到表达式中。像之前一样,我们可以这样调用宏:
如我们之前所学的,括号不是必需的,因此我们也可以这样调用宏:
你可以用字符串或符号参数尝试类似的练习。传递字面量很容易理解,因为它与函数的工作方式相同。但宏和函数之间确实存在细微的差别,我们将在下一节中详细讨论。
传递表达式参数
重要的是要强调,宏参数是以表达式而不是值的形式传递的。对于初学者来说,这可能会看起来有些混乱,因为宏的调用方式与函数相似,但行为完全不同。
让我们确保我们完全理解这意味着什么。当用一个变量调用一个函数时,变量的值会被传递到函数中。考虑以下showme函数的示例代码:
现在,让我们创建一个@showme宏,它除了在控制台显示参数外,不做任何事情。然后我们可以将结果与前面的代码进行比较:
如我们所见,运行宏的结果与调用函数得到的结果完全不同。函数参数x实际上只看到了宏被调用处的表达式。从本节开头的图中,我们可以看到宏应该接受表达式并返回一个单一的表达式作为结果。它们在语法层面上工作,不知道参数的值。
如我们在下一节中将要看到的,当宏运行时,表达式甚至可以被操作。让我们开始吧!
理解宏展开过程
按照惯例,每个宏都必须返回一个表达式。从一个或多个表达式取值并返回一个新的表达式的过程被称为宏展开。有时,看到返回的表达式而不实际运行代码是有帮助的。我们可以使用@macroexpand宏来达到这个目的。让我们尝试使用它来处理我们在这节中之前定义的@hello宏:
从这个输出中,有几个需要注意的事项:
-
i变量被非常奇怪地重命名为#67#i。这是 Julia 编译器为了确保卫生性而做的,我们将在本章后面讨论。宏的卫生性是一个需要记住的重要特性,以确保生成的代码不会与其他代码冲突。 -
在包含源文件和行号信息的循环中插入了一条注释。当使用调试器时,这是表达式的一个有用部分。
-
对
println的函数调用绑定到当前环境中的Main。这很有意义,因为println是Core包的一部分,并且对于每个 Julia 程序都会自动引入作用域。
因此,宏展开何时发生?让我们接下来讨论这个问题。
宏展开的时机
在 REPL 中,任何宏在我们调用它时都会立即展开。有趣的是,当定义包含宏的函数时,宏作为函数定义过程的一部分被展开。
我们可以通过开发一个简单的返回传递给它的任何表达式的@identity宏来看到这一点。在表达式返回之前,我们只是将对象dump到屏幕上。@identity宏的代码如下:
macro identity(ex)
dump(ex)
return ex
end
由于这个宏返回了传递给它的相同的表达式,它最终应该执行宏后面的原始源代码。
现在,让我们定义一个使用@identity宏的函数:
显然,编译器已经发现宏被用于foo函数的定义中,为了编译foo函数,它必须理解@identity宏的作用。因此,它展开了宏,并将其嵌入到函数定义中。在宏展开过程中,表达式被显示出来。
如果我们对foo函数使用@code_lowered宏,我们可以看到展开的代码现在位于foo函数的主体中:
在开发过程中,程序员可能会频繁地更改函数、宏等的定义。因为宏在定义函数时会被展开,所以如果使用的任何宏被更改,重新定义函数就很重要;否则,函数可能会继续使用先前宏定义生成的代码。
@macroexpand实用工具是开发宏不可或缺的工具,特别是对于调试目的非常有用。
接下来,我们将尝试通过在宏中操作表达式来更加有创意。
操作表达式
宏之所以强大,是因为它们允许在宏展开过程中操作表达式。这是一种非常有用的技术,尤其是在代码生成和设计领域特定语言时。让我们通过一些示例来了解可能实现的内容。
示例 1 – 创建新的表达式
让我们从简单的开始。假设我们想要创建一个名为 @squared 的宏,它接受一个表达式并将其平方。换句话说,如果我们运行 @squared(x),那么它应该被翻译成 x * x:
macro squared(ex)
return :($(ex) * $(ex))
end
初看起来,当我们从 REPL 运行它时,它似乎工作得很好:
但这个宏在执行上下文方面存在问题。最好的说明问题的方式是定义一个使用该宏的函数。所以让我们定义一个 foo 函数,如下所示:
function foo()
x = 2
return @squared x
end
现在,当我们调用该函数时,我们得到以下错误:
为什么会这样?这是因为,在宏展开期间,x 符号指的是模块中的变量,而不是 foo 函数中的局部变量。我们可以通过使用 @code_lowered 宏来确认这一点:
显然,我们的意图是平方局部 x 变量,而不是 Main.x。解决这个问题的一个简单方法是,在插值时使用 esc 函数,以便将表达式直接放入语法树中,而不让编译器解析它。以下是如何做到这一点的方法:
macro squared(ex)
return :($(esc(ex)) * $(esc(ex)))
end
由于宏是在 foo 定义之前展开的,我们需要再次定义 foo 函数,如下所示,以便这个更新的宏生效。或者,您也可以启动一个新的 REPL,并再次定义 @squared 宏和 foo 函数。下面是操作步骤:
现在 foo 函数工作正常了。
从这个例子中,我们学习了如何使用插值技术创建新的表达式。我们还了解到,插值变量需要使用 esc 函数进行转义,以避免编译器将其解析为全局作用域。
示例 2 - 调整抽象语法树
假设我们想要设计一个名为 @compose_twice 的宏,它接受一个简单的函数调用表达式,并再次以结果调用相同的函数——例如,如果我们运行 @compose_twice sin(x),那么它应该被翻译成 sin(sin(x))。
在我们编写宏之前,让我们首先熟悉一下表达式的抽象语法树:
sin(sin(x)) 的样子如何?请参考以下内容:
没有惊喜。顶层调用的第二个参数只是另一个看起来与我们之前看到的样子一样的表达式。
我们可以按照以下方式编写宏:
macro compose_twice(ex)
@assert ex.head == :call
@assert length(ex.args) == 2
me = copy(ex)
ex.args[2] = me
return ex
end
前两个 @assert 语句用于确保表达式代表一个接受单个参数的函数调用。由于我们想用类似的表达式替换参数,我们只需复制当前表达式对象并将其分配给 ex.args[2]。然后宏返回用于评估的结果表达式。
我们可以验证宏是否工作正常:
如你所见,我们可以通过直接操作抽象语法树来转换源代码,而不是将变量插值到看起来很棒的表达式中。
到现在为止,你可能已经体会到了元编程的强大之处。与使用插值相比,直接操作表达式并不容易理解,因为生成的表达式并没有在代码中表示;然而,操作表达式的能力为转换源代码提供了最大的灵活性。
接下来,我们将介绍元编程的一个重要特性——宏的卫生性。
理解宏的卫生性
宏的卫生性指的是保持宏生成代码清洁的能力。它被称为卫生性,因为生成的代码不会受到其他代码部分的影响。
注意到许多其他编程语言并不提供这样的保证。以下是一个包含名为SWAP的宏的 C 程序,该宏用于交换两个变量的值:
#include <stdio.h>
#define SWAP(a,b) temp=a; a=b; b=temp;
int main(int argc, char *argv[])
{
int a = 1;
int temp = 2;
SWAP(a,temp);
printf("a=%d, temp=%d\n", a, temp);
}
然而,运行这个 C 程序会产生一个错误的结果:
它没有正确地交换a和temp变量,因为temp变量也在宏的主体中用作临时变量。
让我们回到 Julia。考虑以下宏,它只是运行一个ex表达式,并重复n次:
macro ntimes(n, ex)
quote
times = $(esc(n))
for i in 1:times
$(esc(ex))
end
end
end
由于times变量用于返回的表达式中,如果调用点已经使用了相同的变量名,会发生什么?让我们尝试以下示例代码,它在宏调用之前定义了一个times变量,并在宏调用之后打印相同变量的值:
function foo()
times = 0
@ntimes 3 println("hello world")
println("times = ", times)
end
如果宏展开器将其字面地处理,那么在宏调用之后times变量将被修改为3;然而,我们可以在以下代码中看到它正常工作:
它之所以能工作,是因为宏系统能够通过将times变量重命名为不同的名称来保持卫生性,从而避免冲突。魔法在哪里?让我们通过使用@macroexpand查看展开的代码:
在这里,我们可以看到times变量已经变成了#44#times。循环变量i也变成了#45#i。这些变量名是由编译器动态生成的,以确保宏生成的代码不会与其他用户编写的代码冲突。
宏的卫生性是宏正确运行的一个基本特性。程序员不需要做任何事情:Julia 自动提供保证。
接下来,我们将探讨一种不同类型的宏,它为非标准字符串字面量提供动力。
开发非标准字符串字面量
有一种特殊的宏用于定义非标准字符串字面量,它们看起来像字面量字符串,但在引用时实际上会调用一个宏。
一个好例子是 Julia 的正则表达式字面量——例如,r"^hello"。由于双引号前的 r 前缀,它不是一个标准的字符串字面量。让我们首先检查这种字面量的数据类型。我们可以看到,从字符串创建了一个 Regex 对象:
我们还可以创建自己的非标准字符串字面量。让我们在这里一起尝试一个有趣的例子。
假设,为了开发目的,我们想要方便地创建具有不同类型列的样本数据帧。这样做的方法有点繁琐:
想象一下,我们偶尔需要创建具有不同数据类型的数十列。创建此类数据帧的代码会非常长,作为一个程序员,我会在输入所有这些时感到极其无聊。因此,我们可以设计一个字符串字面量,使其包含构建此类数据帧的规范——让我们称它为 ndf(数值数据帧)字面量。
ndf 的规范只需编码所需的行数和列类型。例如,字面量 ndf"100000:f64,i16" 可以用来表示前面的样本数据帧,其中需要 100,000 行,有两列分别标记为 Float64 和 Int16 列。
要实现这个功能,我们只需定义一个名为 @ndf_str 的宏。该宏接受一个字符串字面量并相应地创建所需的数据帧。以下是一种实现宏的方法:
macro ndf_str(s)
nstr, spec = split(s, ":")
n = parse(Int, nstr) # number of rows
types = split(spec, ",") # column type specifications
num_columns = length(types)
mappings = Dict(
"f64"=>Float64, "f32"=>Float32,
"i64"=>Int64, "i32"=>Int32, "i16"=>Int16, "i8"=>Int8)
column_types = [mappings[t] for t in types]
column_names = [Symbol("x$i") for i in 1:num_columns]
DataFrame([column_names[i] => rand(column_types[i], n)
for i in 1:num_columns]...)
end
前几行解析字符串并确定行数(n),以及列的类型(types)。然后,创建一个名为 mappings 的字典来将缩写映射到相应的数值类型。列名和类型从类型和映射数据生成。最后,它调用 DataFrame 构造函数并返回结果。
现在我们已经定义了宏,我们可以轻松地创建新的数据帧,如下所示:
非标准字符串字面量在特定情况下非常有用。我们可以将字符串规范视为编码在字符串中的迷你领域特定语言。只要字符串规范定义良好,它可以使代码更短、更简洁。
你可能已经注意到,ndf_str 宏返回一个常规的 DataFrame 对象,而不是通常宏会返回的表达式对象。这是完全可以的,因为最终的 DataFrame 对象将按原样返回。你可能认为常量的评估只是常量本身。我们在这里可以只返回一个值而不是表达式,因为返回的值不涉及调用点或模块中的任何变量。
一个好奇的头脑可能会问——为什么我们不能只为这个例子创建一个常规函数呢?我们当然可以为此虚拟示例做这件事。然而,使用字符串字面量在某些情况下可能会提高性能。
例如,当我们在一个函数中使用正则表达式字符串字面量时,Regex对象是在编译时创建的,因此它只执行一次。如果我们使用Regex构造函数,那么对象会在每次函数调用时创建。
我们现在已经结束了关于宏的主题。我们学习了如何通过取表达式并生成一个新的表达式来创建宏。我们使用了@macroexpand宏来调试宏展开过程。我们还学习了如何处理宏的卫生问题。最后,我们查看了一下非标准字符串字面量,并使用宏创建了我们的自定义字符串字面量。
接下来,我们将探讨另一个元编程功能,称为生成函数,它可以用来解决常规宏无法处理的不同类型的问题。
使用生成函数
到目前为止,我们已经解释了如何创建返回表达式对象的宏。由于宏在语法级别工作,它们只能通过检查代码的外观来操作代码。然而,Julia 是一个在运行时确定数据类型的动态系统。因此,Julia 提供了创建生成函数的能力,这允许你检查函数调用的数据类型并返回一个表达式,就像宏一样。当表达式返回时,它将在调用位置被评估。
要理解为什么需要生成函数,让我们回顾一下宏是如何工作的。假设我们创建了一个宏,它将它的参数值加倍。它看起来会像以下这样:
macro doubled(ex)
return :( 2 * $(esc(ex)))
end
无论我们传递给这个宏的什么表达式,它都会盲目地重写代码,使其加倍原始表达式。假设有一天,开发了一个超级无敌的软件,可以让我们快速计算浮点数的两倍。在这种情况下,我们可能希望系统只为浮点数切换到该函数,而不是使用标准的乘法运算符。
因此,我们的第一次尝试可能是尝试以下内容:
# This code does not work. Don't try it.
macro doubled(ex)
if typeof(ex) isa AbstractFloat
return :( double_super_duper($(esc(ex))) )
else
return :( 2 * $(esc(ex)))
end
end
但不幸的是,宏无法做到这一点。为什么?再次,宏只能访问抽象语法树。这是编译管道的早期部分,没有类型信息可用。前面代码中的ex变量仅仅是一个表达式对象。这个问题可以通过生成函数来解决。继续阅读!
定义生成函数
生成函数是在函数定义前带有@generated前缀的函数。这些函数可以返回表达式对象,就像宏一样。例如,我们可以定义doubled函数如下:
@generated function doubled(x)
return :( 2 * x )
end
让我们快速运行一个测试,确保它工作:
代码运行得非常完美,正如预期的那样。
因此,定义生成函数与定义宏非常相似。在两种情况下,我们都可以创建一个表达式对象并返回它,并且我们可以期望表达式被正确评估。
然而,我们还没有充分发挥生成函数的全部威力。接下来,我们将探讨如何使数据类型信息可用,以及如何在生成函数中使用它。
检查生成函数参数
需要记住的一个重要观点是,生成函数的参数包含数据类型,而不是实际值。以下是如何生成函数工作的视觉表示:
这与接受作为值的参数的函数形成鲜明对比。它也与接受作为表达式的参数的宏不同。在这里,生成函数接受参数作为数据类型。这可能会显得有些奇怪,但让我们做一个简单的实验来确认这确实如此。
对于这个实验,我们将再次通过在返回表达式之前在屏幕上显示参数来定义doubled函数。
@generated function doubled(x)
@show x
return :( 2 * x )
end
让我们再次测试这个函数。
如所示,在生成函数执行过程中,参数x的值是Int64而不是2。此外,当函数再次被调用时,它不再显示x的值。这是因为函数现在在第一次调用后被编译。
现在,让我们看看如果我们用不同的类型再次运行会发生什么:
编译器再次启动,并基于Float64的类型编译了一个新版本。从技术上讲,我们现在为每种参数类型都有两个版本的doubled函数。
您可能已经意识到,生成函数在专业化方面与常规函数的行为相似。区别在于我们有在编译发生之前操作抽象语法树的机会。
使用这个新的生成函数,我们现在可以利用假设的超级软件,通过切换到更快的double_super_duper函数来利用它,只要参数的数据类型是AbstractFloat的子类型,如下面的代码所示:
@generated function doubled(x)
if x <: AbstractFloat
return :( double_super_duper(x) )
else
return :( 2 * x )
end
end
使用生成函数,我们可以根据参数的类型来专门化函数。当类型是AbstractFloat时,函数将回退到double_super_duper(x)而不是2 *x表达式。
如官方 Julia 语言参考手册中所述,在开发生成函数时必须谨慎。确切的限制超出了本书的范围。如果您需要为您的软件编写生成函数,强烈建议您查阅手册。
生成函数是处理宏无法处理的案例的有用工具。具体来说,在宏展开过程中,没有关于参数类型的信息。生成函数使我们能够更接近编译过程的核心。有了关于参数类型的额外知识,我们在处理不同情况时更加灵活。
作为元编程工具,宏的使用比生成函数广泛得多。然而,了解这两种工具都可用是很好的。
摘要
在本章中,我们学习了 Julia 如何将表达式解析为抽象语法树结构。我们了解到表达式可以通过编程方式创建和评估。我们还学习了如何将变量插入到引号表达式。
然后,我们转向了宏的主题,宏用于动态创建新代码。我们了解到宏参数是表达式而不是值,并学习了如何从宏中创建新表达式。我们享受着创建宏来操作抽象语法树以处理一些有趣的用例。
最后,我们探讨了生成函数,这些函数可以根据函数参数的类型生成代码。我们学习了生成函数在假设用例中的有用之处。
现在我们已经完成了关于 Julia 编程语言入门部分的书籍。在下一章,我们将开始探讨与代码重用相关的设计模式。
问题
-
我们可以使用哪两种方式来引用表达式,以便稍后可以操作代码?
-
在什么环境下
eval函数执行代码? -
你如何将物理符号插入到引号表达式中,以便它们不会被误解释为源代码?
-
定义非标准字符串字面量的宏的命名约定是什么?
-
你在什么时候使用
esc函数? -
生成函数与宏有什么不同?
-
你如何调试宏?
第三部分:实现设计模式
本节的目标是向您提供一个现代 Julia 特定设计模式以及更传统的面向对象模式的清单。您将学习如何将这些模式应用于各种问题。
本节包含以下章节:
-
第五章,可复用模式
-
第六章,性能模式
-
第七章,可维护性模式
-
第八章,健壮性模式
-
第九章,杂项模式
-
第十章,反模式
-
第十一章,传统面向对象模式
第五章:可复用性模式
在本章中,我们将学习与软件可复用性相关的几种模式。如您从第一章,“设计模式及相关原则”中回忆起,可复用性是构建大型应用程序所需的四个软件质量目标之一。没有人愿意重新发明轮子。能够复用现有的软件组件可以节省时间和精力——这是一项整体的人类进步!本章中的模式是经过验证的技术,可以帮助我们改进应用程序设计,复用现有代码,并减少整体代码量。
在本章中,我们将涵盖以下主题:
-
委派模式
-
神圣特质模式
-
参数化类型模式
技术要求
本章中的代码已在 Julia 1.3.0 环境中进行了测试。
委派模式
委派是一种在软件工程中常用的模式。其主要目标是利用现有组件的能力,通过“包含”关系对其进行包装。
委派模式在面向对象编程社区中得到广泛应用。在面向对象编程的早期,人们认为可以通过继承来实现代码复用。然而,人们逐渐意识到,由于继承存在各种问题,这一承诺无法完全实现。从那时起,许多软件工程师更倾向于使用组合而不是继承。组合的概念是将一个对象包装在另一个对象中。为了复用现有函数,我们必须将函数调用委托给包装对象。本节将解释如何在 Julia 中实现委派。
组合的概念是将一个对象包装在另一个对象中。为了复用现有函数,我们必须将函数调用委托给包装对象。
一种方法是通过添加新功能来增强现有组件。这听起来不错,但在实践中可能会具有挑战性。 考虑以下情况:
-
现有的组件来自供应商产品,源代码不可用。即使代码可用,供应商的许可证可能不允许我们进行自定义更改。
-
现有的组件是由另一个团队为关键任务系统开发和使用的,对该系统来说,更改既不受欢迎也不适用。
-
现有的组件包含大量遗留代码,新的更改可能会影响组件的稳定性,并需要大量的测试工作。
如果修改现有组件的源代码不是一个选择,那么我们至少应该能够通过其发布的编程接口使用该组件。这就是委托模式的价值所在。
将委托模式应用于银行用例
委托模式的想法是通过包装一个称为 父对象 的现有对象来创建一个新的对象。为了重用对象的功能,为新对象定义的函数可以被委托(也称为转发)到父对象。
假设我们有一个提供一些基本账户管理功能的银行库。为了理解它是如何工作的,让我们看看源代码。
银行账户已经设计了一个以下可变数据结构:
mutable struct Account
account_number::String
balance::Float64
date_opened::Date
end
作为编程接口的一部分,库还提供了字段访问器(见第八章,鲁棒性模式)以及进行存款、取款和转账的函数,如下所示:
# Accessors
account_number(a::Account) = a.account_number
balance(a::Account) = a.balance
date_opened(a::Account) = a.date_opened
# Functions
function deposit!(a::Account, amount::Real)
a.balance += amount
return a.balance
end
function withdraw!(a::Account, amount::Real)
a.balance -= amount
return a.balance
end
function transfer!(from::Account, to::Account, amount::Real)
withdraw!(from, amount)
deposit!(to, amount)
return amount
end
当然,在实际应用中,这样的银行库要比这里看到的复杂得多。我怀疑当钱进出银行账户时,会有许多下游影响,例如记录审计跟踪、在网站上提供新的余额、向客户发送电子邮件等等。
让我们继续学习如何利用委托模式。
组合一个包含现有类型的新类型
作为一项新举措的一部分,银行希望我们支持一种新的储蓄账户产品,该产品为顾客提供每日利息。由于现有的账户管理功能对银行业务至关重要,并且由不同的团队维护,我们决定在不修改任何现有源代码的情况下重用其功能。
首先,让我们创建自己的 SavingsAccount 数据类型,如下所示:
struct SavingsAccount
acct::Account
interest_rate::Float64
SavingsAccount(account_number, balance, date_opened, interest_rate) = new(
Account(account_number, balance, date_opened),
interest_rate
)
end
第一个字段 acct 用于存储一个 Account 对象,而第二个字段 interest_rate 包含账户的年利率。还定义了一个构造函数来实例化对象。
为了使用底层的 Account 对象,我们可以使用一种称为 委托 或 方法转发 的技术。这就是我们在 SavingsAccount 中实现相同的 API,并在想要重用底层对象中现有函数时,将调用转发到底层 Account 对象。在这种情况下,我们可以简单地转发 Account 对象的所有字段访问器函数和修改函数,如下所示:
# Forward assessors
account_number(sa::SavingsAccount) = account_number(sa.acct)
balance(sa::SavingsAccount) = balance(sa.acct)
date_opened(sa::SavingsAccount) = date_opened(sa.acct)
# Forward methods
deposit!(sa::SavingsAccount, amount::Real) = deposit!(sa.acct, amount)
withdraw!(sa::SavingsAccount, amount::Real) = withdraw!(sa.acct, amount)
transfer!(sa1::SavingsAccount, sa2::SavingsAccount, amount::Real) = transfer!(
sa1.acct, sa2.acct, amount)
到目前为止,我们已经成功地重用了 Account 数据类型,但不要忘记我们最初实际上是想构建新的功能。储蓄账户应该每天基于每日利息进行过夜计息。因此,对于 SavingsAccount 对象,我们可以实现一个新的 interest_rate 字段访问器和一个名为 accrue_daily_interest! 的新修改函数:
# new accessor
interest_rate(sa::SavingsAccount) = sa.interest_rate
# new behavior
function accrue_daily_interest!(sa::SavingsAccount)
interest = balance(sa.acct) * interest_rate(sa) / 365
deposit!(sa.acct, interest)
end
在这个时候,我们已经创建了一个新的SavingsAccount对象,它的工作方式与原始的Account对象完全一样,除了它还具有累积利息的额外功能!
然而,这些转发方法的数量之多让我们感到有些不满意。如果我们可以不用手动编写所有这些代码,那就太好了。也许有更好的方法...
减少转发方法中的样板代码
你可能会想知道为什么写这么多代码仅仅是为了将方法调用转发到父对象。确实,转发方法除了将完全相同的参数传递给父对象外,没有其他作用。如果程序员按代码行数付费,那么这将会是一个非常昂贵的提议,不是吗?
幸运的是,这种样板代码可以通过宏大大减少。有几个开源解决方案可以帮助这种情况。为了演示目的,我们可以利用来自Lazy.jl包的@forward宏。让我们按照以下方式替换所有的转发方法:
using Lazy: @forward
# Forward assessors and functions
@forward SavingsAccount.acct account_number, balance, date_opened
@forward SavingsAccount.acct deposit!, withdraw!
transfer!(from::SavingsAccount, to::SavingsAccount, amount::Real) = transfer!(
from.acct, to.acct, amount)
@forward的使用相当直接。它接受两个表达式作为参数。第一个参数是你想要转发的SavingsAccount.acct对象,而第二个参数是你希望转发的函数名称的元组,例如account_number、balance和date_opened。
注意,我们能够转发像deposit!和withdraw!这样的可变函数,但对于transfer!则不能这样做。这是因为transfer!需要我们转发它的第一个和第二个参数。在这种情况下,我们只是保留了手动转发的方法。然而,我们仅用两行代码就成功转发了六个函数中的五个。这仍然是一个相当不错的交易!
实际上,我们可以创建更多接受两个或三个参数的转发宏。事实上,还有其他开源包支持这种场景,例如TypedDelegation.jl包。
那么,@forward宏是如何工作的呢?我们可以使用@macroexpand宏来检查代码是如何被展开的。以下是从行号节点中移除的结果。基本上,对于每个被转发的函数(balance和deposit!),它都会创建一个带有所有参数使用args...表示法展开的相应函数定义。它还加入了一个@inline节点,为编译器提供性能优化的提示:
内联是编译器优化,其中函数调用被内联,就像代码被插入到当前代码中一样。它可能通过减少函数重复调用时分配调用栈的开销来提高性能。
@forward宏仅用几行代码就实现了。如果你对元编程感兴趣,鼓励你查看源代码。
你可能想知道为什么有几个有趣的变量名,如#41#x或#42#args。我们可以将它们视为普通变量。它们是由编译器自动生成的,并且选择了特殊的命名约定,以避免与当前作用域中的其他变量冲突。
最后,重要的是要理解我们可能并不总是希望将所有的函数调用转发到对象。如果我们不想使用底层功能的 100%呢?信不信由你,确实存在这样的情况。例如,让我们想象我们必须支持另一种类型的账户,比如定期存款单,也称为 CD。CD 是一种短期投资产品,其利率高于储蓄账户,但在投资期间资金不能提取。一般来说,CD 的期限可以是 3 个月、6 个月或更长。回到我们的代码,如果我们创建一个新的CertificateOfDepositAccount对象并再次重用Account对象,我们就不想转发withdraw!和transfer!方法,因为它们不是 CD 的功能。
你可能想知道委托在面向对象编程语言中与类继承有何不同。例如,在 Java 语言中,父类中的所有公共和受保护方法都会自动继承。这相当于自动转发父类中的所有方法。
无法选择要继承的内容实际上是为什么委托比继承更受欢迎的原因之一。对于更深入的讨论,请参阅第十二章,继承和变异性。
检查一些真实世界的例子
委托模式在开源包中得到了广泛的应用。例如,JuliaArrays GitHub 组织中的许多包实现了AbstractArray接口。特殊的数组类型通常包含一个常规的AbstractArray对象。
示例 1 – OffsetArrays.jl 包
OffsetArrays.jl包允许我们定义具有任意索引的数组,而不是标准的线性或笛卡尔风格索引。一个有趣的例子是使用基于零的数组,就像你可能在其他编程语言中找到的那样:
要理解这是如何工作的,我们需要深入研究源代码。让我们保持简洁,只审查代码的一部分:
struct OffsetArray{T,N,AA<:AbstractArray} <: AbstractArray{T,N}
parent::AA
offsets::NTuple{N,Int}
end
Base.parent(A::OffsetArray) = A.parent
Base.size(A::OffsetArray) = size(parent(A))
Base.size(A::OffsetArray, d) = size(parent(A), d)
Base.eachindex(::IndexCartesian, A::OffsetArray) = CartesianIndices(axes(A))
Base.eachindex(::IndexLinear, A::OffsetVector) = axes(A, 1)
OffsetArray数据类型由parent和offsets字段组成。为了满足AbstractArray接口,它实现了某些基本功能,例如Base.size、Base.eachindex等。由于这些函数足够简单,代码只是手动将调用转发到父对象。
示例 2 – ScikitLearn.jl 包
让我们也看看ScikitLearn.jl包,它定义了一个一致的 API 来拟合机器学习模型和进行预测。
以下是如何定义FitBit类型的:
""" `FitBit(model)` will behave just like `model`, but also supports
`isfit(fb)`, which returns true IFF `fit!(model, ...)` has been called """
mutable struct FitBit
model
isfit::Bool
FitBit(model) = new(model, false)
end
function fit!(fb::FitBit, args...; kwargs...)
fit!(fb.model, args...; kwargs...)
fb.isfit = true
fb
end
isfit(fb::FitBit) = fb.isfit
在这里,我们可以看到FitBit对象包含一个model对象,并且它添加了一个新功能,用于跟踪模型是否已被拟合:
@forward FitBit.model transform, predict, predict_proba, predict_dist, get_classes
它使用@forward宏来代理所有主要功能,即transform、predict等。
考虑事项
你应该记住,代理模式引入了新的间接层次,这可能会增加代码复杂性,并使代码更难以理解。在决定是否使用代理模式时,我们应该考虑一些因素。
首先,你能从现有组件中重用多少代码?是 20%,50%,还是 80%?在你考虑重用现有组件之前,这应该是你最需要问的第一个问题。我们可以把重用量的比例称为利用率。显然,利用率越高,从重用角度来看就越好。
第二,通过重用现有组件可以节省多少开发工作量?如果开发相同功能性的成本很低,那么重用组件并增加额外间接的复杂性可能不值得。
从相反的角度来看,我们也应该审查现有组件中是否存在任何关键的业务逻辑。如果我们决定不重用组件,那么我们可能会再次实现相同的逻辑,违反了不要重复自己(DRY)原则。这意味着不重用组件可能会成为一个维护噩梦。
考虑到这些因素,我们应该对是否使用代理模式做出良好的判断。
接下来,我们将学习如何在 Julia 中实现特性。
神圣的特性模式
神圣的特性模式有一个有趣的名字。有些人也称它为Tim Holy 特性技巧(THTT)。正如你可能猜到的,这个模式是以 Tim Holy 的名字命名的,他是一位长期为 Julia 语言和生态系统做出贡献的贡献者。
特性是什么?简而言之,特性对应于对象的行为。例如,鸟儿和蝴蝶可以飞翔,因此它们都具有CanFly特性。海豚和乌龟可以游泳,因此它们都具有CanSwim特性。鸭子可以飞翔和游泳,因此它具有CanFly和CanSwim特性。特性通常是二元的——要么具有该特性,要么不具有——尽管这不是强制性的要求。
我们为什么想要特性?特性可以用作关于数据类型如何使用的正式合同。例如,如果一个对象具有CanFly特性,那么我们可以相当自信地认为该对象定义了某种fly方法。同样,如果一个对象具有CanSwim特性,那么我们可能可以调用某种swim函数。
让我们回到编程。Julia 语言没有内置对特性的支持。然而,该语言足够灵活,允许开发者通过多分派系统使用特性。在本节中,我们将探讨如何使用被称为神圣特性的特殊技术来实现这一点。
重新审视个人资产管理用例
当设计可重用软件时,我们经常创建抽象的数据类型并将行为与之关联。建模行为的一种方式是利用类型层次结构。遵循 Liskov 替换原则,当调用函数时,我们应该能够用子类型替换类型。
让我们回顾一下从第二章,模块、包和类型概念中管理个人资产的高级类型层次结构:
我们可以定义一个名为value的函数,用于确定任何资产的价值。如果我们假设所有资产类型都附带有某种货币价值,那么这个函数可以应用于Asset层次结构中的所有类型。沿着这条思路,我们可以说几乎每种资产都表现出HasValue特质。
有时,行为只能应用于层次结构中的某些类型。例如,如果我们想定义一个只与流动投资一起工作的trade函数怎么办?在这种情况下,我们会为Investment和Cash定义trade函数,但不会为House和Apartments定义。
流动投资是指可以在公开市场上轻松交易的证券工具。投资者可以快速将流动工具转换为现金,反之亦然。一般来说,大多数投资者在紧急情况下都希望他们的投资中有一部分是流动的。
非流动的投资被称为非流动资产。
编程上,我们如何知道哪些资产类型是流动的?一种方法是将对象类型与表示流动投资的类型列表进行比较。假设我们有一个资产数组,需要找出哪一个可以快速兑换成现金。在这种情况下,代码可能看起来像这样:
function show_tradable_assets(assets::Vector{Asset})
for asset in assets
if asset isa Investment || asset isa Cash
println("Yes, I can trade ", asset)
else
println("Sorry, ", asset, " is not tradable")
end
end
end
前面代码中的if条件有点丑陋,即使是这个玩具示例也是如此。如果我们有更多类型的条件,那么它会更糟。当然,我们可以创建一个联合类型来让它变得稍微好一些:
const LiquidInvestments = Union{Investment, Cash}
function show_tradable_assets(assets::Vector{Asset})
for asset in assets
if asset isa LiquidInvestments
println("Yes, I can trade ", asset)
else
println("Sorry, ", asset, " is not tradable")
end
end
end
这种方法有几个问题:
-
每当我们添加一个新的流动资产类型时,联合类型必须更新。从设计角度来看,这种维护是糟糕的,因为程序员必须记住在向系统中添加新类型时更新这个联合类型。
-
这个联合类型不可扩展。如果其他开发者想重用我们的交易库,他们可能想添加新的资产类型。然而,他们不能更改我们的联合类型定义,因为他们没有源代码。
-
如果-否则逻辑可能在我们的源代码的许多地方重复出现,每当我们需要对流动资产和非流动资产执行不同的操作时。
这些问题可以使用神圣特质模式来解决。
实现神圣特质模式
为了说明这种模式的概念,我们将实现一些函数,用于我们在第二章,模块、包和数据类型概念中开发的个人资产数据类型。如您所回忆的,资产类型层次结构的抽象类型定义如下:
abstract type Asset end
abstract type Property <: Asset end
abstract type Investment <: Asset end
abstract type Cash <: Asset end
abstract type House <: Property end
abstract type Apartment <: Property end
abstract type FixedIncome <: Investment end
abstract type Equity <: Investment end
Asset类型位于层次结构的顶部,具有Property、Investment和Cash子类型。在下一级,House和Apartment是Property的子类型,而FixedIncome和Equity是Investment的子类型。
现在,让我们定义一些具体的类型:
struct Residence <: House
location
end
struct Stock <: Equity
symbol
name
end
struct TreasuryBill <: FixedIncome
cusip
end
struct Money <: Cash
currency
amount
end
我们这里有什么?让我们更详细地看看这些概念:
-
一个
Residence是某人居住的房屋,并且有一个位置。 -
一个
Stock是股权投资,它通过交易符号和公司名称来识别。 -
TreasuryBill是美国政府发行的一种短期证券,它通过一个称为 CUSIP 的标准标识符来定义。 -
Money只是现金,但我们想在这里存储货币和相应的金额。
注意,我们没有注释字段的类型,因为它们在这里说明特性概念并不重要。
定义特性类型
当涉及到投资时,我们可以区分那些在公开市场上可以轻易换成现金的投资和那些需要相当多的努力和时间才能换成现金的投资。那些可以在几天内轻易换成现金的东西被称为是流动的,而难以出售的被称为是非流动的。例如,股票是流动的,而住宅则不是。
我们首先想定义特性本身:
abstract type LiquidityStyle end
struct IsLiquid <: LiquidityStyle end
struct IsIlliquid <: LiquidityStyle end
特性在 Julia 中不过是数据类型!LiquidityStyle特性的整体概念是它是一个抽象类型。这里的具体特性,IsLiquid和IsIlliquid,已经被设置为没有字段的实体类型。
特性的命名没有标准约定,但我的研究似乎表明,包作者倾向于使用Style或Trait作为特性类型的后缀。
识别特性
下一步是为这些特性分配数据类型。方便的是,Julia 允许我们使用函数签名中的<:运算符批量分配特性到整个子类型树。
# Default behavior is illiquid
LiquidityStyle(::Type) = IsIlliquid()
# Cash is always liquid
LiquidityStyle(::Type{<:Cash}) = IsLiquid()
# Any subtype of Investment is liquid
LiquidityStyle(::Type{<:Investment}) = IsLiquid()
让我们看看我们如何解释这三行代码:
-
我们选择默认将所有类型设置为非流动的。请注意,我们也可以反过来,默认将所有东西设置为流动的。这个决定是任意的,取决于特定的用例。
-
我们选择将所有
Cash的子类型都做成流动的,这包括具体的Money类型。::Type{<:Cash}的表示法表明了Cash的所有子类型。 -
我们选择将所有
Investment的子类型都做成流动的。这包括所有FixedIncome和Equity的子类型,在这个例子中涵盖了Stock。
你可能想知道为什么我们不将::Type{<: Asset}作为默认特性函数的参数。这样做使得它更加限制性,因为默认值只适用于在Asset类型层次结构下定义的类型。这可能是或可能不是所希望的,这取决于特性是如何使用的。无论如何都应该是可以的。
实现特性行为
现在我们能够判断哪些类型是流动的,哪些不是,我们可以定义接受具有这些特性的对象的方法。首先,让我们做一些非常简单的事情:
# The thing is tradable if it is liquid
tradable(x::T) where {T} = tradable(LiquidityStyle(T), x)
tradable(::IsLiquid, x) = true
tradable(::IsIlliquid, x) = false
在 Julia 中,类型是一等公民。tradable(x::T) where {T}签名捕获了参数的类型作为T。由于我们已定义了LiquidityStyle函数,我们可以推导出传递的参数是否表现出IsLiquid或IsIlliquid特性。因此,第一个tradable方法只是接受LiquidityStyle(T)的返回值,并将其作为其他两个tradable方法的第一个参数传递。这个简单的例子展示了派发效应。
现在,让我们看看一个更有趣的函数,它利用了相同的特性。由于流动性资产在市场上很容易交易,我们应该能够快速发现它们的市场价格。对于股票,我们可以从证券交易所调用定价服务。对于现金,市场价格只是货币金额。让我们看看这是如何编码的:
# The thing has a market price if it is liquid
marketprice(x::T) where {T} = marketprice(LiquidityStyle(T), x)
marketprice(::IsLiquid, x) = error("Please implement pricing function for ", typeof(x))
marketprice(::IsIlliquid, x) = error("Price for illiquid asset $x is not available.")
代码的结构与tradable函数相同。一个方法用于确定特性,而另外两个方法为流动性和非流动性工具实现不同的行为。在这里,两个marketprice函数只是通过调用错误函数来抛出异常。当然,这并不是我们真正想要的。我们真正想要的应该是一个针对Stock和Money类型的特定定价函数。好的;让我们就这样做:
# Sample pricing functions for Money and Stock
marketprice(x::Money) = x.amount
marketprice(x::Stock) = rand(200:250)
在这里,Money类型的marketprice方法只是返回金额。这在实践中是一种相当简化的做法,因为我们可能需要从货币和金额中计算出当地货币(例如,美元)的金额。至于Stock,我们只是为了测试目的返回一个随机数。在现实中,我们会将这个函数附加到一个股票定价服务上。
为了说明目的,我们开发了以下测试函数:
function trait_test_cash()
cash = Money("USD", 100.00)
@show tradable(cash)
@show marketprice(cash)
end
function trait_test_stock()
aapl = Stock("AAPL", "Apple, Inc.")
@show tradable(aapl)
@show marketprice(aapl)
end
function trait_test_residence()
try
home = Residence("Los Angeles")
@show tradable(home) # returns false
@show marketprice(home) # exception is raised
catch ex
println(ex)
end
return true
end
function trait_test_bond()
try
bill = TreasuryBill("123456789")
@show tradable(bill)
@show marketprice(bill) # exception is raised
catch ex
println(ex)
end
return true
end
这是 Julia REPL 的结果:
完美! tradable函数正确地识别出现金、股票和债券是流动的,而住宅是非流动的。对于现金和股票,marketprice函数能够返回预期的值。因为住宅不是流动的,所以抛出了一个错误。最后,虽然国库券是流动的,但由于marketprice函数尚未定义该工具,所以抛出了一个错误。
使用具有不同类型层次结构的特性
神圣特性模式的最佳部分在于我们可以用它来处理任何对象,即使它的类型属于不同的抽象类型层次结构。让我们看看文学案例,我们可能定义它自己的类型层次结构如下:
abstract type Literature end
struct Book <: Literature
name
end
现在,我们可以让它遵守 LiquidityStyle 特性,如下所示:
# assign trait
LiquidityStyle(::Type{Book}) = IsLiquid()
# sample pricing function
marketprice(b::Book) = 10.0
现在,我们可以像其他可交易资产一样交易书籍。
审查一些常见用法
神圣特性模式在开源包中很常见。让我们看看一些例子。
示例 1 – Base.IteratorSize
Julia Base 库广泛使用特性。这样的特性之一是 Base.IteratorSize。它的定义可以通过 generator.jl 查找:
abstract type IteratorSize end
struct SizeUnknown <: IteratorSize end
struct HasLength <: IteratorSize end
struct HasShape{N} <: IteratorSize end
struct IsInfinite <: IteratorSize end
这个特性与我们之前学到的略有不同,因为它不是二元的。IteratorSize 特性可以是 SizeUnknown、HasLength、HasShape{N} 或 IsInfinite。IteratorSize 函数定义如下:
"""
IteratorSize(itertype::Type) -> IteratorSize
"""
IteratorSize(x) = IteratorSize(typeof(x))
IteratorSize(::Type) = HasLength() # HasLength is the default
IteratorSize(::Type{<:AbstractArray{<:Any,N}}) where {N} = HasShape{N}()
IteratorSize(::Type{Generator{I,F}}) where {I,F} = IteratorSize(I)
IteratorSize(::Type{Any}) = SizeUnknown()
让我们专注于看起来相当有趣的 IsInfinite 特性。Base.Iterators 中定义了一些函数来生成无限序列。例如,Iterators.repeated 函数可以用来无限生成相同的值,我们可以使用 Iterators.take 函数从序列中选取值。让我们看看这是如何工作的:
如果你查看源代码,你会看到 Repeated 是迭代器的类型,并且它被分配了 IteratorSize 特性 IsInfinite:
IteratorSize(::Type{<:Repeated}) = IsInfinite()
我们可以快速测试它,如下所示:
Voila! 它是无限的,正如我们所预期的!但是这个特性是如何被利用的呢?为了找出答案,我们可以查看 Base 库中的 BitArray,这是一个空间高效的布尔数组实现。它的构造函数可以接受任何可迭代对象,例如一个数组:
也许不难理解构造函数实际上无法处理本质上无限的事物!因此,BitArray 构造函数的实现必须考虑到这一点。因为我们可以根据 IteratorSize 特性进行分派,所以当传递这样的迭代器时,BitArray 的构造函数会愉快地抛出一个异常:
BitArray(itr) = gen_bitarray(IteratorSize(itr), itr)
gen_bitarray(::IsInfinite, itr) = throw(ArgumentError("infinite-size iterable used in BitArray constructor"))
要看到它的实际应用,我们可以用 Repeated 迭代器调用 BitArray 构造函数,如下所示:
示例 2 – AbstractPlotting.jl ConversionTrait
AbstractPlotting.jl 是一个抽象绘图库,它是 Makie 绘图系统的一部分。这个库的源代码可以在 github.com/JuliaPlots/AbstractPlotting.jl 找到。
让我们看看一个与数据转换相关的特性:
abstract type ConversionTrait end
struct NoConversion <: ConversionTrait end
struct PointBased <: ConversionTrait end
struct SurfaceLike <: ConversionTrait end
# By default, there is no conversion trait for any object
conversion_trait(::Type) = NoConversion()
conversion_trait(::Type{<: XYBased}) = PointBased()
conversion_trait(::Type{<: Union{Surface, Heatmap, Image}}) = SurfaceLike()
它定义了一个 ConversionTrait,可以用于 convert_arguments 函数。目前,转换逻辑可以应用于三种不同的场景:
-
无转换。这由默认特质类型
NoConversion处理。 -
PointBased转换。 -
SurfaceLike转换。
默认情况下,convert_arguments函数在不需要转换时仅返回未更改的参数:
# Do not convert anything if there is no conversion trait
convert_arguments(::NoConversion, args...) = args
然后,定义了各种convert_arguments函数。以下是用于 2D 绘图的函数:
*"""*
*convert_arguments(P, x, y)::(Vector)*
*Takes vectors `x` and `y` and turns it into a vector of 2D points of the values*
*from `x` and `y`.*
*`P` is the plot Type (it is optional).*
*"""*
convert_arguments(::PointBased, x::RealVector, y::RealVector) = (Point2f0.(x, y),)
使用 SimpleTraits.jl 包
SimpleTraits.jl包(github.com/mauro3/SimpleTraits.jl)可能被用来使编程特质变得更容易。
让我们尝试使用 SimpleTraits 重新做LiquidityStyle的例子。首先,定义一个名为IsLiquid的特质,如下所示:
@traitdef IsLiquid{T}
语法可能看起来有点不自然,因为T似乎没有做什么,但实际上它是必需的,因为特质适用于特定的类型T。接下来,我们需要为此特质分配类型:
@traitimpl IsLiquid{Cash}
@traitimpl IsLiquid{Investment}
然后,可以使用带有四个冒号的特殊语法来定义具有特质的对象的功能:
@traitfn marketprice(x::::IsLiquid) = error("Please implement pricing function for ", typeof(x))
@traitfn marketprice(x::::(!IsLiquid)) = error("Price for illiquid asset $x is not available.")
正例中,参数被注解为x::::IsLiquid,而负例中,参数被注解为x::::(!IsLiquid)。请注意,括号是必需的,这样代码才能被正确解析。现在,我们可以按照以下方式测试函数:
如预期的那样,两种默认实现都会抛出错误。现在,我们可以实现Stock的定价函数,并快速再次测试:
看起来很棒! 如我们所见,SimpleTrait.jl包简化了创建特质的流程。
使用特质可以使你的代码更具可扩展性。然而,我们必须记住,设计适当的特质需要一些努力。文档也同样重要,以便任何想要扩展代码的人都能理解如何利用预定义的特质。
接下来,我们将讨论参数化类型,这是一种常用于轻松扩展数据类型的常用技术。
参数化类型模式
参数化类型是核心语言特性,用于使用参数实现数据类型。这是一个非常强大的技术,因为相同的对象结构可以用于其字段中的不同数据类型。在本节中,我们将展示如何有效地应用参数化类型。
在设计应用时,我们经常创建复合类型以方便地持有多个字段元素。在其最简单形式中,复合类型仅作为字段的容器。随着我们创建越来越多的复合类型,可能会变得明显,其中一些类型看起来几乎相同。此外,操作这些类型的函数可能也非常相似。我们可能会产生大量的样板代码。如果有一个模板允许我们为特定用途自定义通用复合类型会怎么样?
考虑一个支持买卖股票的交易应用。在最初的版本中,我们可能有以下设计:
请注意,前面图表中的符号可能看起来非常像统一建模语言(UML)。然而,由于 Julia 不是面向对象的语言,我们在用这些图表说明设计概念时可能会做出某些例外。
相应的代码如下:
# Abstract type hierarchy for personal assets
abstract type Asset end
abstract type Investment <: Asset end
abstract type Equity <: Investment end
# Equity Instruments Types
struct Stock <: Equity
symbol::String
name::String
end
# Trading Types
abstract type Trade end
# Types (direction) of the trade
@enum LongShort Long Short
struct StockTrade <: Trade
type::LongShort
stock::Stock
quantity::Int
price::Float64
end
我们在前面代码中定义的数据类型相当直接。LongShort枚举类型用于指示交易方向——购买股票将是多头,而卖空股票将是空头。@enum宏方便地用于定义Long和Short常量。
现在,假设我们被要求在软件的下一个版本中支持股票期权。天真地,我们可以定义更多的数据类型,如下所示:
代码已更新,添加了额外的数据类型,如下所示:
# Types of stock options
@enum CallPut Call Put
struct StockOption <: Equity
symbol::String
type::CallPut
strike::Float64
expiration::Date
end
struct StockOptionTrade <: Trade
type::LongShort
option::StockOption
quantity::Int
price::Float64
end
你可能已经注意到StockTrade和StockOptionTrade类型非常相似。这种重复多少有些令人不满意。当我们为这些数据类型定义函数时,看起来更糟糕,如下所示:
# Regardless of the instrument being traded, the direction of
# trade (long/buy or short/sell) determines the sign of the
# payment amount.
sign(t::StockTrade) = t.type == Long ? 1 : -1
sign(t::StockOptionTrade) = t.type == Long ? 1 : -1
# market value of a trade is simply quantity times price
payment(t::StockTrade) = sign(t) * t.quantity * t.price
payment(t::StockOptionTrade) = sign(t) * t.quantity * t.price
对于StockTrade和StockOptionTrade类型,sign和payment方法非常相似。也许不难想象,当我们向应用中添加更多可交易类型时,这并不能很好地扩展。我们必须有更好的方法来做这件事。这正是参数类型发挥作用的地方!
利用去除文本参数类型为股票交易应用
在我们之前描述的交易应用中,我们可以利用参数类型简化代码,并在添加未来的交易工具时使其更具可重用性。
很明显,SingleStockTrade和SingleStockOptionTrade几乎相同。实际上,甚至sign和payment函数的定义也是相同的。在这个非常简单的例子中,我们只为每种类型有两个函数。在实践中,我们可能有更多的函数,这会变得相当混乱。
设计参数类型
为了简化这个设计,我们可以参数化所交易事物的类型。那是什么东西?我们可以在这里利用抽象类型。Stock的超类型是Equity,而Equity的超类型是Investment。由于我们希望保持代码通用,并且买卖投资产品是相似的,我们可以选择接受任何是Investment子类型的类型:
struct SingleTrade{T <: Investment} <: Trade
type::LongShort
instrument::T
quantity::Int
price::Float64
end
现在,我们定义了一个新的类型,称为SingleTrade,其中基础工具的类型为T,T可以是Investment的任何子类型。在这个时候,我们可以创建不同种类的交易:
这些对象实际上有不同的类型——SingleTrade{Stock}和SingleTrade{StockOption}。它们之间是如何关联的呢?它们也是SingleTrade的子类型,如下面的截图所示:
由于这两种类型都是SingleTrade的子类型,这允许我们定义适用于这两种类型的函数,正如我们将在下一节中看到的。
设计参数化方法
为了充分利用编译器的特化功能,我们应该定义同时使用参数化类型的参数化方法,如下所示:
# Return + or - sign for the direction of trade
function sign(t::SingleTrade{T}) where {T}
return t.type == Long ? 1 : -1
end
# Calculate payment amount for the trade
function payment(t::SingleTrade{T}) where {T}
return sign(t) * t.quantity * t.price
end
让我们来测试一下:
但是,嘿,我们刚刚发现了一个小错误。3.50 美元的期权看起来太好了,不像是真的!在查看买卖期权时,每个期权合约实际上代表 100 股基础股票。因此,股票期权交易的支付金额需要乘以 100。为了修复这个问题,我们可以简单地实现一个更具体的支付方法:
# Calculate payment amount for option trades (100 shares per contract)
function payment(t::SingleTrade{StockOption})
return sign(t) * t.quantity * 100 * t.price
end
现在,我们可以再次测试。因此,新方法仅针对期权交易进行分发:
*哇!*这不是很美吗?我们将在下一节中看到一个更复杂的例子。
使用多个参数化类型参数
到目前为止,我们对重构相当满意。然而,我们的老板刚刚打电话来说,我们必须在下一个版本中支持对冲交易。这个新的请求又给我们的设计增添了另一个转折!
对冲交易可以用来实施特定的交易策略,例如市场中性交易或如保护性看涨期权等期权策略。
市场中性交易涉及同时买入一只股票和卖空另一只股票。其理念是抵消市场的影响,以便投资者可以专注于挑选相对于同行表现优异或表现不佳的股票。
保护性看涨期权策略涉及买入股票,但卖出执行价格更高的看涨期权。这允许投资者通过牺牲基础股票有限的上涨潜力来赚取额外的溢价。
这可以通过参数化类型轻松处理。让我们创建一个新的类型,称为PairTrade:
struct PairTrade{T <: Investment, S <: Investment} <: Trade
leg1::SingleTrade{T}
leg2::SingleTrade{S}
end
注意,交易的两侧可以具有不同的类型,T和S,并且它们可以是Investment的任何子类型。因为我们期望每个Trade类型都支持payment函数,所以我们可以轻松实现,如下所示:
payment(t::PairTrade) = payment(t.leg1) + payment(t.leg2)
我们可以重用前一个会话中的stock和option对象,创建一个对冲交易交易,其中我们买入 100 股股票并卖出 1 份期权合约。预期的支付金额是350 = $18,450:
为了欣赏参数化类型如何简化我们的设计,想象一下,如果您必须创建单独的具体类型,您需要编写多少个函数。在这个例子中,由于对冲交易交易中存在两种可能的交易,并且每种交易可以是股票交易或期权交易,我们必须支持 2 x 2 = 4 种不同的场景:
-
payment(PairTradeWithStockAndStock) -
payment(PairTradeWithStockAndStockOption) -
payment(PairTradeWithStockOptionAndStock) -
payment(PairTradeWithStockOptionAndStockOption)
使用参数化类型,我们只需要一个支付函数就可以涵盖所有场景。
现实生活中的例子
你几乎可以在任何开源包中找到参数化类型的使用。让我们来看一些例子。
示例 1 – ColorTypes.jl 包
ColorTypes.jl 是一个定义了表示颜色的各种数据类型的包。在实践中,定义颜色的方式有很多:红-绿-蓝(RGB)、色调-饱和度-亮度(HSV)等等。大多数情况下,可以使用三个实数来定义颜色。在灰度的情况下,只需要一个数字来表示暗度。为了支持透明颜色,可以使用额外的值来存储不透明度值。首先,让我们看看类型定义:
*"""
`Colorant{T,N}` is the abstract super-type of all types in ColorTypes,
and refers to both (opaque) colors and colors-with-transparency (alpha
channel) information. `T` is the element type (extractable with
`eltype`) and `N` is the number of *meaningful* entries (extractable
with `length`), that is, the number of arguments you would supply to the
constructor.
"""*
abstract type Colorant{T,N} end
*# Colors (without transparency)
"""
`Color{T,N}` is the abstract supertype for a color (or
grayscale) with no transparency.
"""*
abstract type Color{T, N} <: Colorant{T,N} end
*"""
`AbstractRGB{T}` is an abstract supertype for red/green/blue color types that
can be constructed as `C(r, g, b)` and for which the elements can be
extracted as `red(c)`, `green(c)`, `blue(c)`. You should *not* make
assumptions about internal storage order, the number of fields, or the
representation. One `AbstractRGB` color-type, `RGB24`, is not
parametric and does not have fields named `r`, `g`, `b`.
"""*
abstract type AbstractRGB{T} <: Color{T,3} end
Color{T,N} 类型可以表示所有种类的颜色,包括透明和不透明的。T 参数代表颜色定义中每个单独值的类型;例如,Int, Float64 等。N 参数代表颜色定义中的值数量,通常为三个。
Color{T,N} 是 Colorant{T,N} 的子类型,代表非透明颜色。最后,AbstractRGB{T} 是 Color{T,N} 的子类型。请注意,在 AbstractRGB{T} 中不再需要 N 参数,因为它已经定义为 N=3。现在,具体的参数化类型 RGB{T} 定义如下:
const Fractional = Union{AbstractFloat, FixedPoint}
*"""
`RGB` is the standard Red-Green-Blue (sRGB) colorspace. Values of the
individual color channels range from 0 (black) to 1 (saturated). If
you want "Integer" storage types (for example, 255 for full color), use `N0f8(1)`
instead (see FixedPointNumbers).
"""*
struct RGB{T<:Fractional} <: AbstractRGB{T}
r::T # Red [0,1]
g::T # Green [0,1]
b::T # Blue [0,1]
RGB{T}(r::T, g::T, b::T) where {T} = new{T}(r, g, b)
end
RGB{T <: Fractional} 的定义相当直接。它包含三个类型为 T 的值,T 可以是 Fractional 的子类型。由于 Fractional 类型定义为 AbstractFloat 和 FixedPoint 的并集,因此 r、g 和 b 字段可以用任何 AbstractFloat 的子类型,如 Float64 和 Float32,或者任何 FixedPoint 数值类型。
FixedPoint 是在 FixedPointNumbers.jl 包中定义的类型。定点数是不同于浮点格式的表示实数的方式。更多信息可以在 github.com/JuliaMath/FixedPointNumbers.jl 找到。
如果你进一步检查源代码,你会发现许多类型是以类似的方式定义的。
示例 2 – NamedDims.jl 包
NamedDims.jl 包为多维数组的每个维度添加了名称。源代码可以在 github.com/invenia/NamedDims.jl 找到。
让我们看看 NamedDimsArray 的定义:
"""
The `NamedDimsArray` constructor takes a list of names as `Symbol`s,
one per dimension, and an array to wrap.
"""
struct NamedDimsArray{L, T, N, A<:AbstractArray{T, N}} <: AbstractArray{T, N}
# `L` is for labels, it should be an `NTuple{N, Symbol}`
data::A
end
不要被签名吓倒。实际上它相当直接。
NamedDimsArray是抽象数组类型AbstractArray{T, N}的子类型。它只包含一个字段,data,用于跟踪底层数据。因为T和N已经在A中作为参数,所以它们也需要在NamedDimsArray的签名中指定。L参数用于跟踪维度的名称。请注意,L在任何一个字段中都没有使用,但它方便地存储在类型签名本身中。
主要构造函数定义如下:
function NamedDimsArray{L}(orig::AbstractArray{T, N}) where {L, T, N}
if !(L isa NTuple{N, Symbol})
throw(ArgumentError(
"A $N dimensional array, needs a $N-tuple of dimension names. Got: $L"
))
end
return NamedDimsArray{L, T, N, typeof(orig)}(orig)
end
该函数只需要一个AbstractArray{T,N},它是一个具有元素类型T的 N 维数组。首先,它检查L是否包含一个包含N个符号的元组。因为类型参数是一等公民,所以可以在函数体中检查它们。假设L包含正确的符号数量,它只需使用已知的参数L、T、N以及数组参数的类型来实例化一个NamedDimsArray。
可能更容易看到它是如何使用的,让我们看一下:
在输出中,我们可以看到类型签名是NamedDimsArray{(:x, :y),Int64,2,Array{Int64,2}}。将其与NamedDimsArray类型的签名匹配,我们可以看到L是两个符号的元组(:x, :y),T是Int64,N是 2,底层数据是Array{Int64, 2}类型。
让我们看看dimnames函数,其定义如下:
dimnames(::Type{<:NamedDimsArray{L}}) where L = L
此函数返回维度元组:
现在,事情变得有点更有趣了。NamedDimsArray{L}是什么?我们在这个类型中不是需要四个参数吗?值得注意的是,像NamedDimsArray{L, T, N, A}这样的类型实际上是NamedDimsArray{L}的子类型。我们可以如下证明这一点:
如果我们真的想了解NamedDimsArray{L}是什么,我们可以尝试以下方法:
看起来正在发生的事情是NamedDimsArray{(:x, :y)}只是NamedDimsArray{(:x, :y),T,N,A}的简写,其中A<:AbstractArray{T,N},N和T是未知的参数。因为这是一个具有三个未知参数的更一般类型,所以我们可以看到为什么NamedDimsArray{(:x, :y),Int64,2,Array{Int64,2}}是NamedDimsArray{(:x, :y)}的子类型。
如果我们希望重用功能,使用参数化类型是非常好的。我们可以几乎将每个类型参数视为一个"维度"。当一个参数化类型有两个类型参数时,我们会根据每个类型参数的各种组合有许多可能的子类型。
摘要
在本章中,我们探讨了与重用性相关的几个模式。这些模式非常有价值,可以在应用程序的许多地方使用。此外,来自面向对象背景的人可能会发现,在设计 Julia 应用程序时,这一章是不可或缺的。
首先,我们详细介绍了委派模式,它可以用来创建新的功能,并允许我们重用现有对象的功能。一般技术涉及定义一个新的数据类型,该类型包含一个父对象。然后,定义转发函数,以便我们可以重用父对象的功能。我们了解到通过使用由 Lazy.jl 包提供的 @forward,可以大大简化实现委派。
然后,我们研究了神圣的特质模式,这是一种正式定义对象行为的方式。其思路是将特质定义为原生类型,并利用 Julia 的内置调度机制来调用正确的方法实现。我们意识到特质在使代码更可扩展方面很有用。我们还了解到来自 SimpleTraits.jl 包的宏可以使特质编码更容易。
最后,我们探讨了参数化类型模式及其如何被用来简化代码的设计。我们了解到参数化类型可以减小我们代码的大小。我们还看到参数可以在参数化函数的主体中使用。
在下一章中,我们将讨论一个吸引许多人学习 Julia 编程语言的重要主题——性能模式!
问题
-
委派模式是如何工作的?
-
特质的目的是什么?
-
特质总是二元的吗?
-
特质能否用于不同类型层次的对象?
-
参数化类型的优点是什么?
-
我们如何存储参数化类型的信息?
第六章:性能模式
本章包括与提高系统性能相关的模式。高性能是科学计算、人工智能、机器学习和大数据处理的主要要求。为什么是这样?
在过去十年中,由于云的可扩展性,数据几乎呈指数级增长。想想看物联网(IoT)。传感器无处不在——家庭安全系统、个人助理,甚至是室温控制都在持续收集大量数据。此外,收集到的数据被希望构建更智能产品的公司存储和分析。这样的用例需要更多的计算能力和速度。
我曾经与一位同事就使用云计算技术来解决计算密集型问题进行了辩论。云计算中确实有计算资源,但它们不是免费的。因此,设计计算机程序以更加高效和优化,以避免在云中产生不必要的成本,这一点非常重要。
幸运的是,Julia 编程语言允许我们轻松地充分利用 CPU 资源。只要遵循一些规则,让事情变得快速并不困难。在线的 Julia 参考手册已经包含了一些技巧。本章提供了由经验丰富的 Julia 开发者广泛使用的进一步模式,以提升性能。
我们将探讨以下设计模式:
-
全局常量
-
数组结构
-
共享数组
-
缓存
-
障碍函数
让我们开始吧!
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
全局常量模式
全局变量通常被认为是有害的。我不是在开玩笑——它们确实是有害的。如果你不相信我,只需在谷歌上搜索一下。它们之所以不好,有很多原因,但在 Julia 语言中,它们也可能成为应用性能不佳的诱因。
我们为什么要使用全局变量?在 Julia 语言中,变量要么在全局作用域,要么在局部作用域。例如,模块顶层所有的变量赋值都被认为是全局的。出现在函数内部的变量是局部的。考虑一个连接外部系统的应用程序——在连接时通常会创建一个句柄对象。这样的句柄对象可以保存在全局变量中,因为模块中的所有函数都可以访问这个变量,而无需将其作为函数参数传递。这就是便利性因素。此外,这个句柄对象只需要创建一次,然后可以在后续操作中随时使用。
不幸的是,全局变量也伴随着成本。一开始可能不明显,但它确实会影响性能——在某些情况下,影响相当严重。在本节中,我们将讨论全局变量如何影响性能,以及如何通过使用全局常量来解决这个问题。
使用全局变量进行性能基准测试
有时,使用全局变量很方便,因为它们可以从代码的任何地方访问。然而,当使用全局变量时,应用程序的性能可能会受到影响。让我们一起找出性能受到了多么严重的影响。这是一个非常简单的函数,它只是将两个数字相加:
variable = 10
function add_using_global_variable(x)
return x + variable
end
为了基准测试这段代码,我们将使用伟大的BenchmarkTools.jl包,它可以多次运行代码并报告一些性能统计数据。让我们开始吧:
对于仅仅加两个数字来说,这似乎有点慢。让我们去掉全局变量,只使用两个函数参数来加这些数字。我们可以定义新的函数如下:
function add_using_function_arg(x, y)
return x + y
end
让我们来基准测试这个新函数:
这真是太令人难以置信了!移除对全局变量的引用使函数的速度提高了近 900 倍。为了了解性能下降的原因,我们可以使用 Julia 的内置内省工具来查看生成的 LLVM 代码。
这是更快版本的生成代码。它很干净,只包含一个add指令:
另一方面,使用全局变量的函数生成了以下丑陋的代码:
为什么会这样?编译器不应该更聪明吗?答案是编译器实际上无法假设全局变量总是整数。因为它是一个变量,这意味着它可以随时更改,编译器必须生成能够处理任何数据类型的代码,以确保安全。好吧,这种额外的灵活性在这种情况下引入了巨大的开销。
享受全局常量的速度
为了提高性能,让我们使用const关键字创建一个全局常量。然后,我们可以定义一个新的函数来访问这个常量,如下所示:
const constant = 10
function add_using_global_constant(x)
return constant + x
end
让我们现在基准测试它的性能:
这是完美的! 如果我们再次内省这个函数,我们得到以下整洁的代码:
接下来,我们将讨论如何使用全局变量(不是常量)并使其稍微好一些。
使用类型信息注释变量
当我们只需使用全局常量时,这是最好的。但如果变量在应用程序的生命周期中确实需要更改呢?例如,它可能是一个全局计数器,用于跟踪网站上的访问者数量。
起初,我们可能会想这样做,但很快我们就意识到 Julia 不支持用类型信息注释全局变量:
相反,我们可以做的是在函数内部注释变量类型,如下所示:
function add_using_global_variable_typed(x)
return x + variable::Int
end
让我们看看它的性能如何:
与未类型化的 31 纳秒版本相比,这已经是一个相当大的速度提升了!然而,它仍然远远落后于全局常量解决方案。
理解常数如何帮助性能
由于以下原因,编译器在处理常数时拥有更多的自由度:
-
值不会改变。
-
常数的类型不会改变。
在我们查看一些简单的例子之后,这会变得清楚。
让我们看看以下函数:
function constant_folding_example()
a = 2 * 3
b = a + 1
return b > 1 ? 10 : 20
end
如果我们只遵循逻辑,那么不难看出它总是返回值为10。让我们快速展开它:
-
a变量有一个值为6。 -
b变量有一个值为a + 1,即7。 -
因为
b变量大于1,它返回10。
从编译器的角度来看,a变量可以被推断为常数,因为它被赋值但从未改变,同样对于b变量也是如此。
我们可以看看 Julia 为这个生成的代码:
Julia 编译器会经过几个阶段。在这种情况下,我们可以使用@code_typed宏,它显示了所有类型信息都已解决的生成的代码。
哇! 编译器已经全部弄明白了,并只为这个函数返回了一个值为10。
我们意识到这里发生了一些事情:
-
当编译器看到两个常数值的乘法(
2 * 3)时,它计算了a的最终值为6。这个过程被称为常数折叠。 -
当编译器推断出
a的值为6时,它计算出b的值为7。这个过程被称为常数传播。 -
当编译器推断出
b的值为7时,它从if-then-else操作中剪除了else分支。这个过程被称为死代码消除。
Julia 的编译器优化真正是处于一流水平。这些只是我们可以自动获得性能提升的一些例子,而无需重构大量代码。
将全局变量作为函数参数传递
另一种解决全局变量问题的方法是,在一个性能敏感的函数中,而不是直接访问全局变量,我们可以将全局变量作为参数传递给函数。
让我们通过添加第二个参数来重构本节中较早的代码,如下所示:
function add_by_passing_global_variable(x, v)
return x + v
end
现在,我们可以通过传递变量来调用函数。让我们按照以下方式基准测试代码:
太棒了! 它的速度和将其视为常量一样快。魔法在哪里?实际上,Julia 的编译器会根据其参数的类型自动生成专门的函数。在这种情况下,当我们以整数值传递变量时,函数被编译为最优化版本,因为参数的类型是已知的。它现在之所以快,是因为和用常量一样的原因。
当然,你可能会争辩说这违背了使用全局变量的初衷。然而,这种灵活性确实存在,并且在你真正需要获得最佳性能时可以加以利用。
当使用BenchmarkTools.jl宏时,我们必须使用美元符号前缀来插值全局变量。否则,引用全局变量所需的时间将包括在性能测试中。
在全局常量中隐藏变量
在我们结束本节之前,还有一个替代方案可以在不损失太多性能的情况下保持全局变量的灵活性。我们可以称之为全局变量占位符。
到现在为止,你可能已经清楚,Julia 可以在编译时知道变量类型的情况下生成高度优化的代码。因此,解决这个问题的方法之一是创建一个常量占位符并在其中存储值。
考虑以下代码:
# Initialize a constant Ref object with the value of 10
const semi_constant = Ref(10)
function add_using_global_semi_constant(x)
return x + semi_constant[]
end
全局常量被分配了一个Ref对象。在 Julia 中,Ref对象不过是一个占位符,其中包含的对象类型是已知的。你可以在 Julia REPL 中尝试这个操作:
如我们所见,根据类型签名Base.RefValue{Int64},Ref(10)内部的值类型为Int64。同样,Ref("abc")内部的值类型为String。
要获取Ref对象内部的值,我们可以使用不带参数的索引运算符。因此,在前面的代码中,我们使用了semi_constant[]。
这种额外的间接引用会增加多少性能开销?让我们像往常一样对代码进行基准测试:
这并不坏。虽然它的性能远未达到使用全局常量的最优性能,但它仍然比使用普通全局变量快大约 15 倍。
因为Ref对象只是一个占位符,所以底层值也可以被赋值:
总结来说,使用Ref允许我们在不牺牲太多性能的情况下模拟全局变量。
转向一些现实生活中的例子
在 Julia 包中,全局常量非常常见。这并不令人惊讶,因为常量也用于避免在函数中直接硬编码值。
示例 1 – SASLib.jl 包
在SASLib.jl包中,大多数常量都定义在位于github.com/tk3369/SASLib.jl/blob/master/src/constants.jl的constants.jl文件中。
下面是代码片段:
# default settings
const default_chunk_size = 0
const default_verbose_level = 1
const magic = [
b"\x00\x00\x00\x00\x00\x00\x00\x00" ;
b"\x00\x00\x00\x00\xc2\xea\x81\x60" ;
b"\xb3\x14\x11\xcf\xbd\x92\x08\x00" ;
b"\x09\xc7\x31\x8c\x18\x1f\x10\x11" ]
const align_1_checker_value = b"3"
const align_1_offset = 32
const align_1_length = 1
const align_1_value = 4
使用这些常量可以使文件读取函数表现良好。
示例 2 – PyCall.jl 包
PyCall.jl包的文档建议用户使用全局变量占位符技术存储 Python 对象。以下摘录可以在其文档中找到:
“对于类型稳定的全局常量,在顶层将常量初始化为PyNULL(),然后在模块的__init__函数中使用copy!函数将其修改为其实际值。”
类型稳定的全局常量通常是高性能代码所希望的。基本上,当模块初始化时,这个全局常量可以用PyNULL()的值初始化。这个常量实际上只是一个占位符对象,稍后可以用实际值修改。
这种技术与在隐藏全局常量中的变量部分中提到的使用Ref类似。
考虑事项
如果一个全局变量可以被替换为全局常量,那么它应该始终这样做。这样做的原因不仅仅是性能。常量有一个很好的特性,即保证它们在整个应用程序生命周期中的值保持不变。一般来说,全局状态变化越少,程序越健壮。修改状态是传统上难以发现的错误来源。
有时,我们可能会遇到不得不使用全局变量的情况。这很糟糕。然而,在我们为此感到悲伤之前,我们也可以检查系统性能是否受到了实质性影响。
在先前的加法示例中,访问全局变量成本相对较高,因为实际操作非常简单和高效。因此,在获取全局变量的访问方面做了更多的工作。另一方面,如果我们有一个更复杂的函数,耗时更长,比如 500 纳秒,那么额外的 25 纳秒开销就变得不那么重要了。在这种情况下,我们可以忽略这个问题,因为开销变得微不足道。
最后,我们应该始终注意当使用过多的全局变量时。当使用更多全局变量时,问题会成倍增加。多少算太多?这完全取决于你的情况,但思考应用程序设计和问自己应用程序是否设计得当是有益的。
在下一节中,我们将讨论一种通过在内存中不同布局数据来提高系统性能的模式。
数组结构模式
近年来,为了满足今天的需要,现代 CPU 架构变得更加复杂。由于各种物理限制,达到更高的处理器速度变得更加困难。许多英特尔处理器现在支持一种称为单指令多数据(SIMD)的技术。通过利用流式 SIMD 扩展(SSE)和高级向量扩展(AVX)寄存器,可以在单个 CPU 周期内执行多个数学运算。
这很好,但使用这些花哨的 CPU 指令的一个先决条件是确保数据最初位于连续的内存块中。这把我们带到了这里的话题。我们如何将数据定位在连续的内存块中?你可能会在这个部分找到解决方案。
与业务领域模型一起工作
在设计应用程序时,我们通常会创建一个对象模型,该模型模仿业务领域概念。目的是以对程序员来说最自然的形式清晰地阐述数据。
假设我们需要从关系型数据库中检索客户数据。客户记录可能存储在 CUSTOMER 表中,每个客户作为表中的一行存储。当我们从数据库中检索客户数据时,我们可以构建一个 Customer 对象并将其推入一个数组。同样,当我们与 NoSQL 数据库一起工作时,我们可能会以 JSON 文档的形式接收数据,并将它们放入对象数组中。在这两种情况下,我们可以看到数据被表示为对象的数组。应用程序通常被设计成使用 struct 语句定义的对象进行操作。
让我们看看分析来自纽约市出租车数据的用例。这些数据作为几个 CSV 文件公开可用。为了说明目的,我们已经下载了 2018 年 12 月的数据,并将其截断到 100,000 条记录。
完整的数据文件可以从 data.cityofnewyork.us/Transportation/2018-Yellow-Taxi-Trip-Data/t29m-gskq 下载。
为了方便起见,一个包含 100,000 条记录的小文件可以从我们的 GitHub 网站获取,网址为 github.com/PacktPublishing/Hands-On-Design-Patterns-with-Julia-1.0/raw/master/Chapter06/StructOfArraysPattern/yellow_tripdata_2018-12_100k.csv。
首先,我们定义一个名为 TripPayment 的类型,如下所示:
struct TripPayment
vendor_id::String
tpep_pickup_datetime::String
tpep_dropoff_datetime::String
passenger_count::Int
trip_distance::Float64
fare_amount::Float64
extra::Float64
mta_tax::Float64
tip_amount::Float64
tolls_amount::Float64
improvement_surcharge::Float64
total_amount::Float64
end
为了将数据读入内存,我们将利用 CSV.jl 包。让我们定义一个函数来将文件读入一个向量:
function read_trip_payment_file(file)
f = CSV.File(file, datarow = 3)
records = Vector{TripPayment}(undef, length(f))
for (i, row) in enumerate(f)
records[i] = TripPayment(row.VendorID,
row.tpep_pickup_datetime,
row.tpep_dropoff_datetime,
row.passenger_count,
row.trip_distance,
row.fare_amount,
row.extra,
row.mta_tax,
row.tip_amount,
row.tolls_amount,
row.improvement_surcharge,
row.total_amount)
end
return records
end
现在,当我们获取数据时,我们最终得到一个数组。在这个例子中,我们下载了 100,000 条记录,如下面的截图所示:
现在,假设我们需要分析这个数据集。在许多数据分析用例中,我们只是计算支付记录中某些属性的统计信息。例如,我们可能想找到平均车费金额,如下所示:
这应该是一个相当快的操作,因为它使用了生成器语法并避免了分配。
一些 Julia 函数接受生成器语法,可以像数组推导式一样编写,无需使用方括号。因为它避免了为中间对象分配内存,所以它非常节省内存。
唯一需要注意的是,它需要为每条记录访问fare_amount字段。如果我们对函数进行基准测试,它将显示以下结果:
我们如何知道它是否以最佳速度运行?除非我们尝试以不同的方式做,否则我们不知道。因为我们所做的只是计算 10 万个浮点数的平均值,我们可以很容易地用简单的数组来复制这个操作。让我们在单独的数组中复制数据:
fare_amounts = [r.fare_amount for r in records];
然后,我们可以通过直接传递数组来基准测试mean函数:
哇! 这里发生了什么?它的速度比之前快了 24 倍。
在这种情况下,编译器能够利用更高级的 CPU 指令。因为 Julia 数组是密集数组,也就是说数据紧凑地存储在连续的内存块中,这使得编译器能够完全优化操作。
将数据转换为数组似乎是一个不错的解决方案。然而,想象一下,你必须为每个单独的字段创建这些临时数组。这样做不再有趣,因为有可能在这个过程中遗漏一个字段。有没有更好的方法来解决这个问题?
使用不同的数据布局来提高性能
我们刚才看到的问题是由使用结构数组引起的。我们真正想要的是数组结构。注意结构数组与数组结构的区别?
在结构数组中,为了访问对象的字段,程序必须首先索引到对象,然后通过内存中的预定偏移量找到字段。例如,TripPayment对象中的passenger_count字段是结构中的第四个字段,前面的三个字段是Int64、String和String类型。因此,第四个字段的偏移量是 24。结构数组具有行导向的布局,因为每一行都存储在连续的内存块中。
我们现在介绍数组结构的概念。在数组结构中,我们采用列导向的方法。在这种情况下,我们只为整个数据集维护一个单一的对象。在对象内部,每个字段代表原始记录中特定字段的数组。例如,fare_amount字段将在这个对象中以票价金额的数组形式存储。列导向的格式针对高性能计算进行了优化,因为数组中的数据值都具有相同的类型。此外,它们在内存中也更加紧凑。
在 64 位系统中,结构通常被对齐到 8 字节内存块。例如,只包含两个字段Int32和Int16类型的结构仍然消耗 8 字节,尽管只需要 6 字节来存储数据。额外的两个字节用于填充数据结构以达到 8 字节的边界。
在接下来的章节中,我们将探讨如何实现这种模式,并确认性能是否有所提高。
构建数组结构
构造数组结构既简单又直接。毕竟,我们之前能够快速为一个单个字段做到这一点。为了完整性,这是我们可以设计的新数据类型,用于以列格式存储相同的行程付款数据。以下代码显示这种模式有助于提高性能:
struct TripPaymentColumnarData
vendor_id::Vector{Int}
tpep_pickup_datetime::Vector{String}
tpep_dropoff_datetime::Vector{String}
passenger_count::Vector{Int}
trip_distance::Vector{Float64}
fare_amount::Vector{Float64}
extra::Vector{Float64}
mta_tax::Vector{Float64}
tip_amount::Vector{Float64}
tolls_amount::Vector{Float64}
improvement_surcharge::Vector{Float64}
total_amount::Vector{Float64}
end
注意,每个字段都已转换为 Vector{T},其中 T 是特定字段的原始数据类型。这看起来相当丑陋,但我们愿意为了性能牺牲这一点。
一般原则是,我们应该保持简单(KISS)。在特定情况下,当我们确实需要更高的运行时性能时,我们可以稍微弯曲一下。
现在,尽管我们有一个更优化性能的数据类型,但我们仍然需要用数据填充它以进行测试。在这种情况下,可以使用数组推导语法轻松实现:
columar_records = TripPaymentColumnarData(
[r.vendor_id for r in records],
[r.tpep_pickup_datetime for r in records],
[r.tpep_dropoff_datetime for r in records],
[r.passenger_count for r in records],
[r.trip_distance for r in records],
[r.fare_amount for r in records],
[r.extra for r in records],
[r.mta_tax for r in records],
[r.tip_amount for r in records],
[r.tolls_amount for r in records],
[r.improvement_surcharge for r in records],
[r.total_amount for r in records]
);
当我们完成时,我们可以证明给自己,新的对象结构确实得到了优化:
是的,它现在具有我们预期的出色性能。
使用 StructArrays 包
前一列结构的不美观让我们感到非常不满意。我们不仅需要创建一个包含大量 Vector 字段的新数据类型,还必须创建一个构造函数来将我们的结构体数组转换为新的类型。
当我们使用 Julia 生态系统中的强大包时,我们可以认识到 Julia 的强大之处。为了完全实现这种模式,我们将引入 StructArrays.jl 包,该包自动处理将结构体数组转换为数组结构的大部分繁琐任务。
实际上,StructArrays 的使用非常简单:
using StructArrays
sa = StructArray(records)
让我们快速查看其内容。首先,我们可以像处理原始数组一样处理 sa——例如,我们可以像以前一样取数组的头三个元素:
如果我们只选择一条记录,它将返回原始的 TripPayment 对象:
为了确保没有错误,我们还可以检查第一条记录的类型:
因此,新的 sa 对象仍然像以前一样工作。现在,当我们需要从单个字段访问所有数据时,差异就出现了。例如,我们可以如下获取 fare_amount 字段:
因为类型已经作为 密集数组 实现了,所以我们可以在进行数值或统计分析时期待该字段有出色的性能,如下所示:
什么是 DenseArray?它实际上是一个抽象类型,其中数组的所有元素都分配在连续的内存块中。DenseArray 是数组的超类型。
Julia 默认支持动态数组,这意味着当我们向数组中推送更多数据时,数组的大小可以增长。当它分配更多内存时,它会将现有数据复制到新的内存位置。
为了避免过多的内存重新分配,当前实现使用了一种复杂的算法来增加内存分配的大小——足够快以避免过多的重新分配,但足够保守以避免过度分配内存。
理解空间与时间的权衡
StructArrays.jl包提供了一个方便的机制,可以快速将结构数组转换为数组结构。我们必须认识到我们付出的代价是在内存中数据的额外副本。因此,我们再次陷入了计算中的经典空间与时间的权衡。
让我们再次快速查看我们的用例。我们可以在 Julia REPL 中使用Base.summarysize函数来查看内存占用:
Base.summarysize函数返回对象的字节数。我们将数字1024除以两次,得到兆字节单位。有趣的是,数组结构sa比原始结构数组records更节省内存。然而,我们在内存中有两个数据副本。
幸运的是,如果我们想节省内存,我们确实有一些选择。首先,如果我们不再需要该结构中的数据,我们可以简单地丢弃records变量中的原始数据。我们甚至可以强制垃圾收集器运行,如下所示:
其次,当我们完成计算后,我们可以丢弃sa变量。
处理嵌套对象结构
上述示例案例适用于任何平面数据结构。如今,设计包含其他复合类型的类型并不罕见。让我们深入探讨一下,看看我们如何处理这种嵌套结构。
首先,假设我们想要将与票价相关的字段分离到单独的复合数据类型中:
struct TripPayment
vendor_id::String
tpep_pickup_datetime::String
tpep_dropoff_datetime::String
passenger_count::Int
trip_distance::Float64
fare::Fare
end
struct Fare
fare_amount::Float64
extra::Float64
mta_tax::Float64
tip_amount::Float64
tolls_amount::Float64
improvement_surcharge::Float64
total_amount::Float64
end
我们可以稍微调整文件读取器:
function read_trip_payment_file(file)
f = CSV.File(file, datarow = 3)
records = Vector{TripPayment}(undef, length(f))
for (i, row) in enumerate(f)
records[i] = TripPayment(row.VendorID,
row.tpep_pickup_datetime,
row.tpep_dropoff_datetime,
row.passenger_count,
row.trip_distance,
Fare(row.fare_amount,
row.extra,
row.mta_tax,
row.tip_amount,
row.tolls_amount,
row.improvement_surcharge,
row.total_amount))
end
return records
end
在我们读取数据后,行程支付数据的数组将如下所示:
如果我们像以前一样只创建StructArray,我们就无法提取fare_amount字段:
为了在更深层次上达到相同的结果,我们可以使用unwrap选项:
unwrap关键字参数的值基本上是一个接受特定字段数据类型的函数。如果函数返回true,则该特定字段将使用嵌套StructArray构建。
我们现在可以通过另一层间接访问fare_amount字段,如下所示:
使用 unwrap 关键字参数,我们可以轻松地遍历整个数据结构,并创建一个允许我们访问紧凑数组结构中任何数据元素的 StructArray 对象。从这一点开始,应用性能可以得到提升。
考虑事项
在设计应用程序时,我们应该确定用户最重视的是什么。同样,在从事数据分析或数据科学项目时,我们应该考虑我们最关心的是什么。在任何决策过程中,以客户为中心的方法都是至关重要的。
假设我们的优先级是实现更好的性能。那么,下一个问题是系统的哪个部分需要优化?如果部分由于使用结构体数组而变慢,我们采用结构体数组模式时能获得多少速度提升?性能提升是否明显——是按毫秒、分钟、小时还是天数来衡量的?
此外,我们还需要考虑系统限制。我们喜欢认为天空是极限。但回到现实中,我们在系统资源方面到处受限——CPU 核心数、可用内存和磁盘空间,以及其他系统管理员强加的限制,例如,最大打开文件数和进程数。
虽然 struct of arrays 可以提高性能,但为新数组分配内存会有开销。如果数据量很大,分配和数据复制操作也会花费一些时间。
在下一节中,我们将探讨另一种有助于节省内存并允许分布式计算的模式——共享数组。
共享数组模式
现代操作系统可以处理许多并发进程并充分利用所有处理器核心。当涉及到分布式计算时,通常将更大的任务分解成更小的任务,以便多个进程可以并发执行任务。有时,这些个别执行的结果可能需要合并或汇总以供最终交付。这个过程被称为归约。
这个概念以各种形式重生。例如,在函数式编程中,通常使用 map-reduce 来实现数据处理。映射过程将列表应用于每个元素,而归约过程则合并结果。在大数据处理中,Hadoop 使用类似的 map-reduce 形式,但它在集群中的多台机器上运行。DataFrames 包含执行 Split-Apply-Combine 模式的函数。这些都基本上是相同的概念。
有时,并行工作进程需要相互通信。通常,进程可以通过某种形式的进程间通信(IPC)相互交谈。有很多种方法可以做到这一点——套接字、Unix 域套接字、管道、命名管道、消息队列、共享内存和内存映射。
Julia 附带一个名为SharedArrays的标准库,该库与操作系统的共享内存和内存映射接口进行交互。这种设施允许 Julia 进程通过共享中央数据源相互通信。
在本节中,我们将探讨如何使用SharedArrays进行高性能计算。
介绍风险管理用例
在风险管理用例中,我们想要使用蒙特卡洛模拟过程来估计投资组合收益的波动性。概念相当简单。首先,我们根据历史数据开发一个风险模型。其次,我们使用该模型以 10,000 种方式预测未来。最后,我们查看投资组合中证券收益的分布,并评估在每种情景下投资组合的收益或损失。
投资组合通常与基准进行比较。例如,股票投资组合可能以标准普尔 500 指数为基准。原因是投资组合经理通常因获得alpha而获得奖励,alpha 是描述超过基准收益的超额收益的术语。换句话说,投资组合经理因其在挑选正确股票方面的技能而获得奖励。
在固定收益市场中,问题要稍微复杂一些。与股市不同,典型的固定收益基准规模相当大,高达 10,000 个债券。在评估投资组合风险时,我们通常想要分析收益的来源。投资组合的价值上升是因为它在牛市中乘风破浪,还是因为大家都开始抛售而下降?与市场波动相关的风险被称为系统性风险。收益的另一个来源与个别债券有关。例如,如果债券发行商经营良好,盈利丰厚,那么债券的风险就会降低,价格也会上涨。这种由于特定个别债券引起的波动被称为特定风险。对于全球投资组合,一些债券还面临着汇率风险。从计算复杂性的角度来看,为了估计 10,000 个基准指数的收益,我们必须进行10,000 个未来情景 x 10,000 个证券 x 3 个收益来源 = 3 亿次定价计算。
回到我们的模拟示例,我们可以生成 10,000 种可能的投资组合未来情景,结果基本上是一组所有这些情景的收益数据。收益数据存储在磁盘上,现在已准备好进行进一步分析。然而,问题来了——资产管理员必须分析超过 1,000 个投资组合,每个投资组合可能需要访问 10,000 到 50,000 个债券的收益数据,具体取决于基准指数的大小。不幸的是,生产服务器内存有限,但 CPU 资源充足。我们如何充分利用我们的硬件,尽可能快地完成分析?
让我们快速总结一下我们的问题:
-
硬件:
-
16 个 vCPU
-
32 GB RAM
-
-
安全收益数据:
-
存储在 100,000 个单独的文件中
-
每个文件包含一个 10,000 x 3 的矩阵(10,000 个未来状态和 3 个回报来源)
-
总内存占用约为 ~22 GB
-
-
任务:
-
对 10,000 个未来状态中的所有证券回报计算统计指标(标准差、偏度和峰度)。
-
尽快完成这项工作!
-
最简单的方法就是按顺序加载所有文件。不用说,无论文件多小,逐个加载 100,000 个文件都不会很快。我们将使用 Julia 分布式计算功能来完成这项工作。
准备示例数据
要遵循后续代码中的此模式,我们可以准备一些测试数据。在运行这里的代码之前,请确保你有足够的磁盘空间来存储测试数据。你需要大约 22 GB 的空闲空间。
而不是将 100,000 个文件放在单个目录中,我们可以将它们分成 100 个子目录。所以,让我们首先创建这些目录。为此创建了一个简单的函数:
function make_data_directories()
for i in 0:99
mkdir("$i")
end
end
我们可以假设每个证券都有一个介于 1 和 100,000 之间的数值索引。让我们定义一个函数来生成查找文件的路径:
function locate_file(index)
id = index - 1
dir = string(id % 100)
joinpath(dir, "sec$(id).dat")
end
该函数被设计为将文件哈希到 100 个子目录之一。让我们看看它是如何工作的:
julia> locate_file.(vcat(1:2, 100:101))
4-element Array{String,1}:
"0/sec0.dat"
"1/sec1.dat"
"99/sec99.dat"
"0/sec100.dat"
因此,前 100 个证券位于名为 0、1、...、99 的目录中。第 101 个证券开始循环并回到目录 0。出于一致性原因,文件名包含证券索引减 1。
现在我们已经准备好生成测试数据。让我们定义一个如下所示的功能:
function generate_test_data(nfiles)
for i in 1:nfiles
A = rand(10000, 3)
file = locate_file(i)
open(file, "w") do io
write(io, A)
end
end
end
要生成所有测试文件,我们只需通过传递 nfiles 参数值为 100,000 调用此函数。在这个练习结束时,你应该会在所有 100 个子目录中散布着测试文件。请注意,generate_test_data 函数生成所有测试数据需要几分钟时间。我们现在就来做这件事:
当它完成时,让我们快速查看终端中的数据文件:
现在我们准备使用共享数组模式来解决这个问题。让我们开始吧。
高性能解决方案概述
SharedArrays 的美妙之处在于数据保持为单个副本,并且多个进程可以同时具有读写访问权限。这是我们问题的完美解决方案。
在这个解决方案中,我们将执行以下操作:
-
主程序创建一个共享数组。
-
使用分布式
for循环,主程序命令工作进程将每个单独的文件读入数组的特定段。 -
再次,使用分布式
for循环,主程序命令工作进程执行统计分析。
由于我们有 16 个 vCPU,我们可以利用它们全部。
在实践中,我们可能应该使用更少的 vCPUs,这样我们就可以为操作系统本身留出一些空间。你的使用情况可能会根据同一服务器上运行的其他内容而有所不同。最佳方法是测试各种配置并确定最佳设置。
在共享数组中填充数据
安全返回文件分布在 100 个不同的目录中。它们存储的位置基于一个简单的公式:文件索引 modulus 100,其中文件索引是每个安全的数值标识符,编号在 1 到 100,000 之间。
每个数据文件都采用简单的二进制格式。上游进程已经为 10,000 个未来状态计算了 3 个源返回,就像一个 10,000 x 3 的矩阵。布局是列导向的,这意味着前 10,000 个数字用于第一个返回源,接下来的 10,000 个数字用于第二个返回源,依此类推。
在我们开始使用分布式计算函数之前,我们必须启动工作进程。Julia 提供了一个方便的命令行选项(-p),用户可以事先指定工作进程的数量,如下所示:
当 REPL 启动时,我们已经有 16 个进程正在运行并准备就绪。nworkers函数确认所有 16 个工作进程都是可用的。
现在我们来看看代码。首先,我们必须加载Distributed和SharedArrays包:
using Distributed
using SharedArrays
为了确保工作进程知道在哪里找到文件,我们必须在它们中更改目录:
@everywhere cd(joinpath(ENV["HOME"], "julia_book_ch06_data"))
@everywhere 宏会在所有工作进程中执行该语句。
主程序看起来是这样的:
nfiles = 100_000
nstates = 10_000
nattr = 3
valuation = SharedArray{Float64}(nstates, nattr, nfiles)
load_data!(nfiles, valuation)
在这种情况下,我们正在创建一个三维共享数组。然后,我们调用load_data!函数来读取所有 100,000 个文件并将数据推入估值矩阵。load_data!函数是如何工作的?让我们看看:
function load_data!(nfiles, dest)
@sync @distributed for i in 1:nfiles
read_val_file!(i, dest)
end
end
这是一个非常简单的for循环,它只是用索引号调用read_val_file!函数。注意这里使用了两个宏——@distributed和@sync。首先,@distributed宏通过将for循环的主体发送到工作进程来实现魔法。一般来说,这里的 master 程序不会等待工作进程返回。然而,@sync宏会阻塞,直到所有作业都完全完成。
它实际上是如何读取二进制文件的?让我们看看:
# Read a single data file into a segment of the shared array `dest`
# The segment size is specified as in `dims`.
@everywhere function read_val_file!(index, dest)
filename = locate_file(index)
(nstates, nattrs) = size(dest)[1:2]
open(filename) do io
nbytes = nstates * nattrs * 8
buffer = read(io, nbytes)
A = reinterpret(Float64, buffer)
dest[:, :, index] = A
end
end
在这里,函数首先定位数据文件的位置。然后,它打开文件并将所有二进制数据读取到一个字节数组中。由于数据只是 64 位浮点数,我们使用reinterpret函数将数据解析为一个Float64值的数组。我们预计每个文件中都有 30,000 个Float64值,代表 10,000 个未来状态和 3 个源返回。当数据准备好后,我们只需将它们保存到特定索引的数组中。
我们还使用@everywhere宏来确保函数被定义并可供所有工作进程使用。locate_file函数稍微有点无趣。它被包含在这里以示完整性:
@everywhere function locate_file(index)
id = index - 1
dir = string(id % 100)
return joinpath(dir, "sec$(id).dat")
end
为了并行加载数据文件,我们可以定义一个load_data!函数,如下所示:
function load_data!(nfiles, dest)
@sync @distributed for i in 1:nfiles
read_val_file!(i, dest)
end
end
在这里,我们只是在for循环前放置了@sync和@distributed宏。Julia 会自动调度并将调用分配给所有工作进程。现在一切准备就绪,我们可以运行程序:
nfiles = 100_000
nstates = 10_000
nattr = 3
valuation = SharedArray{Float64}(nstates, nattr, nfiles)
我们简单地创建一个估值SharedArray对象。然后,我们将其传递给load_data!函数进行处理:
仅需大约三分钟,就使用 16 个并行进程将 10 万个文件加载到内存中。这相当不错!
如果你尝试在自己的环境中运行程序但遇到错误,那可能是因为系统限制。请参考后面的部分,配置系统设置以使用共享内存,获取更多信息。
结果表明,这个练习仍然是 I/O 受限的。在加载过程中,CPU 利用率始终在 5%左右。如果问题需要增量计算,我们可能可以通过启动其他异步进程来利用剩余的 CPU 资源,这些进程在数据被加载到内存后操作数据。
在共享数组上直接分析数据
使用共享数组允许我们在单个内存空间上对数据进行并行操作。只要我们不修改数据,这些操作就可以独立运行,不会发生冲突。这种类型的问题被称为令人尴尬的并行。
为了说明多进程的强大功能,我们先对一个非常简单的函数进行基准测试,该函数计算所有证券的回报率的标准差:
using Statistics: std
# Find standard deviation of each attribute for each security
function std_by_security(valuation)
(nstates, nattr, n) = size(valuation)
result = zeros(n, nattr)
for i in 1:n
for j in 1:nattr
result[i, j] = std(valuation[:, j, i])
end
end
return result
end
n的值代表证券的数量。nattr的值代表回报来源的数量。让我们看看单个进程需要多少时间。最佳时间记录为 5.286 秒:
@benchmark宏提供了一些关于性能基准的统计数据。有时,查看分布并了解 GC 对性能的影响是有用的。
seconds=30参数被指定是因为这个函数需要秒来运行。默认参数值是 5 秒,这不会允许基准测试收集足够的样本以进行报告。
我们现在可以并行运行程序了。首先,我们需要确保所有子进程都已加载了依赖的包:
@everywhere using Statistics: std
然后,我们可以定义一个分布式函数,如下所示:
function std_by_security2(valuation)
(nstates, nattr, n) = size(valuation)
result = SharedArray{Float64}(n, nattr)
@sync @distributed for i in 1:n
for j in 1:nattr
result[i, j] = std(valuation[:, j, i])
end
end
return result
end
这个函数看起来与上一个非常相似,有一些例外:
-
我们已经分配了一个新的共享数组
result来存储计算数据。这个数组是二维的,因为我们把第三维减少到一个标准差值。这个数组可以被所有工作进程访问。 -
在
for循环前面的@distributed宏用于自动将工作(换句话说,for循环的主体)分布到工作进程中。 -
在
for循环前面的@sync宏使得系统等待直到所有工作完成。
我们现在可以使用相同的 16 个工作进程来基准测试这个新函数的性能:
与单个进程的性能相比,这比之前快了大约 6 倍。
理解并行处理的开销
你有没有注意到这里有什么有趣的地方?由于我们有 16 个工作进程,我们本期望并行处理函数的速度接近 16 倍。但结果只达到了大约 6 倍,这比我们预期的要少。为什么?
答案是这只是规模问题。使用并行处理设施会有一些性能开销。通常,这种开销可以忽略不计,因为它与正在执行的工作量相比微不足道。在这个特定的例子中,计算标准差是一项非常简单的计算。因此,从相对意义上讲,协调远程函数调用和收集结果的开销超过了实际工作本身。
也许我们应该证明这一点。让我们再做一些工作,除了计算标准差之外,还要计算偏度和峰度:
using Statistics: std, mean, median
using StatsBase: skewness, kurtosis
function stats_by_security(valuation, funcs)
(nstates, nattr, n) = size(valuation)
result = zeros(n, nattr, length(funcs))
for i in 1:n
for j in 1:nattr
for (k, f) in enumerate(funcs)
result[i, j, k] = f(valuation[:, j, i])
end
end
end
return result
end
并行处理版本类似:
@everywhere using Statistics: std, mean, median
@everywhere using StatsBase: skewness, kurtosis
function stats_by_security2(valuation, funcs)
(nstates, nattr, n) = size(valuation)
result = SharedArray{Float64}((n, nattr, length(funcs)))
@sync @distributed for i in 1:n
for j in 1:nattr
for (k, f) in enumerate(funcs)
result[i, j, k] = f(valuation[:, j, i])
end
end
end
return result
end
让我们现在比较一下它们的性能:
如前所述,并行处理现在快了 9 倍。
配置系统设置以使用共享内存
SharedArrays 的魔法来自于操作系统中对内存映射和共享内存功能的利用。当处理大量数据时,我们可能需要配置系统以处理数据量。
调整系统内核参数
Linux 操作系统对共享内存的大小有限制。要找出这个限制是多少,我们可以使用 ipcs 命令:
E 单位可能看起来有些不熟悉。它是以艾字节为单位的,基本上意味着 18 个零:kilo、mega、giga、tera、peta 和 exa。明白了吗?所以,我们很幸运,因为限制如此之高,我们可能永远也达不到。然而,如果你看到一个很小的数字,那么你可能需要重新配置系统。三个内核参数如下:
-
最大段数(SHMMNI)
-
最大段大小(SHMMAX)
-
最大总共享内存(SHMALL)
我们可以使用 sysctl 命令找到实际值:
为了调整值,我们再次可以使用 sysctl 命令。例如,要将最大段大小(shmmax)设置为 128 GiB,我们可以这样做:
我们可以看到内核设置已经更新。
配置共享内存设备
仅如前所述更改系统限制是不够的。实际上,Linux 内核将/dev/shm设备用作共享内存的内存后端存储。我们可以使用常规的df命令来找出设备的大小:
在当前状态下,如前所述,/dev/shm设备未被使用。整个块设备的大小为 16 GiB。作为一个练习,现在让我们打开一个 Julia REPL 并创建SharedArray:
重新运行df命令,我们可以看到/dev/shm现在正在使用:
既然我们知道SharedArray使用的是/dev/shm设备,我们该如何增加其大小以适应我们的问题,该问题需要超过 22 GiB 的空间?可以使用带有新大小的mount命令来实现:
/dev/shm的大小现在清楚地显示为28G。
调试共享内存大小问题
如果我们忘记按照前面描述的方式增加大小,而超出了共享内存设备的大小,会发生什么?比如说,我们需要分配 20 GiB,但只有 16 GiB:
即使超出了限制,我们也没有错误!我们是不是在免费乘坐?答案是,不是。实际上,Julia 并不知道限制已被违反。我们甚至可以与 16 GiB 标记附近的数组进行“亲密接触”:
前面的代码只是将前 15 GiB 的内存设置为0x01。到目前为止没有显示错误。回到 shell 中,我们再次检查/dev/shm的大小。显然,15 GiB 正在使用中:
现在,如果我们继续给数组后部分赋值,我们会得到一个难看的总线错误和长长的堆栈跟踪:
你可能会想知道为什么 Julia 不能更聪明一些,提前告诉你没有足够的共享内存空间。实际上,如果你使用了底层操作系统的mmap函数,也会有同样的行为。坦白说,Julia 对系统约束没有任何更多信息。
有时候,一个 C 函数的手册页可能会有用,并提供一些提示。例如,关于mmap调用的文档表明,当程序尝试访问内存缓冲区中不可达的部分时,将会抛出一个 SIGBUS 信号。手册页可以在linux.die.net/man/2/mmap找到。
确保工作进程可以访问代码和数据
在开发并行计算时,初学者经常会遇到以下问题:
-
工作者进程中未定义的函数:这可能表明库包未加载,或者一个仅在当前进程中定义但未在工作者进程中定义的函数。这两个问题都可以通过使用前面示例中显示的
@everywhere宏来解决。 -
工作者进程中不可用的数据:这可能表明数据作为变量存储在当前进程中,但没有传递给工作者进程。
SharedArray非常方便,因为它会自动提供给工作者进程。对于其他情况,程序员通常有两个选择:-
明确通过函数参数传递数据。
-
如果数据存储在全局变量中,则可以使用
@everywhere宏进行传输,如下所示:
-
@everywhere my_global_var = whatever_value
对于更高级的使用案例,ParallelDataTransfer.jl 包提供了一些有用的函数,以促进主进程和工作者进程之间的数据传输。
避免并行过程中的竞态条件
SharedArrays 提供了一种简单的方法,可以在多个进程之间共享数据。同时,SharedArray 按设计是所有工作者进程的全局变量。对于每个并行程序,通常的规则是,在数组被变异时应该给予极大的关注。如果需要多个进程写入相同的内存地址,那么这些操作必须同步,否则程序可能会轻易崩溃。
最佳选择是尽可能避免变异。
另一种选择是为每个工作者分配数组中互斥的槽位,这样他们就不会相互冲突。
与共享数组的约束一起工作
SharedArray 中的元素必须是位类型。这意味着什么?位类型的正式定义可以总结如下:
-
类型是不可变的。
-
该类型只包含原始类型或其他位类型。
以下 OrderItem 类型是位类型,因为所有字段都是原始类型:
struct OrderItem
order_id::Int
item_id::Int
price::Float64
quantity::Int
end
以下 Customer 类型不是位类型,因为它包含对 String 的引用,而 String 既不是原始类型也不是位类型:
struct Customer
name::String
age::Int
end
让我们尝试为位类型创建 SharedArray。以下代码确认它工作正常:
如果我们尝试使用非位类型(如可变结构类型)创建 SharedArray,则会导致错误:
总结来说,Julia 的共享数组是向多个并行进程分配数据以进行高性能计算的好方法。编程接口也非常易于使用。
在下一节中,我们将探讨一种通过利用时空权衡来提高性能的模式。
缓存模式
在 1968 年,发表了一篇有趣的文章——它设想计算机应该在执行过程中从经验中学习并提高自己的效率。
在软件开发过程中,我们经常面临执行速度受多种因素限制的情况。可能是一个函数需要从磁盘(也称为 I/O 绑定)读取大量历史数据。或者,一个函数只需要执行一些耗时较多的复杂计算(也称为 CPU 绑定)。当这些函数被反复调用时,应用程序的性能可能会受到严重影响。
记忆化是一个强大的概念,用于解决这些问题。近年来,随着函数式编程变得越来越主流,它变得越来越流行。这个想法真的很简单。当一个函数第一次被调用时,其返回值被存储在缓存中。如果函数再次以与之前完全相同的参数被调用,我们可以从缓存中查找该值并立即返回结果。
如您在本节后面将看到的,记忆化是一种特定的缓存形式,其中函数调用的返回数据根据传递给函数的参数进行缓存。
引入斐波那契函数
在函数式编程中,递归是计算中的一种常见技术。有时,我们可能无意中陷入性能陷阱。一个经典的例子是生成斐波那契序列,它被定义为如下:
它在函数式编程中效果很好,但效率不高。为什么?因为它是以递归方式定义的函数,并且多次以相同的参数调用相同的函数。让我们看看寻找第六个斐波那契数时的计算图,其中每个f(n)节点代表对fib函数的调用:
如您所见,函数被多次调用,尤其是那些位于序列开头部分的函数。为了计算fib(6),我们最终调用了该函数 15 次!而且这就像一个雪球,迅速恶化。
提高斐波那契函数的性能
首先,让我们通过修改函数以跟踪执行次数来分析性能有多糟糕。代码如下:
function fib(n)
if n < 3
return (result = 1, counter = 1)
else
result1, counter1 = fib(n - 1)
result2, counter2 = fib(n - 2)
return (result = result1 + result2, counter = 1 + counter1 + counter2)
end
end
每次调用fib函数时,它都会跟踪一个计数器。如果n的值小于3,则返回1的计数以及结果。如果n是一个更大的数字,则从对fib函数的递归调用中聚合计数。
让我们用不同的输入值运行它几次:
这个简单的例子仅仅说明了当计算机没有关于之前做了什么的记忆时,它会如何迅速变成灾难。一个高中生只需用 18 次加法就能手动计算fib(20),不考虑序列的前两个数字。我们这个不错的小函数会调用自己超过 13,000 次!
现在,让我们恢复原始代码并基准测试该函数。为了说明问题,我将从fib(40)开始:
对于这个任务,函数应该立即返回。430 毫秒在计算机时间上感觉就像永恒!
我们可以使用缓存来解决这个问题。这是我们的第一次尝试:
const fib_cache = Dict()
_fib(n) = n < 3 ? 1 : fib(n-1) + fib(n-2)
function fib(n)
if haskey(fib_cache, n)
return fib_cache[n]
else
value = _fib(n)
fib_cache[n] = value
return value
end
end
首先,我们创建了一个名为fib_cache的字典对象来存储之前计算的结果。然后,斐波那契数列的核心逻辑被捕获在这个私有函数_fib中。
fib函数通过首先从fib_cache字典中查找输入参数来工作。如果找到值,则返回该值。否则,它调用私有函数_fib,并在返回值之前更新缓存。
性能应该会更好。让我们快速测试一下:
到现在为止,我们应该对性能结果感到非常满意。
我们在这里使用了一个Dict对象来缓存计算结果,以供演示。实际上,我们可以通过使用数组作为缓存来进一步优化它。从数组中查找应该比字典键查找快得多。
注意,数组缓存对于fib函数来说效果很好,因为它接受一个正整数参数。对于更复杂的函数,使用Dict缓存会更合适。
自动化构建缓存
虽然我们对前面实现的结果相当满意,但它感觉有点不满意,因为我们每次需要缓存新函数时都必须编写相同的代码。如果缓存可以自动维护,那不是很好吗?现实情况下,我们只需要为每个想要缓存函数的函数维护一个缓存。
所以,让我们稍微改变一下方法。想法是,我们应该能够构建一个高阶函数,它接受一个现有函数并返回其缓存版本。在我们到达那里之前,让我们首先将我们的fib函数重新定义为匿名函数,如下所示:
fib = n -> begin
println("called")
return n < 3 ? 1 : fib(n-1) + fib(n-2)
end
目前,我们添加了一个println语句,只是为了验证我们实现的正确性。如果它工作正常,fib不应该被调用数百万次。继续前进,我们可以定义一个memoize函数,如下所示:
function memoize(f)
memo = Dict()
x -> begin
if haskey(memo, x)
return memo[x]
else
value = f(x)
memo[x] = value
return value
end
end
end
memoize函数首先创建一个名为memo的局部变量来存储之前的返回值。然后,它返回一个捕获memo变量的匿名函数,执行缓存查找,并在需要时调用f函数。这种在匿名函数中捕获变量的编码风格称为闭包。现在,我们可以使用memoize函数来构建一个缓存感知的fib函数:
fib = memoize(fib)
让我们也证明它不会调用原始的fib函数太多次。例如,运行fib(6)应该不超过 6 次调用:
这看起来很令人满意。如果我们再次运行函数,并使用任何小于或等于 6 的输入,那么原始逻辑根本不应该被调用,所有结果都应该直接从缓存中返回。然而,如果输入大于 6,那么它将计算大于 6 的数值。现在让我们试试看:
在我们对新代码进行基准测试之前,我们不能断定我们所做的是否足够好。现在让我们来做:
原始函数计算fib(400)花费了 433 毫秒。这个缓存的版本只用了 50 纳秒。这是一个巨大的差异。
理解通用函数的约束
前述方法的缺点之一是我们必须将原始函数定义为匿名函数而不是通用函数。这似乎是一个主要的限制。问题是为什么它不能与通用函数一起工作?
让我们通过启动一个新的 Julia REPL,再次定义原始的fib函数,并用相同的memoize函数包装它来进行快速测试:
问题在于fib已经被定义为通用函数,并且不能绑定到一个新的匿名函数上,这正是memoize函数返回的内容。为了解决这个问题,我们可能会想将缓存的函数赋予一个新的名称:
fib_fast = memoize(fib)
然而,这实际上并没有起作用,因为原始的fib函数是对自身进行递归调用,而不是对新缓存的版本进行调用。为了更清楚地看到这一点,我们可以展开调用如下:
-
将函数调用为
fib_fast(6)。 -
在
fib_fast函数中,它检查缓存是否包含一个等于 6 的键。 -
答案是否定的,所以它调用
fib(5)。 -
在
fib函数中,由于n是5并且大于3,它递归地调用fib(4)和fib(3)。
如您所见,原始的fib函数被调用,而不是缓存的版本,所以我们回到了之前的问题。因此,如果被缓存的函数使用递归,那么我们必须将函数写成匿名函数。否则,可以创建一个带有新名称的缓存的函数。
支持接受多个参数的函数
在实践中,我们可能会遇到比这更复杂的函数。例如,需要加速的函数可能需要多个参数,以及可能的键控参数。我们之前章节中的memoize函数假设只有一个参数,所以它可能不会正常工作。
修复这个问题的一个简单方法如下所示:
function memoize(f)
memo = Dict()
(args...; kwargs...) -> begin
x = (args, kwargs)
if haskey(memo, x)
return memo[x]
else
value = f(args...; kwargs...)
memo[x] = value
return value
end
end
end
现在返回的匿名函数覆盖了任何数量的位置参数和关键字参数,正如在展开参数args...和kwargs...中指定的那样。我们可以用一个虚拟函数快速测试这一点如下:
# Simulate a slow function with positional arguments and keyword arguments
slow_op = (a, b = 2; c = 3, d) -> begin
sleep(2)
a + b + c + d
end
然后,我们可以创建快速版本如下:
op = memoize(slow_op)
让我们用几个不同的案例来测试缓存的函数:
它运行得很好!
处理参数中的可变数据类型
到目前为止,我们没有过多关注传递给函数的参数或关键字参数。当这些参数中的任何一个可变时,必须小心处理。为什么?因为我们的当前实现使用参数作为字典缓存的键。如果我们更改字典的键,可能会导致意外的结果。
假设我们有一个运行需要 2 秒的函数:
# This is a slow implementation
slow_sum_abs = (x::AbstractVector{T} where {T <: Real}) -> begin
sleep(2)
sum(abs(v) for v in x)
end
知道它相当慢,我们很高兴地像往常一样进行备忘录:
sum_abs = memoize(slow_sum_abs)
初始时,它似乎工作得完美,因为它一直是这样的:
然而,我们对以下观察感到震惊:
糟糕! 它返回的不是21的值,而是像没有向数组中插入-6一样返回了之前的结果。出于好奇,让我们向数组中再推入一个值并再次尝试:
它又正常工作了。为什么会这样呢?为了理解这一点,让我们回顾一下memoize函数是如何编写的:
function memoize(f)
memo = Dict()
(args...; kwargs...) -> begin
x = (args, kwargs)
if haskey(memo, x)
return memo[x]
...
如您所见,我们正在使用(args, kwargs)元组作为字典对象的键来缓存数据。问题是传递给备忘录sum_abs函数的参数是一个可变对象。当键被更改时,字典对象会变得困惑。在这种情况下,它可能不再定位到键。
当我们将-6添加到数组中时,它在字典中找到了相同的对象并返回了缓存的值。当我们向数组中添加7时,它找不到对象。因此,该函数并不总是 100%有效。
为了解决这个问题,我们需要确保考虑的是参数的内容,而不仅仅是容器的内存地址。一个常见的做法是将我们希望用作字典键的东西应用一个hash函数。以下是一个实现示例:
function hash_all_args(args, kwargs)
h = 0xed98007bd4471dc2
h += hash(args, h)
h += hash(kwargs, h)
return h
end
h变量的初始值是随机选择的。在 64 位系统上,我们可以通过调用rand(UInt64)来生成它。hash函数是在Base模块中定义的通用函数。为了说明目的,我们将保持这里的简单性。实际上,一个更好的实现将支持 32 位系统。
现在,memoize函数可以被重写以利用这种哈希方案:
我们可以更广泛地测试它。让我们再次使用新的memoize函数重新定义sum_abs函数。然后,我们运行一个循环并捕获计算结果和计时。
结果如下所示:
太棒了! 即使输入数据已经更改,它现在也能返回正确的结果。
使用宏备忘录通用函数
之前,我们讨论了泛型函数不能由 memoize 函数支持。如果在定义函数时就能将其标记为记忆化的,那将是最棒的。例如,语法将如下所示:
@memoize fib(n) = n < 3 ? 1 : fib(n-1) + fib(n-2)
结果表明,已经有一个名为 Memoize.jl 的出色包可以完成完全相同的功能。这确实非常方便:
在这里,我们可以观察到以下情况:
-
fib(40)的第一次调用已经非常快了,这表明缓存已经被利用。 -
fib(40)的第二次调用几乎是瞬间的,这意味着结果只是缓存查找。 -
fib(39)的第三次调用几乎是瞬间的,这意味着结果只是缓存查找。
应该提醒您,Memoize.jl 也不支持可变数据作为参数。它携带了我们在上一节中描述的相同问题,因为它使用对象的内存地址作为字典的键。
转到现实生活中的例子
记忆化在有些开源包中被使用。在私有应用程序和数据分析中,实际使用可能更为常见。在接下来的几节中,我们将看看记忆化的使用案例。
Symata.jl
Symata.jl 包提供了对斐波那契多项式的支持。正如我们可能已经意识到的,斐波那契多项式的实现也像我们在本节前面讨论的斐波那契序列问题一样是递归的。Symata.jl 使用 Memoize.jl 包创建 _fibpoly 函数,如下所示:
fibpoly(n::Int) = _fib_poly(n)
let myzero = 0, myone = 1, xvar = Polynomials.Poly([myzero,myone]), zerovar = Polynomials.Poly([myzero]), onevar = Polynomials.Poly([myone])
global _fib_poly
@memoize function _fib_poly(n::Int)
if n == 0
return zerovar
elseif n == 1
return onevar
else
return xvar * _fib_poly(n-1) + _fib_poly(n-2)
end
end
end
Omega.jl
Omega.jl 包实现了它自己的记忆化缓存。有趣的是,它使用 Core.Compiler.return_type 函数确保从缓存查找中返回正确的返回类型。这样做是为了避免类型不稳定性问题。在本章后面的“屏障函数模式”部分,我们将更详细地讨论类型不稳定性问题以及如何处理这个问题。查看以下代码示例:
@inline function memapl(rv::RandVar, mω::TaggedΩ)
if dontcache(rv)
ppapl(rv, proj(mω, rv))
elseif haskey(mω.tags.cache, rv.id)
mω.tags.cache[rv.id]::(Core.Compiler).return_type(rv, typeof((mω.taggedω,)))
else
mω.tags.cache[rv.id] = ppapl(rv, proj(mω, rv))
end
end
注意事项
记忆化只能应用于 纯 函数。
什么是纯函数?当一个函数对于相同的输入总是返回相同的值时,我们称之为纯函数。对于每个函数都按这种方式行为可能看起来很直观,但在实践中,这并不那么简单。有些函数由于以下原因不是纯函数:
-
一个函数使用随机数生成器,并期望返回随机结果。
-
一个函数依赖于来自外部源的数据,该数据在不同时间产生不同的数据。
因为记忆化模式使用函数参数作为内存缓存的键,所以对于相同的键,它总是会返回相同的结果。
另一个考虑因素是我们应该意识到由于使用缓存而导致的额外内存开销。对于特定的用例,选择正确的缓存失效策略非常重要。典型的缓存失效策略包括 最近最少使用(LRU)、先进先出(FIFO)和基于时间的过期。
利用 Caching.jl 包
有几个包可以使记忆化更容易。其中一些在此处被提及:
-
Memoize.jl提供了一个@memoize宏。它非常容易使用。 -
Anamnesis.jl提供了一个@anamnesis宏。它比Memoize.jl具有更多的功能。 -
Caching.jl是带着提供更多功能如持久化到磁盘、压缩和缓存大小管理的雄心创建的。
在这里,我们可以看看 Caching.jl,因为它最近开发出来,并且具有许多优秀特性。
让我们按照以下方式构建一个记忆化的 CSV 文件读取器:
@cache 宏创建了一个 read_csv 函数的记忆化版本。为了确认文件只被读取了一次,我们插入了一个 println 语句并计时文件读取操作。
为了演示目的,我们已经从纽约市下载了一份电影许可文件的副本。该文件可在 catalog.data.gov/dataset/film-permits 获取。现在让我们读取数据文件:
在这里,我们可以看到文件只被读取了一次。如果我们再次使用相同的文件名调用 read_csv,那么相同的对象会立即返回。
我们可以检查缓存。在这样做之前,让我们看看 read_csv 支持哪些属性:
不看手册,我们可以猜测 cache 属性代表缓存。让我们快速看一下:
我们还可以将缓存持久化到磁盘。让我们检查缓存文件的名字和大小:
缓存文件的存储位置可以在 filename 属性中找到。文件在未使用 @persist! 宏将数据持久化到磁盘之前不存在。我们也可以通过仅检查从 REPL 的函数 itself 来查看内存或磁盘上存在多少对象:
@empty! 宏可以用来清除内存中的缓存:
有趣的是,因为磁盘上的缓存仍然存在,我们仍然可以不重新填充内存缓存来利用它:
最后,我们可以同步内存和磁盘缓存:
Caching.jl 包具有更多在此处未展示的功能。希望我们已经对它的能力有了基本的了解。
接下来,我们将探讨一种可以用来解决类型不稳定性问题(这是一个常见的导致性能问题的原因)的模式。
障碍函数模式
虽然 Julia 被设计为一种动态语言,但它也旨在实现高性能。其魔力来自于其最先进的编译器。当函数中变量的类型已知时,编译器可以生成高度优化的代码。然而,当变量的类型不稳定时,编译器必须编译更通用的代码,这些代码可以与任何数据类型一起工作。在某种程度上,Julia 可以宽恕——即使它对运行时性能有所牺牲,它也不会让你失败。
什么使得变量的类型不稳定?这意味着在某些情况下,变量可能是一种类型,而在其他情况下,它可能是另一种类型。本节将讨论这种类型不稳定性问题,它可能如何产生,以及我们可以做些什么。
障碍函数模式是一种可以用来解决由于类型不稳定性引起的性能问题的模式。那么,让我们看看如何实现这一点。
识别类型不稳定的函数
在 Julia 中,没有必要指定变量的类型。更准确地说,变量是没有类型的。变量仅仅是与值绑定的,而值是有类型的。这就是 Julia 程序动态性的原因。然而,这种灵活性是有代价的。因为编译器必须生成支持在运行时可能出现的所有可能类型的代码,因此它无法生成优化代码。
考虑一个简单的函数,它只返回一个随机数数组:
random_data(n) = isodd(n) ? rand(Int, n) : rand(Float64, n)
如果n参数是奇数,则返回一个随机的Int值数组。否则,返回一个随机的Float64值数组。
这个看似无辜的函数实际上是类型不稳定的。我们可以使用@code_warntype功能进行检查:
@code_warntype宏显示了代码的中间表示(IR)。编译器在理解了代码中每一行的流程和数据类型后,会生成一个 IR。对于我们这里的用途,我们不需要理解屏幕上打印出的所有内容,但我们可以关注与代码生成数据类型相关的突出文本。一般来说,当你看到红色文本时,它也会是一个红色的警告标志。
在这种情况下,编译器已经推断出这个函数的结果可以是一个Float64类型的数组或一个Int64类型的数组。因此,返回类型只是Union{Array{Float64,1}, Array{Int64,1}}。
通常,@code_warntype输出的红色标志越多,代码中的类型不稳定性问题就越多。
函数确实做了我们想要做的事情。但是当它在另一个函数的主体中使用时,类型不稳定性问题进一步影响了运行时性能。我们可以使用一个障碍函数来解决这个问题。
理解性能影响
当一个函数被调用时,其参数的类型是已知的,然后函数会根据其参数的确切数据类型进行编译。这被称为专业化。那么什么是屏障函数呢?它只是简单地利用 Julia 的函数专业化来稳定变量类型,作为函数调用的一部分。我们将继续前面的例子来说明这项技术。
首先,让我们创建一个简单的函数,该函数使用前面提到的类型不稳定的函数:
function double_sum_of_random_data(n)
data = random_data(n)
total = 0
for v in data
total += 2 * v
end
return total
end
double_sum_of_random_data 函数只是一个简单的函数,它返回由 random_data 函数生成的双倍随机数的和。如果我们只用奇数或偶数参数来基准测试这个函数,它将返回以下结果:
当输入值为 100001 时,调用时间更好,这很可能是由于 Int 的随机数生成器比 Float64 的更好。让我们看看 @code_warntype 对这个函数的反馈:
如您所见,周围有很多红色标记。单个函数的类型不稳定性问题对其使用的其他函数影响更大。
开发屏障函数
屏障函数涉及将现有函数中的一段逻辑重构到一个新的、独立的函数中。完成之后,所有需要的新函数的数据都将作为函数参数传递。继续前面的例子,我们可以将计算数据双倍和的逻辑提取出来如下:
function double_sum(data)
total = 0
for v in data
total += 2 * v
end
return total
end
然后,我们只需修改原始函数以利用这个函数:
function double_sum_of_random_data(n)
data = random_data(n)
return double_sum(data)
end
它真的提高了性能吗?让我们运行测试:
对于 Float64 的情况,这显示出巨大的差异——经过时间从 347 纳秒减少到 245 纳秒。比较浮点数求和与整数求和的情况,结果也完全合理,因为通常整数求和比浮点数求和更快。
处理类型不稳定的输出变量
我们还没有注意到另一个与累加器相关的类型不稳定性问题。在前面的例子中,double_sum 函数有一个 total 变量,用于跟踪双倍数字。问题是该变量被定义为整数,但数组可能包含浮点数。这个问题可以通过对两种情况运行 @code_warntype 来轻松揭示。
当将整数数组传递到函数中时,@code_warntype 的输出如下:
与传递 Float64 数组时的输出进行比较:
如果我们用一个整数数组调用该函数,那么类型是稳定的。如果我们用一个浮点数数组调用该函数,那么我们会看到类型不稳定性问题。
我们该如何解决这个问题呢?嗯,有标准的 Base 函数可以创建类型稳定的零或一。例如,我们不必将 total 的初始值硬编码为整数零,而是可以这样做:
function double_sum(data)
total = zero(eltype(data))
for v in data
total += 2 * v
end
return total
end
如果我们查看 double_sum_of_random_data 函数的 @code_warntype 输出,它比之前好得多。我将让你做这个练习,并将 @code_warntype 输出与之前的一个进行比较。
类似的解决方案使用了参数化方法:
function double_sum(data::AbstractVector{T}) where {T <: Number}
total = zero(T)
for v in data
total += v
end
return total
end
类型参数 T 用于将 total 变量初始化为正确的零值类型。
这种性能问题有时很难捕捉到。为了确保生成优化的代码,始终使用以下函数作为累加器或存储输出值的数组是一个好习惯:
-
zero和zeros为所需类型创建一个 0 或 0 的数组。 -
one和ones为所需类型创建一个 1 或 1 的数组。 -
similar创建一个与数组参数具有相同类型的数组。
例如,我们可以为任何数值类型创建一个 0 或 0 的数组,如下所示:
同样,one 和 ones 函数以相同的方式工作:
如果我们想要创建一个看起来像另一个数组(换句话说,具有相同的类型、形状和大小)的数组,那么我们可以使用 similar 函数:
注意,similar 函数不会将数组的内容清零。
当我们需要创建一个与另一个数组具有相同维度的零数组时,axes 函数可能很有用:
接下来,我们将探讨一种调试类型不稳定性问题的方法。
使用 @inferred 宏
Julia 在 Test 包中提供了一个方便的宏,可以用来检查函数的返回类型是否与函数的 推断 返回类型匹配。推断的返回类型简单地是我们从 @code_warntype 输出中看到的类型。
例如,我们可以检查本节开头的臭名昭著的 random_data 函数:
该宏在实际返回类型与推断返回类型不同时报告错误。它可以作为一个有用的工具,作为持续集成管道中自动化测试套件的一部分来验证类型不稳定性问题。
使用屏障函数的主要原因是提高存在类型不稳定性问题的性能。如果我们更深入地思考,它还有副作用,即迫使我们创建更小的函数。较小的函数更容易阅读和调试,并且性能更好。
我们现在已经总结了本章的所有模式。
摘要
在本章中,我们探讨了与性能相关的几个模式。
首先,我们讨论了全局变量如何影响性能以及全局常量模式的技术。我们研究了编译器如何通过常数折叠、常数传播和死分支消除来优化性能。我们还学习了如何创建一个常数占位符来包装全局变量。
我们讨论了如何利用数组结构模式将结构体数组转换为数组结构体。这种数据结构的新布局允许更好的 CPU 优化,从而提高性能。我们利用了一个非常有用的包StructArrays来自动化这种数据结构转换。我们回顾了一个金融服务用例,其中需要将大量数据加载到内存中并由多个并行进程使用。我们实现了共享数组模式,并介绍了一些在操作系统中正确配置共享内存的技巧。
我们学习了记忆化模式用于缓存函数调用结果。我们使用字典缓存进行了一个示例实现,使其能够与接受各种参数和关键字参数的函数一起工作。我们还找到了一种支持可变对象作为函数参数的方法。最后,我们讨论了屏障函数模式。我们看到了类型不稳定的变量如何降低性能。我们了解到将逻辑拆分到单独的函数中可以让编译器生成更优化的代码。
在下一章中,我们将探讨几个提高系统可维护性的模式。
问题
-
为什么全局变量的使用会影响性能?
-
当全局变量不能被常数替换时,使用什么作为替代方案?
-
为什么数组结构比结构体数组表现更好?
-
SharedArray有哪些局限性? -
使用并行进程之外,多核计算的替代方案是什么?
-
使用记忆化模式时需要注意哪些事项?
-
提高性能的屏障函数背后的魔法是什么?