3 函数进阶

3.1 参数传递模式

Julia的参数传递是“共享传递”(pass by sharing), 这样可以省去复制的开销。 如果参数是标量的数值、字符串、元组(tuple)这样的非可变类型, 参数原来的值不会被修改; 如果参数是数组这样的可变(mutable)数据类型, 则函数内修改了这些参数保存的值,传入的参数也会被修改。

例如,非可变类型不会被修改:

function f(n)
  println("Inside f() before changing, n=", n)
  n = -1
  println("Inside f() after changing, n=", n)
  return
end
function test()
  n = 1
  f(n)
  println("Out of f(), n=", n)
end
test()
## Inside f() before changing, n=1
## Inside f() after changing, n=-1
## Out of f(), n=1

改变了输入的数组(可变类型)的值的例子:

function double!(x)
    for i in eachindex(x)
        x[i] *= 2
    end
end
xx = [1, 2, 3]
double!(xx)
@show xx;
## xx = [2, 4, 6]

Julia函数的命名习惯是, 如果函数会修改自变量的值, 将函数名末尾加上一个叹号后缀。

在上面的例子中, 函数double!()改变了数组自变量x的元素的值。 如果直接对自变量赋值, 则相当于在函数中重新绑定x, 不会对输入的原始数组造成影响,如:

function double_wrong(x)
    x = 2 .* x
    return x
end
xx = [1, 2, 3]
double_wrong(xx)
@show xx;
## xx = [2, 4, 6]

3.2 无名函数

Julia的函数也是所谓“第一类对象”(first class objects), 可以将函数本身绑定在一个变量上, 函数名并非必须, 允许有无名函数。 无名函数在“函数式编程”(functional programming)范式中有重要作用。

无名函数格式是: 参数表 -> 返回值表达式, 其中参数表即自变量表, 没有自变量时参数表写(), 只有一个自变量时可以不写圆括号而只写自变量名, 有多个自变量时将自变量表写成一个元组格式。

比如,函数\(f(x) = x^2 + 1\)又可以写成

x -> x^2 + 1
## #12 (generic function with 1 method)

无名函数的另一种写法如

function (x)
    x^2 + 1
end
## #14 (generic function with 1 method)

这样就产生了一个无名函数。

利用无名函数可以制作“函数工厂”, 即返回值为函数的函数。 例如:

make_power(α) = x -> x^α
f2 = make_power(2)
f3 = make_power(3)
[f2(2), f3(2)] |> show
## [4, 8]

3.3 链式调用与函数复合

x |> f可以表示f(x), 这样,x |> f |> g就是复合调用g(f(x)), 称为链式调用。 但是,不允许写成x |> f() |> g()。 需要的话可以将每一步写成一个无名函数。 例如:

[1:5;] |> (x -> x .^ 2) |> sum
## 55

加点的运算,如g(f.(x)), 可以写成x .|> f |> g, 如:

[1:5;] .|> (x -> x ^ 2) |> sum
## 55

例子中的[1:5;]的写法类似于collect(1:5)。 链式调用中的无名函数应该用()保护, 以免发生优先级疑惑。

g(f(x))也可以用“”运算符表示成g∘f(x), “”的结果仍是函数。 “”输入方法为\circ<TAB>。 这样的复合运算在有确定的函数名时当然没有必要, 但是如果用变量保存了函数, 以至于预先不知道要复合的函数, 就是需要的。 如:

funcs = [uppercase, lowercase, first]
fruits = ["apple", "banana", "carrot"]
fnew = funcs[1]  funcs[3]
y = map(fnew, fruits);
@show y;
## y = ['A', 'B', 'C']

程序对每个单词取第一个字母并转换成大写。 等效于下面每一行:

map(uppercasefirst, fruits)
(uppercasefirst).(fruits)
uppercase.(first.(fruits))
fruits .|> first .|> uppercase 
[(uppercasefirst)(xi) for xi in fruits]
[uppercase(first(xi)) for xi in fruits]

3.3.1 示例:图像点阵下标与xy坐标转换

一个图像可以简单地看成一个\(m \times n\)矩阵, 矩阵元素是像素点, 一般取元素值。 为了对图像进行一些数学变换, 需要将图像看成是直角坐标平面上定义的二元函数, 而矩阵的行列下标与一般的实数值的x、y坐标还是有差别。 下面的函数来自MIT的计算思维导论公开课件, 用于将图像与x、y坐标上的二元函数相互转换。

