9 类型系统

9.1 常用类型

程序中的常量和变量都有类型,比如,常数108的类型为Int64。函数typeof()可以返回常量或变量的类型,如

typeof(108)
## Int64

变量的类型由其中保存的值的类型决定,Julia变量实际是“绑定”到了某个保存了值的地址。如

x = 108; typeof(x)
## Int64

isa运算符可以查看某个对象是否属于某个类型, 如:

"abc" isa String
## true

函数typemax()可以求一个数值类型能保存的最大值, typemin()求能保存的最小值。如

show([typemin(Int8), typemax(Int8)])
## Int8[-128, 127]

eps(Float64)返回64位双精度浮点型数的机器Epsilon值, 即\(1 + \epsilon \neq 1\),但是\(1 + \frac{\epsilon}{2}\)则不能与1.0区分开来。

eps(Float64)
## 2.220446049250313e-16

Float64型的绝对值最小的正常精度的值:

floatmin(Float64)
## 2.2250738585072014e-308

9.2 类型语法

Julia的变量不必须声明类型, 但是必须初始化,未定义的变量用于计算会出错。 对许多用户来说, 不需要知道类型声明就可以完成大部分常见任务。

Julia是动态类型的。 动态类型的语言中的变量只有在运行时才能确定类型, 而静态类型的语言在编译阶段就确定了类型。

在Julia中,某个变量x一开始绑定了一个整数值, 在后续执行中可以重新绑定一个字符串值。 当然,这样的做法是不可取的。

Julia归类到动态类型语言, 是因为不必须声明变量类型。 其它的动态类型语言如Python不支持声明变量类型, Julia是允许不声明变量类型, 也可以声明变量类型, 声明变量类型往往可以改善程序效率, 尤其是函数的自变量类型。 同名的函数可以因为其自变量类型声明的不同而执行不同的操作, 这称为多重分派(multiple dispatch)。

在循环内部声明变量类型可以避免因为每次查询变量类型而引起的运行效率损失。

9.3 类型系统

用程序语言术语描述, Julia语言的类型系统是动态的,主格的(nominative), 参数化的。 通用类型可以带有类型参数。 类型的层次结构是显式声明的, 不是由兼容结构隐含的。 实体类型(concrete types)不能互相继承, 这与很多面向对象系统不同。 Julia的理念认为继承数据结构并不重要,继承行为才是最有用的。

Julia类型系统的其它特点:

  • 对象值与非对象值没有明确的区别。 实际上,Julia中所有值都是对象,都有一个类型。 类型有一个唯一的类型系统, 所有类型都属于一个完全连接的图, 图中所有节点都是第一类对象可以采用的类型。
  • 没有有意义的“编译时类型”。 仅在程序运行时一个值才有真正的类型。 在面向对象语言中这称为“运行时类型”。
  • 只有值才有类型,变量本身没有类型, 变量只是绑定到值上的一个名称。 当然,对一般用户而言, 声明过类型的变量也可以认为是有类型的。
  • 抽象类型和实类型都可以用其它类型参数化, 如果不需要引用参数或者限制参数取值时可以不写类型参数。

Julia的所有类型都可以表示在一个类型树中, 类型分为抽象类型与实体类型, 每个类型有且仅有一个父类型, 最高层的父类型是Any类型。 每个类型都属于Any类型。 实体类型不允许有子类, 一个类型不允许有多个直接的父类。

抽象类型仅用来作为其他类型的父类, 没有取类型的值。 例如,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, Number又分为实数(Real)和其它数, 实数包括Integer和AbstractFloat, Integer中有Signed和Unsigned, AbstractFloat中有Float16, Float32, Float64等, Signed中有Int8, Int16, Int32, Int64, Int128等, Unsigned中有UInt8, UInt16, UInt32, UInt64, UInt128等。 布尔类型Bool是Integer的子类。

程序中如1.2和1.2e-3这样的字面浮点数的类型是Float64, 单精度数(Float32)可以写成如1.2f0, 1.2f-3这样的格式。

BigInt是任意精度整数, BigFloat是任意精度浮点数。 实际上,BigFloat有一个用户可修改的的有效位数设置。

