10 泛型编程和其它内容

10.1 方法

Julia的函数能够针对不同的自变量类型通过即时编译产生高效代码, 不需要声明自变量类型。

函数可以声明自变量类型和返回值类型, 这可以限定能使用的自变量类型, 避免错误的输入, 使得程序意图更明显; 同一个函数可以有不同类型的自变量, 这其实是多个函数共用同一个函数名, 称这些函数为该函数名的“方法”(methods)。 这种做法称为“多重派发”(multiple dispatch), 还可以类似于C++模板, 将函数自变量类型参数化, 这样同一代码可以适用于多个自变量类型。

这部分技术比较复杂, 不恰当地应用可以造成程序错误, 初学者可以暂时忽略, 自定义函数时先不声明自变量类型。 等发现某个函数造成了运行效率瓶颈, 或者Julia语言已经运用比较纯熟时, 再改进原来的函数, 使其对具体的不同自变量类型有不同的实现。

10.1.1 泛型编程

虽然Julia函数在定义时可以声明参数类型和返回值类型, 但大多数情况下也可以不声明类型, 这样的函数能适应更多的输入类型, 而且一般并不损失效率。 称这样的编程模式为泛型编程(generic programming)。 例如, 下面的频数计数函数可以对任何可遍历的元素非可变类型的容器进行元素频数计数:

function freq(x)
    y = Dict()
    for xi in x
        y[xi] = get(y, xi, 0) + 1
    end
    return y
end

这里x可以是数值型一维数组, 字符串(看成是字符序列), 字符串的一维数组或元组, 等等。 使用不同的数据类型作为输入调用该函数时, 会自动生成一个适用于该输入类型的编译版本。 这样的适应多种输入类型的函数称为多态的(polymorphic)。

10.1.2 多重派发

函数可以将0到多个自变量(参数)集合映射为一个返回值, 类似的操作对于不同的参数类型,可能有不同的具体实现。 比如,两个整数相加,两个浮点数相加,一个整数加一个浮点数,是类似的运算, 但实际计算过程很不一样。 尽管如此,因为其概念上的相近似,Julia中将所有这些运算都归结为+函数。

对于这种类似的操作, Julia在定义函数时, 可以不是一下就完整定义, 而是针对不同参数类型逐步地完善函数定义。 函数针对一种参数类型组合所规定的操作叫做一个方法。 借助于参数类型的声明(annotation), 以及参数个数, 可以对同一函数定义多个方法, 每一种参数类型组合定义一个方法。 调用一个函数时, 规定类型最详细、与输入的参数类型最匹配的方法被调用。 设计时考虑比较周到的话,虽然一个函数有多种特殊的处理方法, 其结果看起来还是可以比较一致的。

从一个函数的多种方法选择一个方法来执行的过程称为派发(dispatch)。 Julia在派发问题上与其它的面向对象语言由很大区别, Julia是根据参数个数不同以及所有参数类型的不同来选择方法的, 这称为多重派发(multiple dispatch), 其它面向对象语言一般仅根据第一个参数派发, 而且第一个参数往往不写出来。 Julia的做法更合理,更灵活、更强大。

对于常见的数学计算, 一般用Float64来计算。 可以先定义Float64版本的函数, 然后对于一般的自变量, 可声明为Number, 转换为Float64后调用已有函数即可。

例如:

ff(x::Float64, y::Float64) = 2x + y
ff(x::Number, y::Number) = ff(Float64(x), Float64(y))
println(ff(1.0, 2.0))
## 4.0
println(ff(1, 2))
## 4.0
println(ff(1.0, 2))
## 4.0

上述函数如果还希望输入的xy都是整数时返回整数值,只要再定义一个整数输入的方法:

ff(x::Integer, y::Integer) = 2*Int64(x) + Int64(y)
ff(1,2)
## 4

methods()函数访问一个函数的各个方法, 如:

methods(ff)
## # 3 methods for generic function ff:
##   ff(x::Float64, y::Float64) in Main at In[2]:1
##   ff(x::Integer, y::Integer) in Main at In[3]:1
##   ff(x::Number, y::Number) in Main at In[2]:2

注意,为了使得上述函数可以对向量使用, 不需要单独定义方法, 而只要用加点语法即可,如

ff.([1.0, 2.2], [-1.0, 3.3])
## 2-element Array{Float64,1}:
##  1.0
##  7.7

注意,如果函数定义仅返回值类型不同, 不能作为多重派发。 这是因为使用相同类型的自变量和相同函数名调用某个函数时, 不能表现出需要的返回值类型。 比如:

function f(x) :: Number
    2 * x
end
function f(x) :: String
    string(2 * x)
end

在调用f(10)的时候, 无法表达想返回数值还是表示数值的字符串的意图。

10.1.3 模仿类型

在使用数组时, 如果输入了一个数组x, 希望制作一个与x类型相同、大小不同的副本, 方法如similar(x, (n,)), 其中(n,)是新的大小。 结果返回一个元素类型与x相同、大小为(n,)的新数组。

对于标量也有这样的函数。 zero(x)返回与x类型相同的0值, one(x)返回与x类型相同的1值。 这样的函数有什么用? 在泛型编程时, 可以使得同一代码能够适用于不同的类型。 如:

function fib(n::T) where {T<:Integer} 
    return n <= 2 ? one(n) : fib(n-1) + fib(n-2)
end
fib(10)
## 55
fib(BigInt(10)) :: BigInt
## 55
fib(10.0)
## ERROR: MethodError: no method matching fib(::Float64)

函数中的one(n)使得返回值类型与输入的n的类型一致。

10.1.4 接口和实现

在自定义数据类型和函数时, 内部的实现往往可以改变, 但是访问用的接口, 即可以对自定义类型进行的操作, 自定义函数可以接受的数据类型、格式、顺序等不要随便改变, 这些需要与用户交互的部分称为“接口”(interface)。 可以在不改变接口的情况下改变数据类型和函数的实现。

比如, Julia支持各种数据类型之间的“+”操作, 用户不需要关心具体的实现, 而只需要知道哪些类型支持这样的操作就可以。

10.2

宏(macro)是许多编程语言提供的功能, 著名的比如C语言的预处理功能, SAS语言的宏。 宏功能是在编译运行之前就处理的程序, 它实际是修改已有的程序, 生成新的程序以供编译运行。

例如, Julia提供了一个@time宏, 写在此宏后面的表达式将被测速, 这实际就是在该表达式的前后增添了部分计时和显示计时结果的代码。 如:

function f(x, y)
    z = similar(x)
    for i in eachindex(x)
        z[i] = x[i] + y[i]
    end
    z
end

n = 100
x = rand(n)
y = rand(n)
@time z = f(x, y);
## 0.006905 seconds (7.55 k allocations: 390.089 KiB)

@time的结果包括运行程序经过的时间, 进行了多少次内存分配, 分配了多少内存。

因为Julia函数的首次运行包含编译时间, 所以再次运行:

n = 100_000
x = rand(n)
y = rand(n)
@time z = f(x, y);
## 0.000348 seconds (2 allocations: 781.328 KiB)

用户也可以定义自己的宏。

10.2.1 接口

接口(interface)是指软件的编写者为软件的使用者提供的软件使用标准, 只要用户的数据符合这个标准的要求, 就可以利用软件提供的功能。

例如, Julia的字符串、元组、一维数组、切片都是序列, 可以按顺序遍历其元素, 这样, 允许按遍历元素方法调用的函数就可以接受这些类型作为输入。 例如, sum()函数可以接受数值的序列, 求和:

sum(1:5)
## 15
sum((1,2,3,4,5))
## 15
sum([1,2,3,4,5])
## 15