类型

类型系统传统上分为两个截然不同的阵营:静态类型系统,其中每个程序表达式必须在程序执行之前具有可计算的类型;动态类型系统,其中在运行时之前对类型一无所知,直到程序操作的实际值可用。面向对象允许在静态类型语言中具有一定的灵活性,通过允许编写代码而不必在编译时知道值的精确类型。在不同类型上操作的能力称为多态性。经典动态类型语言中的所有代码都是多态的:只有通过显式检查类型,或者当对象在运行时不支持操作时,才会限制任何值的类型。

Julia 的类型系统是动态的,但通过使指示某些值具有特定类型成为可能,从而获得了一些静态类型系统的优势。这对于生成高效代码可能非常有帮助,但更重要的是,它允许在函数参数的类型上进行方法调度,与语言深度集成。方法调度在 方法 中进行了详细探讨,但它植根于此处介绍的类型系统。

在 Julia 中,当省略类型时,默认行为是允许值具有任何类型。因此,人们可以编写许多有用的 Julia 函数,而无需显式使用类型。但是,当需要额外的表达能力时,可以很容易地在以前“无类型”的代码中逐渐引入显式类型注释。添加注释有三个主要目的:利用 Julia 强大的多重调度机制,提高人类可读性,以及捕获程序员错误。

类型系统 的术语来描述 Julia,它是:动态的、名义的和参数化的。泛型类型可以参数化,类型之间的层次结构关系是 显式声明的,而不是 由兼容的结构暗示的。Julia 类型系统的一个特别显着的特征是具体类型可能不会相互子类型化:所有具体类型都是最终的,并且只能具有抽象类型作为其超类型。虽然这乍一看似乎过分严格,但它具有许多有益的结果,缺点却出奇地少。事实证明,能够继承行为比能够继承结构要重要得多,而继承两者在传统的面向对象语言中会导致重大困难。Julia 类型系统的一些其他高级方面应该在前面提到:

  • 对象和非对象值之间没有区别:Julia 中的所有值都是真正的对象,具有属于单个完全连接的类型图的类型,该图的所有节点作为类型都是同等的一流的。
  • 没有“编译时类型”的意义概念:值具有的唯一类型是在程序运行时其实际类型。在面向对象语言中,这被称为“运行时类型”,其中静态编译与多态性的结合使得这种区别变得重要。
  • 只有值,而不是变量,具有类型 - 变量只是绑定到值的名称,尽管为了简单起见,我们可能将“变量的类型”作为“变量引用的值的类型”的简写。
  • 抽象类型和具体类型都可以由其他类型参数化。它们也可以由符号参数化,由 isbits 返回 true 的任何类型的值参数化(本质上,像数字和布尔值一样存储为 C 类型或没有指向其他对象的指针的 struct),以及由它们的元组参数化。当类型参数不需要被引用或限制时,可以省略它们。

Julia 的类型系统旨在强大且富有表现力,同时清晰、直观且不显眼。许多 Julia 程序员可能永远不会感到需要编写显式使用类型的代码。但是,某些类型的编程通过声明的类型变得更加清晰、简单、快速和健壮。

类型声明

:: 运算符可用于将类型注释附加到程序中的表达式和变量。这样做有两个主要原因:

  1. 作为断言,以帮助确认您的程序按预期工作,以及
  2. 向编译器提供额外的类型信息,这在某些情况下可以提高性能。

当附加到计算值的表达式时,:: 运算符被解释为“是 ... 的实例”。它可以在任何地方使用来断言左侧表达式的值是右侧类型的实例。当右侧的类型是具体类型时,左侧的值必须具有该类型作为其实现 - 回想一下,所有具体类型都是最终的,因此没有实现是任何其他类型的子类型。当类型是抽象类型时,值由一个具体类型实现就足够了,该具体类型是抽象类型的子类型。如果类型断言不成立,则抛出异常,否则返回左侧值

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

julia> (1+2)::Int
3

这允许将类型断言就地附加到任何表达式。

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

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

julia> x = foo()
100

julia> typeof(x)
Int8

此功能对于避免可能由于意外更改变量类型而发生的性能“陷阱”很有用。

此“声明”行为仅在特定上下文中发生

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

并且适用于整个当前范围,甚至在声明之前。

从 Julia 1.8 开始,类型声明现在可以在全局范围内使用,即可以将类型注释添加到全局变量以使其访问类型稳定。

julia> x::Int = 10
10

julia> x = 3.5
ERROR: InexactError: Int64(3.5)

julia> function foo(y)
           global x = 15.8    # throws an error when foo is called
           return x + y
       end
foo (generic function with 1 method)

julia> foo(10)
ERROR: InexactError: Int64(15.8)

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

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 通常被称为“顶端”,因为它位于类型图的顶端。Julia 还具有一个预定义的抽象“底部”类型,位于类型图的最低点,写为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 仅包括实数的浮点表示。整数进一步细分为 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

最后,它调用此特定方法。

因此,抽象类型允许程序员编写泛型函数,这些函数稍后可以用作许多具体类型组合的默认方法。由于多重调度,程序员可以完全控制使用默认方法还是更具体的方法。

需要注意的一点是,如果程序员依赖一个参数为抽象类型的函数,则不会有任何性能损失,因为该函数会在它被调用时的每个具体参数类型的元组中重新编译。(但是,在函数参数为抽象类型容器的情况下,可能会出现性能问题;请参见 性能提示。)