下面是将图像从平面直角坐标定位转换回到整数行、列下标定位的函数, 输入为图像,一对\((x,y)\)坐标, 输出对应的\((i,j)\)下标。 认为坐标原点在图像正中心,超出范围的点返回白色或黑色。 这是一个标量函数,没有针对整个图像变换, 但输入整个图像。 因为Julia的函数自变量是引用传输而不是复制传输, 所以输入整个图像并不损失效率。

function transform_xy_to_ij(img::AbstractMatrix, x::Float64, y::Float64)
    rows, cols = size(img) # 行、列数
    m = max(cols, rows)
    
    # 平移变换
    translate(α,β)  = ((x, y),) -> [x+α, y+β]
    # 坐标互换
    swap(x,y) = [y, x]
    # 纵坐标颠倒
    flipy((x, y)) = [x, -y]
    # 伸缩变换
    scale(α) = ((x,y),) ->*x, α*y)

    # 从平面直角坐标到行、列下标的函数
    xy_to_ij =  translate(rows/2, cols/2)  swap  flipy  scale(m/2)
    # 使用函数将输入的一对(x, y)值转换为整数
    i, j = floor.(Int, xy_to_ij((x, y))) 
    # 注意:返回值仅两个整数,不是向量化的。
end

上面程序的主要语句是xy_to_ij的定义。 该式从右向左解释:

  • 先从[-1,1]区间变换到[-m/2, m/2]区间;
  • 将y轴颠倒次序,这是因为用行下标代表纵轴, 行下标的增长方向是从上到下方向,与纵轴方向相反;
  • 调换x, y,这是因为行下标i代表了上下方向,即y方向, 列下标j代表了左右方向,即x方向;
  • 最后将i最小值变为0,j最小值变为0。

下面是从行列下标转换为平面直角坐标系坐标的函数, 输入一对\((i,j)\)下标, 和图像横、纵方向像素点数的最大值pixels, 输出平面直角坐标系坐标\((x,y)\)

function transform_ij_to_xy(i::Int, j::Int, pixels)
    # 从行、列下标转换为直角平面坐标系坐标的函数
    # pixels是横向、纵向的像素点数的最大值
    
    # 用来转换的函数。
    ij_to_xy =  scale(2/pixels)  flipy  swap  translate(-pixels/2,-pixels/2)
    # 上式从右向左解读:记m=pixels,
    # 先将坐标范围从$[1,m] \times
   ij_to_xy([i,j])
end

上面函数的主要部分是函数ij_to_xy的定义。 因为有参数pixels,所以不直接使用此函数, 而是将其包裹在transform_ij_to_xy中。

ij_to_xy的定义也充分利用了函数复合, 从右向左解读如下(记pixels\(m\)):

  • 将坐标范围从\([1,m] \times [1,m]\)平移到\([-m/2, m/2] \times [-m/2, m/2]\)
  • 交换行、列关系;
  • 将纵轴颠倒;
  • 将坐标范围限制在\([-1,1] \times [-1,]\)之间。

还有一个配套的给定了图像和一个\((x,y)\)坐标, 取出对应的像素点的函数。 超出范围则给出一个纯黑像素点。

using Colors
function getpixel(img::AbstractMatrix,i::Int, j::Int)   
    rows, cols = size(img)
    m = max(cols,rows)
    black(c::RGB) = RGB(0,0,0)
    black(c::RGBA) = RGBA(0,0,0,0.75)
    
    if 1 < i  rows && 1 < j  cols
        img[i, j]
    else
        black(img[1,1])
    end
end

3.4 可变个数参数与元组实参

在自定义函数的自变量名后面加上三个句点作为后缀,如args..., 则此函数可以有可变个数的位置参数, args为一个元组。

如:

function f_vara1(x, args...)
    println("x=", x)
    println("其它参数:", args)
end
f_vara1(11, 1, 2, 3)
## x=11
## 其它参数:(1, 2, 3)

有时需要传递给一个多自变量函数的实参保存在了一个变量中, 比如, 函数max()求各个自变量中最大值, 如

max(1, 3, 1, 4)
## 4

如果要求最大值的数已经在一个元组或数组中如何利用max()求最大值? 可以用“展开”(splatting)的方法将一个变量中的多个值展开成函数的自变量, 方法是在作为实参的自变量名后面加三个句点后缀,如

x = [1, 3, 1, 4]
max(x...)
## 4

