7.3 容器:元组
容器在 Julia 中也被称为集合。但由于集合一词与有着广泛应用的数据结构 Set 的中文译名重复,因而容易导致歧义和误解,所以我们在本书中会统一称之为容器,而集合这个词将特指像 Set 那样的容器。
容器的类型通常都是参数化类型。在很多编程语言中,这也是泛型最经典的运用场景。Julia 中的容器类型就像一种模具,用来制造含有若干格子的置物架。模具不同,制造出来的置物架也不同,并且每一个模具都只能制造一类置物架。每一类置物架都有自己独特的内部结构和存取物品的方式(或者说操作规则),而且同一类置物架在这些方面一定是相同的。
图 7-2 容器类型的示意
通过实例化容器类型构造出来的值就是容器,而存放在容器中的值则被统称为元素值。有的容器类型允许同一个容器接纳不同类型的元素值,但有的容器类型却只让一个容器接受相同类型的元素值。有的容器可以容纳的元素的数量是固定的,而有的容器却可以自行扩展甚至收缩。
我们下面就来一起讨论 Julia 中最简单且常用的容器——元组。
7.3.1 元组概述
元组(tuple)是一种很简单的容器。它可以包含若干个任意类型的元素值。我们在前面其实已经见过这类值很多次了。看一个例子你就应该能明白了:
julia> Drawer{Necklace} <: Drawer{Jewelry}, Drawer{Ring} <: Drawer{Jewelry}
(false, false)
julia> typeof(ans)
Tuple{Bool,Bool}
julia>
我在这里输入的第一行代码是我们之前展示过的一个例子。这行代码包含了两个表达式,并以英文逗号分隔。REPL 环境回显给我们的求值结果是(false, false)
。这个结果值实际上就是一个元组。第二行代码的求值结果Tuple{Bool,Bool}
就是它的类型。
当我们像上面这样让 REPL 环境同时对多个表达式求值时,该环境就会把求值结果都塞入到一个元组值中并回显给我们。这种元组值总是由圆括号包裹,并以英文逗号分隔其中的多个元素值。
此外,我们还可以看到,元组类型Tuple{Bool,Bool}
中有两个参数值。它们依次反映了其实例中的每一个元素值的类型。不过由于(false, false)
中的两个元素值类型相同,所以在视觉上没有显现出来。但我们要记住,元组类型不但会确定其所有元素的类型,还会体现元素的顺序。
7.3.2 普通的元组
普通元组的表示形式与我们调用函数时传入参数值的方式很相似。下面来看一个之前展示过的示例:
julia> function sum1(a::Real, b::Real)
a + b
end
sum1 (generic function with 1 method)
julia> sum1(1.2, 5)
6.2
julia>
函数sum1
拥有一个参数列表。这个参数列表由圆括号包裹,其中定义了两个参数。在调用sum1
函数的时候,我们需要传给它两个符合定义的参数值。在它下面的调用表达式中,我给出的参数值是用(1.2, 5)
来呈现的。这其实就是一种元组。
元组类型与一般的参数化类型有着一个很明显的不同——它具有协变的特性。我们在前面解释过什么是协变。举个例子,有两个确定的元组类型Tuple{Real}
和Tuple{Integer}
。由于它们的类型参数值Real
和Integer
之间存在继承关系,所以Tuple{Real}
和Tuple{Integer}
之间也有着相同的继承关系。验证的代码如下:
julia> Tuple{Real} >: Tuple{Integer}
true
julia> Tuple{Real, Char} >: Tuple{Integer, Char}
true
julia> Tuple{Real, AbstractChar} >: Tuple{Integer, Char}
true
julia> Tuple{Real, Char} >: Tuple{Integer, AbstractChar}
false
julia> Tuple{Real, AbstractChar} >: Tuple{Integer, String}
false
julia> Tuple{Real, Char} >: Tuple{Integer}
false
julia> Tuple{Real} >: Tuple{Integer, Char}
false
julia>
可以看到,仅当两个元组类型拥有相同数量的参数值,并且所有对应位置上的参数值都存在方向一致的继承关系,这种继承关系才会在这两个元组类型上延续。
在值的操作方面,元组值与字符串值有着很多相同之处。比如,我们可以使用索引号访问到一个元组值中的某个元素值。我们现在有这样一个元组值:
julia> tuple1 = (125, 3.1, '中', "编程")
(125, 3.1, '中', "编程")
julia> typeof(tuple1)
Tuple{Int64,Float64,Char,String}
julia>
那么,索引表达式tuple1[1]
的求值结果就是Int64
类型的125
,而表达式tuple1[2]
的求值结果则是Float64
类型的3.1
,以此类推。注意,这里的索引号依然是从1
开始的。与字符串值类似,我们不能通过索引表达式替换元组中的任何元素值。因为 Julia 中的元组也都是不可变的!
我们还可以用范围索引表达式截取元组中的某一段:
julia> tuple1[1:3]
(125, 3.1, '中')
julia> typeof(ans)
Tuple{Int64,Float64,Char}
julia>
这种表达式的求值结果也会是一个元组,而且那些被截取到的元素值的类型也都不会改变。
我们之前讲过的那 4 个用于搜索的函数,即:findfirst
、findlast
、findprev
和findnext
,都可以被用来搜索元组中的元素值。只不过,对于元组,我们传给它们的第一个参数值必须是一个用来做条件判断的函数。也就是说,这个函数的结果值必须是Bool
类型的。下面是一些示例:
julia> findfirst(isequal('中'), tuple1)
3
julia> findlast(isequal('中'), tuple1)
3
julia> findprev(isequal('中'), tuple1, 4)
3
julia> findnext(isequal('中'), tuple1, 2)
3
julia> findnext(isequal('中'), tuple1, 4) == nothing
true
julia>
另外,比较操作符也可以直接用于元组之间的比较。在这种情况下,Julia 会依次比较两个元组中的每一个元素值,直到足以做出判断为止。
对于元组的拼接,操作符+
和*
都是无能为力的。这时我们可以使用tuple
函数和符号...
。它们的用法如下:
julia> tuple(tuple1..., tuple1...)
(125, 3.1, '中', "编程", 125, 3.1, '中', "编程")
julia>
我们在前面说过,符号...
的作用就是,把紧挨在它左边的那个值中的所有元素值都平铺开来,并让它们都成为独立的参数值。所以,上面的这个表达式与如下的表达式等价:
julia> tuple(tuple1[1], tuple1[2], tuple1[3], tuple1[4], tuple1[1], tuple1[2], tuple1[3], tuple1[4])
(125, 3.1, '中', "编程", 125, 3.1, '中', "编程")
julia>
除此之外,我们还可以仅用圆括号来拼接元组:
julia> (tuple1..., tuple1...)
(125, 3.1, '中', "编程", 125, 3.1, '中', "编程")
julia>
元组的拼接总会产生新的元组。但这样的元组不一定是全新的,因为其中的元素值不一定都是位类型的值。还记得吗?位类型的值不会包含任何对其他值的引用。更进一步地说,如果原有元组中的元素值引用了其他值,那么在由拼接产生的新元组中,对应的元素值仍然会引用同一个值。例如,我们有如下的两个元组:
julia> tuple2 = ([1,2,3], [4,5,6,7])
([1, 2, 3], [4, 5, 6, 7])
julia> tuple2_2 = (tuple2..., tuple2...)
([1, 2, 3], [4, 5, 6, 7], [1, 2, 3], [4, 5, 6, 7])
julia>
元组tuple2
包含了两个元素值。这两个元素值都是数组(由方括号包裹,并以英文逗号分隔其包含的多个元素值)。而元组tuple2_2
则是两个tuple2
的拼接。
对于一个确定的元组类型来说,只要它的参数值都属于位类型,那么这个元组类型就一定属于位类型,如:
julia> isbitstype(Tuple{Int64,Float64,Char})
true
julia> isbitstype(Tuple{Float64,String})
false
julia> isbitstype(Tuple{Real})
false
julia>
但数组类型与之不同,它的任何确定类型都不属于位类型。并且,它的值都是可变的。所以,如果我们改变了元组tuple2
包含的某个数组中的元素值,那么这种改变就会立即反映到元组tuple2_2
中。例如:
julia> tuple2[2][1] = tuple2[2][1] * 10
40
julia> tuple2
([1, 2, 3], [40, 5, 6, 7])
julia> tuple2_2
([1, 2, 3], [40, 5, 6, 7], [1, 2, 3], [40, 5, 6, 7])
julia>
我用链式的索引表达式tuple2[2][1]
改变了tuple2
所包含的数组[4, 5, 6, 7]
中的第 1 个元素值。可以看到,tuple2_2
中的两个对应的元素值都有了同样的改变。
7.3.3 有名的元组
有名元组中的“有名”并不是说元组有名字,而是说其中的每一个元素值都拥有自己的名字。例如:
julia> named_tuple1 = (name="Robert", reg_year=2020, extra="something")
(name = "Robert", reg_year = 2020, extra = "something")
julia>
可以看到,有名元组同样由圆括号包裹,也同样以英文逗号分隔其中的多个元素值。但与普通的元组不同的是,在有名元组中的每一个元素值的左侧,都有一个代表了元素名称的标识符和一个等号。这种表示形式与对变量的赋值极其相似。而且这两者的含义也基本相同,即:把一个值与一个标识符绑定在一起。但是,它们的作用域是不同的。虽然我们也可以通过其名称来访问有名元组中的元素值,但这些名称仅在其所属元组的上下文中可用。例如:
julia> named_tuple1[:reg_year]
2020
julia> typeof(:reg_year)
Symbol
julia> reg_year
ERROR: UndefVarError: reg_year not defined
julia>
表达式named_tuple1[:reg_year]
是普通的索引表达式的一种变体。在它的中括号里的不是一个索引号,而是一个Symbol
类型的值。Symbol
的值必须要以英文冒号:
开头,并后跟一个符合变量命名规则的标识符。
Symbol
本来是元编程中的一个概念,它的值可用于表示对变量的访问。在有名元组的上下文中,其值的含义就是指代某个元素值的名称,而在:
后面的就是那个名称。又由于这里的Symbol
类型值与索引号的作用是相同的,因此前述表达式的求值结果就是与reg_year
对应的那个元素值。
有名元组的类型是NamedTuple
。该类型也是一个参数化类型,但它只有固定个数的类型参数。元组named_tuple1
的类型如下:
julia> typeof(named_tuple1)
NamedTuple{(:name, :reg_year, :extra),Tuple{String,Int64,String}}
julia>
可以看到,这个类型的第一个参数值是一个普通的元组。在这个元组里,包含了一些Symbol
类型的值,这些值与named_tuple1
中的元素名称逐一对应。该类型的第二个参数值是一个确定的元组类型。它精确地体现了named_tuple1
中的各个元素值的类型。或者说,如果named_tuple1
中只有元素值而没有元素名,那么它的类型就会如上述示例中的第二个类型参数值。总之,一个有名元组的类型几乎确定了其实例的方方面面,除了元素的值。
还记得吗?对于确定的参数化类型,Julia 会为它自动生成一个全名(即携带花括号的名称)相同的构造函数。这就意味着,NamedTuple
类型的构造函数名往往很长,如NamedTuple{(:name, :reg_year, :extra),Tuple{String,Int64,String}}
。幸好,Julia 允许我们在这里走一个小捷径,不必写出那么长的构造函数名,就像这样:
julia> NamedTuple{(:name, :reg_year, :extra)}(("Robert", 2020, "something"))
(name = "Robert", reg_year = 2020, extra = "something")
julia>
我在这里使用的构造函数名为NamedTuple{(:name, :reg_year, :extra)}
。虽然也不算短,但是比前面的那个全名要好多了。这个函数名只体现了有名元组中的各个元素值的名称,而没有体现它们的类型。不过不用担心,Julia 会根据我们给予的参数值推断出元素值的类型。不知道你注意到没有,我们传给上述构造函数的参数值就是一个普通的元组。
由此可见,有名元组实际上是对普通元组的一种再封装。这从有名元组的类型字面量上也可以看出端倪。这种再封装让元组中的每一个元素值都有了自己的名字,就像我们传给函数的参数值都有对应的参数名那样。另外,顺便说一句,有名元组的类型是非转化的。
7.3.4 可变参数的元组
可变参数(vararg)的意思是参数的数量可多可少,并不固定。单词 vararg 有时也被写成 varargs,是一个出自计算机编程领域的合成词,由 variable 和 argument 合成而来。其含义是数量可变的参数,所以它在中文里常常被简称为可变参数。
由此延伸,可变参数的元组就是指元素数量并不固定的元组。这种元组其实就是普通的元组,只不过在其类型中会有一个特殊的类型参数值,使它的所有实例都可以接纳更多的元素值。
这种元组的类型可以是这样的:
julia> Tuple{Vararg{String}}
Tuple{Vararg{String,N} where N}
julia>
其中的Vararg{String}
就是那个特殊的类型参数值。它是Vararg{String,N} where N
的简写形式。而Vararg
是一个直接继承自Any
的抽象类型,同时也是一个参数化类型。它拥有两个类型参数,其占位符分别是T
和N
。因此,类型Vararg{T,N}
表达的就是N
个T
类型的参数。若放到元组类型的上下文中,它则表示该元组类型的所有实例都要有N
个T
类型的元素值。
我们可以用一个确切的整数替换掉这里的N
,也可以放任不管。如果放任不管,那么就表示参数的数量是任意的。比如Vararg{String}
就表示可以有任意个String
类型的参数。所以,元组类型Tuple{Vararg{String}}
代表的就是那些包含了任意个字符串值的元组。验证的代码如下:
julia> isa((), Tuple{Vararg{String}})
true
julia> isa(("Julia",), Tuple{Vararg{String}})
true
julia> isa(("Julia", "Python", "Golang"), Tuple{Vararg{String}})
true
julia>
可以看到,不论这些元组中的字符串值有多少个,它们都是Tuple{Vararg{String}}
类型的实例。请注意,上述示例中的()
表示的是空元组,也就是不包含任何元素值的元组。而("Julia",)
表示的则是只包含了一个元素值(即"Julia"
)的元组。为了避免歧义,我们若要表示只有一个元素值的元组,就需要在该元素值的后面添加一个英文逗号。否则,Julia 就可能会把圆括号识别为包裹高优先级操作的符号,从而将其忽略掉。示例如下:
julia> ("Julia",)
("Julia",)
julia> typeof(ans)
Tuple{String}
julia> ("Julia")
"Julia"
julia> typeof(ans)
String
julia>
回到可变参数的话题。如果我们把Vararg{T,N}
中的N
也确定下来,比如Vararg{String,2}
,那么它表达的参数数量就是固定的了。这种类型字面量肯定不能用于表示可变参数的元组。不过它们仍然是很有用处的。请思考一下,如果我们要写出一个类型字面量,它需要代表包含了 10 个整数值的元组,那么应该怎样写呢?
实际上,我们不必写出包含 10 个类型参数值的元组类型,只需要像下面这样利用Vararg
类型来编写就可以了:
julia> Tuple{Vararg{Int,10}}
NTuple{10,Int64}
julia> Tuple{Vararg{Int,10}} == Tuple{Int,Int,Int,Int,Int,Int,Int,Int,Int,Int}
true
julia> isa((1,2,3,4,5,6,7,8,9,0), Tuple{Vararg{Int,10}})
true
julia>
示例中的Tuple{Vararg{Int,10}}
就是答案。它等价于长长的拥有 10 个Int
的元组类型。另外,NTuple{10,Int64}
是Tuple{Vararg{Int,10}}
类型的别名。更宽泛地讲,NTuple{N,T}
总是Tuple{Vararg{T,N}}
类型的别名。这显然可以让我们少写一些代码。
最后,关于在元组类型中使用Vararg
,我们还有两点需要注意。第一,在编写元组类型时,Vararg
类型的字面量只能作为它的最后一个类型参数值,否则 Julia 就会直接报错。第二,虽然Vararg
类型在一些时候可以为我们提供便利,但由于它只能表示N
个同类型的参数,所以它的实际应用场景还是相对有限的。要知道,元组类型中的每一个类型参数值都可以是任意的类型。因此,我们应该在考虑使用它的时候认真地权衡一下利弊,不要滥用。
无论是普通的元组还是有名的元组,又或是我们刚刚讲的可变参数的元组,都是非常灵活的容器。原则上,它们都可以用于保存任意数量、任意类型的值。而且,由于它们都是不可修改的,所以我们既不用担心它们保存的值被篡改,也不用担心并发访问的问题。这也是不可变对象的最大优势,可以显著地减少对象创建者和使用者的心智负担。但要注意,元组中的元素值不一定都是不可变的,所以一个元组可能无法做到完全的不可变。