原始类型

警告

将现有的原始类型包装在新的复合类型中,几乎总是比定义自己的原始类型更可取。

此功能存在是为了允许 Julia 引导 LLVM 支持的标准原始类型。一旦它们被定义,就很少有理由再定义更多。

原始类型是一种具体类型,其数据由普通位组成。原始类型的典型示例是整数和浮点值。与大多数语言不同,Julia 允许您声明自己的原始类型,而不是只提供一组固定的内置类型。实际上,标准原始类型都在语言本身中定义

primitive type Float16 <: AbstractFloat 16 end
primitive type Float32 <: AbstractFloat 32 end
primitive type Float64 <: AbstractFloat 64 end

primitive type Bool <: Integer 8 end
primitive type Char <: AbstractChar 32 end

primitive type Int8    <: Signed   8 end
primitive type UInt8   <: Unsigned 8 end
primitive type Int16   <: Signed   16 end
primitive type UInt16  <: Unsigned 16 end
primitive type Int32   <: Signed   32 end
primitive type UInt32  <: Unsigned 32 end
primitive type Int64   <: Signed   64 end
primitive type UInt64  <: Unsigned 64 end
primitive type Int128  <: Signed   128 end
primitive type UInt128 <: Unsigned 128 end

声明原始类型的通用语法如下:

primitive type «name» «bits» end
primitive type «name» <: «supertype» «bits» end

位数指示类型需要多少存储空间,名称为新类型命名。原始类型可以选择声明为某个超类型的子类型。如果省略了超类型,则类型默认为具有Any作为其直接超类型。上面对 Bool 的声明因此意味着布尔值需要八位存储空间,并且具有 Integer 作为其直接超类型。目前,只支持 8 位的倍数大小,并且您可能会在使用上面未使用的其他大小时遇到 LLVM 错误。因此,布尔值虽然实际上只需要一位,但不能声明为小于八位。

类型 BoolInt8UInt8 具有相同的表示:它们是内存中的八位块。然而,由于 Julia 的类型系统是命名的,因此尽管它们具有相同的结构,但它们是不可互换的。它们之间的根本区别在于它们具有不同的超类型:Bool 的直接超类型是 IntegerInt8 的是 Signed,而 UInt8 的是 UnsignedBoolInt8UInt8 之间的其他所有差异都是行为上的差异——当给定这些类型的对象作为参数时,函数的行为方式。这就是为什么需要命名类型系统:如果结构决定类型,而类型又决定行为,那么就无法使 Bool 的行为与 Int8UInt8 不同。

复合类型

复合类型在各种语言中被称为记录、结构体或对象。复合类型是命名字段的集合,其实例可以被视为单个值。在许多语言中,复合类型是唯一一种用户可定义类型,并且它们也是 Julia 中最常用的用户定义类型。

在主流面向对象语言中,如 C++、Java、Python 和 Ruby,复合类型也与命名函数相关联,它们的组合被称为“对象”。在更纯粹的面向对象语言中,如 Ruby 或 Smalltalk,所有值都是对象,无论它们是复合的还是非复合的。在不太纯粹的面向对象语言中,包括 C++ 和 Java,一些值,如整数和浮点值,不是对象,而用户定义的复合类型的实例则是具有关联方法的真对象。在 Julia 中,所有值都是对象,但函数不与它们操作的对象捆绑在一起。这是必要的,因为 Julia 通过多重调度来选择使用函数的哪个方法,这意味着在选择方法时,会考虑函数所有参数的类型,而不仅仅是第一个参数(有关方法和调度的更多信息,请参见 方法)。因此,将函数“归属于”只有它们第一个参数是不合适的。将方法组织到函数对象中,而不是让每个对象“内部”都有一个名为方法的集合,最终成为了语言设计中一个非常有益的方面。

复合类型使用 struct 关键字后跟字段名称块引入,这些名称可以选择使用:: 运算符标注类型

julia> struct Foo
           bar
           baz::Int
           qux::Float64
       end

没有类型标注的字段默认为Any,因此可以容纳任何类型的值。

创建类型为Foo的新对象,方法是将Foo类型对象像函数一样应用于其字段的值

julia> foo = Foo("Hello, world.", 23, 1.5)
Foo("Hello, world.", 23, 1.5)

julia> typeof(foo)
Foo

当类型像函数一样应用时,它被称为构造函数。会自动生成两个构造函数(这些被称为默认构造函数)。一个是接受任何参数并调用 convert 将它们转换为字段类型的构造函数,另一个是接受与字段类型完全匹配的参数的构造函数。之所以生成这两个构造函数,是因为这使得添加新定义变得更容易,而不会无意中替换默认构造函数。

由于bar 字段的类型不受限制,因此任何值都可以。但是,baz 的值必须可以转换为Int

julia> Foo((), 23.5, 1)
ERROR: InexactError: Int64(23.5)
Stacktrace:
[...]

您可以使用 fieldnames 函数找到字段名称列表。

julia> fieldnames(Foo)
(:bar, :baz, :qux)

您可以使用传统的foo.bar 表示法访问复合对象的字段值

julia> foo.bar
"Hello, world."

julia> foo.baz
23

julia> foo.qux
1.5

