5.6 数值类型的提升

Julia 中有一个辅助系统,叫做类型提升系统。它可以将数学运算符操作的多个值统一地转换为某个公共类型的值,以便运算的顺利进行。我们下面就简要地说明一下这个辅助系统的应用和作用。关于公共类型的解释也会在其中。

在 Julia 中,数学运算符其实也是用函数实现的。就拿用于二元加的运算符+来说,它的一个衍生方法的定义是这样的:

  1. +(x::Float64, y::Float64) = add_float(x, y)

这个定义向我们揭示了两个细节。第一个细节就是我刚刚说的,数学运算符是由函数实现的。不仅如此,针对每一类可操作的数值,Julia 都定义了相应的衍生方法。第二个细节是,数学运算符操作的多个值必须是同一个类型的。你可能会有疑问,那为什么我们编写的像1 + 2.0这样的运算依然可以顺利进行呢?实际上,这恰恰得益于 Julia 的类型提升系统。我们来看该系统中的一个定义:

  1. +(x::Number, y::Number) = +(promote(x,y)...)

这个衍生方法的两个参数的类型都是Number。这就意味着,只要参与二元加的操作数都是数值且它们的类型不同,该运算就会被分派到这个方法上。如果两个数值的类型相同,那么二元加运算就会被分派到像前一个定义那样的方法上。

请注意,这个衍生方法的定义中有一个对promote函数的调用。这个函数其实就代表了类型提升系统的核心算法。我们可以在 REPL 环境中输入表达式promote(1, 2.0)并回车。其结果如下:

  1. julia> promote(1, 2.0)
  2. (1.0, 2.0)
  3. julia> typeof(ans)
  4. Tuple{Float64,Float64}
  5. julia>

我们都知道,在 64 位的计算机系统中,字面量1的类型一定是Int64,而字面量2.0的类型肯定是Float64。由此,在那个调用promote函数后得到的元组中,包含了转换自参数值1的、Float64类型的数值1.0,以及保持原样的、Float64类型的数值2.0。这正是类型提升系统所起到的作用。它一般会先找到能够无损地表示输入值的某个公共类型,然后把这些值都转换为此公共类型的值(通常通过调用convert函数实现),最后输出这些类型统一的值。

在一般情况下,如果参数值列表中只包含了整数和有理数,那么promote函数就会把这些参数值都转换为有理数。倘若参数值列表中存在浮点数(但不存在复数),那么这个函数就会把这些参数值都转换为适当类型的浮点数。一旦参数值列表中有复数,那该函数就一定会返回适当类型的复数的元组。另一方面,如果这些参数值的类型只是在宽度上所有不同(如Int64Int8Float16Float32等等),那么promote函数就会把它们都转换为宽度较大的那个类型的值。

我们倒是不用死记硬背这些规则。因为有一个名叫promote_type的函数,它可以接受若干个类型字面量并返回它们的公共类型。例如:

  1. julia> promote_type(Int64, Float64)
  2. Float64
  3. julia> promote_type(Int64, Int8)
  4. Int64
  5. julia> promote_type(Float16, Float32)
  6. Float32
  7. julia>

请注意,我们一直在说的是多个类型的公共类型,而不是多个类型的共同超类型。这两者之间并没有任何关联。如果你确实想得到两个类型的共同超类型,那么可以调用typejoin函数。例如,调用表达式typejoin(Int, Float64)的求值结果会是Real

好了,不论细节如何,经过前文所述的处理之后,这些数值就可以交给普通的运算符实现方法进行操作了。就像这样:

  1. julia> +(promote(1, 2.0)...)
  2. 3.0
  3. julia>

这里对+函数的调用会被分派到我们在前面展示的那个针对Float64类型的衍生方法上。

解释一下,符号...的作用是,把紧挨在它左边的那个值中的所有元素值(如元组(1.0, 2.0)中的1.02.0)都平铺开来,并让这些元素值都成为传入外层函数(如+函数)的独立参数值。所以,调用表达式+((1.0, 2.0)...)就相当于+(1.0, 2.0)

至于什么是元组,你现在可以简单地把它理解为由圆括号包裹的、可承载若干值的容器。函数在同时返回多个值的时候通常就会用这种数据结构呈现。在后面讲参数化类型的那一章里有对元组的详细说明。

除了以上讲的这些,Julia 的类型提升系统还有一个很重要的作用,那就是:让我们可以编写自己的类型提升规则,以自定义数学运算的部分行为,尤其是在操作数的类型不同的时候。例如,若我们想让整数和浮点数的运算结果变成BigFloat类型的值,则可以这样做:

  1. julia> import Base.promote_rule
  2. julia> promote_rule(::Type{Int64}, ::Type{Float64}) = BigFloat
  3. promote_rule (generic function with 137 methods)
  4. julia>

第一行代码是一条导入语句。简单来说,我们在编写某个函数的衍生方法的时候必须先导入这个函数。第二行代码就是我编写的衍生方法。由于与之相关的一些背景知识我们还没有讲到,所以你看不太懂也没有关系。在这里,你只要关注这行代码中的Int64Float64BigFloat就可以了。前两个都代表了操作数的类型,而后一个则代表了它们的公共类型。这正是在定义操作数类型和公共类型的对应关系。

现在,我们再次执行之前的代码:

  1. julia> promote(1, 2.0)
  2. (1.0, 2.0)
  3. julia> typeof(ans)
  4. Tuple{BigFloat,BigFloat}
  5. julia>

可以看到,这次调用promote函数后得到的元组包含了两个BigFloat类型的值。这就说明我们刚刚编写的类型提升规则已经生效了。当然,修改 Julia 内置的类型提升规则是比较危险的。因为这可能会改变已有代码的基本行为,并且会明显地降低程序的稳定性,所以还是要谨慎为之。但对于我们自己搭建的数值类型体系来讲,这一特性的潜力是非常可观的。

总之,Julia 的类型提升系统辅助维护着数学运算的具体实现。其中有着大量的默认规则,并确保着常规运算的有效性。但同时,它也允许我们自定义类型提升的规则,以满足自己的特殊需要。