<:运算符来判断子类关系是否成立,如

Int32 <: Number
## true
Int32 <: AbstractFloat
## false

每个类型有且仅有一个父类型(supertype), 用函数supertype访问,如:

supertyep(Int)
## Signed

写一个函数, 查找所有的父类型:

function allsupertypes(T::DataType)
    sp = supertype(T)
    if sp == Any 
        return [Any]
    else
        return [sp; allsupertypes(sp)]
    end
end
allsupertypes(Int) |> show 
## DataType[Signed, Integer, Real, Number, Any]

实体类型如Int64不允许有子类型, 只有抽象类型可以有子类型。 用subtypes函数查看子类型:

subtypes(Number) |> show
Any[Complex, DualNumbers.Dual, Real, Unit]

9.4 复数类型

Julia内建了复数类型, 这是Number的子类。 关键字im用来表示复数虚部, 比如1.0 + 2.1im表示复数\(1 + 2.1i\)

复数类型用实数类型分别保存实部和虚部, 其定义利用了Julia语言的“参数化类型”: 实部与虚部的实际类型可以是一个“类型参数”。 比如, Complex{Float64}用Float64(即双精度实数)分别保存实部和虚部。

对复数类型可以应用函数abs(), exp(), sqrt(), real(), imag()等。 如

abs(1.0 + 2.1im)
## 2.3259406699226015
sqrt(1.0^2 + 2.1^2)
## 2.3259406699226015 

欧拉等式: \[ e^{i\pi} = -1 \] 其中包含了超越数\(e\), \(\pi\), 虚数单位\(i\), 和基本的整数1, 负数的基础\(-1\)

用Julia计算验证:

^(im * π) + 1
## 0.0 + 1.2246467991473532e-16im

其中自然对数底ℯ用“\euler+TAB”输入, π用“\pi+TAB”输入。

可以用methodswith()函数查询某种类型有关的“方法”, 如:

methodswith(Complex)

9.5 有理数类型

Julia内建了有理数类型, 分别保存分子和分母, Rational{Int64}用Int64类型分别保存分子和分母。 有理数是Number的子类, 支持四则运算, 也可以与其他数值类型混合进行四则运算。

2 // 5表示有理数\(\frac{2}{5}\), 其数据类型为Rational{Int64}。 用float()转换为浮点实数。 浮点数可以用Rational()转换成比较接近的有理数。

较复杂的运算不支持使用有理数, 会转换成浮点数进行计算, 例如, 矩阵求逆运算。

Rational(3.14)
## 7070651414971679//2251799813685248

9.6 缺失值

missing表示缺失值。 可以把missing与正常的值混合在一起, 类型将变成一个并集类型, 如:

[1.5, missing]
2-element Vector{Union{Missing, Float64}}:
 1.5
  missing

ismissing(x)判断x是否缺失值,如:

ismissing.([1.5, missing])
2-element BitVector:
 0
 1
ismissing(NaN)
## false

skipmissing(x)结果类似一个迭代器, 遍历其元素时会跳过缺失值。

特殊值nothing表示“不存在”, 比如, 一个不返回值的函数, 返回值就是nothing

9.7 类型转换与提升

T为类型名, T(x)在可以无损转换的情况下将自变量x转换为T类型返回, 但是要转换为整数时如果不能无损转换则报错, 浮点数和有理数之间可以进行最近似的转换。 如

Int64(1.0)
## 1
> Int64(1.5)
## InexactError: Int64(1.5)
Float64(1)
## 1.0
Int64(true)
## 1
Bool(1)
## true

上述的转换实际是调用了convert(T, x)函数。

string()可以将大多数类型转换为字符型, 也可以连接多项,如

string(1+2)
## "3"
string("1+2=", 1+2)
## "1+2=3"

反过来,为了将字符串中的数字转换成数值型, 用parse()函数, 如

parse(Float64, "1.23") + 10
## 11.23

Julia的四则运算、比较运算也是函数, 通过多重派发定义各个运算符, 使得Julia的四则运算、比较运算等表达式中可以混合使用布尔型、整型、浮点型数, 参与运算的数据类型自动提升到更一般的数据型。 如

