类型
通常,我们把程序语言中的类型系统划分成两类:静态类型和动态类型。对于静态类型系统,在程序运行之前,我们就可计算每一个表达式的类型。而对于动态类型系统,我们只有通过运行那个程序,得到表达式具体的值,才能确定其具体的类型。通过让编写的代码无需在编译时知道值的确切类型,面向对象允许静态类型语言具有一定的灵活性。可以编写在不同类型上都能运行的代码的能力被称为多态。在经典的动态类型语言中,所有的代码都是多态的,这意味着这些代码对于其中值的类型没有约束,除非在代码中去具体的判断一个值的类型,或者对对象做一些它不支持的操作。
Julia 类型系统是动态的,但通过允许指出某些变量具有特定类型,获得了静态类型系统的一些优点。这对于生成高效的代码非常有帮助,但更重要的是,它允许针对函数参数类型的方法派发与语言深度集成。方法派发将在方法中详细探讨,但它根植于此处提供的类型系统。
在类型被省略时,Julia 的默认行为是允许变量为任何类型。因此,可以编写许多有用的 Julia 函数,而无需显式使用类型。然而,当需要额外的表达力时,很容易逐渐将显式的类型注释引入先前的「无类型」代码中。添加类型注释主要有三个目的:利用 Julia 强大的多重派发机制、提高代码可读性以及捕获程序错误。
Julia 用类型系统的术语描述是动态(dynamic)、主格(nominative)和参数(parametric)的。泛型可以被参数化,并且类型之间的层次关系可以被显式地声明,而不是隐含地通过兼容的结构。Julia 类型系统的一个特别显著的特征是具体类型相互之间不能是子类型:所有具体类型都是最终的类型,并且只有抽象类型可以作为其超类型。虽然起初看起来这可能过于严格,但它有许多有益的结果,但缺点却少得出奇。事实证明,能够继承行为比继承结构更重要,同时继承两者在传统的面向对象语言中导致了重大困难。Julia 类型系统的其它高级方面应当在先言明:
对象值和非对象值之间没有分别:Julia 中的所有值都是具有类型的真实对象其类型属于一个单独的、完全连通的类型图,该类型图的所有节点作为类型一样都是头等的。
「编译期类型」是没有任何意义的概念:变量所具有的唯一类型是程序运行时的实际类型。这在面向对象被称为「运行时类型」,其中静态编译和多态的组合使得这种区别变得显著。
值有类型,变量没有类型——变量仅仅是绑定给值的名字而已。
抽象类型和具体类型都可以通过其它类型进行参数化。它们的参数化还可通过符号、使得
isbits
返回 true 的任意类型的值(实质上,也就是像数字或布尔变量这样的东西,存储方式像 C 类型或不包含指向其它对象的指针的struct
)和其元组。类型参数在不需要被引用或限制时可以省略。
Julia 的类型系统设计得强大而富有表现力,却清晰、直观且不引人注目。许多 Julia 程序员可能从未感觉需要编写明确使用类型的代码。但是,某些场景的编程可通过声明类型变得更加清晰、简单、快速和健壮。
类型声明
::
运算符可以用来在程序中给表达式和变量附加类型注释。这有两个主要原因:
- 作为断言,帮助程序确认能是否正常运行,
- 给编译器提供额外的类型信息,这可能帮助程序提升性能,在某些情况下
当被附加到一个计算值的表达式时,::
操作符读作「是······的实例」。在任何地方都可以用它来断言左侧表达式的值是右侧类型的实例。当右侧类型是具体类型时,左侧的值必须能够以该类型作为其实现——回想一下,所有具体类型都是最终的,因此没有任何实现是任何其它具体类型的子类型。当右侧类型是抽象类型时,值是由该抽象类型子类型中的某个具体类型实现的才能满足该断言。如果类型断言非真,抛出一个异常,否则返回左侧的值:
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 的类型系统不只是对象实现的集合。
回想一下,在整数和浮点数中,我们介绍了各种数值的具体类型:Int8
、UInt8
、Int16
、UInt16
、Int32
、UInt32
、Int64
、UInt64
、Int128
、UInt128
、Float16
、Float32
和 Float64
。尽管 Int8
、Int16
、Int32
、Int64
和 Int128
具有不同的表示大小,但都具有共同的特征,即它们都是带符号的整数类型。类似地,UInt8
、UInt16
、UInt32
、UInt64
和 UInt128
都是无符号整数类型,而 Float16
、Float32
和 Float64
是不同的浮点数类型而非整数类型。一段代码只对某些类型有意义是很常见的,比如,只在其参数是某种类型的整数,而不真正取决于特定类型的整数时有意义。例如,最大公分母算法适用于所有类型的整数,但不适用于浮点数。抽象类型允许构造类型的层次结构,提供了具体类型可以适应的上下文。例如,这允许你轻松地为任何类型的整数编程,而不用将算法限制为某种特殊类型的整数。
使用 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
有两个子类型(它还有更多的子类型,但这里只展示了两个,稍后将会看到其它的子类型): Integer
和 AbstractFloat
,将世界分为整数的表示和实数的表示。实数的表示当然包括浮点类型,但也包括其他类型,例如有理数。因此,AbstractFloat
是一个 Real
的子类型,仅包括实数的浮点表示。整数被进一步细分为 Signed
和 Unsigned
两类。
<:
运算符的通常意义为「是······的子类型」,并被用于像这样的声明右侧类型是新声明类型的直接超类型。它也可以在表达式中用作子类型运算符,在其左操作数为其右操作数的子类型时返回 true
:
julia> Integer <: Number
true
julia> Integer <: AbstractFloat
false
抽象类型的一个重要用途是为具体类型提供默认实现。举个简单的例子,考虑:
function myplus(x,y)
x+y
end
首先需要注意的是上述的参数声明等价于 x::Any
和 y::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
,我们需要定义许多其它方法,例如 +
、*
、one
、zero
及类型提升规则等。)默认情况下,此类型的实例只是相当简单地显示有关类型名称和字段值的信息,比如,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 中,你无法根据诸如 true
或 false
之类的值进行分派。然而,你可以根据参数类型进行分派,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 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论