跳转到内容

Julia/类型入门

来自维基教科书,开放的书籍,开放的世界
Previous page
数组和元组
Julia入门 Next page
控制流程
类型

本节介绍类型,下一节介绍函数和方法,建议同时阅读,因为这两个主题紧密相连。

类型的类型

[编辑 | 编辑源代码]

数据元素有不同的形状和大小,这些被称为类型

考虑以下数值:一个浮点数,一个有理数和一个整数

0.5  1//2  1

对于我们人类来说,很容易毫不费力地将这些数字加起来,但是计算机将无法使用简单的加法例程将所有三个值加起来,因为它们的类型不同。用于添加有理数的代码必须考虑分子和分母,而用于添加整数的代码则不会。计算机可能需要将其中两个值转换为与第三个值相同的类型 - 通常整数和有理数将首先转换为浮点数 - 然后将三个浮点数加在一起。

这种类型转换显然需要时间。因此,为了编写真正快速的代码,您需要确保您不会让计算机浪费时间,不断地将值从一种类型转换为另一种类型。当 Julia 编译您的源代码(每当您首次评估函数时就会发生)时,您提供的任何类型指示都允许编译器生成更高效的可执行代码。

类型转换的另一个问题是在某些情况下您会损失精度 - 将有理数转换为浮点数可能会损失一些精度。

Julia 设计者的官方说法是类型是可选的。换句话说,如果您不想担心类型(并且如果您不介意您的代码运行速度比可能慢),那么您可以忽略它们。但是您会在错误消息和文档中遇到它们,因此您最终必须处理它们...

折衷方案是在编写顶层代码时不考虑类型,但是,当您想要加快代码速度时,找出程序花费最多时间的瓶颈,并在该区域清理类型。

类型系统

[编辑 | 编辑源代码]

关于 Julia 的类型系统,有很多需要了解的地方,因此官方文档确实是最适合的地方。但这里简要概述一下。

类型层次结构

[编辑 | 编辑源代码]

在 Julia 中,类型以树状结构组织成层次结构。

在树的根部,我们有一个名为Any的特殊类型,所有其他类型都直接或间接地与其连接。非正式地说,我们可以说Any类型有子类型。它的子类型被称为Any子类型。而子类型的超类型Any。(但是请注意,类型之间的层次关系是显式声明的,而不是由兼容的结构隐含的。)

我们可以通过查看 Number 类型来看到 Julia 类型层次结构的一个很好的示例。

type hierarchy for julia numbers
julia 数字的类型层次结构

Number类型是Any的直接子类型。要查看Number的超类型是什么,我们可以使用supertype()函数

julia> supertype(Number)
 Any

但是我们也可以尝试找到Number的子类型(Number的子类型,因此是Any的孙类型)。为此,我们可以使用subtypes()函数

julia> subtypes(Number)
2-element Array{Union{DataType, UnionAll},1}:
 Complex
 Real   

我们可以观察到Number有两个子类型:ComplexReal。对于数学家来说,实数和复数都是数字。作为一般规则,Julia 的类型层次结构反映了现实世界的层次结构。

例如,如果JaguarLion都是 Julia 类型,那么如果它们的超类型是Feline,这将很自然。我们将有

julia> abstract type Feline end
julia> mutable struct Jaguar <: Feline end
julia> mutable struct Lion <: Feline end
julia> subtypes(Feline)
2-element Array{Any,1}:
 Jaguar
 Lion  

具体类型和抽象类型

[编辑 | 编辑源代码]

Julia 中的每个对象(非正式地,这意味着您可以放入 Julia 变量中的所有内容)都有一种类型。但并非所有类型都可以有相应的对象(该类型的实例)。唯一可以拥有实例的类型被称为具体类型。这些类型不能有任何子类型。可以拥有子类型的类型(例如AnyNumber)被称为抽象类型。因此,我们不能拥有Number类型的对象,因为它是一个抽象类型。换句话说,只有类型树的叶子是具体类型,可以被实例化。

如果我们不能创建抽象类型的对象,那么它们为什么有用呢?有了它们,我们可以编写代码,使该代码对所有子类型通用。例如,假设我们编写一个函数,该函数期望一个Number类型的变量

 #this function gets a number, and returns the same number plus one
 function plus_one(n::Number)
     return n + 1
 end