使用struct 声明的复合对象是不可变的;它们在构造后不能修改。这乍一看可能很奇怪,但它有几个优点

  • 它可能更有效率。一些结构可以有效地打包到数组中,在某些情况下,编译器可以完全避免分配不可变对象。
  • 不可能违反类型构造函数提供的约束条件。
  • 使用不可变对象的代码更容易推理。

不可变对象可能包含可变对象,例如数组,作为字段。这些包含的对象将保持可变;只有不可变对象本身的字段不能更改为指向不同的对象。

如果需要,可变复合对象可以使用关键字 mutable struct 声明,将在下一节中讨论。

如果不可变结构的所有字段不可区分 (===),那么包含这些字段的两个不可变值也是不可区分的

julia> struct X
           a::Int
           b::Float64
       end

julia> X(1, 2) === X(1, 2)
true

关于如何创建复合类型的实例还有很多要说的,但讨论取决于 参数类型方法,并且重要到需要在它自己的部分中讨论:构造函数

对于许多用户定义的类型 X,您可能希望定义一个方法 Base.broadcastable(x::X) = Ref(x),以便该类型的实例充当 广播 的 0 维“标量”。

可变复合类型

如果复合类型使用 mutable struct 而不是 struct 声明,则可以修改其实例

julia> mutable struct Bar
           baz
           qux::Float64
       end

julia> bar = Bar("Hello", 1.5);

julia> bar.qux = 2.0
2.0

julia> bar.baz = 1//2
1//2

可以通过 实例属性 提供字段和用户之间的额外接口。这使得使用 bar.baz 符号可以更好地控制可以访问和修改的内容。

为了支持变异,此类对象通常在堆上分配,并具有稳定的内存地址。可变对象就像一个小的容器,可能随着时间的推移而包含不同的值,因此只能通过其地址可靠地识别。相反,不可变类型的实例与特定的字段值相关联——字段值本身告诉你关于对象的一切。在决定是否使类型可变时,问问自己具有相同字段值的两个实例是否被认为是相同的,或者它们是否可能随着时间的推移而独立变化。如果它们被认为是相同的,则该类型可能应该是不可变的。

总结一下,两个基本属性定义了 Julia 中的不可变性

  • 不允许修改不可变类型的值。
    • 对于位类型,这意味着一旦设置了值的位模式,它将永远不会改变,并且该值是位类型的标识。
    • 对于复合类型,这意味着其字段值的标识将永远不会改变。当字段是位类型时,这意味着它们的位永远不会改变,对于其值为可变类型(如数组)的字段,这意味着这些字段将始终引用相同的可变值,即使该可变值的内容本身可能会被修改。
  • 具有不可变类型的对象可以被编译器自由复制,因为它的不可变性使其无法以编程方式区分原始对象和副本。
    • 特别是,这意味着足够小的不可变值(如整数和浮点数)通常以寄存器(或堆栈分配)传递给函数。
    • 另一方面,可变值是在堆上分配的,并且作为指向堆分配值的指针传递给函数,除非编译器确定这不可能发生。

在否则可变结构的一个或多个字段已知是不可变的情况下,可以使用 const 声明这些字段,如下所示。这将启用一些不可变结构的优化,但并非全部,并且可用于对标记为 const 的特定字段强制执行不变式。

Julia 1.8

const 用于注释可变结构的字段,需要 Julia 1.8 或更高版本。

julia> mutable struct Baz
           a::Int
           const b::Float64
       end

julia> baz = Baz(1, 1.5);

julia> baz.a = 2
2

julia> baz.b = 2.0
ERROR: setfield!: const field .b of type Baz cannot be changed
[...]

声明的类型

前几节中讨论的三种类型(抽象、原始、复合)实际上都是密切相关的。它们共享相同的关键属性

  • 它们是显式声明的。
  • 它们有名字。
  • 它们有显式声明的超类型。
  • 它们可能具有参数。

由于这些共享属性,这些类型在内部表示为相同概念的实例,即 DataType,它是这些类型中任何一个的类型

julia> typeof(Real)
DataType

julia> typeof(Int)
DataType

DataType 可以是抽象的或具体的。如果它是具体的,它将具有指定的大小、存储布局以及(可选)字段名称。因此,原始类型是具有非零大小但没有字段名称的 DataType。复合类型是具有字段名称或为空(零大小)的 DataType

系统中的每个具体值都是某个 DataType 的实例。

类型联合

类型联合是一种特殊的抽象类型,它包含其所有参数类型的任何实例,这些实例使用特殊的 Union 关键字构建

julia> IntOrString = Union{Int,AbstractString}
Union{Int64, AbstractString}

julia> 1 :: IntOrString
1

julia> "Hello!" :: IntOrString
"Hello!"

julia> 1.0 :: IntOrString
ERROR: TypeError: in typeassert, expected Union{Int64, AbstractString}, got a value of type Float64

许多语言的编译器都有一个内部联合结构来推理类型;Julia 只是将其公开给程序员。Julia 编译器能够在存在少量类型的 Union 类型的情况下生成有效代码[1],方法是为每种可能的类型生成不同分支的专用代码。

