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

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


致读者: 小时百科一直以来坚持所有内容免费无广告,这导致我们处于严重的亏损状态。 长此以往很可能会最终导致我们不得不选择大量广告以及内容付费等。 因此,我们请求广大读者热心打赏 ,使网站得以健康发展。 如果看到这条信息的每位读者能慷慨打赏 20 元,我们一周就能脱离亏损, 并在接下来的一年里向所有读者继续免费提供优质内容。 但遗憾的是只有不到 1% 的读者愿意捐款, 他们的付出帮助了 99% 的读者免费获取知识, 我们在此表示感谢。

                     

友情链接: 超理论坛 | ©小时科技 保留一切权利