在这个例子中,函数期望一个变量nn的类型必须是Number的子类型(直接或间接),如::语法所示(但现在不要担心语法)。这意味着什么?无论n的类型是Int(整数)还是Float64(浮点数),函数plus_one()都将正常工作。此外,plus_one()将无法与任何不是Number的子类型的类型(例如文本字符串、数组)一起工作。

我们可以将具体类型分为两类:原始(或基本)类型和复杂(或复合)类型。原始类型是构建块,通常硬编码到 Julia 的核心,而复合类型将许多其他类型组合在一起以表示更高级的数据结构。

您可能会看到以下原始类型

  • 基本整数和浮点数类型(带符号和无符号):Int8UInt8Int16UInt16Int32UInt32Int64UInt64Int128UInt128Float16Float32Float64
  • 更高级的数字类型:BigFloatBigInt
  • 布尔值和字符类型:BoolChar
  • 文本字符串类型:String

Rational是一个复合类型的简单示例,用于表示分数。它由两个部分组成,一个分子和一个分母,它们都是整数(Int类型)。

调查类型

[编辑 | 编辑源代码]

Julia 提供了两个用于浏览类型层次结构的函数:subtypes()supertype()

julia> subtypes(Integer)
4-element Array{Union{DataType, UnionAll},1}:
 BigInt  
 Bool    
 Signed  
 Unsigned

julia> supertype(Float64)
AbstractFloat

sizeof()函数告诉您此类型的一个项目占用了多少字节

julia> sizeof(BigFloat)
 32

julia> sizeof(Char)
 4

如果您想知道您可以将多大的数字放入特定类型,则这两个函数很有用

julia> typemax(Int64)
 9223372036854775807

julia> typemin(Int32)
 -2147483648

Julia 基本系统中有超过 340 种类型。您可以使用以下函数调查类型层次结构

 function showtypetree(T, level=0)
     println("\t" ^ level, T)
     for t in subtypes(T)
         showtypetree(t, level+1)
     end
 end
 
 showtypetree(Number)

它为不同的 Number 类型生成类似于以下内容的结果

julia> showtypetree(Number)
Number
	Complex
	Real
		AbstractFloat
			BigFloat
			Float16
			Float32
			Float64
		Integer
			BigInt
			Bool
			Signed
				Int128
				Int16
				Int32
				Int64
				Int8
			Unsigned
				UInt128
				UInt16
				UInt32
				UInt64
				UInt8
		Irrational
		Rational

这表明,例如,Real数字的四个主要子类型:AbstractFloatIntegerRationalIrrational,如树形图所示。

type hierarchy for julia numbers
julia 数字的类型层次结构

指定变量的类型

[编辑 | 编辑源代码]

我们已经看到,如果您没有指定类型,Julia 会尽力找出您在代码中放置的内容的类型

julia> collect(1:10)
10-element Array{Int64,1}:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10

julia> collect(1.0:10)
10-element Array{Float64,1}:
 1.0
 2.0
 3.0
 4.0
 5.0
 6.0
 7.0
 8.0
 9.0
10.0

我们还看到您可以为新的空数组指定类型

julia> fill!(Array{String}(undef, 3), "Julia")
3-element Array{String,1}:
 "Julia"
 "Julia"
 "Julia"

对于变量,您可以指定其值必须具有的类型。由于技术原因,您无法在 REPL 的顶层执行此操作 - 您只能在定义内部执行此操作。语法使用::语法,这意味着“是类型”。所以

function f(x::Int64)

表示函数f有一个方法,它接受一个参数x,该参数预计是 Int64。见函数.

类型稳定性

[编辑 | 编辑源代码]

这是一个关于Julia代码性能如何受到变量类型选择的示例。这是一些用于探索Collatz猜想的代码。

function chain_length(n, terms)
    length = 0
    while n != 1
        if haskey(terms, n)
            length += terms[n]
            break
        end
        if n % 2 == 0      # is n even?
            n /= 2
        else
            n = 3n + 1
        end
        length += 1
    end
    return length
end

function main()
    ans = 0
    limit = 1_000_000
    score = 0
    terms = Dict()         # define a dictionary
    for i in 1:limit
        terms[i] = chain_length(i, terms)
        if terms[i] > score
            score = terms[i]
            ans = i
        end
    end
    return ans
end

我们可以使用@time宏来计时(尽管BenchmarkTools包提供了更好的基准测试工具)。

julia> @time main() 
 2.634295 seconds (17.95 M allocations: 339.074 MiB, 13.50% gc time)