3.5 递归调用

函数定义时允许调用自己, 这使得许多本质上是递推的计算程序变得很简单, 比如,\(n! = n(n-1)!\), 用递归函数可以写成:

function myfact(n)
    if n==1
        return 1
    else
        return n*myfact(n-1)
    end
end
myfact(5)
## 120

递归程序必须有初始结果, 比如上面程序中\(n=1\)时结果为1; 递归必须能逐步退回到初始结果的地方。

再比如Fibonacci序列, \(F_1 = 1\), \(F_2 = 1\), \(F_n = F_{n-1} + F_{n-2}\), 前几个数是1, 1, 2, 3, 5, 8, 13, 21, 34。 用递归调用写成

function myfib(n)
    if n <= 0
        return 0
    elseif n==1 || n==2
        return 1
    else
        return myfib(n-1) + myfib(n-2)
    end
end
show([(n, myfib(n)) for n=0:9])
## [(0, 0), (1, 1), (2, 1), (3, 2), (4, 3), 
##  (5, 5), (6, 8), (7, 13), (8, 21), (9, 34)]

递归程序只是把用递推表示的问题变得容易变成解决, 很多时候效率并不好。 比如, 为了计算\(F_5\), 需要用到\(F_4\)\(F_3\), 算\(F_4\)的时候又需要重新计算\(F_3\), 所以\(F_3\)被重复计算了2次, 计算\(F_3\)要计算\(F_2\)\(F_4\)也要计算\(F_2\), 所以\(F_2\)也被重复计算了\(3\)次。 这都是不必要的额外计算, 比如改成如下的正向循环:

function myfib2(n)
  local f1, f2, f3
  
  if n <= 0
    return 0
  elseif n==1 || n==2
    return 1
  end 

  f1 = 1
  f2 = 1
  for i=3:n
    f3 = f1 + f2 
    f1, f2 = f2, f3 
  end 
  return f3
end
myfib2.(1:10) |> show 
## [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

3.6 闭包

可以在函数内定义内嵌函数并以此内嵌函数为函数返回值, 称这样的内嵌函数为闭包(closure)。 闭包的好处是可以保存定义时的局部变量作为一部分, 产生“有状态的函数”, 类似于面向对象语言中的方法, 而Julia并不支持传统的面向对象语言中那样的类。

例如, 某个函数希望能记住被调用的次数。 传统的方法无法解决问题,比如下面的版本是无效的:

function counter_old()
    n = 0
    n = n+1
    return n
end
println(counter_old(), ", ", counter_old())
## 1, 1

可以用如下的闭包做法:

function make_counter()
    n = 0
    function counter()
        n += 1
        return n
    end
end
my_counter = make_counter()
typeof(my_counter)
## var"#counter#22"
println(my_counter(), ", ", my_counter())
## 1, 2

递归函数在反复调用自身以后效率会很低, 比如,前面计算Fibonacci数的递归函数, 因为对每个\(n\)都要多次调用自变量为\(0, 1, 2, \dots\)的情形, 所以进行许多不必要的重复计算。 可以用闭包的方法将已有结果保存, 使得计算效率大大提高:

function makefib()
    saved = Dict(0=>0, 1=>1)
    function fib(n)
        if n  keys(saved)
            saved[n] = fib(n-1) + fib(n-2)
        end
        return saved[n]
    end
end
myfibnew = makefib()
show(myfibnew.(0:9))
## [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

3.7 函数式编程

无名函数经常用在map(), filter()reduce()这样的函数式编程函数中。

3.7.1 map

map(f, x)将函数f作用到容器x的每个元素上, 有时可以用f.(x)代替, 但有些情况下不能使用广播(加点语法)。

如:

map(x -> x^2 + 1, [1,2,3])
3-element Array{Int64,1}:
  2
  5
 10

在调用map()函数时, 如果对每个元素执行的操作需要用多行代码完成, 用无名函数就不太方便。 例如,对数组的每个元素, 负数映射到0, 大于100的数映射到100, 其它数值不变,用有名函数可以写成:

function fwins(x)
    if x < 0
        y = 0
    elseif x > 100
        y = 100
    else
        y = x
    end
    return y
end
fwins.([-1, 0, 80, 120]) |> show
## [0, 0, 80, 100]

也可以写成map(fwins, [-1, 0, 80, 120])。 如果要用无名函数的格式, Julia还提供了map函数的一种do块格式,如