Union 类型的一个特别有用的情况是 Union{T, Nothing},其中 T 可以是任何类型,而 Nothing 是只有一个实例的对象的单例类型,即对象 nothing。这种模式是 Julia 等同于其他语言中的 NullableOptionMaybe 类型。将函数参数或字段声明为 Union{T, Nothing} 允许将其设置为类型为 T 的值,或者设置为 nothing 以指示没有值。有关详细信息,请参阅 此常见问题解答条目

参数类型

Julia 类型系统的一个重要且强大的特性是它是参数化的:类型可以接受参数,因此类型声明实际上引入了整个类型的族——每个可能的参数值组合一个。许多语言支持某种形式的 泛型编程,其中数据结构和用于操作它们的算法可以在不指定所涉及的精确类型的情况下指定。例如,ML、Haskell、Ada、Eiffel、C++、Java、C#、F# 和 Scala 中都存在某种形式的泛型编程,仅举几例。这些语言中的一些支持真正的参数多态性(例如 ML、Haskell、Scala),而另一些支持基于模板的泛型编程的临时风格(例如 C++、Java)。由于各种语言中存在如此多的泛型编程和参数类型的变体,我们甚至不会尝试将 Julia 的参数类型与其他语言进行比较,而是专注于解释 Julia 自己的系统。但是,我们会注意到,由于 Julia 是一种动态类型语言,不需要在编译时做出所有类型决策,因此静态参数类型系统中遇到的许多传统困难可以相对容易地处理。

所有声明的类型(DataType 种类)都可以参数化,在每种情况下使用相同的语法。我们将按以下顺序讨论它们:首先是参数化的复合类型,然后是参数化的抽象类型,最后是参数化的原始类型。

参数化的复合类型

类型参数在类型名称之后立即引入,并用花括号括起来

julia> struct Point{T}
           x::T
           y::T
       end

此声明定义了一个新的参数化类型 Point{T},它包含两个类型为 T 的“坐标”。人们可能会问,T 是什么?好吧,这正是参数类型的重点:它可以是任何类型(或者实际上是任何位类型的值,尽管在这里它明显用作类型)。Point{Float64} 是一种具体类型,等同于通过将 Point 的定义中的 T 替换为 Float64 定义的类型。因此,此单个声明实际上声明了无限数量的类型:Point{Float64}Point{AbstractString}Point{Int64} 等。现在,它们中的每一个都是可用的具体类型

julia> Point{Float64}
Point{Float64}

julia> Point{AbstractString}
Point{AbstractString}

类型 Point{Float64} 是一个点,其坐标是 64 位浮点数,而类型 Point{AbstractString} 是一个“点”,其“坐标”是字符串对象(请参阅 字符串)。

Point 本身也是一个有效的类型对象,包含所有实例 Point{Float64}Point{AbstractString} 等作为子类型

julia> Point{Float64} <: Point
true

julia> Point{AbstractString} <: Point
true

当然,其他类型不是它的子类型

julia> Float64 <: Point
false

julia> AbstractString <: Point
false

具有不同 T 值的具体 Point 类型永远不会彼此的子类型

julia> Point{Float64} <: Point{Int64}
false

julia> Point{Float64} <: Point{Real}
false
警告

最后一点非常重要:即使 Float64 <: Real 我们Point{Float64} <: Point{Real}

换句话说,用类型论的术语来说,Julia 的类型参数是不变的,而不是 协变的(甚至逆变的)。这是出于实际原因:虽然任何 Point{Float64} 的实例在概念上可能也像 Point{Real} 的实例一样,但两种类型在内存中的表示方式不同

  • Point{Float64} 的实例可以作为一对 64 位值的直接对紧凑且高效地表示;
  • Point{Real} 的实例必须能够保存任何一对 Real 实例。由于 Real 实例的对象可以具有任意大小和结构,在实践中,Point{Real} 的实例必须表示为一对指向单独分配的 Real 对象的指针。

能够以直接值存储 Point{Float64} 对象所带来的效率在数组的情况下被极大地放大:Array{Float64} 可以存储为 64 位浮点数的连续内存块,而 Array{Real} 必须是指向单独分配的 Real 对象的指针数组——这些对象很可能是 装箱的 64 位浮点数,但也可能是任意大小的复杂对象,这些对象被声明为 Real 抽象类型的实现。

由于 Point{Float64} 不是 Point{Real} 的子类型,因此以下方法不能应用于类型为 Point{Float64} 的参数

function norm(p::Point{Real})
    sqrt(p.x^2 + p.y^2)
end

定义接受类型为 Point{T} 的所有参数的正确方法,其中 TReal 的子类型,是

function norm(p::Point{<:Real})
    sqrt(p.x^2 + p.y^2)
end

(等效地,可以定义 function norm(p::Point{T} where T<:Real)function norm(p::Point{T}) where T<:Real;请参阅 UnionAll 类型。)

更多示例将在后面的 方法 中讨论。

如何构造 Point 对象?可以为复合类型定义自定义构造函数,这将在 构造函数 中详细讨论,但在没有特殊构造函数声明的情况下,有两种创建新复合对象的方法,一种是显式给出类型参数,另一种是通过对象构造函数的参数隐式给出类型参数。

由于类型 Point{Float64} 是一个具体类型,等同于使用 Float64 代替 T 声明的 Point,因此可以相应地将其用作构造函数

julia> p = Point{Float64}(1.0, 2.0)
Point{Float64}(1.0, 2.0)

julia> typeof(p)
Point{Float64}

