返回介绍

类型

发布于 2019-07-03 15:53:49 字数 14528 浏览 1212 评论 0 收藏 0

通常,我们把程序语言中的类型系统划分成两类:静态类型和动态类型。对于静态类型系统,在程序运行之前,我们就可计算每一个表达式的类型。而对于动态类型系统,我们只有通过运行那个程序,得到表达式具体的值,才能确定其具体的类型。通过让编写的代码无需在编译时知道值的确切类型,面向对象允许静态类型语言具有一定的灵活性。可以编写在不同类型上都能运行的代码的能力被称为多态。在经典的动态类型语言中,所有的代码都是多态的,这意味着这些代码对于其中值的类型没有约束,除非在代码中去具体的判断一个值的类型,或者对对象做一些它不支持的操作。

Julia 类型系统是动态的,但通过允许指出某些变量具有特定类型,获得了静态类型系统的一些优点。这对于生成高效的代码非常有帮助,但更重要的是,它允许针对函数参数类型的方法派发与语言深度集成。方法派发将在方法中详细探讨,但它根植于此处提供的类型系统。

在类型被省略时,Julia 的默认行为是允许变量为任何类型。因此,可以编写许多有用的 Julia 函数,而无需显式使用类型。然而,当需要额外的表达力时,很容易逐渐将显式的类型注释引入先前的「无类型」代码中。添加类型注释主要有三个目的:利用 Julia 强大的多重派发机制、提高代码可读性以及捕获程序错误。

Julia 用类型系统的术语描述是动态(dynamic)、主格(nominative)和参数(parametric)的。泛型可以被参数化,并且类型之间的层次关系可以被显式地声明,而不是隐含地通过兼容的结构。Julia 类型系统的一个特别显著的特征是具体类型相互之间不能是子类型:所有具体类型都是最终的类型,并且只有抽象类型可以作为其超类型。虽然起初看起来这可能过于严格,但它有许多有益的结果,但缺点却少得出奇。事实证明,能够继承行为比继承结构更重要,同时继承两者在传统的面向对象语言中导致了重大困难。Julia 类型系统的其它高级方面应当在先言明:

  • 对象值和非对象值之间没有分别:Julia 中的所有值都是具有类型的真实对象其类型属于一个单独的、完全连通的类型图,该类型图的所有节点作为类型一样都是头等的。

  • 「编译期类型」是没有任何意义的概念:变量所具有的唯一类型是程序运行时的实际类型。这在面向对象被称为「运行时类型」,其中静态编译和多态的组合使得这种区别变得显著。

  • 值有类型,变量没有类型——变量仅仅是绑定给值的名字而已。

  • 抽象类型和具体类型都可以通过其它类型进行参数化。它们的参数化还可通过符号、使得 isbits 返回 true 的任意类型的值(实质上,也就是像数字或布尔变量这样的东西,存储方式像 C 类型或不包含指向其它对象的指针的 struct)和其元组。类型参数在不需要被引用或限制时可以省略。

Julia 的类型系统设计得强大而富有表现力,却清晰、直观且不引人注目。许多 Julia 程序员可能从未感觉需要编写明确使用类型的代码。但是,某些场景的编程可通过声明类型变得更加清晰、简单、快速和健壮。

类型声明

:: 运算符可以用来在程序中给表达式和变量附加类型注释。这有两个主要原因:

  1. 作为断言,帮助程序确认能是否正常运行,
  2. 给编译器提供额外的类型信息,这可能帮助程序提升性能,在某些情况下

当被附加到一个计算值的表达式时,:: 操作符读作「是······的实例」。在任何地方都可以用它来断言左侧表达式的值是右侧类型的实例。当右侧类型是具体类型时,左侧的值必须能够以该类型作为其实现——回想一下,所有具体类型都是最终的,因此没有任何实现是任何其它具体类型的子类型。当右侧类型是抽象类型时,值是由该抽象类型子类型中的某个具体类型实现的才能满足该断言。如果类型断言非真,抛出一个异常,否则返回左侧的值:

julia> (1+2)::AbstractFloat
ERROR: TypeError: in typeassert, expected AbstractFloat, got Int64

julia> (1+2)::Int
3

可以在任何表达式的所在位置做类型断言。

当被附加到赋值左侧的变量或作为 local 声明的一部分时,:: 操作符的意义有所不同:它声明变量始终具有指定的类型,就像静态类型语言(如 C)中的类型声明。每个被赋给该变量的值都将使用 convert 转换为被声明的类型:

julia> function foo()
           x::Int8 = 100
           x
       end
