Julia 容器:元组

                     

贡献者: xzllxls

   本文授权转载自郝林的 《Julia 编程基础》。原文链接:第 7 章 参数化类型

7.3 容器:元组

   容器在 Julia 中也被称为集合。但由于集合一词与有着广泛应用的数据结构 Set 的中文译名重复,因而容易导致歧义和误解,所以我们在本书中会统一称之为容器,而集合这个词将特指像 Set 那样的容器。

   容器的类型通常都是参数化类型。在很多编程语言中,这也是泛型最经典的运用场景。Julia 中的容器类型就像一种模具,用来制造含有若干格子的置物架。模具不同,制造出来的置物架也不同,并且每一个模具都只能制造一类置物架。每一类置物架都有自己独特的内部结构和存取物品的方式(或者说操作规则),而且同一类置物架在这些方面一定是相同的。

图
图 1:图 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}。由于它们的类型参数值 RealInteger 之间存在继承关系,所以 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 个用于搜索的函数,即:findfirstfindlastfindprevfindnext,都可以被用来搜索元组中的元素值。只不过,对于元组,我们传给它们的第一个参数值必须是一个用来做条件判断的函数。也就是说,这个函数的结果值必须是 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 的抽象类型,同时也是一个参数化类型。它拥有两个类型参数,其占位符分别是 TN。因此,类型 Vararg{T,N} 表达的就是 NT 类型的参数。若放到元组类型的上下文中,它则表示该元组类型的所有实例都要有 NT 类型的元素值。

   我们可以用一个确切的整数替换掉这里的 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 个同类型的参数,所以它的实际应用场景还是相对有限的。要知道,元组类型中的每一个类型参数值都可以是任意的类型。因此,我们应该在考虑使用它的时候认真地权衡一下利弊,不要滥用。

   无论是普通的元组还是有名的元组,又或是我们刚刚讲的可变参数的元组,都是非常灵活的容器。原则上,它们都可以用于保存任意数量、任意类型的值。而且,由于它们都是不可修改的,所以我们既不用担心它们保存的值被篡改,也不用担心并发访问的问题。这也是不可变对象的最大优势,可以显著地减少对象创建者和使用者的心智负担。但要注意,元组中的元素值不一定都是不可变的,所以一个元组可能无法做到完全的不可变。

                     

© 小时科技 保留一切权利