有两行代码阻止函数“类型稳定”。这些是编译器无法使用最佳和最有效类型来完成手头任务的地方。你能找出它们吗?

第一个是将n除以2,在测试n是否为偶数之后。n最初是一个整数,但/除法运算符始终返回一个浮点数。Julia编译器无法生成纯整数代码或纯浮点数代码,必须在每个阶段决定使用哪一种。结果,编译后的代码不像它本可以的那样快或简洁。

第二个问题是这里的字典定义。它是在没有类型信息的情况下定义的,因此键和值可以是任何类型的。虽然这通常是可以的,但在这种类型的任务中,在循环中频繁访问,维护键和值可能存在不同类型的额外任务会使代码更加复杂。

julia> Dict()
Dict{Any, Any}()

如果我们告诉Julia编译器这个字典只包含整数(这是一个好的假设),编译后的代码将更加高效,并且类型稳定。

所以,在将n /= 2更改为n ÷= 2,以及terms = Dict()更改为terms = Dict{Int, Int}()之后,我们预计编译器会生成更有效的代码,实际上它更快了。

Julia> @time main()
0.450561 seconds (54 allocations: 65.170 MiB, 19.33% gc time)

你可以从编译器获得一些关于代码中可能存在由于类型不稳定而导致问题的提示。例如,对于此函数,你可以输入@code_warntype main()并查找以红色突出显示的项目或“Any”。

创建类型

[edit | edit source]

在Julia中,程序员可以非常轻松地创建新的类型,并受益于与原生类型(由Julia的创建者创建的类型)相同的性能和语言级集成。

抽象类型

[edit | edit source]

假设我们要创建一个抽象类型。为此,我们使用Julia的关键字abstract,后跟要创建的类型的名称。

abstract type MyAbstractType end

默认情况下,你创建的类型是Any的直接子类型。

julia> supertype(MyAbstractType)
 Any

你可以使用<:运算符更改此设置。例如,如果你希望你的新抽象类型成为Number的子类型,你可以声明

abstract type MyAbstractType2 <: Number end

现在,我们得到

julia> supertype(MyAbstractType2)
 Number

请注意,在同一个Julia会话中(不退出REPL或结束脚本),无法重新定义类型。这就是为什么我们必须创建一个名为MyAbstractType2的类型。

具体类型和复合类型

[edit | edit source]

你可以创建新的复合类型。为此,使用structmutable struct关键字,它们的语法与声明超类型相同。新类型可以包含多个字段,对象在其中存储值。例如,让我们定义一个具体类型,它是MyAbstractType的子类型。

 mutable struct MyType <: MyAbstractType
    foo
    bar::Int
 end

我们刚刚创建了一个名为MyType的复合结构体类型,它是MyAbstractType的子类型,具有两个字段:foo可以是任何类型的,barInt类型的。

我们如何创建一个MyType对象?默认情况下,Julia会自动创建一个**构造函数**,一个返回该类型对象的函数。该函数与类型的名称相同,并且函数的每个参数都对应于每个字段。在这个例子中,我们可以通过输入以下内容来创建一个新对象:

julia> x = MyType("Hello World!", 10)
 MyType("Hello World!", 10)

这将创建一个MyType对象,将Hello World!分配给foo字段,将10分配给bar字段。我们可以使用**点**符号访问x的字段。

julia> x.foo
 "Hello World!"

julia> x.bar
 10

此外,我们可以轻松地更改可变结构体的字段值。

julia> x.foo = 3.0
 3.0

julia> x.foo
 3.0

请注意,由于我们在创建类型定义时没有指定foo的类型,因此我们可以随时更改其类型。这与尝试更改x.bar字段的类型(根据MyType的定义,我们指定它为Int)不同。

julia> x.bar = "Hello World!"
LoadError: MethodError: Cannot `convert` an object of type String to an object of type Int64
This may have arisen from a call to the constructor Int64(...),
since type constructors fall back to convert methods.

错误消息告诉我们Julia无法更改x.bar的类型。这保证了类型稳定的代码,并且可以在编程时提供更好的性能。作为一个性能提示,在定义类型时指定字段类型通常是一个好习惯。

默认构造函数用于简单情况,在这种情况下,你键入类似于**typename(field1, field2)**的内容来生成类型的新实例。但是,有时你在构造新实例时想要做更多的事情,例如检查传入的值。为此,你可以使用内部构造函数,即类型定义内的函数。下一节将展示一个实际示例。