foo (generic function with 1 method)

julia> foo()
100

julia> typeof(ans)
Int8

这个特性用于避免性能「陷阱」,即给一个变量赋值时意外更改了类型。

此「声明」行为仅发生在特定上下文中:

local x::Int8  # in a local declaration
x::Int8 = 10   # as the left-hand side of an assignment

并应用于整个当前作用域,甚至在该声明之前。目前,类型声明不能在全局作用域中使用,例如在 REPL 中就不可以,因为 Julia 还没有常量类型的全局变量。

声明也可以附加到函数定义:

function sinc(x)::Float64
    if x == 0
        return 1
    end
    return sin(pi*x)/(pi*x)
end

此函数的返回值就像赋值给了一个类型已被声明的变量:返回值始终转换为Float64

抽象类型

抽象类型不能实例化,只能作为类型图中的节点使用,从而描述由相关具体类型组成的集合:那些作为其后代的具体类型。我们从抽象类型开始,即使它们没有实例,因为它们是类型系统的主干:它们形成了概念的层次结构,这使得 Julia 的类型系统不只是对象实现的集合。

回想一下,在整数和浮点数中,我们介绍了各种数值的具体类型:Int8UInt8Int16UInt16Int32UInt32Int64UInt64Int128UInt128Float16Float32Float64。尽管 Int8Int16Int32Int64Int128 具有不同的表示大小,但都具有共同的特征,即它们都是带符号的整数类型。类似地,UInt8UInt16UInt32UInt64UInt128 都是无符号整数类型,而 Float16Float32Float64 是不同的浮点数类型而非整数类型。一段代码只对某些类型有意义是很常见的,比如,只在其参数是某种类型的整数,而不真正取决于特定类型的整数时有意义。例如,最大公分母算法适用于所有类型的整数,但不适用于浮点数。抽象类型允许构造类型的层次结构,提供了具体类型可以适应的上下文。例如,这允许你轻松地为任何类型的整数编程,而不用将算法限制为某种特殊类型的整数。

使用 abstract type 关键字来声明抽象类型。声明抽象类型的一般语法是:

abstract type «name» end
abstract type «name» <: «supertype» end

abstract type 关键字引入了一个新的抽象类型,«name» 为其名称。此名称后面可以跟 <: 和一个已存在的类型,表示新声明的抽象类型是此「父」类型的子类型。

如果没有给出超类型,则默认超类型为 Any——一个预定义的抽象类型,所有对象都是它的实例并且所有类型都是它的子类型。在类型理论中,Any 通常称为「top」,因为它位于类型图的顶点。Julia 还有一个预定义的抽象「bottom」类型,在类型图的最低点,写成 Union{}。这与 Any 完全相反:任何对象都不是 Union{} 的实例,所有的类型都是 Union{} 的超类型。

让我们考虑一些构成 Julia 数值类型层次结构的抽象类型:

abstract type Number end
abstract type Real     <: Number end
abstract type AbstractFloat <: Real end
abstract type Integer  <: Real end
abstract type Signed   <: Integer end
abstract type Unsigned <: Integer end

Number 类型为 Any 类型的直接子类型,并且 Real 为它的子类型。反过来,Real 有两个子类型(它还有更多的子类型,但这里只展示了两个,稍后将会看到其它的子类型): IntegerAbstractFloat,将世界分为整数的表示和实数的表示。实数的表示当然包括浮点类型,但也包括其他类型,例如有理数。因此,AbstractFloat 是一个 Real 的子类型,仅包括实数的浮点表示。整数被进一步细分为 SignedUnsigned 两类。

<: 运算符的通常意义为「是······的子类型」,并被用于像这样的声明右侧类型是新声明类型的直接超类型。它也可以在表达式中用作子类型运算符,在其左操作数为其右操作数的子类型时返回 true

julia> Integer <: Number
true

julia> Integer <: AbstractFloat
false

抽象类型的一个重要用途是为具体类型提供默认实现。举个简单的例子,考虑:

function myplus(x,y)
    x+y
end

首先需要注意的是上述的参数声明等价于 x::Anyy::Any。当函数被调用时,例如 myplus(2,5),派发器会选择与给定参数相匹配的名称为 myplus 的最具体方法。(有关多重派发的更多信息,请参阅方法。)

假设没有找到比上述方法更具体的方法,Julia 接下来会在内部定义并编译一个名为 myplus 的方法,专门用于基于上面给出的泛型函数的两个 Int 参数,即它定义并编译:

function myplus(x::Int,y::Int)
    x+y
