Julia 的三种主要类型

                     

贡献者: xzllxls

   本文授权转载自郝林的 《Julia 编程基础》。原文链接:第 4 章 类型系统

4.4 三种主要类型

   如果以是否可以被实例化来划分的话,Julia 中的类型可以被分为两大类:抽象类型和具体类型。而具体类型还可以再细分。我们先从抽象类型说起。

   4.4.1 抽象类型

   抽象类型不能被实例化。正因为如此,抽象类型只能作为类型图中的节点,而不能作为终端。如果把类型图比喻成一棵树的话,那么抽象类型只能是这棵树的树干或枝条,而不可能是树上的叶子。即使是 Union{} 这个特殊的层类型,也无法成为叶子并与一般的值和变量扯上关系。

   有了抽象类型,我们就可以去构造自己的类型层次结构。比如,由 AbstractString 类型延伸出一些特殊的字符串类型,以便适配一些具体的情况。又比如,从直接继承自 Any 的某个类型开始,一步步构建和扩展我们自己的类型(子)图,从而描绘出一个面向某个领域的数据类型体系。

   另外,抽象类型让我们在编写数据结构和算法时不必指定具体的类型。在很多时候,具体类型就意味着严格的限制。这可能会让程序更加稳定,但也可能会使程序失去必要的灵活性。当有了抽象类型和类型层次结构,我们就可以根据自己的需要去权衡稳定性与灵活性之间的关系了。我们在前面编写的那个 sum1 函数就是一个很好的例子。

   下面,我们一起来看看怎样定义一个抽象类型。这种定义的一般语法是这样的:

abstract type <类型名> end

   或

abstract type <name> <: <超类型名> end

   注意,其中的成对尖括号及其包含的内容是需要我们替换掉的。

   这里有两种形式。它们都以多词关键字 abstract type 开头,并后接类型的名称。不同的是,第一种形式没有显式地指定它的超类型,而直接以 end 结尾了。在这种情况下,这个被定义的类型的超类型就是 Any。而第二种形式在类型名和 end 之间插入了操作符 <: 和超类型名。我们之前说过,这个操作符在这里表示 “A 直接继承自 B”,或者说 “A 是 B 的直接子类型”。其中 A 代表该操作符左侧的类型,而 B 则代表操作符右侧的类型。

   我们在前面展示过的抽象类型 Signed 的定义使用的就是第二种形式。Signed 类型直接继承了 Integer 类型:

abstract type Signed <: Integer end

   如果我们把焦点扩散开来,就会发现这只是数值类型层次结构中的一小段。下面是数值类型子图的示意。

图
图 1:数值类型的层次结构

   图中由圆角矩形包裹的类型都是抽象类型,而由直角矩形包裹的类型都是具体类型。再次强调,只有具体类型(如 Float32BoolInt64 等)才可能被实例化,而抽象类型(如 RealIntegerSigned 等)一定不能被实例化。不过一个值却可以是某个抽象类型的实例。比如,10 这个值就是 SignedIntegerReal 类型的实例。原因是这几个抽象类型都是具体类型 Int64 的超类型。

   我们可以说,抽象类型就是 Julia 类型图中的支柱。没有它们,整个类型层次结构就不复存在。其根本原因是,具体类型不能像抽象类型那样被继承。也就是说,具体类型只能是类型图中的终端或树上的叶子。

   顺便提一句,我们可以使用 isabstracttype 函数来判断一个类型是否属于抽象类型,还可以用 isconcretetype 函数判断一个类型是否属于具体类型。显然,对于同一个类型,这两个函数总会给出相反的结果。

   4.4.2 原语类型

   原语类型是一种具体类型。它的结构相当简单,仅仅是一个扁平的比特(bit)序列。我们在前面提到的数值类型中有很多都属于原语类型,具体如下:

   除此之外,Char 类型也属于原语类型。所以,Julia 预定义的原语类型一共有 15 个。

   原语类型的定义方式与抽象类型的很相似。只不过它以多词关键字 primitive type 开头,而不是以 abstract type 开头。我们之前提到过 Int64 类型的定义,它是这样的:

primitive type Int64 <: Signed 64 end

   注意,在这个定义的超类型名 Signed 和关键字 end 之间有一个数字 64。这个数字代表的就是该类型的比特序列的长度。或者说,它代表的是该类型的值需要占据的存储空间的大小,单位是比特。为了与值的显示长度区分开,我们通常把这个数字称为类型的宽度。实际上,这里的宽度已经体现在 Int64 类型的名称中了。

   由此,我们可以得知,Int8UInt8 的宽度是相同的,Int16UInt16 的宽度也是相同的。以此类推。虽然宽度相同,但由于它们的名称不同,所以还是不同的类型。更何况,它们的含义也是不一样的。

   Bool 类型和 Char 类型的宽度都没有体现在名称上。但通过其含义,我们可以倒推出它们的宽度。Bool 是用于存储布尔值的类型。布尔值总共才有两个,即:truefalse。因此按理说使用一个比特来存储就足够了。但由于计算机内存的最小寻址单位是字节(即 8 个比特),更小的存储空间既不利于内存寻址也无益于性能优化,所以布尔值最少也要用 8 个比特来存储。至于 Char,它的值代表单个 Unicode 字符。由于一个 Unicode 字符最多也只会占用 4 个字节,所以把 Char 类型的宽度设定为 32 个比特就足够了。

   顺便说一下,如果我们想在程序中获得一个类型的宽度,那么可以使用 sizeof 函数,就像这样:

julia> sizeof(Bool)
1

julia> sizeof(Char)
4

julia>

   该函数会返回一个 Int64 类型的结果值。但要注意,从这里得到的类型宽度的单位是字节,而不是比特。

   与很多其他的编程语言都不同,Julia 允许我们定义自己的原语类型。如此一来,我们就可以在一个固定大小的空间中存放自己的比特级数据了。例如,我们可以定义这样一个原语类型:

primitive type MyUInt64 <: Unsigned 64 end

   原语类型 MyUInt64 直接继承自 Unsigned 类型,所以它的值可以被用来存储无符号整数。又因为它的宽度是 64,所以其值需要占据的存储空间是 8 个字节。

   不过,要想让这个类型真正实用,我们还需要编写更多的代码。对于这些代码,你目前阅读起来可能会有些难。所以我把它们存放到了相对路径为 src/ch04/primitive/main.jl 的源码文件中。如果你现在就对此感兴趣,可以打开这个文件看一看。其中的注释会帮助你更好地理解代码。

   4.4.3 复合类型

   复合类型也是一种具体类型。它的结构可以很简单,也可以相对复杂。这完全取决于我们的具体定义。我们可以在定义一个复合类型的时候为它添加若干个有名称、有类型的字段,以满足我们对数据结构的要求。这里的字段也是由一个标识符代表的。它与变量很类似,只不过它只能存在于复合类型的内部。对于一个复合类型的字段,我们只能通过其实例才能访问到。

   在 Julia 中,复合类型是唯一一种可以由我们完全掌控的类型,同时也是最常用的一种类型。很多编程语言也都有类似的类型。有的语言把它的实例称为对象,而有的语言把它的实例称为结构体。在这里,为了体现这种类型的特点,我们把 Julia 中的复合类型的实例也称为结构体(但它们也都是对象)。在一些编程语言中(比如 Java 和 Golang),每个复合类型都可以关联一些方法。而在 Julia 中,复合类型是不可以关联任何方法的。对此,我们已经在前面有所提及。这种设计可以让程序变得更加灵活。

   4.4.3.1 定义

   复合类型的定义需要由关键字 struct 开头,并且再加上一个类型的名称作为第一行。与其他的很多定义一样,复合类型的定义也需要以独占一行的 end 作为结尾。下面是一个简单的例子:

julia> struct User
           name::String
           reg_year::UInt16
           extra
       end

julia>

   我定义了一个名为 User 的类型。它包含了 3 个字段,分别是 namereg_yearextra。每个字段的定义都独占一行。其中,name 代表姓名,是 String 类型的,而 reg_year 代表注册年份,是 UInt16 类型的。至于 extra,我打算用它来存储一些额外的信息,具体是什么我现在还不能确定。所以我没有为这个字段添加类型标注。这样的话,这个字段的类型将会是 Any 类型。也就是说,我可以赋给它任何类型的值。

   4.4.3.2 实例化

   复合类型的实例化需要用到构造函数(constructor)。不过,我们并不用自己手动编写,这与原语类型一样。一个使用示例如下:

julia> u1 = User("Robert", 2020, "something")
User("Robert", 0x07d0, "something")

julia>

   可以看到,构造函数是与对应的类型同名的,并且我是按照 User 类型中字段定义的顺序来为构造函数传入参数值的。

   Julia 会为每一个复合类型都自动生成两个构造函数。它们也被称为默认的构造函数。第一个构造函数的所有参数都一定是 Any 类型的,我们称之为泛化的构造函数。这种构造函数可以让我们用任何类型的参数值去尝试构造 User 类型的实例。不过,这不一定会成功。原因是,当参数类型不匹配时,Julia 会尝试使用 convert 函数把参数值转换成对应字段的那个类型的值。如果这种转换失败,那么对复合类型的实例化也将失败。例如:

julia> u2 = User("Robert", 2020.1, "something")
ERROR: InexactError: UInt16(2020.1)
# 省略了一些回显的内容。

julia>

   在复合类型的第二个默认的构造函数中,每一个参数的类型都一定会与对应字段的类型一致。这个函数中不会有任何的参数类型转换。如果像下面这样调用,就一定会用到该函数:

julia> u2 = User("Robert", UInt16(2020), "something")
User("Robert", 0x07d0, "something")

julia>

   真的是这样吗?空口无凭,我们怎么验证这种规则呢?这就需要用到一个名叫 @which 的宏了。至于什么是宏,你现在可以简单地把它理解为一种特殊的函数。它可以像操纵数据那样去操纵代码。我们在使用宏的时候需要在其名称之前插入一个专用的符号 @。拿 @which 来说,这个宏的本名其实是 which

   怎样使用 @which 宏呢?很简单,我们可以把前面示例中的等号右边的代码作为参数值传给它,就像这样:

julia> @which User("Robert", UInt16(2020), "something")
User(name::String, reg_year::UInt16, extra) in Main at REPL[1]:2

julia>

   可以看到,我只用空格分隔了宏名称和它的参数值。我们当然也可以像调用普通函数那样用圆括号包裹住参数值。但由于这里传入的是一段代码,所以为了清晰我选用了第一种方式。

   我们现在来看 REPL 环境回显的内容。显然,User(name::String, reg_year::UInt16, extra) 正是我们在前面描述的第二个默认的构造函数。它的每个参数的类型都与对应字段的类型相一致。前面那句话由此得到了证实。

   相对应的,当我们给 u1 赋值时,等号右边的代码实际上调用的是第一个默认的构造函数。证据如下:

julia> @which User("Robert", 2020, "something")
User(name, reg_year, extra) in Main at REPL[1]:2

julia>

   关于复合类型的构造函数,我们就先介绍到这里。在后面讲函数和方法的时候,我们还会说到它。另外,在本教程的最后一部分,我们还会专门讨论宏和元编程。

   4.4.3.3 字段的访问

   我们再来说一下字段的访问方式。我在刚开始讲复合类型的时候说过:只有通过其实例才能访问到其中的字段。那具体应该怎样做呢?

   其实很简单,通过一个英文点号就可以做到。这个英文点号在这里被称为选择符。我们可以用它来选择复合类型实例中的某个字段。示例如下:

julia> u2.name
"Robert"

julia> Int16(u2.reg_year)
2020

julia>

   这里的代码 u2.name 也常被称为选择表达式。注意,虽然我们可以像上面那样访问到 u2 的字段,但是却不能用这种方式修改它们的值:

julia> u2.name = "Eric"
ERROR: setfield! immutable struct of type User cannot be changed
# 省略了一些回显的内容。

julia>

   依据错误信息,我们得知这个 User 类型的结构体竟然是不可变的!没错,我们定义的所有复合类型的实例都是不可变的。你会觉得这很不合常理吗?虽然不同于其他的一些编程语言的做法,但这样做是非常有好处的。具体如下:

  1. 提高了程序的安全性。我们完全不用担心因操作失误而造成的数据更改。尤其是在与其他的代码共享数据的时候。在其他的编程语言中,我们往往需要通过控制访问权限来做到这一点。
  2. 提高了程序的性能。Julia 对不可变的实例会有很多优化的手段。比如,当它们在函数间传递的时候并不会造成多余的内存分配。因为对于不可变的值,通常只存一份就足够了。
  3. 可以减少我们的心智负担。这很好理解,我们肯定不用担心一个不可变的值会在某个时刻突然发生变化。而且,我们可以完全确定,一个复合类型的所有实例都是不可变的。这可以省去不少用于检查的代码。

   不过要注意,虽然复合类型的实例本身是不可变的,但如果它包含了某种可变类型(比如数组)的字段,那么它还是可以被改变的。复合类型的实例本身只能保证,可变类型的字段始终只会引用同一个可变值。关于这一点,我们需要在定义复合类型的时候就考虑清楚。

   4.4.3.4 可变的复合类型

   Julia 当然允许我们定义可变的复合类型。定义的方式与通常的复合类型定义非常相似。只需要把单词关键字 struct 替换为多词关键字 mutable struct 就可以了。显然,其中的 “mutable” 是唯一的关键。

   例如,我们可以定义一个可变的复合类型 Person,然后构造一个此类型的实例,并随意地修改其中的字段值。代码如下:

julia> mutable struct Person
           name::String
           age::UInt8
           extra
       end

julia> p1 = Person("Robert", 30, "something")
Person("Robert", 0x1e, "something")

julia> p1.age = 37
37

julia> Int8(p1.age)
37

julia>

   此外,可变的复合类型与不可变的复合类型还有一个很重要的不同,那就是:

   操作符 === 用于比较两个值是否完全相同。对于可变的值,这个操作符会比较它们在内存中的存储地址。对于不可变的值,该操作符会逐个比特地比较它们。此类操作的结果总会是一个 Bool 类型的值。

   因此,p1p2 不相同的原因是,它们是被分别构造的,它们的值被存储在了不同的内存地址上。而 u1u2 显然是同一个值,因为它们的内容完全一样。

   到这里,我们已经介绍了 Julia 中 3 种主要的类型,即:抽象类型、原语类型和复合类型。它们可以构建起一幅拥有层次结构的类型图。抽象类型不能被实例化,但可以被继承。它们是类型图的支柱。没有它们,整个类型层次结构就不复存在。这是由于具体类型虽然可以被实例化,但却不能被继承。

   原语类型和复合类型都属于具体类型。它们都可以由我们自己定义。但用得最多的还是复合类型。注意,复合类型在默认情况下是不可变的。我们只有使用多词关键字 mutable struct 进行定义,才能让复合类型成为可变类型。

   最后,再提示一点,Julia 中的类型继承只支持单继承。也就是说,一个类型的超类型只可能有一个。


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

                     

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