示例:英镑货币

[edit | edit source]

这是一个关于如何创建一个简单的复合类型来处理老式英镑货币的示例。在英国看到光明并引入十进制货币之前,货币系统使用英镑、先令和便士,其中一英镑包含20先令,一先令包含12便士。这被称为£sd或LSD系统(拉丁语为Librae,Solidii,Denarii,因为该系统起源于罗马帝国)。

要定义合适的类型,请开始一个新的复合类型声明。

 struct LSD

为了包含以英镑、先令和便士表示的价格,这个新类型应该包含三个字段:英镑、先令和便士。

   pounds::Int 
   shillings::Int
   pence::Int

重要的任务是创建一个**构造函数**。它与类型的名称相同,并接受三个值作为参数。经过一些对无效值的检查后,特殊的new()函数将创建一个包含传入值的新对象。请记住,我们仍然在type定义中——这是一个内部构造函数。

  function LSD(a,b,c)
    if a < 0 || b < 0 || c < 0
      error("no negative numbers")
    end
    if c > 12 || b > 20
      error("too many pence or shillings")
    end
    new(a, b, c) 
  end

现在我们可以完成类型定义。

end

以下是完整的类型定义。

struct LSD
   pounds::Int 
   shillings::Int
   pence::Int
   
   function LSD(a, b, c)
    if a < 0 || b < 0 
      error("no negative numbers")
    end
    if c > 12 || b > 20
      error("too many pence or shillings")
    end
    new(a, b, c) 
   end   
end

现在可以创建存储老式英镑价格的新对象。你可以使用它的名称(它调用构造函数)来创建一个这种类型的新对象。

julia> price1 = LSD(5, 10, 6)
LSD(5, 10, 6)

julia> price2 = LSD(1, 6, 8)
LSD(1, 6, 8)

你无法创建错误的价格,因为构造函数中添加了简单的检查。

julia> price = LSD(1, 0, 13)
ERROR: too many pence or shillings
Stacktrace:
[1] LSD(::Int64, ::Int64, ::Int64)

如果你检查我们创建的其中一个价格“对象”的字段。

julia> fieldnames(typeof(price1))
3-element Array{Symbol,1}:
 :pounds   
 :shillings
 :pence    

你可以看到三个字段,它们正在存储值。

julia> price1.pounds
5
julia> price1.shillings
10
julia> price1.pence
6

接下来的任务是使这个新类型像其他Julia对象一样运行。例如,我们无法添加两个价格。

julia> price1 + price2
ERROR: MethodError: no method matching +(::LSD, ::LSD)
Closest candidates are:
  +(::Any, ::Any, ::Any, ::Any...) at operators.jl:420

并且输出可以肯定地得到改进。

julia> price2
LSD(5, 10, 6)

Julia已经具有添加函数(+),其中定义了针对许多类型的对象的方法。以下代码添加了另一种可以处理两个LSD对象的方法。

function Base.:+(a::LSD, b::LSD)
    newpence = a.pence + b.pence
    newshillings = a.shillings + b.shillings
    newpounds = a.pounds + b.pounds
    subtotal = newpence + newshillings * 12 + newpounds * 240
    (pounds, balance) = divrem(subtotal, 240)
    (shillings, pence) = divrem(balance, 12)
    LSD(pounds, shillings, pence)
end

这个定义教Julia如何处理新的LSD对象,并向+函数添加了一种新方法,该方法接受两个LSD对象,将它们加在一起,并产生一个包含总和的新LSD对象。

现在你可以添加两个价格。

julia> price1 + price2
LSD(6,17,2)

这确实是将LSD(5,10,6)和LSD(1,6,8)相加的结果。

下一个要解决的问题是LSD对象不美观的显示方式。这可以通过添加一个新方法来解决,但这次是添加给show()函数,该函数属于Base环境。