map([-1, 0, 80, 120]) do x
    if x < 0
        y = 0
    elseif x > 100
        y = 100
    else
        y = x
    end
    return y
end
4-element Array{Int64,1}:
   0
   0
  80
 100

注意这样调用map()时圆括号中仅有要处理的数据, 要进行的操作写在do关键字后面, 操作写成了不带->符号的无名函数格式。

对于多元函数, 计算是按对应元素进行的,如:

map((x,y) -> 2*x + y, [1, 2, 3], [10, 20, 30]) |> show
## [12, 24, 36]

3.7.2 filter

函数filter(f, x)f是返回布尔值的函数, 称这样的函数为示性函数(indicator functions), x是向量, filter(f, x)的结果是将f作用在x的每个元素上, 输出f的结果为真值的那些元素组成的数组。如

filter(x -> x>0, [-2, 0, 1,2,3]) |> show
## [1, 2, 3]

3.7.3 reduce

reduce(f, x)f是接受两个自变量的函数, 如加法、乘法,结果是将x中的元素用f反复按结合律计算结果。 称这种做法为约化计算。

比如, 求x的元素和, 除了sum(x)函数, 也可以写成reduce(+, x):

reduce(+, 1:3)
## 6

对于多维数组, 可以指定沿哪一个维度方向进行简化计算

mat = reshape([1:9;], 3, 3)
3×3 Array{Int64,2}:
 1  4  7
 2  5  8
 3  6  9
reduce(+, mat, dims=1)
1×3 Array{Int64,2}:
 6  15  24
reduce(+, x, dims=2)
3×1 Array{Int64,2}:
 12
 15
 18

有一些reduce的操作已经写成了内置函数, 如sum, prod, minimum, maximum, all, any

可以将mapreduce合并为mapreduce()函数,如:

mapreduce(x -> x ^ 2, +, [1:3;])
## 14

3.7.4 accumulate

类似于sum()求总和而cumsum()给出累计求和的所有中间结果, cumprod()计算连乘过程中的所有中间结果, 函数accumulate()可以将二元运算累计地计算并给出每一步的中间结果。 如:

cumsum(1:5) |> show
## [1, 3, 6, 10, 15]
accumulate(+, 1:5) |> show
## [1, 3, 6, 10, 15]

用于多维数组时可以指定在某个维方向上进行累计计算。如:

mat = reshape([1:9;], 3, 3)
accumulate(+, mat, dims=1)
3×3 Array{Int64,2}:
 1   4   7
 3   9  15
 6  15  24
accumulate(+, mat, dims=2)
3×3 Array{Int64,2}:
 1  5  12
 2  7  15
 3  9  18

map()filter()reduce()对非数值元素的数组也是适用的。

3.8 异常处理

只要是程序, 就难以避免会有出错的时候。 为了在程序出错的时候造成严重后果, 传统的办法是对程序增加许多判断, 确保输入和处理是处于合法和正常的状态。 这样的做法过于依赖于程序员的经验, 程序代码也过于繁复。

现代程序设计语言增加了“异常处理”功能。 程序出错时, 称为发生了异常(exception), 编程时可以用专门的程序“捕获”这些异常, 并进行处理。 这并不能保证程序不出错, 而是出错时能得到及时的、不至于造成严重后果的处理。

Julia中捕获异常并处理的基本结构是:

try
  可能出错的程序
catch 异常类型变量名
  异常处理程序
end

x = [2, -2, "a"]
for xi in x 
    try
        y = sqrt(xi)
        println("√", xi, " = ", y)
    catch ex 
        if isa(ex, DomainError)
            println("√", xi, ": 平方根函数定义域异常")
        else
            print("√", xi, ": 平方根函数其它异常")
        end
    end 
end
## √2 = 1.4142135623730951
## √-2: 平方根函数定义域异常
## √a: 平方根函数其它异常

异常处理代码会降低程序效率, 一些常见错误还是应该使用逻辑判断预先排除。

throw()函数可以当场生成(称为“抛出”)一个异常对象, 如throw(DomainError())。 Julia中内建了一些异常类型, 详见Julia的手册。

error(msg)可以直接抛出ErrorException对象, 使得程序停止, 并显示给出的msg字符串的内容。

try结构可以有一个finally部分, 表示不论有没有出错, 都需要在最后运行的语句。 框架如:

try
    可能出错的程序
catch 异常类型变量名
    异常处理程序
finally
    无论如何最后都要执行的程序
end