More about types
如果你使用Julia一段时间,你会理解类型所扮演的基本角色。在这里,我们试图深入探讨,特别关注Parametric Types。
Types and sets (and Any and Union{}/Bottom)
也许最容易通过集合来理解Julia的类型系统。虽然程序操作单个值,但类型指的是一组值。这与集合不同;例如,Set 的值本身就是一个单一的 Set 值。相反,类型描述了一组可能的值,表达了我们对拥有哪个值的不确定性。
一个 具体 类型 T 描述了值的集合,其直接标签由 typeof 函数返回,为 T。一个 抽象 类型描述了一些可能更大的值集合。
Any 描述了所有可能值的整个宇宙。 Integer 是 Any 的一个子集,包括 Int、Int8 和其他具体类型。在内部,Julia 还大量使用另一种类型,称为 Bottom,也可以写作 Union{}。这对应于空集合。
Julia 的类型支持集合论的标准操作:你可以通过 T1 <: T2 来询问 T1 是否是 T2 的“子集”(子类型)。同样,你可以使用 typeintersect 交集两个类型,使用 Union 取它们的并集,并使用 typejoin 计算一个包含它们并集的类型:
julia> typeintersect(Int, Float64)
Union{}
julia> Union{Int, Float64}
Union{Float64, Int64}
julia> typejoin(Int, Float64)
Real
julia> typeintersect(Signed, Union{UInt8, Int8})
Int8
julia> Union{Signed, Union{UInt8, Int8}}
Union{UInt8, Signed}
julia> typejoin(Signed, Union{UInt8, Int8})
Integer
julia> typeintersect(Tuple{Integer, Float64}, Tuple{Int, Real})
Tuple{Int64, Float64}
julia> Union{Tuple{Integer, Float64}, Tuple{Int, Real}}
Union{Tuple{Int64, Real}, Tuple{Integer, Float64}}
julia> typejoin(Tuple{Integer, Float64}, Tuple{Int, Real})
Tuple{Integer, Real}虽然这些操作看起来可能很抽象,但它们是Julia的核心。例如,方法调度是通过遍历方法列表中的项来实现的,直到找到一个参数元组的类型是方法签名的子类型。为了使这个算法有效,重要的是方法按其特异性进行排序,并且搜索从最特异的方法开始。因此,Julia还在类型上实现了部分顺序;这通过类似于<:的功能实现,但有一些下面将讨论的不同之处。
UnionAll types
Julia的类型系统还可以表达迭代联合类型:对某个变量的所有值的类型的联合。这对于描述参数类型是必要的,因为某些参数的值是未知的。
例如,Array 有两个参数,如 Array{Int,2} 中所示。如果我们不知道元素类型,我们可以写成 Array{T,2} where T,这是所有 T 值的 Array{T,2} 的并集:Union{Array{Int8,2}, Array{Int16,2}, ...}。
这种类型由一个 UnionAll 对象表示,该对象包含一个变量(在此示例中为 T,类型为 TypeVar)和一个包装类型(在此示例中为 Array{T,2})。
考虑以下方法:
f1(A::Array) = 1
f2(A::Array{Int}) = 2
f3(A::Array{T}) where {T<:Any} = 3
f4(A::Array{Any}) = 4签名 - 如在 Function calls 中描述 - 的 f3 是一个 UnionAll 类型,包装了一个元组类型:Tuple{typeof(f3), Array{T}} where T。除了 f4 之外,其他都可以用 a = [1,2] 调用;除了 f2 之外,其他都可以用 b = Any[1,2] 调用。
让我们更仔细地看看这些类型:
julia> dump(Array)
UnionAll
var: TypeVar
name: Symbol T
lb: Union{}
ub: Any
body: UnionAll
var: TypeVar
name: Symbol N
lb: Union{}
ub: Any
body: Array{T, N} <: DenseArray{T, N}
ref::MemoryRef{T}
size::NTuple{N, Int64}这表明 Array 实际上命名了一个 UnionAll 类型。每个参数都有一个嵌套的 UnionAll 类型。语法 Array{Int,2} 等价于 Array{Int}{2};在内部,每个 UnionAll 都是用特定的变量值逐个实例化的,从外到内。这为省略尾部类型参数赋予了自然的意义;Array{Int} 给出了一个等价于 Array{Int,N} where N 的类型。
TypeVar 本身不是一个类型,而应该被视为 UnionAll 类型结构的一部分。类型变量对其值有下限和上限(在字段 lb 和 ub 中)。符号 name 纯粹是装饰性的。在内部,TypeVar 是通过地址进行比较的,因此它们被定义为可变类型,以确保可以区分“不同”的类型变量。然而,按照惯例,它们不应该被修改。
可以手动构造 TypeVar:
julia> TypeVar(:V, Signed, Real)
Signed<:V<:Real有一些方便的版本,允许您省略除 name 符号之外的任何参数。
语法 Array{T} where T<:Integer 被降低为
let T = TypeVar(:T,Integer)
UnionAll(T, Array{T})
end因此,手动构造 TypeVar 是很少必要的(实际上,这应该避免)。
Free variables
自由 类型变量的概念在类型系统中极为重要。我们说变量 V 在类型 T 中是自由的,如果 T 不包含引入变量 V 的 UnionAll。例如,类型 Array{Array{V} where V<:Integer} 没有自由变量,但其中的 Array{V} 部分确实有一个自由变量 V。
一个具有自由变量的类型在某种意义上并不是真正的类型。考虑类型 Array{Array{T}} where T,它指的是所有同质的数组的数组。内部类型 Array{T},单独来看,似乎可以指任何类型的数组。然而,外部数组的每个元素必须具有相同的数组类型,因此 Array{T} 不能仅仅指任何旧的数组。可以说,Array{T} 实际上“出现”了多次,并且 T 在每次“出现”时必须具有相同的值。
因此,C API 中的函数 jl_has_free_typevars 非常重要。对于返回 true 的类型,在子类型化和其他类型函数中将不会给出有意义的答案。
TypeNames
以下两种 Array 类型在功能上是等效的,但打印结果不同:
julia> TV, NV = TypeVar(:T), TypeVar(:N)
(T, N)
julia> Array
Array
julia> Array{TV, NV}
Array{T, N}这些可以通过检查类型的 name 字段来区分,该字段是 TypeName 类型的对象:
julia> dump(Array{Int,1}.name)
TypeName
name: Symbol Array
module: Module Core
names: empty SimpleVector
wrapper: UnionAll
var: TypeVar
name: Symbol T
lb: Union{}
ub: Any
body: UnionAll
var: TypeVar
name: Symbol N
lb: Union{}
ub: Any
body: Array{T, N} <: DenseArray{T, N}
cache: SimpleVector
...
linearcache: SimpleVector
...
hash: Int64 -7900426068641098781
mt: MethodTable
name: Symbol Array
defs: Nothing nothing
cache: Nothing nothing
max_args: Int64 0
module: Module Core
: Int64 0
: Int64 0在这种情况下,相关字段是 wrapper,它持有对用于创建新 Array 类型的顶级类型的引用。
julia> pointer_from_objref(Array)
Ptr{Cvoid} @0x00007fcc7de64850
julia> pointer_from_objref(Array.body.body.name.wrapper)
Ptr{Cvoid} @0x00007fcc7de64850
julia> pointer_from_objref(Array{TV,NV})
Ptr{Cvoid} @0x00007fcc80c4d930
julia> pointer_from_objref(Array{TV,NV}.name.wrapper)
Ptr{Cvoid} @0x00007fcc7de64850Array 的 wrapper 字段指向自身,但对于 Array{TV,NV} 它指回类型的原始定义。
其他字段呢?hash 为每种类型分配一个整数。要检查 cache 字段,选择一个使用频率低于数组的类型是有帮助的。让我们首先创建我们自己的类型:
julia> struct MyType{T,N} end
julia> MyType{Int,2}
MyType{Int64, 2}
julia> MyType{Float32, 5}
MyType{Float32, 5}当你实例化一个参数化类型时,每个具体类型都会保存在类型缓存中(MyType.body.body.name.cache)。然而,包含自由类型变量的实例不会被缓存。
Tuple types
元组类型构成了一个有趣的特殊情况。为了使调度在像 x::Tuple 这样的声明上工作,该类型必须能够容纳任何元组。让我们检查一下参数:
julia> Tuple
Tuple
julia> Tuple.parameters
svec(Vararg{Any})与其他类型不同,元组类型在其参数中是协变的,因此此定义允许 Tuple 匹配任何类型的元组:
julia> typeintersect(Tuple, Tuple{Int,Float64})
Tuple{Int64, Float64}
julia> typeintersect(Tuple{Vararg{Any}}, Tuple{Int,Float64})
Tuple{Int64, Float64}然而,如果一个可变参数(Vararg)元组类型具有自由变量,它可以描述不同种类的元组:
julia> typeintersect(Tuple{Vararg{T} where T}, Tuple{Int,Float64})
Tuple{Int64, Float64}
julia> typeintersect(Tuple{Vararg{T}} where T, Tuple{Int,Float64})
Union{}注意,当 T 在 Tuple 类型中是自由的(即其绑定的 UnionAll 类型在 Tuple 类型之外),整个类型中只能有一个 T 值有效。因此,异构元组不匹配。
最后,值得注意的是 Tuple{} 是不同的:
julia> Tuple{}
Tuple{}
julia> Tuple{}.parameters
svec()
julia> typeintersect(Tuple{}, Tuple{Int})
Union{}“主要”元组类型是什么?
julia> pointer_from_objref(Tuple)
Ptr{Cvoid} @0x00007f5998a04370
julia> pointer_from_objref(Tuple{})
Ptr{Cvoid} @0x00007f5998a570d0
julia> pointer_from_objref(Tuple.name.wrapper)
Ptr{Cvoid} @0x00007f5998a04370
julia> pointer_from_objref(Tuple{}.name.wrapper)
Ptr{Cvoid} @0x00007f5998a04370所以 Tuple == Tuple{Vararg{Any}} 确实是主要类型。
Diagonal types
考虑类型 Tuple{T,T} where T。具有此签名的方法如下所示:
f(x::T, y::T) where {T} = ...根据对 UnionAll 类型的通常解释,这个 T 涉及所有类型,包括 Any,因此这个类型应该等同于 Tuple{Any,Any}。然而,这种解释会导致一些实际问题。
首先,T 的值需要在方法定义内部可用。对于像 f(1, 1.0) 这样的调用,T 应该是什么并不明确。它可以是 Union{Int,Float64},或者可能是 Real。直观上,我们期望声明 x::T 意味着 T === typeof(x)。为了确保这个不变性成立,我们需要在这个方法中满足 typeof(x) === typeof(y) === T。这意味着该方法只能对完全相同类型的参数进行调用。
事实证明,能够判断两个值是否具有相同类型是非常有用的(例如,这被提升系统使用),因此我们有多个理由希望对 Tuple{T,T} where T 有不同的解释。为了使这项工作得以进行,我们向子类型添加了以下规则:如果一个变量在协变位置出现多次,则它被限制为仅在具体类型范围内。 (“协变位置”意味着在变量的一个出现和引入它的 UnionAll 类型之间仅出现 Tuple 和 Union 类型。)这样的变量被称为“对角变量”或“具体变量”。
例如,Tuple{T,T} where T 可以被视为 Union{Tuple{Int8,Int8}, Tuple{Int16,Int16}, ...},其中 T 取遍所有具体类型。这产生了一些有趣的子类型结果。例如,Tuple{Real,Real} 不是 Tuple{T,T} where T 的子类型,因为它包含一些类型,如 Tuple{Int8,Int16},其中两个元素具有不同的类型。Tuple{Real,Real} 和 Tuple{T,T} where T 具有非平凡的交集 Tuple{T,T} where T<:Real。然而,Tuple{Real} 是 Tuple{T} where T 的子类型,因为在这种情况下,T 只出现一次,因此不是对角线的。
接下来考虑如下签名:
f(a::Array{T}, x::T, y::T) where {T} = ...在这种情况下,T 在 Array{T} 内部处于不变位置。这意味着传递的任何类型的数组都明确地确定了 T 的值——我们称 T 具有 相等约束。因此,在这种情况下,对角线规则并不是特别必要,因为数组确定了 T,然后我们可以允许 x 和 y 是 T 的任何子类型。因此,出现在不变位置的变量从不被视为对角线。这种行为的选择略显争议——一些人认为这个定义应该写成
f(a::Array{T}, x::S, y::S) where {T, S<:T} = ...澄清 x 和 y 是否需要具有相同的类型。在这个版本的签名中,它们需要相同,或者我们可以引入一个第三个变量来表示 y 的类型,如果 x 和 y 可以具有不同的类型。
下一个复杂性是联合和对角变量的交互,例如。
f(x::Union{Nothing,T}, y::T) where {T} = ...考虑这个声明的含义。y 的类型是 T。那么 x 可以是相同的类型 T,或者是类型 Nothing。因此,以下所有调用都应该匹配:
f(1, 1)
f("", "")
f(2.0, 2.0)
f(nothing, 1)
f(nothing, "")
f(nothing, 2.0)这些例子告诉我们一些事情:当 x 是 nothing::Nothing 时,y 没有额外的约束。就好像方法签名中有 y::Any。实际上,我们有以下类型等价:
(Tuple{Union{Nothing,T},T} where T) == Union{Tuple{Nothing,Any}, Tuple{T,T} where T}一般规则是:在协变位置上的具体变量如果在子类型算法中只使用一次,就表现得像不是具体的。当 x 的类型为 Nothing 时,我们不需要在 Union{Nothing,T} 中使用 T;我们只在第二个位置使用它。这自然源于观察到在 Tuple{T} where T 中,将 T 限制为具体类型没有区别;无论哪种方式,类型都等于 Tuple{Any}。
然而,出现在 不变 位置会使变量失去具体性,无论该变量的出现是否被使用。否则,类型的行为可能会根据它们所比较的其他类型而有所不同,从而使得子类型关系不具备传递性。例如,考虑
Tuple{Int,Int8,Vector{Integer}} <: Tuple{T,T,Vector{Union{Integer,T}}} where T如果 Union 内部的 T 被忽略,那么 T 是具体的,答案是“假”,因为前两种类型不相同。但考虑一下
Tuple{Int,Int8,Vector{Any}} <: Tuple{T,T,Vector{Union{Integer,T}}} where T现在我们不能忽视 Union 中的 T(我们必须有 T == Any),所以 T 不是具体的,答案是“真”。这将使 T 的具体性依赖于其他类型,这是不可接受的,因为一个类型必须有明确的独立含义。因此,T 在 Vector 中的出现被认为在两种情况下都是有效的。
Subtyping diagonal variables
对角变量的子类型算法有两个组成部分:(1) 识别变量出现,(2) 确保对角变量仅在具体类型上取值。
第一个任务是通过在环境中为每个变量保持计数器 occurs_inv 和 occurs_cov(在 src/subtype.c 中)来完成的,分别跟踪不变和协变出现的次数。当 occurs_inv == 0 && occurs_cov > 1 时,变量是对角的。
第二个任务是通过对变量的下界施加条件来完成的。当子类型算法运行时,它会缩小每个变量的范围(提高下界和降低上界),以跟踪子类型关系成立的变量值范围。当我们完成对一个对角线变量的 UnionAll 类型的主体的评估时,我们查看边界的最终值。由于变量必须是具体的,如果它的下界不能是某个具体类型的子类型,就会发生矛盾。例如,像 AbstractArray 这样的抽象类型不能是具体类型的子类型,但像 Int 这样的具体类型可以,而空类型 Bottom 也可以。如果下界未通过此测试,算法将停止并返回 false。
例如,在问题 Tuple{Int,String} <: Tuple{T,T} where T 中,我们推导出如果 T 是 Union{Int,String} 的超类型,则这将成立。然而,Union{Int,String} 是一个抽象类型,因此该关系不成立。
这个具体性测试是通过函数 is_leaf_bound 完成的。请注意,这个测试与 jl_is_leaf_type 略有不同,因为它也会对 Bottom 返回 true。目前这个函数是启发式的,并不能捕捉所有可能的具体类型。困难在于,某个下界是否具体可能依赖于其他类型变量下界的值。例如,Vector{T} 仅在 T 的上下界都等于 Int 时才等同于具体类型 Vector{Int}。我们尚未制定出一个完整的算法来解决这个问题。
Introduction to the internal machinery
大多数处理类型的操作都可以在文件 jltypes.c 和 subtype.c 中找到。一个好的开始方式是观察子类型的实际操作。使用 make debug 构建 Julia,并在调试器中启动 Julia。gdb debugging tips 提供了一些可能有用的提示。
因为子类型代码在 REPL 中被广泛使用——因此该代码中的断点经常被触发——如果您做出以下定义,将是最简单的:
julia> function mysubtype(a,b)
ccall(:jl_breakpoint, Cvoid, (Any,), nothing)
a <: b
end然后在 jl_breakpoint 中设置一个断点。一旦这个断点被触发,您可以在其他函数中设置断点。
作为热身,尝试以下内容:
mysubtype(Tuple{Int, Float64}, Tuple{Integer, Real})我们可以通过尝试一个更复杂的案例来使其更有趣:
mysubtype(Tuple{Array{Int,2}, Int8}, Tuple{Array{T}, T} where T)Subtyping and method sorting
type_morespecific 函数用于在方法表中的函数上施加部分顺序(从最具体到最不具体)。具体性是严格的;如果 a 比 b 更具体,则 a 不等于 b,并且 b 也不比 a 更具体。
如果 a 是 b 的严格子类型,那么它会被自动认为是更具体的。从这里开始,type_morespecific 使用一些不太正式的规则。例如,subtype 对参数的数量敏感,但 type_morespecific 可能不敏感。特别是,Tuple{Int,AbstractFloat} 比 Tuple{Integer} 更具体,尽管它不是一个子类型。(在 Tuple{Int,AbstractFloat} 和 Tuple{Integer,Float64} 之间,两个都不比另一个更具体。)同样,Tuple{Int,Vararg{Int}} 不是 Tuple{Integer} 的子类型,但它被认为更具体。然而,morespecific 在长度上会获得额外的加分:特别是,Tuple{Int,Int} 比 Tuple{Int,Vararg{Int}} 更具体。
如果您正在调试方法的排序方式,定义函数可能会很方便:
type_morespecific(a, b) = ccall(:jl_type_morespecific, Cint, (Any,Any), a, b)这允许你测试元组类型 a 是否比元组类型 b 更具体。