对于默认构造函数,必须为每个字段提供一个参数

julia> Point{Float64}(1.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64)
[...]

julia> Point{Float64}(1.0,2.0,3.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64, ::Float64, ::Float64)
[...]

对于参数化类型,只会生成一个默认构造函数,因为无法覆盖它。这个构造函数接受任何参数,并将它们转换为字段类型。

在很多情况下,提供要构造的Point对象的类型是多余的,因为构造函数调用的参数类型已经隐式地提供了类型信息。出于这个原因,你也可以将Point本身用作构造函数,前提是参数类型T的隐式值是明确的。

julia> p1 = Point(1.0,2.0)
Point{Float64}(1.0, 2.0)

julia> typeof(p1)
Point{Float64}

julia> p2 = Point(1,2)
Point{Int64}(1, 2)

julia> typeof(p2)
Point{Int64}

Point的情况下,当且仅当Point的两个参数类型相同时,T的类型才会被明确地推断出来。当情况并非如此时,构造函数将失败并抛出一个MethodError

julia> Point(1,2.5)
ERROR: MethodError: no method matching Point(::Int64, ::Float64)

Closest candidates are:
  Point(::T, !Matched::T) where T
   @ Main none:2

Stacktrace:
[...]

可以定义构造函数方法来适当地处理这些混合情况,但这些将在后面的构造函数中讨论。

参数化抽象类型

参数化抽象类型声明声明了一个抽象类型的集合,与普通抽象类型声明非常相似。

julia> abstract type Pointy{T} end

有了这个声明,Pointy{T}对于T的每个类型或整数值都是一个不同的抽象类型。与参数化复合类型一样,每个这样的实例都是Pointy的子类型。

julia> Pointy{Int64} <: Pointy
true

julia> Pointy{1} <: Pointy
true

参数化抽象类型是不变的,就像参数化复合类型一样。

julia> Pointy{Float64} <: Pointy{Real}
false

julia> Pointy{Real} <: Pointy{Float64}
false

Pointy{<:Real} 符号可以用来表示 Julia 中协变类型的类似物,而Pointy{>:Int} 表示逆变类型的类似物,但从技术上讲,它们表示类型的集合(参见UnionAll 类型)。

julia> Pointy{Float64} <: Pointy{<:Real}
true

julia> Pointy{Real} <: Pointy{>:Int}
true

就像普通抽象类型用于在具体类型上创建有用的类型层次结构一样,参数化抽象类型在参数化复合类型方面发挥着相同的目的。例如,我们可以将Point{T}声明为Pointy{T}的子类型,如下所示。

julia> struct Point{T} <: Pointy{T}
           x::T
           y::T
       end

有了这样的声明,对于T的每种选择,我们都有Point{T}作为Pointy{T}的子类型。

julia> Point{Float64} <: Pointy{Float64}
true

julia> Point{Real} <: Pointy{Real}
true

julia> Point{AbstractString} <: Pointy{AbstractString}
true

这种关系也是不变的。

julia> Point{Float64} <: Pointy{Real}
false

julia> Point{Float64} <: Pointy{<:Real}
true

Pointy这样的参数化抽象类型有什么用呢?假设我们创建一个点状实现,它只需要一个坐标,因为该点位于对角线 *x = y* 上。

julia> struct DiagPoint{T} <: Pointy{T}
           x::T
       end

现在,Point{Float64}DiagPoint{Float64} 都是 Pointy{Float64} 抽象的实现,类似地,对于每种其他可能的类型选择 T 也是如此。这允许对所有 Pointy 对象的通用接口进行编程,该接口在 PointDiagPoint 中实现。然而,在下一节方法中介绍方法和分派之前,无法完全演示这一点。

在某些情况下,类型参数可能不适合在所有可能的类型上自由变化。在这种情况下,可以像这样约束T的范围。

julia> abstract type Pointy{T<:Real} end

有了这样的声明,在T的位置使用任何Real的子类型都是可以接受的,但不能使用不是Real子类型的类型。

julia> Pointy{Float64}
Pointy{Float64}

julia> Pointy{Real}
Pointy{Real}

julia> Pointy{AbstractString}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got Type{AbstractString}

julia> Pointy{1}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got a value of type Int64

参数化复合类型的类型参数可以以相同的方式进行限制。

struct Point{T<:Real} <: Pointy{T}
    x::T
    y::T
end

为了给出所有这些参数化类型机制如何有用的现实世界示例,这里给出了 Julia 的Rational不可变类型的实际定义(除了为了简单起见,我们这里省略了构造函数),它表示整数的精确比率。

struct Rational{T<:Integer} <: Real
    num::T
    den::T
end

对整数值进行比率运算是有意义的,因此参数类型T被限制为Integer的子类型,而整数比率表示实数轴上的一个值,因此任何Rational都是Real抽象的一个实例。

元组类型

元组是函数参数的抽象 - 不包括函数本身。函数参数的突出方面是它们的顺序和类型。因此,元组类型类似于参数化不可变类型,其中每个参数都是一个字段的类型。例如,一个 2 元素元组类型类似于以下不可变类型。

struct Tuple2{A,B}
    a::A
    b::B
end

然而,有三个关键区别。

  • 元组类型可以有任意数量的参数。
  • 元组类型在其参数中是协变的:Tuple{Int}Tuple{Any} 的子类型。因此,Tuple{Any} 被认为是一个抽象类型,并且元组类型只有在其参数是具体类型时才是具体类型。
  • 元组没有字段名称;字段只能通过索引访问。