1 + 2*1.5 - false
## 4.0

函数promote()将其输入自变量转换为相同的可运算类型,如:

promote(true, -2, 1.33)
## (1.0, -2.0, 1.33)

9.8 类型声明

Julia用::表示类型归属, 此运算符有两个含义:类型验证(assertion)与类型声明。

9.8.1 类型验证

在变量名和表达式末尾添加::然后可以要求类型验证。 如x:Float64(x+1)::Int64。 这样做的理由是

  1. 作为一个额外的验证确保程序是按照预想的逻辑执行的;
  2. 给编译器提供类型信息以改善程序效率。

::符号的意思是“is instance of”(是某某类的一个实例), 即左边的表达式是右边的类的一个实例: 如果类型是实类型,则左边必须确实是此类型; 如果类型是抽象类型,则左边应该是其子类。 类型声明不成立的时候会发生异常,否则结果是左边的值。

例如,

(1+2)::AbstractFloat

结果出错

ERROR: TypeError: in typeassert, expected AbstractFloat, got Int64

(1+2)::Int

类型验证无误,则验证不起作用, 结果为表达式的值3

9.8.2 类型声明

当带有类型声明的变量被赋值时或用local声明时, 此变量被强制规定成此类型, 等号右边的值被转换成此类型再赋值。 这样的做法避免了类型被无意间改变而使得程序效率损失, 能够给编译器提供额外信息以产生高效可执行代码。 注意这种声明会作用到整个当前作用域,包括声明之前的程序。 在全局作用域如命令行还不支持这样的声明。 函数自变量也可以用如此方法声明, 这样的声明可以为一个函数针对不同输入类型规定不同的处理方法, 称为“多重派发”(multiple dispatch)。

局部变量类型声明例如

function foo()
  local x::Int8;
  x = 100;
  x
end
typeof(foo())
## Int8

这等效于如下的写法:

function foo()
  x::Int8 = 100;
  x
end
typeof(foo())
## Int8

函数的返回值也可以声明返回值类型,比如, 下例中函数总是返回Float64类型,即使是return 1的结果也会转换成Float64类型:

function sqp(x)::Float64
    return sqrt(x+1)
end
[typeof(sqp(0)), typeof(sqp(1))]
2-element Vector{DataType}:
 Float64
 Float64

通过对函数返回类型的声明, 不论结果是\(1\)还是\(\sqrt{2}\), 结果类型都是Float64。

9.9 抽象类型

抽象类型不能实例化, 只是作为实类型的父类, 好处是可以使得函数自变量类型取某些相近类型中任意一个。 比如将函数自变量声明为Number, 则输入的值为Int8, Float64等都是允许的。 详见Julia手册。

抽象类型定义如:

abstract type MyType end

抽象类型也可以继承其它抽象类型,如:

abstract type MyContType <: MyType end

9.10 初等类型

初等类型就是基础的二进制表示,如整数,浮点数。 Julia允许自定义初等类型,用primitive type命令。 Julia提供的初等类型也是用Julia定义的。 详见Julia手册。

9.11 复合类型

9.11.1 语法

复合类型就是其它编程语言中的记录,结构,对象等。 复合类型是若干个有命名的域的集合, 此种类型的实例可以看成一个值。 复合类型是最常用的用户自定义类型。

很多语言中复合类型是与匹配的函数耦合在一起的,称为对象(objects), 比如Java、C++、Python等。 在Julia中,所有的值都是对象,都是某个类的实例,但是对象没有耦合在一起的函数。 Julia的做法是调用函数时根据参数的类型不同而选择不同的操作方式, 称为多重派发(multiple dispatch)。 多重派发和一般的对象系统差别在于函数的操作选择依赖于所有参数的类型, 而不仅仅是第一个参数的类型, 但是函数一般没有对应的状态(属性)。

用struct定义的复合类型是不可修改的(immutable),这样的好处是

  • 更高效。有时可以高效地包装进数组中,有时甚至不需为其分配额外存储空间。
  • 不能修改构造器提供的值。
  • 不能修改的内容,也使得程序比较简单。

