More about types

如果你使用Julia一段时间,你会理解类型所扮演的基本角色。在这里,我们试图深入探讨,特别关注Parametric Types

Types and sets (and Any and Union{}/Bottom)

也许最容易通过集合来理解Julia的类型系统。虽然程序操作单个值,但类型指的是一组值。这与集合不同;例如,Set 的值本身就是一个单一的 Set 值。相反,类型描述了一组可能的值,表达了我们对拥有哪个值的不确定性。

一个 具体 类型 T 描述了值的集合,其直接标签由 typeof 函数返回,为 T。一个 抽象 类型描述了一些可能更大的值集合。

Any 描述了所有可能值的整个宇宙。 IntegerAny 的一个子集,包括 IntInt8 和其他具体类型。在内部,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 类型结构的一部分。类型变量对其值有下限和上限(在字段 lbub 中)。符号 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 不包含引入变量 VUnionAll。例如,类型 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} @0x00007fcc7de64850

Arraywrapper 字段指向自身,但对于 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{}

注意,当 TTuple 类型中是自由的(即其绑定的 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 类型之间仅出现 TupleUnion 类型。)这样的变量被称为“对角变量”或“具体变量”。

例如,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} = ...

在这种情况下,TArray{T} 内部处于不变位置。这意味着传递的任何类型的数组都明确地确定了 T 的值——我们称 T 具有 相等约束。因此,在这种情况下,对角线规则并不是特别必要,因为数组确定了 T,然后我们可以允许 xyT 的任何子类型。因此,出现在不变位置的变量从不被视为对角线。这种行为的选择略显争议——一些人认为这个定义应该写成

f(a::Array{T}, x::S, y::S) where {T, S<:T} = ...

澄清 xy 是否需要具有相同的类型。在这个版本的签名中,它们需要相同,或者我们可以引入一个第三个变量来表示 y 的类型,如果 xy 可以具有不同的类型。

下一个复杂性是联合和对角变量的交互,例如。

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)

这些例子告诉我们一些事情:当 xnothing::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 的具体性依赖于其他类型,这是不可接受的,因为一个类型必须有明确的独立含义。因此,TVector 中的出现被认为在两种情况下都是有效的。

Subtyping diagonal variables

对角变量的子类型算法有两个组成部分:(1) 识别变量出现,(2) 确保对角变量仅在具体类型上取值。

第一个任务是通过在环境中为每个变量保持计数器 occurs_invoccurs_cov(在 src/subtype.c 中)来完成的,分别跟踪不变和协变出现的次数。当 occurs_inv == 0 && occurs_cov > 1 时,变量是对角的。

第二个任务是通过对变量的下界施加条件来完成的。当子类型算法运行时,它会缩小每个变量的范围(提高下界和降低上界),以跟踪子类型关系成立的变量值范围。当我们完成对一个对角线变量的 UnionAll 类型的主体的评估时,我们查看边界的最终值。由于变量必须是具体的,如果它的下界不能是某个具体类型的子类型,就会发生矛盾。例如,像 AbstractArray 这样的抽象类型不能是具体类型的子类型,但像 Int 这样的具体类型可以,而空类型 Bottom 也可以。如果下界未通过此测试,算法将停止并返回 false

例如,在问题 Tuple{Int,String} <: Tuple{T,T} where T 中,我们推导出如果 TUnion{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.csubtype.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 函数用于在方法表中的函数上施加部分顺序(从最具体到最不具体)。具体性是严格的;如果 ab 更具体,则 a 不等于 b,并且 b 也不比 a 更具体。

如果 ab 的严格子类型,那么它会被自动认为是更具体的。从这里开始,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 更具体。