function Base.show(io::IO, money::LSD)
    print(io, $(money.pounds).$(money.shillings)s.$(money.pence)d")
end

在这里,io是当前由所有show()方法使用的输出通道。我们添加了一个简单的表达式,它以适当的标点符号和分隔符显示字段值。

julia> println(price1 + price2)
£6.17s.2d
julia> show(price1 + price2 + LSD(0,19,11) + LSD(19,19,6))
£27.16s.7d

你可以添加一个或多个别名,它们是特定类型的替代名称。由于PriceLSD更能表达含义,因此我们将创建一个有效的替代方案。

julia> const Price=LSD 
LSD

julia> show(Price(1, 19, 11))
£1.19s.11d

到目前为止,一切都很好,但这些LSD对象还没有完全开发。如果你想做减法、乘法和除法,你必须为这些函数定义额外的处理LSD的方法。减法很容易,只需要对先令和便士进行一些调整,所以我们现在就跳过它,但乘法呢?将价格乘以数字涉及两种类型的对象,一种是价格/LSD对象,另一种是——嗯,任何正实数都应该是可能的。

function Base.:*(a::LSD, b::Real)
    if b < 0
        error("Cannot multiply by a negative number")
    end

    totalpence = b * (a.pence + a.shillings * 12 + a.pounds * 240)
    (pounds, balance) = divrem(totalpence, 240)
    (shillings, pence) = divrem(balance, 12)
    LSD(pounds, shillings, pence)
end

就像我们添加到Base的+函数中的+方法一样,这个为Base的*函数定义的新*方法专门用于将价格乘以数字。对于第一次尝试,它运行得很好。

julia> price1 * 2
£11.1s.0d
julia> price1 * 3
£16.11s.6d
julia> price1 * 10
£55.5s.0d
julia> price1 * 1.5
£8.5s.9d
julia> price3 = Price(0,6,5)
£0.6s.5d
julia> price3 * 1//7
£0.0s.11d

然而,一些失败是不可避免的。我们没有考虑到非常老式的便士分数:半便士和法令。

julia> price1 * 0.25
ERROR: InexactError()
Stacktrace:
 [1] convert(::Type{Int64}, ::Float64) at ./float.jl:675
 [2] LSD(::Float64, ::Float64, ::Float64) at ./REPL[36]:40
 [3] *(::LSD, ::Float64) at ./REPL[55]:10

(答案应该是£1.7s.7½d。不幸的是,我们的LSD类型不允许便士分数。)

但是还有一个更紧迫的问题。目前,您必须先给出价格,然后给出乘数;反过来就不行。

julia> 2 * price1
ERROR: MethodError: no method matching *(::Int64, ::LSD)
Closest candidates are:
 *(::Any, ::Any, ::Any, ::Any...) at operators.jl:420
 *(::Number, ::Bool) at bool.jl:106
...

这是因为,虽然 Julia 可以找到一个匹配 (a::LSD, b::Number) 的方法,但它却找不到反过来的方法:(a::Number, b::LSD)。但添加它非常容易。

function Base.:*(a::Number, b::LSD)
  b * a
end

这为 Base 的 * 函数添加了另一个方法。

julia> price1 * 2
£11.1s.0d
julia> 2 * price1 
£11.1s.0d
julia> for i in 1:10
          println(price1 * i)
       end
£5.10s.6d
£11.1s.0d
£16.11s.6d
£22.2s.0d
£27.12s.6d
£33.3s.0d
£38.13s.6d
£44.4s.0d
£49.14s.6d
£55.5s.0d

现在,价格看起来就像 19 世纪一家老式的英国商店,真是太棒了!

如果您想查看到目前为止为这种老式的英镑类型添加了多少个方法,请使用 methodswith() 函数。

julia> methodswith(LSD)
4-element Array{Method,1}:
*(a::LSD, b::Real) at In[20]:4
*(a::Number, b::LSD) at In[34]:2
+(a::LSD, b::LSD) at In[13]:2
show(io::IO, money::LSD) at In[15]:2

到目前为止只有四个……您可以继续添加方法来使该类型更通用——这取决于您或其他人如何设想使用它。例如,您可能希望添加除法和模运算方法,并对负的货币值进行智能处理。

可变结构体

[编辑 | 编辑源代码]

这个用于保存英国价格的复合类型被定义为一个不可变类型。您无法在创建这些价格对象后更改它们的值。

julia> price1.pence
6

julia> price1.pence=10
ERROR: type LSD is immutable

要基于现有价格创建新价格,您需要执行以下操作:

julia> price2 = Price(price1.pounds, price1.shillings, 10)
£5.10s.10d

对于这个特定的示例,这不是一个大问题,但对于很多应用程序,您可能希望修改或更新类型中某个字段的值,而不是创建一个具有正确值的新的字段。

对于这些情况,您需要创建一个 mutable struct。根据对类型的要求选择 structmutable struct

有关模块以及从其他模块导入函数的更多信息,请参见 模块和包

华夏公益教科书