元组值使用括号和逗号来编写。当构造一个元组时,一个合适的元组类型将按需生成。

julia> typeof((1,"foo",2.5))
Tuple{Int64, String, Float64}

注意协变的含义。

julia> Tuple{Int,AbstractString} <: Tuple{Real,Any}
true

julia> Tuple{Int,AbstractString} <: Tuple{Real,Real}
false

julia> Tuple{Int,AbstractString} <: Tuple{Real,}
false

直观地说,这对应于函数参数的类型是函数签名(当签名匹配时)的子类型。

变长元组类型

元组类型的最后一个参数可以是特殊值Vararg,它表示任意数量的尾随元素。

julia> mytupletype = Tuple{AbstractString,Vararg{Int}}
Tuple{AbstractString, Vararg{Int64}}

julia> isa(("1",), mytupletype)
true

julia> isa(("1",1), mytupletype)
true

julia> isa(("1",1,2), mytupletype)
true

julia> isa(("1",1,2,3.0), mytupletype)
false

此外,Vararg{T} 对应于零个或多个类型为 T 的元素。变长元组类型用于表示变长方法接受的参数(参见变长函数)。

特殊值 Vararg{T,N}(当用作元组类型的最后一个参数时)对应于恰好 N 个类型为 T 的元素。NTuple{N,T}Tuple{Vararg{T,N}} 的便捷别名,即包含恰好 N 个类型为 T 的元素的元组类型。

命名元组类型

命名元组是NamedTuple类型的实例,它有两个参数:一个包含字段名称的符号元组,以及一个包含字段类型的元组类型。为了方便起见,NamedTuple 类型使用@NamedTuple宏进行打印,该宏提供了方便的类似于 struct 的语法,通过 key::Type 声明来声明这些类型,其中省略的 ::Type 对应于 ::Any

julia> typeof((a=1,b="hello")) # prints in macro form
@NamedTuple{a::Int64, b::String}

julia> NamedTuple{(:a, :b), Tuple{Int64, String}} # long form of the type
@NamedTuple{a::Int64, b::String}

@NamedTuple 宏的 begin ... end 形式允许声明跨越多行(类似于 struct 声明),但在其他方面是等效的。

julia> @NamedTuple begin
           a::Int
           b::String
       end
@NamedTuple{a::Int64, b::String}

NamedTuple 类型可以用作构造函数,接受一个元组参数。构造的 NamedTuple 类型可以是具体类型,其两个参数都已指定,也可以是仅指定字段名称的类型。

julia> @NamedTuple{a::Float32,b::String}((1, ""))
(a = 1.0f0, b = "")

julia> NamedTuple{(:a, :b)}((1, ""))
(a = 1, b = "")

如果指定了字段类型,则将转换参数。否则,将直接使用参数的类型。

参数化原始类型

原始类型也可以被参数化声明。例如,指针被表示为原始类型,在 Julia 中将这样声明。

# 32-bit system:
primitive type Ptr{T} 32 end

# 64-bit system:
primitive type Ptr{T} 64 end

与典型的参数化复合类型相比,这些声明的略微奇怪的特性在于类型参数T本身不在类型的定义中使用 - 它只是一个抽象标签,本质上定义了具有相同结构的整个类型族,它们只通过其类型参数来区分。因此,Ptr{Float64}Ptr{Int64} 是不同的类型,即使它们具有相同的表示。当然,所有特定的指针类型都是伞形Ptr类型的子类型。

julia> Ptr{Float64} <: Ptr
true

julia> Ptr{Int64} <: Ptr
true

UnionAll 类型

我们已经说过,像Ptr这样的参数化类型充当所有实例(Ptr{Int64} 等)的超类型。这是怎么工作的?Ptr 本身不能是普通数据类型,因为在不知道所引用数据的类型的情况下,该类型显然不能用于内存操作。答案是 Ptr(或其他参数化类型,如 Array)是一种称为UnionAll类型的不同类型的类型。这种类型表达了对于某个参数的所有值的类型的迭代并集

UnionAll 类型通常使用关键字 where 来编写。例如,Ptr 可以更准确地写成 Ptr{T} where T,表示所有类型为 Ptr{T} 的值,其中 T 的值为某个值。在这种情况下,参数 T 也常被称为“类型变量”,因为它就像一个在类型上取值的变量。每个 where 引入一个类型变量,所以这些表达式对于具有多个参数的类型是嵌套的,例如 Array{T,N} where N where T

类型应用语法 A{B,C} 要求 AUnionAll 类型,并首先将 B 替换为 A 中最外层的类型变量。结果应为另一个 UnionAll 类型,然后将 C 替换到其中。所以 A{B,C} 等同于 A{B}{C}。这解释了为什么可以部分实例化一个类型,例如 Array{Float64}:第一个参数值已固定,但第二个仍然在所有可能的值上取值。使用显式的 where 语法,可以固定参数的任何子集。例如,所有一维数组的类型可以写成 Array{T,1} where T