end

最后,调用这个具体的方法。

因此,抽象类型允许程序员编写泛型函数,之后可以通过许多具体类型的组合将其用作默认方法。多亏了多重分派,程序员可以完全控制是使用默认方法还是更具体的方法。

需要注意的重点是,即使程序员依赖参数为抽象类型的函数,性能也不会有任何损失,因为它会针对每个调用它的参数元组的具体类型重新编译。(但在函数参数是抽象类型的容器的情况下,可能存在性能问题;请参阅[性能建议]

通常,人们会想要自定义显示类型实例的方式。这可通过重载 show 函数来完成。举个例子,假设我们定义一个类型来表示极坐标形式的复数:

julia> struct Polar{T<:Real} <: Number
           r::T
           Θ::T
       end

julia> Polar(r::Real,Θ::Real) = Polar(promote(r,Θ)...)
Polar

在这里,我们添加了一个自定义的构造函数,这样就可以接受不同 Real 类型的参数并将它们类型提升为共同类型(请参阅[构造函数](http://127.0.0.5/@ref man-constructors)和[类型转换和类型提升](http://127.0.0.5/@ref conversion-and-promotion))。(当然,为了让它表现地像个 Number,我们需要定义许多其它方法,例如 +*onezero 及类型提升规则等。)默认情况下,此类型的实例只是相当简单地显示有关类型名称和字段值的信息,比如,Polar{Float64}(3.0,4.0)

如果我们希望它显示为 3.0 * exp(4.0im),我们将定义以下方法来将对象打印到给定的输出对象 io(其代表文件、终端、及缓冲区等;请参阅网络和流):

julia> Base.show(io::IO, z::Polar) = print(io, z.r, " * exp(", z.Θ, "im)")

Polar 对象的输出可以被更精细地控制。特别是,人们有时想要啰嗦的多行打印格式,用于在 REPL 和其它交互式环境中显示单个对象,以及一个更紧凑的单行格式,用于 print 函数或在作为其它对象(比如一个数组)的部分是显示该对象。虽然在两种情况下默认都会调用 show(io, z) 函数,你仍可以定义一个不同的多行格式来显示单个对象,这通过重载三参数形式的 show 函数,该函数接收 text/plain MIME 类型(请参阅 多媒体 I/O)作为它的第二个参数,举个例子:

julia> Base.show(io::IO, ::MIME"text/plain", z::Polar{T}) where{T} =
           print(io, "Polar{$T} complex number:\n   ", z)

(请注意 print(..., z) 在这里调用的是双参数的 show(io, z) 方法。)这导致:

julia> Polar(3, 4.0)
Polar{Float64} complex number:
   3.0 * exp(4.0im)

julia> [Polar(3, 4.0), Polar(4.0,5.3)]
2-element Array{Polar{Float64},1}:
 3.0 * exp(4.0im)
 4.0 * exp(5.3im)

其中单行格式的 show(io, z) 仍用于由 Polar 值组成的数组。从技术上讲,REPL 调用 display(z) 来显示单行的执行结果,其默认为 show(stdout, MIME("text/plain"), z),而后者又默认为 show(stdout, z),但是你不应该定义新的 display 方法,除非你正在定义新的多媒体显示管理器(请参阅多媒体 I/O)。

此外,你还可以为其它 MIME 类型定义 show 方法,以便在支持的环境(比如 IJulia)中实现更丰富的对象显示(HTML、图像等)。例如,我们可以定义 Polar 对象的 HTML 显示格式,使其带有上标和斜体:

julia> Base.show(io::IO, ::MIME"text/html", z::Polar{T}) where {T} =
           println(io, "<code>Polar{$T}</code> complex number: ",
                   z.r, " <i>e</i><sup>", z.Θ, " <i>i</i></sup>")

之后会在支持 HTML 显示的环境中自动使用 HTML 显示 Polar 对象,但如果你想,也可以手动调用 show 来获取 HTML 输出:

julia> show(stdout, "text/html", Polar(3.0,4.0))
<code>Polar{Float64}</code> complex number: 3.0 <i>e</i><sup>4.0 <i>i</i></sup>
<p>An HTML renderer would display this as: <code>Polar{Float64}</code> complex number: 3.0 <i>e</i><sup>4.0 <i>i</i></sup></p>

根据经验,单行 show 方法应为创建的显示对象打印有效的 Julia 表达式。当这个 show 方法包含中缀运算符时,比如上面的 Polar 的单行 show 方法里的乘法运算符(*),在作为另一个对象的部分打印时,它可能无法被正确解析。要查看此问题,请考虑下面的表达式对象(请参阅程序表示),它代表 Polar 类型的特定实例的平方:

julia> a = Polar(3, 4.0)
Polar{Float64} complex number:
   3.0 * exp(4.0im)

julia> print(:($a^2))
3.0 * exp(4.0im) ^ 2

因为运算符 ^ 的优先级高于 *(请参阅运算符的优先级与结合性),所以此输出不忠实地表示了表达式 a ^ 2,而该表达式等价于 (3.0 * exp(4.0im)) ^ 2。为了解决这个问题,我们必须为 Base.show_unquoted(io::IO, z::Polar, indent::Int, precedence::Int) 创建一个自定义方法,在打印时,表达式对象会在内部调用它:

julia> function Base.show_unquoted(io::IO, z::Polar, ::Int, precedence::Int)
           if Base.operator_precedence(:*) <= precedence
               print(io, "(")
               show(io, z)
               print(io, ")")
           else
               show(io, z)
           end
       end

julia> :($a^2)
:((3.0 * exp(4.0im)) ^ 2)

当正在调用的运算符的优先级大于等于乘法的优先级时,上面定义的方法会在 show 调用的两侧加上括号。这个检查允许在没有括号的情况下被正确解析的表达式(例如 :($a + 2):($a == 2))在打印时省略括号:

julia> :($a + 2)
:(3.0 * exp(4.0im) + 2)

julia> :($a == 2)
:(3.0 * exp(4.0im) == 2)

在某些情况下,根据上下文调整 show 方法的行为是很有用的。这可通过 IOContext 类型实现,它允许一起传递上下文属性和封装后的 IO 流。例如,我们可以在 :compact 属性设置为 true 时创建一个更短的表示,而在该属性为 false 或不存在时返回长的表示:

julia> function Base.show(io::IO, z::Polar)
           if get(io, :compact, false)
               print(io, z.r, "ℯ", z.Θ, "im")
           else
               print(io, z.r, " * exp(", z.Θ, "im)")
           end
       end

当传入的 IO 流是设置了 :compact(译注:该属性还应当设置为 true)属性的 IOContext 对象时,将使用这个新的紧凑表示。特别地,当打印具有多列的数组(由于水平空间有限)时就是这种情况:

julia> show(IOContext(stdout, :compact=>true), Polar(3, 4.0))
3.0ℯ4.0im

julia> [Polar(3, 4.0) Polar(4.0,5.3)]
1×2 Array{Polar{Float64},2}:
 3.0ℯ4.0im  4.0ℯ5.3im

有关调整打印效果的常用属性列表,请参阅文档 IOContext

值类型

在 Julia 中,你无法根据诸如 truefalse 之类的值进行分派。然而,你可以根据参数类型进行分派,Julia 允许你包含「plain bits」值(类型、符号、整数、浮点数和元组等)作为类型参数。Array{T,N} 里的维度参数就是一个常见的例子,在那里 T 是类型(比如 Float64),而 N 只是个 Int

你可以创建把值作为参数的自定义类型,并使用它们控制自定义类型的分派。为了说明这个想法,让我们引入参数类型 Val{x} 和构造函数 Val(x) = Val{x}(),它可以作为一种习惯的方式来利用这种技术需要更精细的层次结构。这可以作为利用这种技术的惯用方式,而且不需要更精细的层次结构。

Val 的定义为:

julia> struct Val{x}
       end

julia> Val(x) = Val{x}()
Val

Val 的实现就只需要这些。一些 Julia 标准库里的函数接收 Val 的实例作为参数,你也可以使用它来编写你自己的函数,例如:

julia> firstlast(::Val{true}) = "First"
firstlast (generic function with 1 method)

julia> firstlast(::Val{false}) = "Last"
firstlast (generic function with 2 methods)

julia> firstlast(Val(true))
"First"

julia> firstlast(Val(false))
"Last"

为了保证 Julia 的一致性,调用处应当始终传递 Val 实例而不是类型,也就是使用 foo(Val(:bar)) 而不是 foo(Val{:bar})

值得注意的是,参数「值」类型非常容易被误用,包括 Val;情况不太好时,你很容易使代码性能变得更糟糕。一般使用时,你可能从来不会想要写出上方示例那样的代码。有关 Val 的正确(和不正确)使用的更多信息,请阅读[性能建议](http://127.0.0.5/@ref man-performance-tips)中更广泛的讨论。

[^1]: 「少数」由常数 MAX_UNION_SPLITTING 定义,目前设置为 4。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文