Julia 设计模式与最佳实践实用指南(四)
原文:
annas-archive.org/md5/cf7c7ac4f7ce92066baf2d230554da6f译者:飞龙
第十章:反模式
在过去的五章中,我们详细探讨了可重用性、性能、可维护性、安全性和一些杂项设计模式。这些模式非常有用,可以应用于各种不同类型的应用程序的不同场景。虽然了解最佳实践很重要,但了解要避免的陷阱也同样有益。为此,我们将在本章中介绍几个反模式。
反模式是程序员可能无意中采取的坏做法。有时,这些问题可能不够严重,不足以造成麻烦;然而,由于设计不当,应用程序可能会变得不稳定或性能下降。在本章中,我们将涵盖以下主题:
-
盗版反模式
-
窄参数类型反模式
-
非具体字段类型反模式
到本章结束时,您将学会如何避免开发盗版函数。您还将对指定函数参数类型时的抽象级别更加警觉和明智。最后,您将能够在设计自己的复合类型时利用更多的参数化类型,以实现高性能应用。
让我们从最有趣的话题——盗版!开始吧。
技术要求
示例源代码位于github.com/PacktPublishing/Hands-on-Design-Patterns-and-Best-Practices-with-Julia/tree/master/Chapter10.
代码在 Julia 1.3.0 环境中进行了测试。
盗版反模式
在第二章“模块、包和数据类型概念”中,我们学习了如何使用模块创建新的命名空间。如您所回忆的那样,模块用于定义函数,以便它们在逻辑上分离。因此,我们可以定义两个不同的函数——一个在模块 X 中,另一个在模块 Y 中,这两个函数具有完全相同的名称。实际上,这些函数甚至不需要具有相同的意义。例如,在一个数学包中,我们可以为矩阵定义一个trace函数。在计算机图形学包中,我们可以定义一个用于进行光线追踪工作的trace函数。这两个trace函数执行不同的操作,并且它们不会相互干扰。
另一方面,一个函数也可以设计成可以从另一个包扩展。例如,在Base包中,AbstractArray接口被设计成可以扩展。以下是一个例子:
# My own array-like type for tracking scores
struct Scores <: AbstractVector{Float64}
values::Vector{Float64}
end
# implement AbstractArray interface
Base.size(s::Scores) = (length(s.values),)
Base.getindex(s::Scores, i::Int) = s.values[i]
在这里,我们扩展了来自 Base 包的 size 和 getindex 函数,以便它们可以与我们的自定义数据类型一起工作。这是 Julia 语言的一个非常好的用法;然而,当我们没有正确扩展其他包的函数时,可能会出现问题。特别是,“盗版”指的是第三方函数被错误地替换或扩展的情况。这是一个反模式,因为它可能导致系统行为变得非确定性。为了方便起见,我们可以定义三种不同类型的盗版:
-
类型 I 盗版行为:功能被重新定义
-
类型 II 盗版行为:在没有任何参数中使用自己的类型扩展函数
-
类型 III 盗版行为:函数被扩展但用于不同的目的
我们现在将更详细地探讨每一个。
类型 I – 重新定义函数
类型 I 盗版行为指的是程序员在自己的模块中重新定义第三方函数的情况。也许你不喜欢第三方模块中的原始实现,并用你自己的实现替换了该函数。
类型 I 盗版行为最糟糕的形式是,在不遵守原始函数接口的情况下替换函数。让我们做一个实验看看会发生什么。我们将使用 Base 中的 + 函数作为例子。正如你所知,当 + 函数传递两个 Int 参数时,它应该返回一个 Int 类型的结果。如果我们替换函数使其返回一个字符串会发生什么?让我们打开一个 REPL 并试一试:
砰! Julia REPL 在定义函数的瞬间立即崩溃。这是因为这个 + 函数的返回值预期是一个整数。当我们返回一个字符串时,它违反了这个函数的契约,并且所有依赖于 + 函数的功能都会受到负面影响。鉴于 + 是一个常用的函数,它立即导致系统崩溃。
为什么 Julia 甚至允许我们这样做?在某些情况下,这种能力可能是有用的。比如说,你发现了一个第三方包中特定函数的 bug—you 可以立即注入修复,而不必等待上游的 bug 修复。同样,你可以用更高效的版本替换一个慢函数。理想情况下,这些更改应该发送到上游,但你也有立即实施更改的灵活性。
唯一的要求是,被替换的函数应该遵守最初打算的相同契约。因此,需要对第三方包的设计有深入的了解。实际上,如果你在应用盗版之前能联系到原始作者并讨论更改,那就更好了。
*权力越大,责任越大。*如果我们想利用类型 I 盗版行为,就必须非常小心。
接下来,我们将探讨类型 II 盗版行为,这在 Julia 生态系统中的包中更为常见。
类型 II 盗版 – 没有使用自己的类型进行扩展
类型 II 盗版在 Julia 开发者社区中通常被称为 类型盗版。它指的是在没有使用程序员自己的类型作为任何函数参数的情况下扩展第三方函数的情况。这通常发生在您想通过注入自己的代码来扩展第三方包时。让我们通过一个假设的例子来探讨。
假设您想在 JavaScript 中模拟将字符串和数字相加的行为,其中值像字符串一样连接:
要在 Julia 中实现这一点,我们可能会在 MyModule 中做以下操作:
module MyModule
import Base.+
(+)(s::AbstractString, n::Number) = "$s$n"
end
我们可以在 REPL 中粘贴前面的代码并进行快速测试:
这看起来工作得很好!但是,这种方法还有一些隐藏的问题。让我们看看为什么这仍然是一个坏主意。
与另一个海盗冲突
现在我们正在使用 + 函数的增强版本,我们能依赖这个函数始终如我们所期望的那样工作吗?也许令人惊讶的是,答案是否定的。
假设我们找到一个名为 AnotherModule 的开源包,我们想在我们的 MyModule 模块中使用它。AnotherModule 模块恰好也做了同样的类型 II 盗版;然而,作者决定做正确的事情——不是像字符串一样连接参数,而是将字符串参数解析为数字,然后将两个数字相加。代码如下:
module AnotherModule
import Base: +, -, *, /
(+)(s::AbstractString, n::T) where T <: Number = parse(T, s) + n
(-)(s::AbstractString, n::T) where T <: Number = parse(T, s) - n
(*)(s::AbstractString, n::T) where T <: Number = parse(T, s) * n
(/)(s::AbstractString, n::T) where T <: Number = parse(T, s) / n
end
如果我们回到 REPL 并定义这个模块,那么我们会得到新的定义:
现在我们有两个具有完全相同签名的相同函数实现,但它们返回的结果不同。谁将获胜?是定义在 MyModule 中的那个还是定义在 AnotherModule 中的那个?只有一个可以生效。这意味着 AnotherModule 或 MyModule 中将有一个会出问题。这个问题可能导致灾难性的情况,并且难以发现的错误。
避免类型 II 盗版的另一个原因是未来兼容性问题。我们将在下一节讨论这个问题。
代码的未来兼容性
假设我们已经将 Base 中的 + 函数扩展如下:
module MyModule
import Base.+
(+)(s::AbstractString, n::Number) = "$s$n"
end
今天这看起来可能是一个很好的补充;然而,并不能保证在未来的 Julia 版本中相同的函数不会被实现。可以想象(这并不意味着它可能或不可能)+ 函数将来会被增强以支持字符串。
此外,这类更改将被视为非破坏性更改,这意味着 Julia 开发团队只需通过小版本发布即可添加此功能。不幸的是,现在您的应用程序因为非破坏性的 Julia 升级而崩溃。这不是我们通常期望的事情。
如果您想使代码具有未来兼容性,那么请不要成为海盗!
避免类型盗版
通过创建自己的类型并使用它们作为函数参数,可以减轻Ⅱ级海盗行为。在这种情况下,也许我们应该考虑创建一个包装类型来保存字符串,并使用这个新类型进行分发:
module MyModule
export @str_str
import Base: +, show
struct MyString
value::AbstractString
end
macro str_str(s::AbstractString)
MyString(s)
end
show(io::IO, s::MyString) = print(io, s.value)
(+)(s::MyString, n::Number) = MyString(s.value * string(n))
(+)(n::Number, s::MyString) = MyString(string(n) * s.value)
(+)(s::MyString, t::MyString) = MyString(s.value * t.value)
end
在这里,我们重新定义了模块,使用新的 MyString 类型来保存字符串。然后,我们仍然可以扩展 + 函数以将 MyString 与任何数量的字符串连接起来。为了完整性,我们已定义了三个 + 函数变体,用于接受任意顺序的 MyString 和 Number 参数,以及另一个接受两个 MyString 参数的变体。我们还定义了一个 str_str 宏以方便使用。新的模块按如下方式正常工作:
通过在函数参数中使用自己的类型,我们可以避免与其他依赖包发生冲突,并为 Julia 升级提供未来保障。
最后一种海盗行为稍微轻微一些,但仍值得一看。让我们看看下一个。
Ⅲ级海盗行为 – 使用你自己的类型,但用于不同的目的
Ⅲ级海盗行为指的是扩展了函数,但用于不同的目的的情况。这是扩展代码的正确程序,但以错误的方式执行。这种海盗行为也被 Julia 开发者称为 pun。为了理解它是什么,让我们在这里考虑一个有趣的例子。
假设我们正在开发一个简单的派对注册应用程序。类型定义和构造函数如下所示:
# A Party just contains a title and guest names
struct Party
title::String
guests::Vector{String}
end
# constructor
Party(title) = Party(title, String[])
Party 类型仅包含一个标题和一组嘉宾名称。构造函数仅接受标题并将嘉宾数组初始化为空数组。现在,为了显得可爱,我们可以定义一个如下所示的参加派对的函数:
Base.join(name::String, party::Party) = push!(party.guests, name)
这是 Base 中 join 方法的扩展。我们为什么要这样做呢?好吧,如果我们在我们自己的命名空间中创建 join 函数,那么我们可能会与标准 join 函数发生命名冲突。为了避免处理这种冲突,也许直接从 Base 扩展函数更容易。
初看起来,它应该按预期工作:
然而,这里有一个隐藏的陷阱。如果我们让多个人同时参加派对,那么我们很容易陷入麻烦:
发生了什么?让我们看看 join 函数的原始含义,如 help 屏幕所示:
help?> join
join([io::IO,] strings, delim, [last])
join 函数的目的是将多个字符串组合在一起,并用某种分隔符分隔。因此,前面代码中对 join 函数的调用最终使用了 Party 对象作为分隔符。
让我们稍微思考一下我们是如何陷入麻烦的。当我们使用自己的类型(Party)定义函数时,我们没有预料到我们的函数会被除我们自己的代码之外的任何代码使用。然而,这里并非如此。我们的函数显然被 Base 包中的字符串连接逻辑所使用。
结果表明,我们是不幸的鸭子类型的受害者。如果你查看 Julia 的源代码,你会发现一些join函数在参数中未指定任何类型。因此,当我们向join函数传递Party对象时,它就会泄露到原始的join逻辑中。更糟糕的是,没有抛出错误,因为一切只是正常工作。
最好完全避免类型 III 盗用。在前面的例子中,我们可以在自己的模块中定义join函数,而不是扩展Base中的函数。如果我们被名称冲突问题所困扰,我们也可以选择不同的函数名——例如,register。我们必须意识到,加入一个派对的意义并不等同于将字符串连接起来。
所有三种盗用类型都是不好的,它们可能导致难以找到或调试的 bug。我们应该尽可能地避免它们。
接下来,我们将讨论与函数定义中指定参数类型相关的另一种反模式。
狭义参数类型反模式
在 Julia 中设计函数时,我们有很多关于是否以及如何提供参数类型的选择。狭义参数类型反模式指的是参数类型被过于狭窄地指定,导致函数不必要的无用。
让我们考虑一个简单的示例函数,该函数用于计算两个向量的乘积之和:
function sumprod(A::Vector{Float64}, B::Vector{Float64})
return sum(A .* B)
end
这个设计没有问题,只是功能只能在参数是Float64值向量时使用。其他可能的选择有哪些?让我们接下来看看。
考虑参数类型的各种选项
Julia 的调度机制可以在传递的参数类型与函数签名匹配的情况下选择正确的函数来调用。基于类型层次结构,我们可以指定抽象类型,并且函数仍然会被正确选择。
这种灵活性给我们提供了很多选择。我们可以考虑以下任何一种:
-
sumprod(A::Vector{Float64}, B::Vector{Float64}) -
sumprod(A::Vector{Number}, B::Vector{Number}) -
sumprod(A::Vector{T}, B::Vector{T}) where T <: Number -
sumprod(A::Vector{S}, B::Vector{T}) where {S <: Number, T <: Number} -
sumprod(A::Array{S,N}, B::Array{T,N}) where {N, S <: Number, T <: Number} -
sumprod(A::AbstractArray{S,N}, B::AbstractArray{T,N}) where {N, S <: Number, T <: Number} -
sumprod(A, B)
我们的功能最合适的选项是哪一个?我们还没有确定,但我们可以始终回顾我们的需求,在得出结论之前进行一些测试。
让我们首先定义我们计划支持的场景。正如我们所期望的,这只是一个数值计算:我们希望支持任何支持广播的数值容器。广播是必需的,因为我们使用点符号来计算前面代码中 A 和 B 的乘积。
我们的测试场景涉及以下参数组合:
| 场景 | 参数 1 | 参数 2 |
|---|---|---|
| 1 | Array{Float64, 1} | Array{Float64, 1} |
| 2 | Array{Int64, 1} | Array{Int64, 1} |
| 3 | Array{Int, 1} | Array{Float64, 1} |
| 4 | Array{Float64, 2} | Array{Float64, 2} |
| 5 | Array{Number,1} | Array{Number,1} |
为了测试各种函数签名选项的这些场景,我们可以构建一个测试框架函数,如下所示:
function test_harness(f, scenario, args...)
try
f(args...)
println(f, " #$(scenario) success")
catch ex
if ex isa MethodError
println(f, " #$(scenario) failure (method not selected)")
else
println(f, " #$(scenario) failure (unknown error $ex)")
end
end
end
测试框架将函数 f 与提供的参数 args 对特定 scenario 进行应用。如果函数被调度,它将在控制台显示成功消息;否则,它将显示失败消息。由于我们想要测试前面列出的场景,我们可以定义一个额外的函数,这样我们就可以轻松地执行我们的测试:
function test_sumprod(f)
test_harness(f, 1, [1.0,2.0], [3.0, 4.0]);
test_harness(f, 2, [1,2], [3,4]);
test_harness(f, 3, [1,2], [3.0,4.0]);
test_harness(f, 4, rand(2,2), rand(2,2));
test_harness(f, 5, Number[1,2.0], Number[3.0, 4]);
end
test_sumprod 函数接受一个函数并执行前五个测试用例。
现在我们已经准备好了。让我们分析每个选项,看看它们对我们有多有效。
选项 1 – Float64 值的向量
第一个选项是我们在这个部分开始时使用的。它具有最具体的参数类型。缺点是它只能与 Float64 值的向量一起工作。
让我们按照以下方式定义我们的函数,以便我们可以将其传递给测试函数:
sumprod_1(A::Vector{Float64}, B::Vector{Float64}) = sum(A .* B)
我们现在可以尝试我们的测试框架了:
如预期的那样,这个函数可以在两个参数都是 Float64 值的向量时与第一个场景一起工作。因此,它并不满足我们的所有要求。让我们尝试下一个选项。
选项 2 – Number 实例的向量
第二个选项稍微有趣一些。我们将类型参数从 Float64 更改为 Number,这是数值类型层次结构中最顶层的抽象类型:
sumprod_2(A::Vector{Number}, B::Vector{Number}) = sum(A .* B)
现在让我们测试一下:
初看之下,使用 Number 作为类型参数似乎会使它更通用。但实际上,它只能接受 Number 类型的数组,这意味着它必须是一个异构数组,其中每个元素可以是不同类型,只要所有元素类型都是 Number 的子类型。因此,Float64 值的向量不是 Number 值向量的子类型。请检查以下代码片段:
因此,除了最后一个选项之外,没有任何场景成功,最后一个选项接受 Number 类型的向量作为参数。所以这个选项也不是一个好的选择。让我们继续前进!
选项 3 – 类型为 T 的向量,其中 T 是 Number 的子类型
第三个选项是取类型为 T 的向量,其中 T 只是 Number 的子类型。
函数可以定义如下:
sumprod_3(A::Vector{T}, B::Vector{T}) where T <: Number = sum(A .* B)
让我们先试一下:
由于类型参数 T 可以是 Number 的任何子类型,这个函数可以舒适地处理 Float64、Int64 以及甚至 Number 类型的向量。不幸的是,它不能处理不同类型的参数,但我们应该能够进一步改进它。让我们尝试下一个选项。
选项 4 – 类型为 S 和 T 的向量,其中 S 和 T 是 Number 的子类型
这个选项与选项 3 的区别仅在于参数类型是分别指定的。因此,函数可以接受第一和第二个参数的不同类型。函数定义如下:
sumprod_4(A::Vector{S}, B::Vector{T}) where {S <: Number, T <: Number} = sum(A .* B)
我们现在可以尝试一下:
我们现在已经解决了混合参数类型的问题。我们越来越接近最终目标。场景 4 是参数是矩阵而不是向量的情况。我们当然知道如何解决这个问题,所以让我们接下来做。
选项 5 – 类型为 S 和 T 的数组,其中 S 和 T 是 Number 的子类型
由于 Julia 数组支持广播,我们可以将函数参数从 Vector{T} 通用化到 Array{T,N} 签名,以支持多维数组。现在让我们定义函数如下:
sumprod_5(A::Array{S,N}, B::Array{T,N}) where {N, S <: Number, T <: Number} =
sum(A .* B)
我们相当有信心这会起作用。现在让我们测试它:
太棒了! 我们终于满足了测试场景中列出的所有要求。我们完成了吗?也许还没有。为了辩论,我们可能希望支持其他类型的容器,这些容器不一定是密集数组。如果输入是稀疏矩阵怎么办?让我们再次改进这个函数。
选项 6 – 抽象数组
AbstractArray 是所有 Julia 数组容器的抽象类型。许多 Julia 包实现了数组接口,并成为 AbstractArray 的子类型。如果我们把 sumprod 函数做得足够通用,却不能支持稀疏矩阵或其他类型的数组容器,那就太遗憾了。为了使其更通用,让我们将函数定义从 Array 转换为 AbstractArray,如下所示:
sumprod_6(A::AbstractArray{S,N}, B::AbstractArray{T,N}) where
{N, S <: Number, T <: Number} = sum(A .* B)
签名与上一个选项相同,只是函数可以使用任何 AbstractArray 容器类型进行分发。让我们确保函数按预期工作:
函数在我们的现有案例中运行正常。让我们再次尝试使用稀疏矩阵类型来测试它:
太棒了! 现在它运行得很好,甚至是非密集数组类型。我们几乎完成了。让我们看看我们的最后一个选项——鸭式类型。
选项 7 – 鸭式类型
我们最后一个选项基本上跳过了函数参数中的类型。这也被称为鸭式类型,因为只要提供了两个参数,函数就会被分发。Julia 将针对不同参数类型的变体进行特化和编译新版本。函数简单地定义为如下:
sumprod_7(A, B) = sum(A .* B)
为了完整性,我们将再次运行测试:
这个选项的好处是函数在签名中没有类型信息,看起来非常干净。然而,缺点是函数可以针对任何类型进行分发——甚至不是数组或数值。当垃圾数据传递给函数时,输出也是垃圾,或者当传递的对象没有定义*运算符函数时,函数会抛出错误。
现在我们已经考虑了所有选项并执行了相应的测试,让我们总结一下到目前为止我们已经做了什么,以及我们接下来想做什么。
总结所有选项
让我们现在总结一下到目前为止我们已经考虑的所有选项:
| 选项 | 签名 | 所有测试都通过吗? |
|---|---|---|
| 1 | sumprod(A::Vector{Float64}, B::Vector{Float64}) | 否 |
| 2 | sumprod(A::Vector{Number}, B::Vector{Number}) | 否 |
| 3 | sumprod(A::Vector{T}, B::Vector{T}) where T <: Number | 否 |
| 4 | sumprod(A::Vector{S}, B::Vector{T}) where {S <: Number, T <: Number} | 否 |
| 5 | sumprod(A::Array{S,N}, B::Array{T,N}) where {N, S <: Number, T <: Number} | 是 |
| 6 | sumprod(A::AbstractArray{S,N}, B::AbstractArray{T,N}) where {N, S <: Number, T <: Number} | 是 |
| 7 | sumprod(A, B) | 是 |
从技术上来说,选项 5、6 或 7 可以适用于所有数组类型。选项 6 和 7 支持其他数组容器,例如稀疏矩阵。选项 7 与非AbstractArray类型一起工作,只要类型支持广播乘法和加法。
在我们得出结论之前,让我们从性能的角度进行最后一次测试。你是否想知道让函数接受更通用的类型是否会牺牲性能?了解这一点唯一的方法是通过实际实验来证明。让我们接下来这么做。
评估性能
当我们在函数参数中接受更通用的类型时,我们会牺牲性能吗?让我们进行一些基准测试,看看它们的性能如何。
在这里,我们将使用完全相同的输入:两个包含 10,000 个元素的Float64向量,对选项 1、5、6 和 7 中的函数进行基准测试:
using BenchmarkTools
A = rand(10_000);
B = rand(10_000);
@btime sumprod_1($A, $B);
@btime sumprod_5($A, $B);
@btime sumprod_6($A, $B);
@btime sumprod_7($A, $B);
下面是测试结果:
如您所见,这些选项之间没有实质性的差异。如何指定参数类型不会影响函数的运行时性能。
总结来说,我们关于这种反模式学到的经验是,函数参数不应该无必要地设置得太窄。当范围广泛时,一个函数可以更加有用。一个可以接受和支持更多输入类型的函数自动具有更高的可重用性。
我们下一个反模式与设计数据类型时如何选择字段类型有关。这是一个极其重要的话题,因为它可以显著影响系统性能。
非具体字段类型反模式
非具体字段类型的反模式是一种结构字段不是具体类型的反模式。对于字段的非具体类型的主要问题是它们可能会引起重大的性能问题。为了理解为什么,让我们看看具有非具体类型与具体类型组合类型的内存布局,然后设计和比较这两个。
理解复合数据类型的内存布局
让我们先看看一个用于跟踪点坐标的复合类型的简单例子:
struct Point
x
y
end
当字段类型未指定时,它隐式地解释为所有类型的超类型 Any,因此前面的代码在语法上等同于以下代码(除了我们将类型名称重命名为 Point2 以避免混淆):
struct Point2
x::Any
y::Any
end
字段 x 和 y 有 Any 类型,这意味着它们可以是任何东西:Int64、Float64 或任何其他数据类型。为了比较内存布局和利用率,值得创建一个新的点类型,它使用小的具体类型,如 UInt8:
struct Point3
x::UInt8
y::UInt8
end
如我们所知,UInt8 应该占用单个字节的存储空间。x 和 y 字段同时存在应该只消耗两个字节的存储空间。也许我们应该亲自证明这一点。检查以下代码:
明显地,一个单独的 Point3 对象只占用两个字节。让我们用原始的 Point 对象做同样的操作:
Point 对象占用 16 字节,尽管我们只想存储两个字节。正如我们所知,Point 对象可以在 x 和 y 字段中存储任何数据类型。现在,让我们用更大的数据类型,如 Int128,来做同样的练习:
Int128 是一个 128 位整数,在内存中占用 16 字节。有趣的是,尽管我们在 Point 中携带了两个 Int128 字段,但对象的大小仍然保持在 16 字节。
为什么?这是因为 Point 实际上存储了两个 64 位指针,每个指针占用八字节的存储空间。我们可以这样可视化 Point 对象的内存:
当字段类型是具体的时,Julia 编译器确切地知道内存布局看起来像什么。对于两个 UInt8 字段,它以紧凑的方式用两个字节表示。对于两个 Int128 字段,它将占用 32 字节。让我们在 REPL 中尝试一下:
Point4 的内存布局紧凑,如下面的图所示:
现在我们知道了内存布局的差异,我们可以立即看到使用具体类型的优势。每次我们需要访问x或y字段时,如果它是具体类型,那么数据就在那里。如果字段只是指针,那么我们必须取消引用指针以找到数据。此外,x和y的物理内存位置可能甚至不相邻,这可能导致硬件缓存未命中,从而进一步影响性能。
那么,我们是否只是遵循在字段定义中直接使用具体类型的规则?不一定。我们还有其他可以考虑的选项,我们将在接下来的章节中讨论。
考虑具体类型设计复合类型
也许我们最初在字段中使用抽象类型的原因是为了支持字段中的不同类型数据。以上一节中的Point类型为例,我们可以看到这种类型在计算机游戏环境中非常有用,因为在游戏中坐标是通过屏幕上的整数像素位置来识别的。另一方面,我们也认为同样的类型可能对存储建筑图纸中形状的坐标也很有用,在这种情况下,我们可能需要使用浮点值。
如果我们想要更灵活,我们希望支持任何Real类型的子类型的Point字段。从概念上讲,我们希望得到如下所示的东西:
struct Point
x::Real
y::Real
end
然而,由于Real是一个抽象类型,我们预计性能会较差,就像使用Any一样。为了在不牺牲支持其他数值类型灵活性的情况下利用具体类型,我们可以将Point转换为参数化类型。让我们重新启动 REPL 并定义新的Point类型,如下所示:
struct Point{T <: Real}
x::T
y::T
end
将其设计为参数化类型的好处是它是具体的。我们可以很容易地从 REPL 中检查这一点。以下是一个基本的语法实现:
以下代码展示了另一个示例:
到目前为止,我们一直假设在struct字段中,具体类型会比非具体类型表现更好。了解这种差异有多大会有所帮助。现在让我们试试看。
比较混凝土与非混凝土字段类型之间的性能
我们可以使用这两种不同的类型进行性能测试,如下所示:
我们的基准测试函数将计算数组中所有点的中心,如下所示:
using Statistics: mean
function center(points::AbstractVector{T}) where T
return T(
mean(p.x for p in points),
mean(p.y for p in points))
end
此外,我们还将定义一个函数,可以用于为任何我们想要的类型创建点的数组:
make_points(T::Type, n) = [T(rand(), rand()) for _ in 1:n]
让我们从PointAny类型开始。
我们将生成 100,000 个点,并使用BenchmarkTools来测量时间:
接下来,我们将对Point类型进行性能测试:
如我们所见,两者之间存在着巨大的差异。使用参数化Point类型比使用Any作为字段类型的速度快约 25 倍。
从这个反模式中我们学到的经验是,我们应该为在复合类型中定义的字段使用具体类型。将我们想要的抽象类型提取出来作为一个类型参数是非常容易的。这样做可以让我们在不牺牲支持其他数据类型能力的情况下,从具体类型中获得性能上的好处。
摘要
在本章中,我们了解了 Julia 编程中的一些反模式。当我们详细研究每个反模式时,我们也找到了应用替代设计解决方案的方法。
我们从盗版反模式开始,它指的是与从第三方模块扩展函数相关的坏习惯。为了方便起见,我们将盗版反模式分为三种不同类型——I 型、II 型和 III 型。每种类型都会在导致系统不稳定或未来可能引起问题的过程中带来不同的问题。
接下来,我们研究了狭隘的参数类型反模式。当函数参数过于狭窄时,它们的可重用性会降低。因为 Julia 可以为各种参数类型对函数进行特殊化,所以尽可能使参数类型通用化,利用抽象类型,这样做更有益。我们详细探讨了几个设计选项,并得出结论:最通用的类型可以在不牺牲性能的情况下使用。
最后,我们回顾了非具体字段类型反模式。我们证明了由于产生的低效内存布局结构,拥有非具体类型会带来性能问题。我们推测,这个问题可以通过使用参数类型,将具体类型指定为类型参数的一部分来轻松解决。
在下一章中,我们将关注传统的面向对象设计模式,并探讨它们如何在 Julia 编程中应用。系好安全带:如果你曾经是面向对象程序员,你的旅程可能会有些颠簸!
问题
-
类型 I 盗版的风险和潜在好处是什么?
-
类型 II 盗版可能引发什么问题?
-
类型 III 盗版是如何引起麻烦的?
-
指定函数参数时我们应该注意什么?
-
使用抽象函数参数会如何影响系统性能?
-
使用抽象字段类型为复合类型时,系统性能会受到怎样的影响?
第十一章:传统面向对象模式
到目前为止,我们已经学习了成为一名有效的 Julia 程序员所需了解的许多设计模式。前几章中提出的案例包括了我们可以通过编写惯用的 Julia 代码解决的问题。有些人可能会问,经过这么多年,我已经学习和适应了面向对象编程(OOP)范式;我如何在 Julia 中应用同样的概念?一般的回答是,你不会以同样的方式解决问题。用 Julia 编写的解决方案将看起来不同,反映了不同的编程范式。尽管如此,思考如何在 Julia 中采用一些 OOP 技术仍然是一个有趣的练习。
在本章中,我们将涵盖经典《四人帮》(GoF)《设计模式》书中所有的 23 种设计模式。我们将保持传统,以下章节中组织主题:
-
创建型模式
-
行为模式
-
结构模式
到本章结束时,你将了解这些模式如何在 Julia 中应用,与面向对象方法相比。
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
创建型模式
创建型模式指的是构建和实例化对象的各种方式。由于 OOP 将数据和操作组合在一起,并且由于一个类可能从祖先类继承结构和行为,因此在构建大型系统时涉及额外的复杂性。设计上,Julia 已经通过不允许在抽象类型中声明字段和不允许从具体类型创建新子类型来解决了许多问题。尽管如此,在某些情况下,这些模式可能有所帮助。
创建型模式包括工厂方法、抽象工厂、单例、建造者和原型模式。我们将在以下章节中详细讨论它们。
工厂方法模式
工厂方法模式的想法是提供一个单一接口来创建符合接口的不同类型的对象,同时隐藏实际实现细节。这种抽象将客户端与功能提供者的底层实现解耦。
例如,一个程序可能需要在输出中格式化一些数字。在 Julia 中,我们可能想使用Printf包来格式化数字,如下所示:
也许我们不想与 Printf 包耦合,因为我们希望在将来能够切换并使用不同的格式化包。为了使应用程序更加灵活,我们可以设计一个接口,其中数字可以根据它们的类型进行格式化。以下接口在文档字符串中描述:
"""
format(::Formatter, x::T) where {T <: Number}
Format a number `x` using the specified formatter.
Returns a string.
"""
function format end
format 函数接受一个 formatter 和一个数值 x,并返回一个格式化的字符串。Formatter 类型定义如下:
abstract type Formatter end
struct IntegerFormatter <: Formatter end
struct FloatFormatter <: Formatter end
然后,工厂方法基本上创建用于调度的单例类型:
formatter(::Type{T}) where {T <: Integer} = IntegerFormatter()
formatter(::Type{T}) where {T <: AbstractFloat} = FloatFormatter()
formatter(::Type{T}) where T = error("No formatter defined for type $T")
默认实现可能如下所示,利用 Printf 包:
using Printf
format(nf::IntegerFormatter, x) = @sprintf("%d", x)
format(nf::FloatFormatter, x) = @sprintf("%.2f", x)
将所有内容放入 FactoryExample 模块中,我们可以运行以下测试代码:
function test()
nf = formatter(Int)
println(format(nf, 1234))
nf = formatter(Float64)
println(format(nf, 1234))
end
输出如下:
如果我们未来想要更改格式化器,我们只需提供一个新实现,其中定义了我们要支持的数值类型的格式化函数。当我们有很多数字格式化代码时,这很有用。切换到不同的格式化器实际上只需要两行代码的改变(在这个例子中)。
让我们看看抽象工厂模式。
抽象工厂模式
抽象工厂模式用于通过一组工厂方法创建对象,这些方法从具体实现中抽象出来。抽象工厂模式可以看作是工厂的工厂。
我们可以探索构建一个支持 Microsoft Windows 和 macOS 的多平台 GUI 库的例子。由于我们想要开发跨平台的代码,我们可以利用这个设计模式。这种设计在以下 UML 图中描述:
简而言之,我们在这里展示了两种类型的 GUI 对象:Button 和 Label。对于 Microsoft Windows 和 macOS 平台,概念是相同的。客户端不关心这些对象是如何实例化的;相反,它要求一个抽象工厂 GUIFactory 返回支持多个工厂方法的工厂(即 MacOSFactory 或 WindowsFactory),以创建平台相关的 GUI 对象。
Julia 的实现可以通过适当的抽象和具体类型简单地建模。让我们从操作系统级别开始:
abstract type OS end
struct MacOS <: OS end
struct Windows <: OS end
我们原本打算使用 MacOS 和 Windows 作为后续调度目的的单例类型。现在,让我们继续并定义抽象类型 Button 和 Label,如下所示。此外,我们分别为每种类型定义了 show 方法:
abstract type Button end
Base.show(io::IO, x::Button) =
print(io, "'$(x.text)' button")
abstract type Label end
Base.show(io::IO, x::Label) =
print(io, "'$(x.text)' label")
我们确实需要为这些 GUI 对象提供具体实现。现在让我们定义它们:
# Buttons
struct MacOSButton <: Button
text::String
end
struct WindowsButton <: Button
text::String
end
# Labels
struct MacOSLabel <: Label
text::String
end
struct WindowsLabel <: Label
text::String
end
为了简单起见,我们只保留一个文本字符串,无论是按钮还是标签。由于工厂方法是平台相关的,我们可以利用 OS 特性和多重分派来调用正确的 make_button 或 make_label 函数:
# Generic implementation using traits
current_os() = MacOS() # should get from system
make_button(text::String) = make_button(current_os(), text)
make_label(text::String) = make_label(current_os(), text)
为了测试,我们硬编码了current_os函数以返回MacOS()。实际上,这个函数应该通过检查适当的系统变量来返回MacOS()或Windows()以识别平台。最后,我们需要按如下方式实现每个平台的具体函数:
# MacOS implementation
make_button(::MacOS, text::String) = MacOSButton(text)
make_label(::MacOS, text::String) = MacOSLabel(text)
# Windows implementation
make_button(::Windows, text::String) = WindowsButton(text)
make_label(::Windows, text::String) = WindowsLabel(text)
我们简单的测试只是调用make_button函数:
通过多态,我们可以轻松扩展到新的平台或新的 GUI 对象,只需为特定的操作系统定义新函数即可。
接下来,我们将探讨单例模式。
单例模式
单例模式用于创建对象的单个实例并在任何地方重用它。单例对象通常在应用程序启动时构建,或者可以在对象首次使用时懒加载创建。对于多线程应用程序,单例模式有一个有趣的要求,即单例对象的实例化只能发生一次。如果对象创建函数从多个线程中懒加载,这可能会成为一个挑战。
假设我们想要创建一个名为AppKey的单例,该单例用于应用程序中的加密:
# AppKey contains an app id and encryption key
struct AppKey
appid::String
value::UInt128
end
初始时,我们可能会倾向于使用全局变量。鉴于我们已经了解了全局变量的性能影响,我们可以应用在第六章“性能模式”中学到的全局常量模式。本质上,创建了一个Ref对象作为占位符,如下所示:
# placeholder for AppKey object.
const appkey = Ref{AppKey}()
appkey全局常量最初创建时没有分配任何值,但随后可以在单例实例化时更新。单例的构建可以按如下方式进行:
function construct()
global appkey
if !isassigned(appkey)
ak = AppKey("myapp", rand(UInt128))
println("constructing $ak")
appkey[] = ak
end
return nothing
end
当只有一个线程时,此代码运行正常。如果我们用多个线程测试它,那么isassigned检查就成问题了。例如,两个线程可能会同时检查密钥是否已分配,并且两个线程都可能会认为需要实例化单例对象。在这种情况下,我们最终会构建单例两次。
测试代码如下所示:
function test_multithreading()
println("Number of threads: ", Threads.nthreads())
global appkey
Threads.@threads for i in 1:8
construct()
end
end
我们可以演示以下问题。让我们用四个线程启动 Julia REPL:
然后,我们可以运行测试代码:
如您所见,这里的单例被构建了两次。
那么,我们该如何解决这个问题呢?我们可以使用锁来同步单例构造逻辑。让我们首先创建另一个全局常量来持有锁:
const appkey_lock = Ref(ReentrantLock())
要使用锁,我们可以按如下方式修改construct函数:
在检查appkey[]是否已经被分配之前,我们必须首先获取锁。当我们完成单例对象的构建(或者如果它已经被创建,则跳过它)后,我们释放锁。请注意,我们将代码的关键部分包裹在一个try块中,并将unlock函数放在finally块中。这样做是为了确保无论单例对象的构建是否成功,锁都会被释放。
我们的新测试显示单例对象只被构建一次:
当我们需要保持一个单一对象时,单例模式很有用。实际的应用场景包括数据库连接或其他外部资源的引用。接下来,我们将探讨建造者模式。
建造者模式
建造者模式用于通过逐步构建更简单的部分来构建复杂对象。我们可以想象工厂装配线将以类似的方式工作。在这种情况下,产品将逐步组装,越来越多地添加部件,并在装配线末端,产品完成并准备好。
这种模式的优点之一是建造者代码看起来像线性数据流,对于某些人来说更容易阅读。在 Julia 中,我们可能想要编写如下内容:
car = Car() |>
add(Engine("4-cylinder 1600cc Engine")) |>
add(Wheels("4x 20-inch wide wheels")) |>
add(Chassis("Roadster Chassis"))
实质上,这正是第九章中描述的精确功能管道模式,杂项模式。对于这个例子,我们可以为构建每个部分(如轮子、引擎和底盘)开发高阶函数。以下代码演示了如何创建一个用于创建轮子的 curry(高阶)函数:
function add(wheels::Wheels)
return function (c::Car)
c.wheels = wheels
return c
end
end
add函数只是返回一个匿名函数,该函数接受一个Car对象作为输入并返回一个增强的Car对象。同样,我们可以为Engine和Chassis类型开发类似的功能。一旦这些函数准备就绪,我们只需通过链式调用这些函数来构建一辆车。
接下来,我们将讨论原型模式。
原型模式
原型模式通过从现有对象或原型对象克隆字段来创建新对象。其理念是,某些对象难以构建或构建耗时,因此制作一个对象的副本并进行少量修改将其称为新对象会很有用。
由于 Julia 将数据和逻辑分开,复制对象实际上等同于复制内容。这听起来很简单,但我们不应忽视浅拷贝和深拷贝之间的区别。
对象的浅拷贝仅仅是一个从另一个对象复制所有字段的简单对象。对象的深拷贝是通过递归进入对象的字段并复制它们的底层字段来创建的。因此,浅拷贝可能不是理想的选择,因为某些数据可能与原始对象共享。
为了说明这一点,让我们考虑以下银行账户示例的结构定义:
mutable struct Account
id::Int
balance::Float64
end
struct Customer
name::String
savingsAccount::Account
checkingAccount::Account
end
现在,假设我们有一个从该函数返回的Customer对象数组:
function sample_customers()
a1 = Account(1, 100.0)
a2 = Account(2, 200.0)
c1 = Customer("John Doe", a1, a2)
a3 = Account(3, 300.0)
a4 = Account(4, 400.0)
c2 = Customer("Brandon King", a3, a4)
return [c1, c2]
end
sample_customer函数返回两个客户的数组。为了测试目的,让我们构建一个测试框架来更新第一个客户的余额,如下所示:
function test(copy_function::Function)
println("--- testing ", string(copy_function), " ---")
customers = sample_customers()
c = copy_function(customers)
c[1].checkingAccount.balance += 500
println("orig: ", customers[1].checkingAccount.balance)
println("new: ", c[1].checkingAccount.balance)
end
如果我们使用内置的copy和deepcopy函数对测试框架进行练习,我们会得到以下结果:
意外地,我们在orig输出中得到了错误的结果,因为我们本应该给新客户增加$500。为什么原始客户记录和新客户记录的余额相同呢?这是因为当使用copy函数时,从客户数组中创建了一个浅拷贝。当这种情况发生时,客户记录在原始数组和新数组之间实际上是共享的。这意味着修改新记录也会影响原始记录。
在结果的第二部分中,只有客户记录的新副本被更改。这是因为使用了deepcopy函数。根据定义,原型模式要求对副本进行修改。如果应用此模式,进行深拷贝可能更安全。
我们已经涵盖了所有五个创建型模式。这些模式允许我们以有效的方式构建新对象。
接下来,我们将介绍一组行为设计模式。
行为模式
行为模式指的是对象如何被设计来相互协作和通信。从面向对象范式中有 11 个 GoF 模式。我们将在这里通过一些有趣的动手示例涵盖所有这些模式。
责任链模式
责任链(CoR)模式用于使用请求处理链来处理请求,其中每个处理程序都有自己的独特和独立的责任。
这种模式在许多应用中都很常见。例如,Web 服务器通常使用所谓的中间件来处理 HTTP 请求。每个中间件部分负责执行特定的任务——例如,验证请求、维护 cookie、验证请求和执行业务逻辑。关于责任链模式的一个特定要求是,链的任何部分都可以在任何时候被打破,从而导致过程的早期退出。在前面的 Web 服务器示例中,认证中间件可能已经决定用户未通过认证,因此用户应该被重定向到另一个网站进行登录。这意味着除非用户通过了认证步骤,否则将跳过其余的中间件。
我们如何在 Julia 中设计这样的东西?让我们看看一个简单的例子:
mutable struct DepositRequest
id::Int
amount::Float64
end
DepositRequest对象包含客户想要存入其账户的金额。我们的营销部门希望我们如果存款金额超过$100,000,就向客户提供感谢信。为了处理此类请求,我们设计了三个函数,如下所示:
@enum Status CONTINUE HANDLED
function update_account_handler(req::DepositRequest)
println("Deposited $(req.amount) to account $(req.id)")
return CONTINUE
end
function send_gift_handler(req::DepositRequest)
req.amount > 100_000 &&
println("=> Thank you for your business")
return CONTINUE
end
function notify_customer(req::DepositRequest)
println("deposit is finished")
return HANDLED
end
这些函数的职责是什么?
-
update_account_handler函数负责使用新的存款更新账户。 -
send_gift_handler函数负责向客户发送感谢信,以感谢其大额存款。 -
notify_customer函数负责在存款完成后通知客户。
这些函数也返回一个枚举值,要么是CONTINUE,要么是HANDLED,以指示在当前处理程序完成后是否应将请求传递给下一个处理程序。
应该很清楚,这些函数以特定的顺序运行。特别是,notify_customer函数应在交易结束时运行。因此,我们可以建立一个函数数组:
handlers = [
update_account_handler,
send_gift_handler,
notify_customer
]
我们还可以有一个函数来按顺序执行这些处理程序:
function apply(req::DepositRequest, handlers::AbstractVector{Function})
for f in handlers
status = f(req)
status == HANDLED && return nothing
end
end
作为这个设计的一部分,如果任何处理程序返回HANDLED值,循环将立即结束。我们用于测试向 VIP 客户发送感谢信功能的测试代码如下所示:
function test()
println("Test: customer depositing a lot of money")
amount = 300_000
apply(DepositRequest(1, amount), handlers)
println("\nTest: regular customer")
amount = 1000
apply(DepositRequest(2, amount), handlers)
end
运行测试给出以下结果:
我将把这个任务留给你,在链中构建另一个函数以执行早期退出。但到目前为止,让我们继续到下一个模式——中介者模式。
中介者模式
中介者模式用于促进应用程序中不同组件之间的通信。这样做的方式是使各个组件相互解耦。在大多数应用程序中,一个组件的变化可能会影响另一个组件。有时,也会有级联效应。中介者可以承担在组件发生变化时被通知的责任,并且它可以通知其他组件关于该事件的详细信息,以便进行进一步的下游更新。
例如,我们可以考虑图形用户界面(GUI)的使用案例。假设我们有一个屏幕,其中包含三个字段,用于我们的最喜欢的银行应用程序:
-
金额:账户中的当前余额。
-
利率:以百分比表示的当前利率。
-
利息金额:利息金额。这是一个只读字段。
它们是如何相互作用的?如果金额发生变化,那么利息金额需要更新。同样,如果利率发生变化,那么利息金额也需要更新。
为了模拟 GUI,我们可以为屏幕上的单个 GUI 对象定义以下类型:
abstract type Widget end
mutable struct TextField <: Widget
id::Symbol
value::String
end
Widget 是一个抽象类型,它可以作为所有 GUI 对象的超类型。这个应用程序只需要文本字段,所以我们只定义了一个 TextField 小部件。文本字段通过 id 来标识,并包含一个 value。为了从文本字段小部件中提取和更新值,我们可以定义如下函数:
# extract numeric value from a text field
get_number(t::TextField) = parse(Float64, t.value)
# set text field from a numeric value
function set_number(t::TextField, x::Real)
println("* ", t.id, " is being updated to ", x)
t.value = string(x)
return nothing
end
从前面的代码中,我们可以看到 get_number 函数从文本字段小部件中获取值,并将其作为浮点数返回。set_number 函数使用提供的数值填充文本字段小部件。现在,我们还需要创建应用程序,所以我们方便地定义了一个结构体如下:
Base.@kwdef struct App
amount_field::TextField
interest_rate_field::TextField
interest_amount_field::TextField
end
对于这个例子,我们将实现一个 notify 函数来模拟用户输入值后发送到文本字段小部件的事件。在现实中,GUI 平台通常会执行这个功能。让我们称它为 on_change_event,如下所示:
function on_change_event(widget::Widget)
notify(app, widget)
end
on_change_event 函数除了向中介(应用程序)传达这个小部件刚刚发生了某些事情之外,没有做其他任何事情。至于应用程序本身,以下是它处理通知的方式:
# Mediator logic - handling changes to the widget in this app
function notify(app::App, widget::Widget)
if widget in (app.amount_field, app.interest_rate_field)
new_interest = get_number(app.amount_field) * get_number(app.interest_rate_field)/100
set_number(app.interest_amount_field, new_interest)
end
end
如您所见,它只是检查正在更新的小部件是否是金额或利率字段。如果是,它计算新的利息金额,并用新值填充利息金额字段。让我们快速测试一下:
function test()
# Show current state before testing
print_current_state()
# double principal amount from 100 to 200
set_number(app.amount_field, 200)
on_change_event(app.amount_field)
print_current_state()
end
test 函数显示应用程序的初始状态,更新金额字段,并显示新状态。为了简洁起见,这里没有显示 print_current_state 函数的源代码,但可以在本书的 GitHub 网站上找到。测试程序的输出如下所示:
使用 2 中介模式的优点是每个对象都可以专注于自己的职责,而不用担心下游的影响。一个中心的中介承担组织活动和处理事件以及通信的责任。
接下来,我们将探讨备忘录模式。
备忘录模式
备忘录模式是一种状态管理技术,您可以在需要时将工作恢复到先前的状态。一个常见的例子是文字处理应用程序的撤销功能。在做出 10 次更改后,我们总是可以撤销先前的操作,并返回到这 10 次更改之前的原始状态。同样,一个应用程序可能会记住最近打开的文件,并提供一个选择菜单,以便用户可以快速重新打开之前打开的文件。
在 Julia 中实现备忘录模式非常简单。我们只需将先前状态存储在数组中,在做出更改时,我们可以将新状态推送到数组中。当我们想要撤销操作时,我们可以通过从数组中弹出来恢复先前的状态。为了说明这个想法,让我们考虑一个博客文章编辑应用程序的案例。我们可以定义数据类型如下:
struct Post
title::String
content::String
end
struct Blog
author::String
posts::Vector{Post}
date_created::DateTime
end
如您所见,一个Blog对象包含一个Post对象的数组。按照惯例,数组中的最后一个元素是博客文章的当前版本。如果数组中有五个帖子,那么这意味着已经进行了四次更改。创建一个新的博客就像以下代码所示:
function Blog(author::String, post::Post)
return Blog(author, [post], now())
end
默认情况下,一个新的博客对象只包含一个版本。随着用户进行更改,数组将增长。为了方便,我们可以提供一个version_count函数,该函数返回用户迄今为止所做的修订次数。
version_count(blog::Blog) = length(blog.posts)
要获取当前帖子,我们可以简单地取数组的最后一个元素:
current_post(blog::Blog) = blog.posts[end]
现在,当我们需要更新博客时,我们必须将新版本推送到数组中。以下是用来用新标题或内容更新博客的函数:
function update!(blog::Blog;
title = nothing,
content = nothing)
post = current_post(blog)
new_post = Post(
something(title, post.title),
something(content, post.content)
)
push!(blog.posts, new_post)
return new_post
end
update!函数接受一个Blog对象,并且可以可选地接受更新后的title、content或两者。基本上,它创建一个新的Post对象并将其推入posts数组。撤销操作如下:
function undo!(blog::Blog)
if version_count(blog) > 1
pop!(blog.posts)
return current_post(blog)
else
error("Cannot undo... no more previous history.")
end
end
我们可以用以下test函数来测试它:
function test()
blog = Blog("Tom", Post("Why is Julia so great?", "Blah blah."))
update!(blog, content = "The reasons are...")
println("Number of versions: ", version_count(blog))
println("Current post")
println(current_post(blog))
println("Undo #1")
undo!(blog)
println(current_post(blog))
println("Undo #2") # expect failure
undo!(blog)
println(current_post(blog))
end
输出如下所示:
如您所见,实现备忘录模式相当简单。我们将在下一节介绍观察者模式。
观察者模式
观察者模式对于将观察者注册到对象中非常有用,以便在该对象中所有状态变化都会触发向观察者发送通知。在支持一等函数的语言中——例如,Julia——可以通过维护一个在对象状态变化前后可以调用的函数列表来轻松实现此类功能。有时,这些函数被称为钩子。
Julia 中观察者模式的实现可能包括两个部分:
-
扩展对象的
setproperty!函数以监控状态变化并通知观察者。 -
维护一个可以用来查找要调用的函数的字典。
对于这个演示,我们将再次使用银行账户示例:
mutable struct Account
id::Int
customer::String
balance::Float64
end
这是维护观察者的数据结构:
const OBSERVERS = IdDict{Account,Vector{Function}}();
在这里,我们选择使用IdDict而不是常规的Dict对象。IdDict是一种特殊类型,它使用 Julia 的内部对象 ID 作为字典的键。为了注册观察者,我们提供了以下函数:
function register(a::Account, f::Function)
fs = get!(OBSERVERS, a, Function[])
println("Account $(a.id): registered observer function $(Symbol(f))")
push!(fs, f)
end
现在,让我们扩展setproperty!函数:
function Base.setproperty!(a::Account, field::Symbol, value)
previous_value = getfield(a, field)
setfield!(a, field, value)
fs = get!(OBSERVERS, a, Function[])
foreach(f -> f(a, field, previous_value, value), fs)
end
这个新的setproperty!函数不仅更新了对象的字段,而且在字段更新后还调用观察者函数,传递了前一个状态和当前状态。为了测试目的,我们将创建一个观察者函数如下:
function test_observer_func(a::Account, field::Symbol, previous_value, current_value)
println("Account $(a.id): $field was changed from $previous_value to $current_value")
end
我们的test函数编写如下:
function test()
a1 = Account(1, "John Doe", 100.00)
register(a1, test_observer_func)
a1.balance += 10.00
a1.customer = "John Doe Jr."
return nothing
end
当运行测试程序时,我们得到以下输出:
从输出中,我们可以看到每次属性更新时都会调用test_observer_func函数。观察者模式是一个容易开发的东西。接下来,我们将探讨状态模式。
状态模式
状态模式用于对象根据其内部状态表现出不同行为的情况。网络服务是一个很好的例子。一个基于网络服务的典型实现是监听特定的端口号。当远程进程连接到服务时,它会建立连接,并且它们使用它进行通信,直到会话结束。当网络服务当前处于监听状态时,它应该允许打开新的连接;然而,在连接打开之前不应允许任何数据传输。然后,在连接打开后,我们应该能够发送数据。相比之下,如果连接已经关闭,则不应允许通过网络连接发送任何数据。
在 Julia 中,我们可以使用多重分派来实现状态模式。让我们首先定义以下对网络连接有意义的类型:
abstract type AbstractState end
struct ListeningState <: AbstractState end
struct EstablishedState <: AbstractState end
struct ClosedState <: AbstractState end
const LISTENING = ListeningState()
const ESTABLISHED = EstablishedState()
const CLOSED = ClosedState()
在这里,我们利用了单例类型模式。至于网络连接本身,我们可以定义类型如下:
struct Connection{T <: AbstractState,S}
state::T
conn::S
end
现在,让我们开发一个send函数,它用于通过连接发送消息。在我们的实现中,send函数除了收集连接的当前状态并将调用转发到特定状态send函数之外,不做任何事情:
# Use multiple dispatch
send(c::Connection, msg) = send(c.state, c.conn, msg)
# Implement `send` method for each state
send(::ListeningState, conn, msg) = error("No connection yet")
send(::EstablishedState, conn, msg) = write(conn, msg * "\n")
send(::ClosedState, conn, msg) = error("Connection already closed")
你可能认识这是神圣的特质模式。对于单元测试,我们可以为创建具有指定消息的新Connection和向Connection对象发送消息开发一个test函数:
function test(state, msg)
c = Connection(state, stdout)
try
send(c, msg)
catch ex
println("$(ex) for message '$msg'")
end
return nothing
end
然后,测试代码简单地运行了三次test函数,每次对应一个可能的状态:
function test()
test(LISTENING, "hello world 1")
test(CLOSED, "hello world 2")
test(ESTABLISHED, "hello world 3")
end
当运行test函数时,我们得到以下输出:
只有第三条消息成功发送,因为连接处于ESTABLISHED状态。现在,让我们看看策略模式。
策略模式
策略模式允许客户端在运行时选择最佳的算法。而不是将客户端与预定义的算法耦合,当需要时,客户端可以配置为特定的算法(策略)。此外,有时算法的选择不能提前确定,因为决策可能取决于输入数据、环境或其他因素。
在 Julia 中,我们可以使用多重分派来解决这个问题。让我们考虑斐波那契数列生成器的例子。正如我们从第六章,“性能模式”中学到的,当我们递归实现时,计算第n个斐波那契数可能很棘手,因此我们的第一个算法(策略)可能是记忆化。此外,我们还可以使用不使用任何递归的迭代算法来解决这个问题。
为了支持记忆化和迭代算法,让我们创建以下一些新类型:
abstract type Algo end
struct Memoized <: Algo end
struct Iterative <: Algo end
Algo 抽象类型是所有斐波那契算法的超类型。目前,我们只有两种算法可供选择:Memoized 或 Iterative。现在,我们可以定义 fib 函数的备忘录版本如下:
using Memoize
@memoize function _fib(n)
n <= 2 ? 1 : _fib(n-1) + _fib(n-2)
end
function fib(::Memoized, n)
println("Using memoization algorithm")
_fib(n)
end
首先定义一个备忘录函数 _fib。然后定义一个包装函数 fib,它将 Memoized 对象作为第一个参数。相应的迭代算法可以如下实现:
function fib(algo::Iterative, n)
n <= 2 && return 1
prev1, prev2 = 1, 1
local curr
for i in 3:n
curr = prev1 + prev2
prev1, prev2 = curr, prev1
end
return curr
end
在这次讨论中,算法的实际工作方式并不重要。由于第一个参数是 Iterative 对象,我们知道这个函数将被相应地调度。
从客户端的角度来看,它可以选择备忘录版本或迭代函数,具体取决于其需求。由于备忘录版本以 O(1) 的速度运行,当 n 较大时应该更快;然而,对于 n 的较小值,迭代版本会更好。我们可以以下列方式调用 fib 函数:
fib(Memoized(), 10)
fib(Iterative(), 10)
如果客户端选择实现算法选择过程,可以很容易地做到,如下所示:
function fib(n)
algo = n > 50 ? Memoized() : Iterative()
return fib(algo, n)
end
成功的测试结果如下所示:
如您所见,实现策略模式相当简单。多分派的不合理有效性再次拯救了! 接下来,我们将讨论另一个称为模板方法的行性行为模式。
模板方法模式
模板方法模式用于创建一个定义良好的过程,可以使用不同类型的算法或操作。作为一个模板,它可以根据客户端的需求定制任何算法或函数。
在这里,我们将探讨如何在机器学习(ML)管道用例中利用模板方法模式。对于那些不熟悉 ML 管道的人来说,以下是数据科学家可能采取的简化版本:
首先,将数据集分成两个单独的数据集,用于训练和测试。训练数据集被输入到一个过程中,将数据拟合到统计模型中。然后,validate 函数使用该模型来预测测试集(也称为目标)变量中的响应变量。最后,它将预测值与实际值进行比较,以确定模型的准确性。
假设我们已经将管道设置为如下所示:
function run(data::DataFrame, response::Symbol, predictors::Vector{Symbol})
train, test = split_data(data, 0.7)
model = fit(train, response, predictors)
validate(test, model, response)
end
为了简洁起见,具体的函数 split_data、fit 和 validate 在这里没有展示;如果您想查看它们,可以在本书的 GitHub 网站上查找。然而,管道概念在前面的逻辑中得到了演示。让我们快速尝试预测波士顿房价:
在这个例子中,响应变量是 :MedV,我们将基于 :Rm、:Tax 和 :Crim 建立一个统计模型。
波士顿住房数据集包含美国人口普查局收集的有关马萨诸塞州波士顿地区住房的数据。它在大量统计分析教育文献中被广泛使用。我们在这个例子中使用到的变量有:
MedV: 房主自住房屋的中位数(单位:千美元)
Rm: 每套住宅的平均房间数
Tax: 每$10,000 的完整价值财产税率
Crim: 每镇的人均犯罪率
模型的准确性由rmse变量(表示均方根误差)捕捉。默认实现使用线性回归作为拟合函数。
要实现模板方法模式,我们应该允许客户端插入过程的任何部分。因此,我们可以通过关键字参数修改函数:
function run2(data::DataFrame, response::Symbol, predictors::Vector{Symbol};
fit = fit, split_data = split_data, validate = validate)
train, test = split_data(data, 0.7)
model = fit(train, response, predictors)
validate(test, model, response)
end
在这里,我们添加了三个关键字参数:fit、split_data和validate。函数被命名为run2以避免混淆,因此客户端应该能够通过传递自定义函数来自定义任何一个参数。为了说明它是如何工作的,让我们创建一个新的fit函数,该函数使用广义线性模型(GLM):
using GLM
function fit_glm(df::DataFrame, response::Symbol, predictors::Vector{Symbol})
formula = Term(response) ~ +(Term.(predictors)...)
return glm(formula, df, Normal(), IdentityLink())
end
现在我们已经自定义了拟合函数,我们可以通过传递fit关键字参数来重新运行程序:
如您所见,客户端可以通过传递函数来轻松自定义管道。这是可能的,因为 Julia 支持一等函数。
在下一节中,我们将回顾一些其他传统的行为模式。
命令、解释器、迭代器和访问者模式
命令、解释器和访问者模式被归入本节,仅仅是因为我们已经在本书的早期部分讨论了它们的使用案例。
命令模式用于参数化将要执行的操作。在第九章杂项模式部分中的单例类型分派模式部分,我们探讨了 GUI 调用不同命令并响应用户请求的特定操作的使用案例。通过定义单例类型,我们可以利用 Julia 的多分派机制来执行适当的函数。我们可以通过简单地添加接受新单例类型的新函数来扩展到新的命令。
解释器模式用于为特定领域模型建模抽象语法树。结果证明,我们已经在第七章中这样做过,即可维护性模式部分中的领域特定语言部分。每个 Julia 表达式都可以被建模为抽象语法树,而无需任何额外的工作,因此我们可以使用常规元编程设施(如宏和生成函数)来开发领域特定语言(DSL)。
迭代器模式用于使用标准协议遍历一组对象。在 Julia 中,已经有一个官方建立的迭代接口,任何集合框架都可以实现。只要为自定义对象定义了一个iterate函数,对象中的元素就可以作为任何循环结构的一部分进行迭代。更多信息可以在官方 Julia 参考手册中找到。
最后,访问者模式用于在面向对象范式中扩展现有类的功能。在 Julia 中,通过泛型函数的扩展,可以轻松地向现有系统添加新功能。例如,Julia 生态系统中有许多类似数组的包,如OffsetArrays、StridedArrays和NamedArrays。所有这些都是对现有的AbstractArray框架的扩展。
我们现在已经完成了行为模式。让我们继续前进,看看最后一组——结构模式。
结构模式
结构设计模式用于将对象组合在一起以形成更大的东西。随着你继续开发系统并添加功能,其大小和复杂性也在增长。我们不仅想要将组件集成在一起,同时我们也希望尽可能多地重用组件。通过学习本节中描述的结构模式,我们在项目中遇到类似情况时有一个遵循的模板。
在本节中,我们将回顾传统的面向对象模式,包括适配器、桥接、组合、装饰器、外观、享元和代理模式。让我们从适配器模式开始。
适配器模式
适配器模式用于使一个对象与另一个对象协同工作。比如说,我们需要集成两个子系统,但它们不能相互通信,因为接口要求没有得到满足。在现实生活中,你可能遇到过去不同国家旅行麻烦的情况,因为电源插头不同。为了解决这个问题,你可能需要带一个通用电源适配器,它作为中介使你的设备能够与外国的电源插座工作。同样,通过使用适配器,不同的软件可以被制作成相互兼容。
只要与子系统交互的接口是清晰的,那么创建适配器就可以是一个直接的任务。在 Julia 中,我们可以使用委托模式来包装一个对象,并提供符合所需接口的附加功能。
让我们想象一下,我们正在使用一个执行计算并返回链表的库。链表是一个方便的数据结构,它支持非常快的 O(1)速度的插入。现在,假设我们想要将数据传递给另一个需要我们符合AbstractArray接口的子系统。在这种情况下,我们不能直接传递链表,因为它不合适!
我们如何解决这个问题?首先,让我介绍一下LinkedList的实现:
这是一个相当标准的双向链表设计。每个节点包含一个数据值,同时也维护对前一个和后一个节点的引用。这种链表的典型用法如下所示:
通常,我们可以通过使用prev和next函数来遍历链表。当我们插入3的值时需要调用next(LL)的原因是我们希望将其插入到第二个节点之后。
由于使用链表没有实现AbstractArray接口,我们实际上无法通过索引引用任何元素,也无法确定元素的数量:
在这种情况下,我们可以构建一个符合AbstractArray接口的包装器(或称为适配器)。首先,让我们创建一个新的类型,并使其成为AbstractArray的子类型:
struct MyArray{T} <: AbstractArray{T,1}
data::Node{T}
end
由于我们只需要支持单维数组,我们已将超类型定义为AbstractArray{T,1}。底层数据只是对链表Node对象的引用。为了符合AbstractArray接口,我们应该实现Base.size和Base.getindex函数。下面是size函数的样子:
function Base.size(ar::MyArray)
n = ar.data
count = 0
while next(n) !== nothing
n = next(n)
count += 1
end
return (1 + count, 1)
end
该函数通过使用next函数遍历链表来确定数组的长度。为了支持索引元素,我们可以定义getindex函数如下:
function Base.getindex(ar::MyArray, idx::Int)
n = ar.data
for i in 1:(idx-1)
next_node = next(n)
next_node === nothing && throw(BoundsError(n.data, idx))
n = next_node
end
return value(n)
end
这就是我们需要为包装器做的所有事情。现在让我们试运行一下:
现在我们已经在链表之上有了可索引的数组,我们可以将其传递给任何期望数组作为输入的库。
在需要数组变动的情形下,我们只需实现Base.setindex!函数即可。或者,我们可以将链表物理地转换为数组。数组具有 O(1)快速索引的性能特征,但在插入时相对较慢。
使用适配器使我们更容易使组件相互通信。接下来,我们将讨论组合模式。
组合模式
组合模式用于模拟可以组合在一起同时又能像单个对象一样被处理的对象。这种情况并不少见——例如,在一个绘图应用程序中,我们可能能够绘制不同类型的形状,如圆形、矩形和三角形。每个形状都有一个位置和大小,因此我们可以确定它们在屏幕上的位置以及它们的大小。当我们把几个形状组合在一起时,我们仍然可以确定组合后的大对象的位姿。此外,还可以对单个形状对象以及组合对象应用调整大小、旋转和其他变换功能。
在投资组合管理中也会出现类似的情况。我有一个由多个共同基金组成的退休投资账户。每个共同基金可能投资于股票、债券或两者兼有。然后,一些基金也可能投资于其他共同基金。从会计角度来看,我们可以始终确定股票、债券、股票基金、债券基金和基金组合的市场价值。在 Julia 中,我们可以通过为不同类型的工具实现market_value函数来解决这个问题,无论是股票、债券还是基金。现在让我们看看一些代码。
假设我们为股票/债券持仓定义了以下类型:
struct Holding
symbol::String
qty::Int
price::Float64
end
Holding类型包含交易符号、数量和当前价格。我们可以定义投资组合如下:
struct Portfolio
symbol::String
name::String
stocks::Vector{Holding}
subportfolios::Vector{Portfolio}
end
投资组合由一个符号、一个名称、一个持仓数组和一个subportfolios数组来标识。为了测试,我们可以创建一个示例投资组合:
function sample_portfolio()
large_cap = Portfolio("TOMKA", "Large Cap Portfolio", [
Holding("AAPL", 100, 275.15),
Holding("IBM", 200, 134.21),
Holding("GOOG", 300, 1348.83)])
small_cap = Portfolio("TOMKB", "Small Cap Portfolio", [
Holding("ATO", 100, 107.05),
Holding("BURL", 200, 225.09),
Holding("ZBRA", 300, 257.80)])
p1 = Portfolio("TOMKF", "Fund of Funds Sleeve", [large_cap, small_cap])
p2 = Portfolio("TOMKG", "Special Fund Sleeve", [Holding("C", 200, 76.39)])
return Portfolio("TOMZ", "Master Fund", [p1, p2])
end
从缩进输出的结构中可以更清楚地可视化:
由于我们希望支持在任何级别计算市场价值的能力,我们只需要为每种类型定义market_value函数。最简单的一个是对于持仓:
market_value(s::Holding) = s.qty * s.price
市场价值不过是数量乘以价格。计算投资组合的市场价值稍微复杂一些:
market_value(p::Portfolio) =
mapreduce(market_value, +, p.stocks, init = 0.0) +
mapreduce(market_value, +, p.subportfolios, init = 0.0)
在这里,我们使用mapreduce函数来计算单个股票(或subportfolios)的市场价值,并将它们加起来。由于一个投资组合可能包含多个持仓和多个subportfolios,我们需要对两者都进行计算并将它们相加。由于每个子投资组合也是一个portfolio对象,这段代码自然会递归地深入到子-subportfolios,依此类推。
复合体并没有什么特别之处。因为 Julia 支持泛型函数,所以我们可以为单个对象以及分组对象提供实现。
我们将在下一节讨论飞点模式。
飞点模式
飞点模式用于通过共享相似/相同对象的内存来有效地处理大量细粒度对象。
处理字符串的一个很好的例子涉及字符串。在数据科学领域,我们经常需要读取和分析以表格格式表示的大量数据。在许多情况下,某些列可能包含大量重复的字符串。例如,人口普查调查可能有一个表示性别的列,因此它将包含Male或Female。
与其他一些编程语言不同,Julia 中的字符串不会被内部化。这意味着Male这个词的 10 个副本将被反复存储,占用 10 倍于单个Male字符串的内存空间。我们可以很容易地从 REPL 中看到这个效果,如下所示:
因此,存储 100,000 个Male字符串副本大约占用 800 KB 的内存。这相当浪费内存。解决这个问题的常见方法是通过维护一个池化数组。我们不需要存储 100,000 个字符串,而只需编码数据并存储 100,000 字节,这样0x01对应男性,0x00对应女性。我们可以通过以下方式将内存占用减少八倍:
你可能会想知道为什么报告了额外的 40 字节。这 40 字节实际上是数组容器使用的。现在,鉴于性别列在这种情况下是二进制的,我们实际上可以通过存储位而不是字节来进一步压缩它,如下所示:
再次强调,我们通过使用BitArray来存储性别值,将内存使用量大约减少了八倍(从 1 字节减少到 1 位)。这是一种对内存使用的激进优化。但是,我们仍然需要将Male和Female字符串存储在某个地方,对吧?这是一个简单的任务,因为我们知道它们可以在任何数据结构中追踪,例如字典:
总结来说,我们现在能够在 12,568 + 370 = 12,938 字节内存中存储 100,000 个性别值。与直接存储字符串的原始笨拙方式相比,我们节省了超过 98%的内存消耗!我们是如何实现如此巨大的节省的呢?因为所有记录都共享相同的两个字符串。我们唯一需要维护的是指向那些字符串的引用数组。
因此,这就是享元模式的概念。同样的技巧在许多地方被反复使用。例如,CSV.jl包使用一个名为CategoricalArrays的包,它提供了本质上相同类型的内存优化。
接下来,我们将回顾最后几个传统模式——桥接模式、装饰器模式和外观模式。
桥接模式、装饰器模式和外观模式
让我解释一下桥接模式、装饰器模式和外观模式是如何工作的。在这个阶段,我们不会为这些模式提供更多的代码示例,仅仅是因为它们相对容易实现,因为你已经从之前的设计模式章节中获得了许多想法。也许不会太令人惊讶,你迄今为止学到的一些技巧——委托、单例类型、多重分派、一等函数、抽象类型和接口——都是你可以用来解决任何类型问题的。
桥接模式用于解耦抽象与其实现,以便它们可以独立演变。在 Julia 中,我们可以为实施者构建一个抽象类型的层次结构,他们可以开发符合这些接口的软件。
Julia 的数值类型是这样一个系统如何设计的良好例子。有许多抽象类型可供选择,例如Integer、AbstractFloat和Real。然后,还有由Base包提供的具体实现,如Int和Float64。这种抽象设计得如此之好,以至于人们可以提供数字的替代实现。例如,SaferInteger包为整数提供了一个更安全的实现,避免了数值溢出。
装饰器模式也易于实现。它可以用来增强现有对象的新功能,因此得名装饰器。假设我们购买了一个第三方库,但我们并不完全满意其功能。使用装饰器模式,我们可以通过用新函数包装现有库来增加价值。
这可以通过委托模式自然地完成。通过用新类型包装现有类型,我们可以通过委托到基础对象来重用现有功能。然后,我们可以在新类型中添加新函数以获得新能力。我们看到这个模式被反复使用。
外观模式用于封装复杂的子系统,并为客户端提供一个简化的接口。在 Julia 中我们如何做到这一点?到目前为止,我们应该已经一次又一次地看到了这个模式;我们所需做的只是创建一个新的类型,并提供一个简单的 API 来操作这个新类型。我们可以使用委托模式将请求转发到其他封装的类型。
现在我们已经审视了所有传统的面向对象模式。你可能已经注意到,许多用例可以用这本书中描述的标准 Julia 特性和模式来解决。这不是巧合——这只是处理 Julia 中的复杂问题如此简单。
摘要
在本章中,我们广泛地讨论了传统的面向对象设计模式。我们从这样一个谦卑的信念开始,即面向对象编程中的相同模式通常需要在 Julia 编程中应用。
我们开始回顾创建型设计模式,包括工厂方法、抽象工厂、单例、建造者和原型模式。这些模式涉及创建对象的各种技术。当涉及到 Julia 时,我们可以主要使用抽象类型、接口和多重分派来解决这些问题。
我们还投入了大量精力研究行为设计模式。这些模式旨在处理应用程序组件之间的协作和通信。我们研究了 11 个模式:责任链、中介者、备忘录、观察者、状态、策略、模板方法、命令、解释器、迭代器和访问者。这些模式可以使用特性、接口、多重分派和一等函数在 Julia 中实现。
最后,我们回顾了几个结构化设计模式。这些模式通过复用现有组件来构建更大的组件。这包括适配器、组合、享元、桥接、装饰器和外观模式。在 Julia 中,它们可以通过抽象类型、接口和委托设计模式来处理。
我希望你们已经相信,构建软件并不一定困难。尽管面向对象编程让我们相信我们需要所有这些复杂性来设计软件,但这并不意味着我们在 Julia 中必须这样做。本章中提出的问题的解决方案大多需要你在本书中找到的基本软件设计技能和模式。
在下一章中,我们将深入探讨有关数据类型和分发的更高级主题。准备好迎接挑战!
问题
-
我们可以使用什么技术来实现抽象工厂模式?
-
我们如何防止在多线程应用程序中多次初始化单例?
-
Julia 中实现观察者模式的关键特性是什么?
-
我们如何使用模板方法模式来自定义一个操作?
-
我们如何制作一个适配器来实现目标接口?
-
享元模式的好处是什么?我们可以使用什么策略来实现它?
-
我们可以使用 Julia 的哪个特性来实现策略模式?
第四部分:高级主题
本节的目标是为您提供对 Julia 语言更深入的分析。理解这些高级概念将有助于您提出更好的设计方案。
本节包含以下章节:
- 第十二章,继承与变体
第十二章:继承和可变性
如果我们不得不选择在 Julia 或任何编程语言中最重要的学习内容,那么它必定是数据类型的概念。抽象类型和具体类型协同工作,为程序员提供了一种强大的工具来模拟解决方案,以解决现实世界的问题。多重分派依赖于定义良好的数据类型来调用正确的函数。参数化类型被用来使我们能够重用具有特定物理数据表示的对象的基本结构。正如你所看到的,在软件工程实践中,对数据类型进行周密的设计至关重要。
在第二章,模块、包和数据类型概念中,我们学习了抽象类型和具体类型的基础知识以及如何基于类型之间的继承关系构建类型层次结构。在第三章设计函数和接口和第五章重用模式中,我们也简要提到了参数化类型和参数化方法。为了有效地利用这些概念和语言特性,我们需要很好地理解子类型是如何工作的。它听起来可能类似于继承,但它在本质上是有区别的。
在本章中,我们将更深入地探讨子类型及其相关主题的含义,包括以下主题:
-
实现继承和行为子类型
-
协方差、反协方差和不变性
-
参数化方法和对角线规则
到本章结束时,你将对 Julia 中的子类型有很好的理解。你将更有能力设计自己的数据类型层次结构,并更有效地利用多重分派。
技术要求
代码在 Julia 1.3.0 环境中进行了测试。
实现继承和行为子类型
当我们学习继承时,我们意识到抽象类型可以用来描述现实世界概念。我们可以相当自信地说,我们已经知道如何通过父子关系来分类概念。有了这些知识,我们可以在这些概念周围构建类型层次结构。例如,来自第二章,模块、包和数据类型概念的个人资产类型层次结构看起来如下:
在前面的图中展示的所有数据类型都是抽象类型。从下往上,我们知道House和Apartment都是Property的子类型,我们也知道Property和Investment都是Asset的子类型。这些都是基于我们日常生活中对这些概念的讨论的合理解释。
我们还讨论了具体类型,它们是抽象概念的物理实现。对于这个相同的例子,我们最终得到Stock作为Equity的子类型,Bond作为FixedIncome的子类型。如您所回忆的那样,Stock类型可以定义为以下内容:
struct Stock <: Equity
symbol::String
name::String
end
在那时,我们没有强调不能在抽象类型中声明任何字段的事实,这是某些面向对象编程(OOP)语言(如 Java)中固有的。如果您来自 OOP 背景,那么您可能会错误地感觉到这是 Julia 继承系统中的一个巨大限制。Julia 为什么被设计成这样?在本节中,我们将尝试更深入地分析继承并回答这个问题。
与继承相关联的两个重要概念非常相似,但本质上不同——实现继承和行为子类型。我们将在接下来的几节中讨论这两个概念。让我们从实现继承开始。
理解实现继承
实现继承允许子类从其超类继承字段和方法。由于 Julia 不支持实现继承,我们将暂时改变语言,以下是用 Java 提供的示例。这是一个提供容器以容纳任意数量对象的类:
import java.util.ArrayList;
public class Bag
{
ArrayList<Object> items = new ArrayList<Object>();
public void add(final Object object) {
this.items.add(object);
}
public void addMany(final Object[] objects) {
for (Object obj : objects) {
this.add(obj);
}
}
}
Bag类基本上维护了一个对象列表在items字段中,并提供两个方便的函数,add和addMany,用于向包中添加单个对象或对象数组。
为了展示代码重用,我们可以开发一个新的CountingBag类,它从Bag继承并提供了跟踪包中存储了多少项的附加功能:
public class CountingBag extends Bag
{
int count = 0;
public void add(Object object) {
super.add(object);
this.count += 1;
}
public int size() {
return count;
}
}
在这个CountingBag类中,我们有一个新的字段count,用于跟踪包的大小。每当向包中添加新项目时,count变量就会增加。size函数用于报告包的大小。那么CountingBag的情况如何?让我们快速总结:
-
count字段在此处定义可用。 -
items字段作为从Bag继承而来是可用的。 -
add方法覆盖了父类的实现,但它也通过super.add重用了父类的方法。 -
addMany方法作为从Bag继承而来是可用的。 -
size方法在此处定义可用。
由于字段和方法都是继承的,这被称为实现继承。其效果几乎等同于将超类中的代码复制到子类中。
接下来,让我们谈谈行为子类型。
理解行为子类型
行为子类型有时被称为接口继承。为了避免与重载的单词继承混淆,我们在这里将避免使用接口继承这个术语。行为子类型表示子类型仅从超类型继承行为。
当我们将语言切换回 Julia 时,我们将引用类型而不是类。
Julia 支持行为子类型。每个数据类型都继承为其超类型定义的函数。让我们在 Julia 的 REPL 中进行一个快速有趣的练习:
在这里,定义了一个抽象类型Vehicle及其子类型Car。我们还为Vehicle定义了一个move函数。当我们向move函数传递一个Car对象时,它仍然可以正常工作,因为Car是Vehicle的子类型。这与 Liskov 替换原则一致,该原则表示接受类型 T 的程序也可以接受 T 的任何子类型,并且可以继续正常工作,而不会出现任何意外的结果。
现在,方法的继承可以在多个级别上传播得很远。让我们创建另一个抽象级别:
我们刚刚定义了一个新的FlyingVehicle抽象类型和一个Helicopter结构体。move函数可以通过从Vehicle继承而来在直升机中使用,liftoff函数也可以使用,因为它是从FlyingVehicle继承而来的。
可以为更具体的类型定义额外的方法,并且会选择最具体的方法进行调度。这样做本质上与实现继承中的方法覆盖具有相同的效果。以下是一个例子:
到目前为止,我们已经定义了两种起飞方法——一种接受FlyingVehicle,另一种用于Helicopter。当将Helicopter对象传递给函数时,它会被分配到为Helicopter定义的方法,因为它是最具体的方法,适用于直升机。
这种关系可以用以下图表来总结:
根据行为子类型,汽车应该像车辆一样行为,飞行车辆应该像车辆一样行为,直升机应该像飞行车辆一样行为,也像车辆一样行为。行为子类型允许我们重用为超类型已定义的行为。
在 Java 中,可以使用接口实现行为子类型。
现在我们已经了解了实现继承和行为子类型,我们可以回顾我们之前的问题:为什么 Julia 不支持实现继承?不遵循其他主流面向对象编程语言的原因是什么?为了理解这一点,我们可以回顾一些与实现继承相关的一些知名问题。让我们从正方形-矩形问题开始。
正方形-矩形问题
Julia 不支持实现继承。让我们列出不支持实现继承的原因:
-
所有具体类型都是最终的,因此无法从另一个具体类型创建新的子类型。因此,不可能从任何地方继承对象字段。
-
在抽象类型中,你不能声明任何字段,否则它将不再是抽象的,而是具体的。
Julia 编程语言的核心开发者出于多个原因,在早期设计决策中决定避免实现继承。其中之一就是所谓的正方形-矩形问题,有时也称为圆-椭圆问题。
正方形-矩形问题对实现继承提出了一个明显的挑战。正如常识所知,每个正方形都是一个矩形,它有一个额外的约束,即两边的长度相等。为了在面向对象的语言中通过类来建模这些概念,我们可能会尝试创建一个Rectangle类和一个Square子类:
很快,我们就意识到我们已经使自己陷入了麻烦。如果Square必须从其父类继承所有字段,那么它就会继承width和height。但我们真正想要的是一个名为length的单个字段。
有时,完全相同的问题被表述为圆-椭圆问题。在这种情况下,圆是椭圆,但只有一个半径而不是主轴和副轴长度。
我们如何解决这个问题?好吧,一种方法是不理会这个问题,创建一个没有任何字段定义的Square子类。然后,当用特定的长度实例化Square时,width和height字段都填充了相同的值。这足够好吗?答案是不足够的。鉴于Square还继承了Rectangle的方法,我们可能需要提供覆盖方法,例如setWidth和setHeight,以便我们可以保持两个字段具有相同的值。最终,我们得到了一个似乎在功能上可行但性能和内存使用都很差的解决方案。
但我们最初是如何陷入麻烦的呢?为了进一步分析,我们应该意识到,虽然正方形可以被归类为矩形,但在本质上它是一个更严格的矩形版本。这已经开始听起来不太直观了——通常,当我们创建子类时,我们会扩展父类并添加更多的字段和功能。我们什么时候想在子类中删除字段或功能?这似乎在逻辑上是倒退的。也许我们应该让Rectangle成为Square的子类?这听起来也不太合理。
我们陷入了一个困境。一方面,我们希望在代码中正确地建模现实世界概念。另一方面,代码并不适合,不会引起维护或性能问题。到目前为止,我们不禁要问自己,我们是否真的想编写绕过实现继承问题的代码。我们不想。
也许你还没有 100%确信实现继承比抽象继承更糟糕。让我们看看另一个问题。
不稳定的基类问题
实现继承的另一个问题是,对基类(父类)的更改可能会破坏其子类的功能。从早期的 Java 示例中,我们有一个从Bag类扩展的CountingBag类。让我们看看完整的源代码,包括main函数:
程序简单地创建了一个CountingBag对象。然后使用add方法添加apple,并使用addMany方法添加banana和orange。最后,它打印出包中的项目和包的大小。输出如下代码所示:
目前一切看起来都很正常。但假设Bag类的原始作者意识到可以通过直接向items数组列表中添加对象来改进addMany方法:
不幸的是,这个看似安全的父类更改最终导致了CountingBag的灾难:
发生了什么?当设计CountingBag时,假设在向包中添加新项目时总是会调用add方法。当addMany方法停止调用add方法时,这个假设就不再适用了。
这是谁的错?当然,Bag类的开发者无法预见谁会继承这个类。addMany方法的变化并没有违反任何契约;提供的功能相同,只是在底层有不同的实现。CountingBag类的开发者认为跟随并利用addMany已经调用add方法的事实是明智的,因此只需要覆盖add方法以使计数工作。
这提出了实现继承的第二个问题。子类开发者对父类的实现了解得太多。覆盖父类add方法的能力也违反了封装原则。
面向对象编程是如何解决这个问题呢?在 Java 中,有多种设施可以防止前面示例中提出的问题:
-
可以使用
final关键字注解方法以防止子类覆盖该方法。 -
可以使用
private关键字注解字段以防止子类访问该字段。
问题在于,开发者必须预测类将如何在未来被继承。必须仔细检查方法,以确定是否允许子类访问或覆盖它。同样适用于字段。正如你所见,这个问题之所以被称为不稳定的基类问题,是有充分的理由的。
希望我们已经向您展示了实现继承弊大于利。为了参考,在 GoF 设计模式书中,也建议优先使用组合而非继承。Julia 采取了更为激进的策略,完全禁止了实现继承。
接下来,我们将进一步探讨一种特定的行为子类型,称为鸭子类型。
回顾鸭子类型
实现行为子类型有两种方式:名义子类型和结构子类型:
-
在名义子类型中,你必须明确定义类型与其超类型之间的关系。Julia 使用名义子类型,其中类型在函数参数中明确标注。这就是为什么需要构建类型层次结构来表达类型关系。
-
在结构子类型中,只要子类型实现了超类型所需的功能,关系就隐式地推导出来。当函数使用参数定义而没有标注任何类型时,Julia 支持结构子类型。
Julia 通过鸭子类型支持结构子类型。我们首次在第三章中提到了鸭子类型,设计函数和接口。说法如下:
“如果它走路像鸭子,叫起来也像鸭子,那么它就是一只鸭子。”
在动态类型语言中,我们有时更关注我们是否得到了想要的行为,而不是确切的类型。如果我们只想听到嘎嘎声,谁会在意我们得到的是青蛙?只要它能发出嘎嘎声,我们就会满意。
有时,我们出于良好原因想要使用鸭子类型。例如,我们通常不会把马视为交通工具;然而,想想过去马被用于运输的日子。在我们的定义中,任何实现了move函数的东西都可以被视为交通工具。所以,如果我们有任何需要移动对象的算法,就没有理由不能将horse对象传递给该算法:
对于一些人来说,鸭子类型有点宽松,因为你不能轻易地判断一个类型是否支持接口(如move)。一般的补救方法是使用第五章中描述的圣物特质模式,可重用性模式。
接下来,我们将探讨一个重要的概念,称为可变性。
协变、不变性和逆变
实际上,子类型的规则并不非常直接。当你查看一个简单的类型层次结构时,你可以立即通过追踪层次结构中数据类型之间的关系来判断一个类型是否是另一个类型的子类型。当涉及到参数化类型时,情况变得更加复杂。在本节中,我们将探讨 Julia 是如何设计以可变性为依据的,这是一个解释参数化类型子类型关系的概念。
让我们先回顾一下不同类型的可变性。
理解不同类型的可变性
计算机科学文献中描述了四种不同的方差类型。我们首先将它们以正式的方式描述,然后回来进行更多的动手练习,以加强我们的理解。
假设 S 是 T 的子类型,那么有四种不同的方式来推理参数化类型 P{S} 和 P{T} 之间的关系:
-
协变:
P{S}是P{T}的子类型 (co这里表示相同方向) -
反协变:
P{T}是P{S}的子类型 (contra这里表示相反方向) -
不变量:既不是协变的也不是反协变的
-
双协变:既协变又反协变
我们在什么时候会发现方差有用?也许不会太令人惊讶,方差是当多态发生作用时的关键成分。根据 Liskov 替换原则,语言运行时必须在分派到方法之前确定传递的对象是否是方法参数的子类型。
有趣的是,方差是不同编程语言之间经常存在差异的东西。有时,这有历史原因,有时则取决于语言的目标用例。在接下来的几节中,我们将从几个角度探讨这个主题。我们将从参数化类型开始。
参数化类型是不变的
为了说明,我们将考虑一些面向对象文献中使用的流行类型层次结构——动物王国!每个人都喜欢猫和狗。我还包括鳄鱼来解释相关概念:
构建此类层次结构的相应代码如下:
abstract type Vertebrate end
abstract type Mammal <: Vertebrate end
abstract type Reptile <: Vertebrate end
struct Cat <: Mammal
name
end
struct Dog <: Mammal
name
end
struct Crocodile <: Reptile
name
end
为了方便起见,我们也可以为这些新类型定义 show 函数:
Base.show(io::IO, cat::Cat) = print(io, "Cat ", cat.name)
Base.show(io::IO, dog::Dog) = print(io, "Dog ", dog.name)
Base.show(io::IO, croc::Crocodile) = print(io, "Crocodile ", croc.name)
给定这样的类型层次结构,我们可以通过以下 adopt 函数验证子类型是如何处理的。由于没有人想领养鳄鱼(至少我不这么认为),我们限制函数参数只接受 Mammal 的子类型:
function adopt(m::Mammal)
println(m, " is now adopted.")
return m
end
如预期的那样,我们只能采用猫和狗,但不能采用鳄鱼:
如果我们想同时采用许多宠物呢?直观上,我们可以定义一个新的函数,它接受一个哺乳动物数组,如下所示:
adopt(ms::Array{Mammal,1}) = "adopted " * string(ms)
不幸的是,它已经未能通过我们为采用费利克斯和加菲尔德所做的第一次测试:
发生了什么事?我们知道猫是哺乳动物,那么为什么一个猫的数组不能传递给接受哺乳动物数组的函数呢?答案是简单的——参数化类型是不变的。这对于来自面向对象背景的人来说是一个非常大的惊喜,因为参数化类型通常是协变的。
通过不变性,尽管 Cat 是 Mammal 的子类型,但我们不能说 Array{Cat,1} 是 Array{Mammal,1} 的子类型。此外,Array{Mammal,1} 实际上代表一个 Mammal 对象的一维数组,其中每个对象可以是 Mammal 的任何子类型。由于每个具体类型可能有不同的内存布局要求,这个数组必须存储指针而不是实际值。另一种说法是,对象是 装箱 的。
为了调度到这个方法,我们必须创建一个 Array{Mammal,1}。这可以通过在数组构造函数前加上 Mammal 来实现,如下所示:
adopt(Mammal[Cat("Felix"), Cat("Garfield")])
在实践中,当我们必须处理同一类型的对象数组时,这种情况更为常见。在 Julia 中,我们可以使用类型表达式 Array{T,1} where T 来表达这样的同质数组。这意味着我们可以定义一个新的 adopt 方法,只要它们是同一类型的哺乳动物,就可以接受多个哺乳动物:
function adopt(ms::Array{T,1}) where {T <: Mammal}
return "accepted same kind:" * string(ms)
end
现在让我们测试新的 adopt 方法。结果如下所示:
如预期的那样,新的 adopt 方法根据数组是否包含 Mammal 指针或猫或狗的实际值相应地调度。
在 Julia 中,选择使参数化类型不变是一个出于实际考虑的自觉设计决策。当一个数组包含具体的类型对象时,内存可以以非常紧凑的方式分配来存储这些对象。另一方面,当一个数组包含装箱对象时,每个元素的引用都会涉及解引用一个指向对象的指针,因此性能会受到影响。
确实有一个地方 Julia 使用了协变,那就是方法参数。我们将在下面讨论这些。
方法参数是协变的
方法参数是协变的应该是相当直观的,因为这就是今天多态工作的方式。考虑以下函数:
friend(m::Mammal, f::Mammal) = "$m and $f become friends."
在 Julia 中,方法参数正式表示为一个元组。在上面的例子中,方法参数仅仅是 Tuple{Mammal,Mammal}。
当我们用类型为 S 和 T 的两个参数调用此函数时,它只有在 S <: Mammal 和 T <: Mammal 的情况下才会被调度。在这种情况下,我们应该能够传递任何哺乳动物的组合——狗/狗、狗/猫、猫/狗和猫/猫。以下截图证明了这一点:
让我们也检查鳄鱼是否能参加派对:
如预期的那样,Tuple{Cat,Crocodile} 不是 Tuple{Mammal,Mammal} 的子类型,因为 Crocodile 不是 Mammal。
接下来,让我们转向一个更复杂的场景。众所周知,函数是 Julia 中的第一公民。我们在调度期间如何确定一个函数是否是另一个函数的子类型?
函数类型的剖析
在 Julia 中,函数是一等公民。这意味着函数可以作为变量传递,并且可以出现在方法参数中。由于我们已经学习了方法参数的协变属性,那么当函数作为参数传递时,我们该如何处理这种情况呢?
理解这个问题的最好方法就是看看函数通常是如何传递的。让我们从一个简单的 Base 示例中挑选一个:
all 函数可以用来检查数组中所有元素是否都评估为 true 的条件。为了使其更加灵活,它可以接受一个自定义谓词函数。例如,我们可以检查数组中所有数字是否都是奇数,如下所示:
虽然我们知道它被正确调度了,但我们也可以确认 isodd 的类型是 Function 的子类型,如下所示:
结果表明,所有 Julia 函数都有它们自己的唯一类型,如下面的代码中显示的 typeof(isodd),并且它们都有一个超类型 Function:
由于 all 方法被定义为接受任何 Function 对象,我们实际上可以传递任何函数,Julia 会乐意调度到该方法。不幸的是,这可能会导致不希望的结果,如下面的截图所示:
我们在这里遇到了错误,因为传递给 all 函数的函数应该接受一个元素并返回一个布尔值。由于 println 总是返回 nothing,所以 all 函数只是抛出了一个异常。
在需要更强类型的情况下,可以强制指定特定的函数类型。以下是如何创建一个更安全的 all 函数的方法:
const SignFunctions = Union{typeof(isodd),typeof(iseven)};
myall(f::SignFunctions, a::AbstractArray) = all(f, a);
SignFunctions 常量是一个联合类型,仅由 isodd 和 iseven 函数的类型组成。因此,myall 方法只有在第一个参数是 isodd 或 iseven 时才会被调度;否则,将抛出一个方法错误,如下面的截图所示:
当然,这样做严重限制了函数的实用性。我们还必须枚举所有可能传递的函数,而这并不总是可行的。因此,处理函数参数的手段似乎有些有限。
回到方差的话题,当所有函数都是最终的,并且它们只有一个超类型时,实际上真的没有什么可说的。
在实践中,当我们设计软件时,我们确实关心函数的类型。正如前一个例子所示,all函数只能与接受单个参数并返回布尔值的函数一起工作。这应该是接口合同。然而,我们如何强制执行这个合同呢?最终,我们需要对函数和调用者与被调用者之间的合同有更好的理解。合同可以被视为方法参数和返回类型的组合。让我们在下一节中找出是否有更好的方法来处理这个问题。
确定函数类型的变异性
在本节中,我们将尝试理解如何推理函数类型。虽然 Julia 在形式化函数类型方面没有提供太多帮助,但它并没有阻止我们自行进行分析。在一些强类型、静态 OOP 语言中,函数类型被更正式地定义为方法参数和返回类型的组合。
假设一个函数接受三个参数并返回一个单一值。然后我们可以用以下符号来描述该函数:
让我们继续动物王国的例子,并定义一些新的变量和函数,如下所示:
female_dogs = [Dog("Pinky"), Dog("Pinny"), Dog("Moonie")]
female_cats = [Cat("Minnie"), Cat("Queenie"), Cat("Kittie")]
select(::Type{Dog}) = rand(female_dogs)
select(::Type{Cat}) = rand(female_cats)
在这里,我们定义了两个数组——一个用于母狗,另一个用于母猫。select函数可以用来随机选择一只狗或猫。接下来,让我们考虑以下函数:
match(m::Mammal) = select(typeof(m))
match函数接受一个Mammal并返回相同类型的对象。这是它的工作方式:
由于match函数只能返回Dog或Cat,我们可以这样推理函数类型:
假设我们定义了两个额外的函数,如下所示:
# It's ok to kiss mammals :-)
kiss(m::Mammal) = "$m kissed!"
# Meet a partner
function meet_partner(finder::Function, self::Mammal)
partner = finder(self)
kiss(partner)
end
meet_partner函数接受一个finder函数作为第一个参数。然后,它调用finder函数来找到一个伴侣,并最终与伴侣kiss。按照设计,我们将传递之前代码中定义的match函数。让我们看看它是如何工作的:
到目前为止,一切顺利。从meet_partner函数的角度来看,它期望finder函数接受一个Mammal参数并返回一个Mammal对象。这正是match函数的设计方式。现在,让我们看看我们能否通过定义一个不返回哺乳动物的函数来搞砸它:
neighbor(m::Mammal) = Crocodile("Solomon")
尽管neighbor函数可以接受一个哺乳动物作为参数,但它返回的是鳄鱼,而鳄鱼是一种爬行动物,不是哺乳动物。如果我们尝试将其传递给meet_partner函数,我们就会遇到灾难:
我们刚刚证明的内容相当直观。由于finder函数的返回类型预期为Mammal,任何返回Mammal任何子类型的其他finder函数也会起作用。因此,函数类型的返回类型是协变的。
现在,关于函数类型的参数是什么?再次,meet_partner函数预期将任何哺乳动物传递给finder函数。finder函数必须能够接受dog或cat对象。如果finder函数只接受猫或狗,那么它将不起作用。让我们看看如果有一个更限制性的finder函数会发生什么:
buddy(cat::Cat) = rand([Dog("Astro"), Dog("Goofy"), Cat("Lucifer")])
在这里,buddy函数接受一只猫并返回一个哺乳动物。如果我们将其传递给meet_partner函数,那么当我们想要为我们的狗Chef找到一个伴侣时,它将不起作用:
因此,函数类型的参数不是协变的。它们可能是反协变的吗?嗯,为了是反协变的,finder函数必须接受Mammal的超类型。在我们的动物王国中,唯一的超类型是Vertebrate;然而,Vertebrate是一个抽象类型,不能被实例化。如果我们实例化任何其他是Vertebrate子类型的具体类型,它就不会是哺乳动物(否则,它已经被认为是哺乳动物了)。因此,函数参数是不变的。
更正式地说,这看起来如下所示:
函数g是函数f的子类型,只要T是Mammal,且S是Mammal的子类型。关于这一点有一句话:"在接受方面要宽容,在产生方面要保守。"
虽然做这种分析很有趣,但考虑到 Julia 运行时不支持像我们所见的那样细粒度的函数类型,我们是否真的获得了什么?似乎我们可以自己模拟一个类型检查的效果,这是下一节的主题。
实现我们自己的函数类型分派
正如我们在本节前面所看到的,Julia 为每个函数创建一个唯一的函数类型,它们都是Function抽象类型的子类型。我们似乎错过了一个多态的机会。以Base中的all函数为例,如果我们能够设计一个表示谓词函数的类型,而不是让all在传递不兼容的函数时失败,那将会非常棒。
为了绕过这个限制,让我们定义一个名为PredicateFunction的参数化类型,如下所示:
struct PredicateFunction{T,S}
f::Function
end
PredicateFunction参数化类型只是包装了一个函数f。类型参数T和S用于表示函数参数的类型,并分别返回f的类型。例如,iseven函数可以被包装如下,因为我们知道该函数可以接受一个数字并返回一个布尔值:
PredicateFunction{Number,Bool}(iseven)
便利的是,由于 Julia 支持可调用的结构体,我们可以使PredicateFunction结构体可以被调用,就像它本身是一个函数一样。为了实现这一点,我们可以定义以下函数:
(pred::PredicateFunction{T,S})(x::T; kwargs...) where {T,S} =
pred.f(x; kwargs...)
如您所见,这个函数只是将调用转发到包装的pred.f函数。一旦定义了它,我们就可以做一些小实验来看看它是如何工作的:
看起来相当不错。让我们定义我们自己的 safe 版本的 all 函数,如下所示:
function safe_all(pred::PredicateFunction{T,S}, a::AbstractArray) where
{T <: Any, S <: Bool}
all(pred, a)
end
safe_all 函数接受一个 PredicteFunction{T,S} 作为第一个参数,约束条件是 T 是 Any 的子类型,而 S 是 Bool 的子类型。这正是我们想要的谓词函数的类型签名。知道 Number <: Any 和 Bool <: Bool,我们可以肯定地将 iseven 函数传递给 safe_all。现在让我们测试一下:
Bravo! 我们已经创建了一个安全的 all 函数版本。第一个参数必须是一个接受任何内容并返回布尔值的谓词函数。我们不再需要接受一个通用的 Function 参数,现在我们可以强制严格的类型匹配并参与多重分发。
关于变异性就讲这么多。接下来,我们将继续并重新审视参数化方法调用的规则。
参数化方法重新审视
根据子类型关系进行分发的功能是 Julia 语言的一个关键特性。我们最初在 第三章 设计函数和接口 中介绍了参数化方法的概念。在本节中,我们将更深入地探讨一些关于方法选择分发的微妙情况。
让我们从基础开始:我们如何为参数化方法指定类型变量?
指定类型变量
当我们定义一个参数化方法时,我们使用 where 子句来引入类型变量。让我们来看一个简单的例子:
triple(x::Array{T,1}) where {T <: Real} = 3x
triple 函数接受一个 Array{T},其中 T 是 Real 的任何子类型。这段代码非常易于阅读,这是大多数 Julia 开发者选择来指定类型参数的格式。那么 T 的值可能是什么?它可以是具体类型、抽象类型,或者两者都是?
为了回答这个问题,我们可以在 REPL 中测试它:
因此,该方法确实在抽象类型(Real)和具体类型(Int64)上进行了分发。值得一提的是,where 子句也可以放在方法参数旁边:
triple(x::Array{T,1} where {T <: Real}) = 3x
从函数式编程的角度来看,无论 where 子句是放在内部还是外部,都是相同的。
然而,有一些细微的差别。当 where 子句放在外部时,你将获得两个额外的优势:
-
类型变量
T在方法体内部是可访问的。 -
类型变量
T可以用来强制多个方法参数具有相同的值。
结果表明,第二点导致了 Julia 分发系统中一个有趣的功能。我们将在下一节中介绍这一点。
匹配类型变量
当一个类型变量在方法签名中多次出现时,它被用来强制所有出现位置具有相同的类型。考虑以下函数:
add(a::Array{T,1}, x::T) where {T <: Real} = (T, a .+ x)
add 函数接受一个 Array{T} 和一个类型为 T 的值。它返回一个包含 T 和将值添加到数组后的结果的元组。直观上,我们希望 T 在两个参数中保持一致。换句话说,我们希望函数在调用时针对 T 的每个实现进行特殊化。显然,当类型一致时,函数工作得很好:
在第一种情况下,T 被确定为 Int64,而在第二种情况下,T 被确定为 Float64。也许并不令人意外,当类型不匹配时,我们可能会得到一个方法错误:
由于我们说 T 可以是一个抽象类型,我们能否将方法分派到这个方法上,因为 T 可以被认为是 Real?答案是不了,因为参数化类型是 不变的!一个 Real 对象的数组不等于一个 Int64 值的数组。更正式地说,Array{Int} 不是 Array{Real} 的子类型。
当 T 是数组中的抽象类型时,事情会变得更有趣。让我们试试这个:
在这里,T 明确设置为 Signed,并且由于 Int8 是 Signed 的子类型,方法被正确分派。
接下来,我们将探讨另一个独特的类型特性,称为对角线规则。
理解对角线规则
如我们之前所学的,能够匹配类型变量并在方法参数中保持一致性是一个很好的特性。在实践中,有些情况下我们希望在确定每个类型变量的正确类型时更加具体。
考虑这个函数:
diagonal(x::T, y::T) where {T <: Number} = T
diagonal 函数接受两个相同类型的参数,其中类型 T 必须是 Number 的子类型。类型变量 T 简单地返回给调用者。
当 T 是具体类型时,很容易推理出类型是一致的。例如,我们可以传递一对 Int64 值或一对 Float64 值给函数,并期望看到相应的具体类型返回:
直观上,我们也期望当类型不一致时这会失败:
虽然看起来直观,但我们可能会争辩说类型变量 T 是一个抽象类型,比如 Real。由于 1 的值是 Int64 且 Int64 是 Real 的子类型,以及 2.0 的值是 Float64 且 Float64 是 Real 的子类型,那么方法是否仍然应该被分派?为了使这一点更加清晰,我们甚至可以在调用函数时将参数注释为如下:
结果表明,Julia 被设计成给我们更直观的行为。这也是引入对角线规则的真正原因。对角线规则指出,当一个类型变量在协变位置(即方法参数)中多次出现时,该类型变量将被限制仅与具体类型匹配。
在这种情况下,类型变量T被视为对角线变量,因此T必须是一个具体类型。
尽管存在对角线规则的例外。我们将在下一节讨论这个问题。
对角线规则的例外
对角线规则指出,当一个类型变量在协变位置(即方法参数)中多次出现时,该类型变量将被限制仅与具体类型匹配;然而,该规则有一个例外——当类型变量可以从不变位置明确确定时,它允许是抽象类型而不是具体类型。
考虑以下例子:
not_diagonal(A::Array{T,1}, x::T, y::T) where {T <: Number} = T
与上一节中的diagonal函数不同,这个函数允许T是抽象的。我们可以这样证明:
原因是T出现在参数类型的第一参数中。正如我们所知,参数类型是不变的,我们已经确定T是Signed。因为Int64是Signed的子类型,所以一切匹配。
在下一节中,我们将讨论类型变量的可用性。
类型变量的可用性
参数方法的一个重要特性是,where子句中指定的类型变量也可以从方法体中访问。与您可能认为的相反,这并不总是正确的。在这里,我们将展示一个类型变量在运行时不可用的例子。
考虑以下函数:
mytypes1(a::Array{T,1}, x::S) where {S <: Number, T <: S} = T
mytypes2(a::Array{T,1}, x::S) where {S <: Number, T <: S} = S
我们可以使用mytypes1和mytypes2函数来实验 Julia 运行时推导出的类型变量。让我们从一个愉快的例子开始:
然而,情况并不总是如此美好。在其他情况下,它可能并不总是 100%有效。以下是一个例子:
为什么S在这里没有定义?首先,我们已经知道T是Signed,因为参数类型是不变的。作为where子句的一部分,我们也知道T是S的子类型。因此,S可以是Integer、Real、Number甚至Any。由于可能的答案太多,Julia 运行时决定不对S分配任何值。
这个故事的意义是,不要假设类型变量总是被定义并且可以从方法中访问,尤其是在这种更复杂的情况下。
摘要
在本章中,我们学习了与子类型、变体和调度相关的各种主题。这些概念是创建更大、更复杂应用程序的基本构建块。
我们首先讨论了实现继承和行为子类型化以及它们之间的区别。我们推理出,由于各种问题,实现继承不是一个很好的设计模式。我们得出结论,Julia 的类型系统是为了避免我们在其他编程语言中看到的问题而设计的。
然后,我们回顾了不同种类的变异性,这些不过是解释参数化类型之间子类型关系的方法。我们详细地解释了参数化类型是如何不变的,方法参数是如何协变的。然后我们更进一步讨论了函数类型的变异性以及我们如何可以构建自己的数据类型来封装函数以实现分派目的。
最后,我们重新审视了参数化方法,并探讨了在分派过程中类型变量是如何指定和匹配的。我们了解了对角线规则,这是 Julia 语言中的一个关键设计特性,它允许我们以直观的方式强制方法参数的类型一致性。
我们现在已经完成了这一章节和整本书。感谢您阅读它!
问题
-
实现继承和行为子类型化有何不同?
-
实现继承有哪些主要问题?
-
什么是鸭子类型?
-
方法参数的变异性是什么,为什么?
-
为什么在 Julia 中参数化类型是不变的?
-
对角线规则何时适用?
第十三章:评估
第一章
使用设计模式有哪些好处?
设计模式帮助程序员将已经证明有效的方法应用于常见问题。在次优实现之后,将节省更多时间用于寻找适当的解决方案或修复设计问题。反模式为避免常见设计缺陷提供了额外的指导。
有哪些关键的设计原则?
关键设计原则包括 SOLID、DRY、KISS、POLA、YAGNI 和 POLP。这些原则被广泛认为是面向对象编程的良好指导,但它们同样适用于其他编程范式。
开放/封闭原则解决了什么问题?
开放/封闭原则鼓励程序员设计一个易于扩展的系统,而无需修改正在扩展的组件。它促进了软件组件更好的重用性。
为什么接口分离对于软件重用很重要?
接口分离促进了接口的最简设计,以便软件组件更容易实现相应的接口。一个庞大而复杂的接口难以实现,并且使组件的可重用性降低。
开发可维护的软件的最简单方法是什么?
最简单的方式是遵守 KISS、DRY、POLA 和 SOLID 等通用设计原则。
避免过度设计和臃肿软件的好习惯是什么?
避免过度设计和臃肿软件的最佳方式是根据 YAGNI 原则仅实现绝对必要的功能。同时,保持简单(KISS)并避免重复代码(DRY)。
内存使用如何影响系统性能?
当系统分配更多内存时,它也会更频繁地触发垃圾回收器(GC)。垃圾回收是一个相对昂贵的操作,因此,它可能会减慢系统。避免过度内存分配通常是优化应用程序性能的最佳方法之一。
第二章
我们如何创建一个新的命名空间?
命名空间是通过模块块创建的。通常,模块被定义为 Julia 包的一部分。
我们如何将模块的功能暴露给外部世界?
可以使用导出语句将模块内定义的函数和其他对象暴露出来。
当同一函数名从不同的包导出时,我们如何引用正确的函数?
我们可以直接在函数名前加上包名。作为替代方案,我们可以对一个包使用using语句,对另一个使用导入语句,这样我们就可以直接使用第一个包的函数名,而对其他包使用前缀语法。
我们在什么时候将代码分离成多个模块?
当代码变得太大,难以管理时,是时候考虑将代码分离成模块了。我们期望进行一些重构,以确保模块之间适当的耦合级别。
为什么语义版本化在管理包依赖时很重要?
语义版本化定义了在新版本中引入破坏性更改时的明确合同。当正确且一致地使用时,它有助于程序员确定更改是否与现有软件兼容,以及是否需要额外的测试。
定义抽象类型的函数行为有何用途?
为抽象类型定义函数行为是有用的,因为可以将相同的行为应用于相应的子类型。
何时应该使类型可变?
当预期数据类型的一些部分需要更改时,将类型设置为可变是合适的。出于性能原因需要减少内存分配时,这也很有用。
参数类型有何用途?
参数类型允许在不硬编码字段类型的情况下定义具体类型,因此可以使用相同的类型为不同的目的生成新的变体。
第三章
位置参数与关键字参数有何不同?
位置参数必须按照它们在函数签名中定义的顺序传递。它们通常是必需的,但可以通过提供默认值来使其可选。关键字参数可以按它们书写的任何顺序传递,并且当未提供默认值时是可选的。
展开和吸入有何区别?
展开和吸入具有相同的语法,但在不同的上下文中意味着不同的事情。展开指的是从元组或数组自动分配函数参数。吸入指的是将多个函数参数传递为一个单一的元组变量,该变量可以从函数体中访问。
do-syntax 的目的是什么?
Do-syntax 是一种方便的方式来格式化需要作为匿名函数包装并传递给另一个函数的代码块。这使得代码更加易于阅读。
有什么工具可以检测与多重分派相关的方法歧义?
可以使用来自Test包的detect_ambiguities函数来检测单个模块或多个模块内的方法歧义。
我们如何确保在参数方法中传递相同的具体类型?
确保函数的参数传递相同的具体类型的一个方便方法是将这些参数指定为类型参数(例如,T)。请注意,只要类型参数作为独立类型使用,而不是参数类型的一部分,例如,AbstractVector{T},这就会起作用。
在没有正式语言语法的情况下如何实现接口?
即使 Julia 没有指定接口的正式语法,也可以根据接口设计者的规范实现接口。
我们如何实现特质,特质有何用途?
特质可以通过一个函数来实现,该函数接受特定的数据类型并返回一个标志。通常,特质被定义为返回布尔值,即特质是否存在。然而,它也可以设计为返回多个值以指示各种特质。如果开发者需要通过编程方式确定数据类型(或数据类型的组合)是否具有特定行为,特质是有用的。
第四章
有哪两种方法可以引用表达式以便稍后进行代码操作?
一种方法是将表达式用:(和)括起来。另一种方法是将代码放在quote和end关键字之间。一般来说,quote 块用于多行表达式。
eval函数在哪个作用域中执行代码?
eval函数评估全局作用域中的代码。因此,如果它从一个模块内的函数中使用,那么被评估的代码将位于模块的作用域内。
我们如何将物理符号插入到引号表达式中,而不是将其误解释为源代码?
要将符号插入到引号表达式,创建一个QuoteNode对象并正常插入该对象。
定义非标准字符串字面量的宏的命名约定是什么?
非标准字符串字面量定义为以_str结尾的宏。例如,当为 IP 地址定义ip_str宏时,它可以这样写:ip"192.168.1.1"。
何时使用esc函数?
esc函数需要确保引号表达式在调用点被评估,这可能是函数的局部作用域。
生成的函数与宏有何不同?
生成的函数可以访问参数的类型。它们是按定义是函数,因此与宏不同,它们没有访问源代码的能力。宏在语法级别上操作,并且没有任何运行时信息。生成的函数和宏都应返回表达式。
我们如何调试元编程代码?
调试宏可能具有挑战性。这归结于确保返回的表达式是正确的。我们可以使用@macroexpand宏(或相应的macroexpand函数)来验证结果。此外,由于宏或生成的函数是使用常规 Julia 代码定义的,因此可以使用相同的调试技术,例如println。
第五章
委托模式是如何工作的?
委托模式可以通过将父对象包装在新对象中来实现。新对象的功能可以转发(或委托)给父对象。
特质的目的何在?
特质的目的是正式定义某些对象的行为。一旦定义了特质,我们就可以通过编程方式检查一个对象是否具有该特质。
特质总是二元的吗?
特性通常是二元的,但没有强制性要求。只要特性是互斥的,那就没问题。Julia 的Base.IteratorSize特性就是一个多值特性的好例子。
能否将特性用于不同类型层次结构中的对象?
是的,特性不受抽象类型层次结构定义方式的限制。相同的特性可以分配给来自不同类型层次结构的对象。
参数类型有哪些好处?
参数类型允许我们为数据类型定义一个模板。可以通过填充参数来程序性地创建新的数据类型。参数类型的主要好处是代码变得更短,因为我们不需要列出每个可能的具体类型。
我们如何使用参数类型来存储信息?
可以将额外的信息作为参数存储在类型本身中。访问此类数据非常方便,因为它是第一类数据,并且可以在接受参数类型参数的函数中使用。
第六章
为什么使用全局变量会影响性能?
全局变量是无类型的。每次使用时,编译器都必须生成可以处理可能遇到的所有数据类型的代码。因此,编译器不能生成高度优化的代码。
当无法用常量替换时,使用全局变量的良好替代方案是什么?
我们可以定义一个有类型的全局常量作为占位符。Ref类型也可以用来保存变量的单个值。因为Ref包含数据类型,编译器可以生成更优化的代码。
为什么数组结构体比结构体数组表现更好?
现代 CPU 可以并行执行许多数值计算。当内存对齐并打包成数组时,硬件缓存可以快速查找它们。结构体数组可能将对象散布在内存中,这会损害性能。
SharedArray的局限性是什么?
SharedArray只支持位类型。如果我们需要并行处理非位类型数据,那么就不能使用 SharedArrays。
除了使用并行进程之外,有什么是替代多核计算的方法?
一种替代方案是使用多线程功能。Julia 1.3 版本实现了一个支持多级并行的最先进的多线程调度器。
在使用缓存模式时,必须注意哪些问题?
缓存化以空间换取时间。使用缓存需要更多的内存空间。根据函数结果,它可能或可能不会影响应用程序的内存占用。如果系统中的内存已经受限,这可能不是最佳选择。
性能提升中屏障函数背后的魔法是什么?
当使用barrier函数时,编译器可以根据传递给函数的参数类型来专门化函数。即使参数类型不稳定,当遇到新类型时,也会自动编译一个新的专门化函数。
第七章
什么是输入耦合和输出耦合?
输入耦合表示有多少外部组件依赖于当前组件。相比之下,输出耦合表示当前组件依赖于多少外部组件。这些测量有助于确定当前组件与其他组件的耦合程度。
从可维护性的角度来看,双向依赖为什么不好?
双向依赖往往会引入混乱的意大利面条式代码。为了理解单个组件,开发者必须处理并理解它所使用和依赖的其他组件。
有什么简单的方法可以即时生成代码?
@eval宏可以用来生成代码。例如,它可以在for循环中使用,以便将变量插入到函数的定义中。结果是定义了多个函数,它们在代码结构和逻辑方面都相似。
代码生成的替代方案是什么?
有时,不需要代码生成。相反,开发者可以选择使用函数式编程技术,如闭包,来重用现有逻辑。代码生成可能会增加程序的大小,并使程序更难调试。因此,在深入代码生成技术之前,开发者考虑其他选项将是明智的。
何时以及为什么我们应该考虑构建一个特定领域的语言?
特定领域语言(DSL)通常用于编写特定领域内清晰且易于理解代码。例如,DifferentialEquations包允许开发者使用与相应数学方程非常相似的语法编写代码。由于语法友好,它允许开发者专注于数学建模而不是编码方面。
开发特定领域语言有哪些可用的工具?
MacroTools包提供了几个方便的宏,这些宏在编写宏和特定领域语言中非常有帮助。@capture宏允许用户执行模式匹配和解析源代码。prewalk和postwalk函数允许我们在抽象语法树中手术性地替换表达式。@capture和prewalk/postwalk的组合使其成为开发特定领域语言的一个非常强大的工具。
第八章
开发评估函数有哪些好处?
评估函数是向特定对象的用户提供官方 API 的绝佳方式。因此,底层实现与接口解耦。如果实现有任何变化,只要评估函数的契约保持不变,就不会对对象的用户产生任何影响。
有什么简单的方法可以阻止使用对象的内部字段?
最简单的方法是使用特殊的命名约定来阻止使用对象的内部字段。常用的约定是将下划线作为字段名称的前缀。如果程序员尝试使用该字段,那么他们会提醒自己该字段应该是私有的。
哪些函数可以作为属性接口的一部分进行扩展?
Base 包中有三个函数可以被扩展以提供特定功能,用于字段访问的点表示法。这些函数是 getproperty、setproperty! 和 propertynames。一个需要记住的重要点是,一旦这些函数被定义,所有直接的字段访问都必须改为 getfield 和 setfield! 以避免递归问题。
如何在捕获异常后从 catch-block 中捕获堆栈跟踪?
一旦捕获到异常,我们可以使用 catch_backtrace 函数来捕获异常被捕获之前的堆栈帧。然后我们可以将结果传递给 stacktrace 函数以检索 StackFrame 对象的数组。
如何避免对需要最佳性能的系统中的 try-catch 块的性能影响?
避免使用 try-catch 块的性能影响最好的方法是根本不使用它。我们应该找到其他处理异常的方法。例如,我们可以检查任何可能导致后续函数失败的条件。在这种情况下,我们可以主动处理这种情况。另一种选择是在循环外部捕获异常;因此,我们会在更高的级别处理异常。
使用 retry 函数有哪些好处?
retry 函数是一种自动重复可能失败的操作的绝佳方式。这样做可以确保重要任务能够得到保证完成,除非有其他类型的不可恢复异常。
我们如何隐藏模块内部使用的全局变量和函数?
我们可以使用 let-block,这样全局变量就被绑定在 let-block 中,而不是暴露在模块的全局作用域中。当需要将函数暴露给模块时,定义在 let-block 内部的函数可以被声明为全局。
第九章
可以使用哪种预定义的数据类型方便地创建新的单例类型?
内置的 Val 类型可以轻松地创建新的单例类型。Val 构造函数可以接受任何位类型值,并返回类型为 Val{X} 的单例,其中 X 是传递给构造函数的值。
使用单例类型分发的优点是什么?
使用单例类型分发,我们可以消除依赖于数据类型的条件语句。它还允许我们通过仅定义新函数来添加新功能,而无需修改现有函数。因为 Julia 原生支持分派,所以不需要创建任何自定义函数仅用于分派。
为什么我们要创建模拟器?
模拟器在自动化测试中非常有用。首先,如果一个函数需要连接到远程网络服务,那么始终连接到实时服务可能不方便,甚至可能成本高昂。在这种情况下,可以使用模拟器来代替服务。其次,模拟器可以被设计成测试所有正面和负面场景,以便将所需的测试包含在自动化测试过程中。
模拟和模拟之间的区别是什么?
模拟器关注的是状态验证,即在模拟器使用后被测试函数(FUT)的输出。另一方面,模拟关注的是行为验证,即模拟函数是如何被 FUT 使用的。一般来说,模拟也像模拟器一样包括状态验证。
组合性意味着什么?
组合性意味着函数可以组合起来创建更大的东西有多容易。可组合函数允许通过重用现有代码来构建应用程序。因为函数在 Julia 中是一等公民,所以只要函数只接受单个参数,它们就可以很容易地组合。
使用功能管道的主要约束是什么?
功能管道的主要约束是管道中参与的功能只能接受单个参数。需要多个参数的函数可以被转换为一个curried函数,这样高阶函数就可以参与管道。
功能管道有什么用途?
功能管道对于数据处理管道非常有用,特别是如果过程本质上是线性的。对于某些人来说,语法易于阅读。
第十章
一级盗版的风险和潜在好处是什么?
一级盗版指的是第三方功能被自定义实现重新定义的情况。风险在于自定义实现可能不符合第三方模块预期的合同。如果代码编写错误,系统可能会变得不稳定并崩溃。
由于二级盗版可能会出现什么问题?
二级盗版指的是在函数参数中不使用自己的类型扩展第三方功能的情况。这可能会出现问题,因为没有保证另一个依赖包也实现了二级盗版,可能与你的盗版函数冲突。结果可能是一个不稳定的系统。
三级盗版是如何引起麻烦的?
类型 III 盗用指的是一种情况,即第三方函数通过您的自定义类型进行了扩展,但目的不同。虽然函数定义使用自定义类型作为参数,但无法保证第三方模块不会因为鸭子类型而最终使用您的函数。因此,您的盗用函数泄漏到第三方模块中,导致意外结果。
在指定函数参数时,我们应该注意哪些问题?
在指定函数参数时,我们应该避免使参数类型过于狭窄。过于狭窄的参数限制了函数的可重用性。
使用抽象函数参数如何影响系统性能?
当使用抽象类型指定函数参数时,系统性能不受影响。Julia 总是根据传递给函数的类型来指定函数。因此,没有运行时开销。
使用抽象字段类型为复合类型时,系统性能如何受到影响?
当在复合类型的字段中使用抽象类型时,系统性能会受到负面影响。Julia 编译器必须在内存中存储这些对象的指针,因为它必须支持与这些字段相关的任何数据类型。因为必须解引用指针才能访问数据,所以系统性能可能会大幅下降。
第十一章
我们可以使用什么技术来实现抽象工厂模式?
为了实现抽象工厂模式,我们可以创建一个抽象类型的层次结构。然后,我们可以实现接受单例类型作为参数的具体函数。通过多分派,我们应该能够调用适合正确平台或环境的正确函数。
如何在多线程应用程序中避免单例被多次初始化?
为了避免单例的多次初始化,我们可以使用可重入锁来同步线程。第一个线程将能够获取锁并初始化单例,而其他线程应在初始化完成后等待。必须在初始化结束时释放锁。
实现观察者模式时,Julia 的哪个特性是必不可少的?
我们可以实现setproperty!函数,以便可以监控对象的字段的所有更新,并触发额外的操作。
我们如何使用模板方法模式来自定义操作?
我们可以设计模板函数,通过关键字参数接受自定义函数。关键字参数可以默认为标准实现,同时调用者也可以传递自定义函数。函数的预期接口应该有明确的文档说明。
我们如何制作适配器以实现目标接口?
我们可以通过创建一个新的类型来包装原始类型来制作一个适配器。然后,我们可以在新类型上实现预期的接口。使用委托模式,新类型可以通过将特定函数转发到原始类型来重用现有功能。
享元模式有哪些好处,我们可以使用什么策略来实现它?
使用享元模式时,由于对象是共享的,我们可以潜在地节省大量内存空间。一般技术是维护一个参考表,该表使用更紧凑的数据元素作为查找键。该键用于查找更占用内存的对象。
我们可以使用 Julia 的哪个特性来实现策略模式?
我们可以使用单例类型作为函数参数来实现策略模式。具有适当算法(策略)的函数在运行时通过多重分派自动选择。
第十二章
实现继承与行为子类型化有何不同?
实现继承允许子类从超类继承字段和方法。行为子类型化允许子类型继承为超类型定义的方法。
与实现继承相关的一些主要问题是什么?
实现继承是有问题的,因为有时,子类可能不想从超类继承字段,即使定义父子关系在逻辑上是有意义的。正如从正方形-矩形问题中所示,子类可能更加限制性,并移除功能,而不是在超类之上添加新功能。其次,实现继承受到脆弱基类问题的困扰,对超类的更改可能无意中修改了子类的行为。
什么是鸭式类型?
鸭式类型是一种动态特性,它允许在不进行强类型检查的情况下分发方法。只要函数遵循预期的接口合约,就可以分发函数。
方法参数的变异性是什么,为什么?
方法参数是协变的,因为它们与 Liskov 替换原则一致,该原则指出,定义为接受类型 S 的函数应该能够与 S 的任何子类型一起工作。
为什么在 Julia 中参数化类型是不变的?
在 Julia 中,参数化类型是不变的,这是一个非常实际的原因。类型参数明确地确定了底层容器的内存布局。当它是不变的,就有机会通过连续压缩存储数据来达到高性能,而不需要解引用指针。
对角线规则何时适用?
当类型变量在协变位置出现多次时,会应用对角线规则。当从不变位置(如参数化类型)明确确定相同的类型变量时,该规则存在例外。