类型变量可以使用子类型关系进行限制。Array{T} where T<:Integer 指的是所有元素类型为某种Integer的数组。语法 Array{<:Integer}Array{T} where T<:Integer 的便捷简写。类型变量可以同时具有下限和上限。Array{T} where Int<:T<:Number 指的是所有能够包含 IntNumber数组(因为 T 必须至少与 Int 一样大)。语法 where T>:Int 也可以用来只指定类型变量的下限,而 Array{>:Int} 等同于 Array{T} where T>:Int

由于 where 表达式是嵌套的,所以类型变量边界可以引用外部类型变量。例如,Tuple{T,Array{S}} where S<:AbstractArray{T} where T<:Real 指的是 2 元组,其第一个元素是某个Real,而其第二个元素是任何类型数组的 Array,其元素类型包含第一个元组元素的类型。

where 关键字本身可以嵌套在更复杂的声明中。例如,考虑以下声明创建的两种类型。

julia> const T1 = Array{Array{T, 1} where T, 1}
Vector{Vector} (alias for Array{Array{T, 1} where T, 1})

julia> const T2 = Array{Array{T, 1}, 1} where T
Array{Vector{T}, 1} where T

类型 T1 定义了一个一维数组,它包含一维数组;每个内部数组包含相同类型的对象,但这种类型在不同内部数组之间可能不同。另一方面,类型 T2 定义了一个包含一维数组的一维数组,所有内部数组必须具有相同的类型。请注意,T2 是一个抽象类型,例如 Array{Array{Int,1},1} <: T2,而 T1 是一个具体类型。因此,T1 可以用一个无参数构造函数 a=T1() 创建,但 T2 无法用无参数构造函数创建。

有一种便捷的语法来命名此类类型,类似于函数定义语法的简写形式

Vector{T} = Array{T, 1}

这等效于 const Vector = Array{T,1} where T。写 Vector{Float64} 等效于写 Array{Float64,1},并且伞类型 Vector 的实例包括所有 Array 对象,其中第二个参数(数组维数)为 1,而不管元素类型是什么。在必须始终完全指定参数化类型的语言中,这并不特别有用,但在 Julia 中,这允许人们只为包括所有任何元素类型的一维密集数组的抽象类型写 Vector

单例类型

没有字段的不可变复合类型被称为单例。正式地,如果

  1. T 是一个不可变复合类型(即用 struct 定义),
  2. a isa T && b isa T 意味着 a === b

那么 T 是一个单例类型。[2] Base.issingletontype 可用于检查类型是否为单例类型。根据构造,抽象类型 不能是单例类型。

从定义中可以看出,此类类型只能有一个实例

julia> struct NoFields
       end

julia> NoFields() === NoFields()
true

julia> Base.issingletontype(NoFields)
true

=== 函数确认 NoFields 的构造实例实际上是同一个实例。

当上述条件成立时,参数化类型可以是单例类型。例如,

julia> struct NoFieldsParam{T}
       end

julia> Base.issingletontype(NoFieldsParam) # Can't be a singleton type ...
false

julia> NoFieldsParam{Int}() isa NoFieldsParam # ... because it has ...
true

julia> NoFieldsParam{Bool}() isa NoFieldsParam # ... multiple instances.
true

julia> Base.issingletontype(NoFieldsParam{Int}) # Parametrized, it is a singleton.
true

julia> NoFieldsParam{Int}() === NoFieldsParam{Int}()
true

函数类型

每个函数都有自己的类型,它是 Function 的子类型。

julia> foo41(x) = x + 1
foo41 (generic function with 1 method)

julia> typeof(foo41)
typeof(foo41) (singleton type of function foo41, subtype of Function)

注意 typeof(foo41) 如何打印成它本身。这仅仅是一个打印约定,因为它是一个可以像任何其他值一样使用的第一类对象

julia> T = typeof(foo41)
typeof(foo41) (singleton type of function foo41, subtype of Function)

julia> T <: Function
true

在顶层定义的函数类型是单例。必要时,您可以使用 === 对它们进行比较。

闭包 也拥有自己的类型,通常以以 #<number> 结尾的名称打印。在不同位置定义的函数的名称和类型是不同的,但不保证在不同会话中以相同方式打印。

julia> typeof(x -> x + 1)
var"#9#10"

闭包的类型不一定是单例。

julia> addy(y) = x -> x + y
addy (generic function with 1 method)

julia> typeof(addy(1)) === typeof(addy(2))
true

julia> addy(1) === addy(2)
false

julia> Base.issingletontype(typeof(addy(1)))
false

Type{T} 类型选择器

对于每个类型 TType{T} 是一个抽象参数化类型,其唯一的实例是对象 T。在我们讨论 参数化方法转换 之前,很难解释这种构造的用途,但简而言之,它允许人们将函数行为专门化到特定类型上,作为。这对于编写方法(特别是参数化方法)很有用,这些方法的行为取决于作为显式参数给出的类型,而不是由其参数之一的类型隐含的类型。

由于定义有点难以解析,让我们看一些例子

julia> isa(Float64, Type{Float64})
true

julia> isa(Real, Type{Float64})
false

julia> isa(Real, Type{Real})
true

julia> isa(Float64, Type{Real})
false

换句话说,isa(A, Type{B}) 为真当且仅当 AB 是同一个对象,并且该对象是一个类型。

特别地,由于参数化类型是不变的,我们有

julia> struct TypeParamExample{T}
           x::T
       end

julia> TypeParamExample isa Type{TypeParamExample}
true