如果某个域是可修改类型(mutable),如数组, 则该域保存的内容还是可以修改的, 这里不可修改是指这样的域不能再绑定到别的对象上。

用mutable struct定义可修改复合类型(Mutable composite types), 其域的值可以修改。 这样的数据类型一般在堆上分配内存,有稳定的内存地址。 这是一个容器,不同运行时刻的容器内容可以变化, 但是其地址不变。 另一方面,不可变的类型是与各个域的值紧密联系的, 域的值就决定了对象的一切。

在赋值和函数参数传递时不可变类型复制传递, 可变类型按引用传递; 不可变复合类型的域不可修改 (不能绑定到其它值,但是如果域本身是可变类型还是可以修改内容的)。 典型的可变类型是数组, 而元组(tuple)是不可变类型。

可以用fieldnames(类型名)查看某类型的各个域名。 可以用如isdefined(obj, :name)查看对象obj是否定义了域名name

新的复合类型定义以及实例化方法详见Julia手册。

9.11.2 自定义复合类型示例

例如,下面的程序定义了自己的一个时间类型:

"""
Personal data structure, represents time.

Fields: hour, minute, second
"""
struct MyTime
    hour
    minute
    second
end

为了明确各个域(属性)的数据类型, 提高程序效率, 可以写成:

"""
Personal data structure, represents time.

Fields: hour, minute, second
"""
struct MyTime
    hour::Int64
    minute::Int64
    second::Float64
end

这样的复合类型有默认的构建函数, 即生成该类型的一个对象的函数, 就是用类型名为函数名, 用该类型的属性为参数的函数调用,如:

t0 = MyTime(13, 21, 15)
## MyTime(13, 21, 15.0)

为了对时间进行运算, 可以将其统一转换为秒数, 然后从秒数转换为MyTime:

function timetosecs(time)
    return 3600*time.hour + 60*time.minute + time.second
end
timetosecs(t0)
## 48075.0
function secstotime(seconds)
    minute, second = divrem(seconds, 60)
    hour, minute = divrem(minute, 60)
    return MyTime(hour, minute, second)
end
secstotime(48075.0)
MyTime(13, 21, 15.0)

Julia与其它语言的一个区别是它对泛型编程(generic programming)支持很好。 所有的类型都从Any类型继承而来, 只要自定义类型提供了必要的接口, Julia的复合类型、算法都可以自动支持。

例如, 生成MyTime的一维数组:

times = MyTime.([8, 10, 11, 12], [32, 11, 15, 10], [3, 6, 7, 50])
4-element Vector{MyTime}:
 MyTime(8, 32, 3.0)
 MyTime(10, 11, 6.0)
 MyTime(11, 15, 7.0)
 MyTime(12, 10, 50.0)

定义了两个时间比较的isless之后, 可以用findfirst在数组中查询:

function Base.isless(t1::MyTime, t2::MyTime)
    return (t1.hour, t1.minute, t1.second) < (
        t2.hour, t2.minute, t2.second)
end
findfirst(t -> t > MyTime(11, 0, 0), times)
## 3

定义了加法,可以给一个时间延后若干秒:

function Base.:+(t::MyTime, secs::Real)
    secstotime(timetosecs(t) + secs)
end
MyTime(13, 15, 30) + 10*60
## MyTime(13, 25, 30.0)

9.12 参数化类型

参数化类型是类似于C++中模板(template)的类型。 一个类型可以将其元素的值类型作为类型参数, 一次性地定义一批类型。 参数化类型使得同一算法可以用来处理不同类型的数据。

数组(Array)就是参数化类型。 Vector{Float64}Array{Float64,1} 就是元素为Float64的一维数组, 而Vector{String}Array{String,1} 就是字符串的一维数组。 Array{Float64,2}是元素为Float64的二维数组, 也称为矩阵。 这里Float64, String就是参数化类型的类型参数。

参数化类型涉及到Julia函数的多重派发应用, 技术比较复杂, 详见Julia手册。

9.12.1 自定义多项式类型

Polynomials包已经提供了很完善的多项式功能。 作为示例,我们自己实现一个参数化的多项式类型:

# 自定义多项式类型
struct Polyn{T}
    c::Vector{T}
end

# 提取信息
function coef(P::Polyn)
    return P.c 
end

# 定制化显示
function Base.show(io::IO, P::Polyn)
    for (i, ci) in enumerate(P.c)
        if ci > 0
            if i==1 
                print(io, ci)
            else
                print(io, " + " , ci==1 ? "" : string(ci),
                    "x^$(i-1)")
            end
        elseif ci < 0
            if i==1 
                print(io, ci)
            else
                print(io, " - " , ci==-1 ? "" : string(-ci),
                    "x^$(i-1)")
            end
        end
    end
end
P = Polyn([1, 0, -1])
## 1 - x^2

## 定制四则运算
function Base.:+(P1::Polyn, P2::Polyn)
    c1 = P1.c
    c2 = P2.c 
    n = max(length(c1), length(c2))
    c = [get(c1, i, 0) + get(c2, i, 0) for i in 1:n]
    while c[n] == 0
        pop!(c)
        n -= 1
    end
    Polyn(c)
end 

function Base.:-(P::Polyn)
    Polyn(-P.c)
end

function Base.:-(P1::Polyn, P2::Polyn)
    P1 + (-P2)
end 

# 数乘
function Base.:*::Number, P::Polyn)
    Polyn.* P.c)
end 
function Base.:*(P::Polyn, λ::Number)
    Polyn.* P.c)
end 

function convolve(a, b)
    na = length(a)
    nb = length(b)
    c = fill(zero(eltype(a)), na+nb-1)
    for i=0:(na-1)
        for j=0:(nb-1)
            c[i+j+1] += a[i+1] * b[j+1]
        end
    end
    return c
end

function Base.:*(P1::Polyn, P2::Polyn)
    c1 = P1.c
    c2 = P2.c 
    c = convolve(c1, c2)
    Polyn(c)
end 
Polyn([1,1]) * Polyn([2,1])
## 2 + 3x^1 + x^2

# 令多项式可求值
function (p::Polyn)(x)
    Base.Math.evalpoly(x, p.c)
end
P = Polyn([1, 0, -1])
P(3)
## -8

9.13 Julia类型系统的缺点

Julia类型系统很灵活、强大, 配合多重派发,可以产生高效执行的代码。 但是, 与R、Python这样类型不那么复杂的语言相比, Julia有一些类似C++、Java这样对类型的执着, 稍有偏离就可能出错。

例9.1 某个函数声明了Float64为自变量类型, 则程序中输入了一个整数常数就会出错。

示例程序:

f(x::Float64) = sqrt(1 + x^2)
f(1)
# MethodError: no method matching f(::Int64)

为了安全起见, 不如不用自变量的类型声明,写成:

f(x) = sqrt(1 + x^2)
f(1)
# f(1) = 1.4142135623730951

例9.2 数组元素类型不兼容问题。

程序示例:

## 数组元素类型限制
x = [1,2,3]
x[1] = 0.5
## ERROR: InexactError: Int64(0.5)
## ......

在上面的程序中,x已经被识别为整型数组, 所以再给x[1]赋值为0.5就不能自动将x转换为浮点型数组, 程序意图为将浮点型0.5转换为整型存储, 程序出错停止。

例9.3 混合元素类型的数组。

大多数程序语言的数组都是期望其元素为同一基本类型的, 比如,同为Int64,同为Float64。 但是,在Julia中, 有时不小心会生成整数和浮点数类型元素混合在一起的数组。 如:

f(x) = x > 0 ? 0 : 0.5
@show f.([-1, 0, 1]);
## f.([-1, 0, 1]) = Real[0.5, 0.5, 0]

结果输出了Real类型的数组。 Real不是一个实体类型而是一个抽象类型, 许多Julia的函数遇到这样的混合元素类型的数组会出错。 这个程序的问题是f(x)的定义有问题, 它的返回值可能是整型的0或者浮点值的0.5。 所以自定义函数时, 应保证其返回值都是相同的实体类型的。

9.14 DataStructure库

DataStructure库提供了多种数据结构, 如双向队列(Deque)、堆栈、队列、树等。