julia> TypeParamExample{Int} isa Type{TypeParamExample}
false

julia> TypeParamExample{Int} isa Type{TypeParamExample{Int}}
true

没有参数,Type 只是一个抽象类型,其所有类型对象都是它的实例

julia> isa(Type{Float64}, Type)
true

julia> isa(Float64, Type)
true

julia> isa(Real, Type)
true

任何不是类型的对象都不是 Type 的实例

julia> isa(1, Type)
false

julia> isa("foo", Type)
false

虽然 Type 与其他任何抽象参数化类型一样是 Julia 类型层次结构的一部分,但它通常不会在方法签名之外使用,除了某些特殊情况。Type 的另一个重要用例是锐化字段类型,否则将被更不精确地捕获,例如在以下示例中作为 DataType,其中默认构造函数会导致代码中出现性能问题,这些代码依赖于精确的包装类型(类似于抽象类型参数)。

julia> struct WrapType{T}
       value::T
       end

julia> WrapType(Float64) # default constructor, note DataType
WrapType{DataType}(Float64)

julia> WrapType(::Type{T}) where T = WrapType{Type{T}}(T)
WrapType

julia> WrapType(Float64) # sharpened constructor, note more precise Type{Float64}
WrapType{Type{Float64}}(Float64)

类型别名

有时,为一个已经可以表达的类型引入一个新名称很方便。这可以使用简单的赋值语句来完成。例如,UInt 被别名为 UInt32UInt64,具体取决于系统上的指针大小

# 32-bit system:
julia> UInt
UInt32

# 64-bit system:
julia> UInt
UInt64

这是通过以下代码在 base/boot.jl 中完成的

if Int === Int64
    const UInt = UInt64
else
    const UInt = UInt32
end

当然,这取决于 Int 被别名为什么,但这被预定义为正确的类型,无论是 Int32 还是 Int64

(请注意,与 Int 不同,Float 不作为特定大小的 AbstractFloat 的类型别名存在。与整数寄存器不同,整数寄存器的 Int 大小反映了该机器上原生指针的大小,浮点寄存器的大小由 IEEE-754 标准指定。)

类型操作

由于 Julia 中的类型本身是对象,因此普通函数可以对它们进行操作。一些特别适用于处理或探索类型的函数已经介绍过了,例如 <: 运算符,它指示其左操作数是否为其右操作数的子类型。

isa 函数测试对象是否为给定类型,并返回真或假

julia> isa(1, Int)
true

julia> isa(1, AbstractFloat)
false

typeof 函数(在手册中的示例中已经使用过)返回其参数的类型。由于如上所述,类型是对象,它们也有类型,我们可以询问它们的类型是什么

julia> typeof(Rational{Int})
DataType

julia> typeof(Union{Real,String})
Union

如果我们重复这个过程会怎么样?类型类型的类型是什么?事实证明,类型都是复合值,因此都具有 DataType 类型

julia> typeof(DataType)
DataType

julia> typeof(Union)
DataType

DataType 拥有它自己的类型。

另一个适用于某些类型的操作是 supertype,它揭示了类型的超类型。只有声明的类型(DataType)具有明确的超类型

julia> supertype(Float64)
AbstractFloat

julia> supertype(Number)
Any

julia> supertype(AbstractString)
Any

julia> supertype(Any)
Any

如果将 supertype 应用于其他类型对象(或非类型对象),则会引发 MethodError

julia> supertype(Union{Float64,Int64})
ERROR: MethodError: no method matching supertype(::Type{Union{Float64, Int64}})
Closest candidates are:
[...]

自定义漂亮打印

通常,人们希望自定义类型实例的显示方式。这可以通过重载 show 函数来实现。例如,假设我们定义了一个类型来表示极坐标形式的复数

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

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

在这里,我们添加了一个自定义构造函数,以便它可以接受不同 Real 类型的参数,并将它们提升到一个公共类型(参见 构造函数转换和提升)。(当然,我们必须定义许多其他方法,以便它像一个 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 对象的显示进行更细粒度的控制是可能的。特别地,有时人们希望同时使用 verbose 的多行打印格式(用于在 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) 将调用 2 参数 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 Vector{Polar{Float64}}:
 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>")

然后,Polar 对象将自动使用 HTML 在支持 HTML 显示的环境中显示,但如果您想获得 HTML 输出,可以手动调用 show

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>

HTML 渲染器将显示为:Polar{Float64} 复数:3.0 e4.0 i

作为经验法则,单行 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时,我们可以在show方法中构建更短的表示形式,如果属性为false或不存在,则回退到长表示形式。

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

当传递的IO流是具有:compact属性设置的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 Matrix{Polar{Float64}}:
 3.0ℯ4.0im  4.0ℯ5.3im

请参阅IOContext文档,了解可用于调整打印的常用属性列表。

"值类型"

在Julia中,你无法根据(如truefalse)进行调度。但是,你可以在参数化类型上进行调度,并且Julia允许你将“普通位”值(类型、符号、整数、浮点数、元组等)作为类型参数。一个常见的例子是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的正确(和不正确)使用方式的更多信息,请阅读性能技巧中的更详细讨论

  • 1“小”由max_union_splitting配置定义,该配置当前默认为4。
  • 2一些流行的语言有单例类型,包括Haskell、Scala和Ruby。