Julia 学习笔记——6. 函数进阶
前面的函数笔记主要记录函数的基本语法,这一篇继续看 Julia 函数体系里更核心的部分:方法、多重分派、参数化方法、构造函数,以及一些常用的函数式写法。
概述
如果说前一篇还只是“怎么把函数写出来”,那么这一篇才真正开始接触 Julia 函数系统最有代表性的部分。
在 Python 中,函数更多只是可调用对象;而在 Julia 中,函数名背后通常对应的是一整组方法实现,调用时再根据参数类型决定走哪一个。
默认读者已经熟悉 Python / MATLAB,因此这里也不再解释高阶函数、匿名函数这些概念本身,直接看 Julia 的实现方式。
函数的参数类型
Julia 允许给函数的(一部分或全部)参数加上类型约束,例如
1 | function add2(x::Int64, y::Int64) |
正常使用例如
1 | add2(1, 3) # 4 |
注意 Julia 不会对参数进行自动的类型转换,即使转换是安全无损的,例如
1 | add2(Int32(1), Int32(2)) # error |
使用过于具体的参数类型约束通常不是合适的选择,可以使用更抽象的类型约束,例如使用一般的整数类型
1 | function add3(x::Integer, y::Integer) |
此时可以支持更多种类的参数进行调用,例如
1 | add3(Int32(1), Int32(10)) # 11 |
注意到这里允许两个参数是不同的整数类型,Julia 提供了 where 关键字为泛型编程增加额外约束,例如要求两个参数的类型一致
1 | function add4(x::T, y::T) where {T <: Integer} |
方法与多重分派
一个函数,多个方法
Julia 中通常说的“函数”,很多时候其实是 generic function,也就是“一个函数名下面挂着很多不同的方法实现”。
例如
1 | f(x::Int32) = :int32 |
这里 f 不是被覆盖了三次,而是拥有了多个方法:
1 | f(10) # :int64 |
这就是 Julia 的多重分派机制。
与 C++ 重载相似的是:都会根据参数类型选择不同实现。
与 C++ 虚函数不同的是:Julia 不是只看某个“接收者对象”的类型,而是可以同时看所有参数的类型,这也是 “multiple dispatch” 名称的来源。
多个参数一起参与分派
例如
1 | same_type(x::T, y::T) where {T} = true |
这里 Julia 会自动选择“更具体”的那个方法。
再例如
1 | combine(x::Integer, y::Integer) = :int_int |
调用时会同时参考两个参数的类型。
方法覆盖与方法歧义
如果新定义的方法签名与已有方法完全一致,那么它会直接覆盖旧方法,而不是新增。
更麻烦的问题是方法歧义。例如
1 | g(x::Number, y::Int) = 1 |
这时
1 | g(1, 1) |
两个方法都“挺合适”,Julia 会报方法歧义错误。解决方式通常是手动补上一个更具体的方法:
1 | g(x::Int, y::Int) = 3 |
查看方法列表
使用 methods() 可以查看某个 generic function 的所有方法,例如
1 | methods(f) |
使用 @which 可以查询某次调用到底命中了哪个方法,例如
1 | f(10) |
这两个工具对于理解分派行为非常有帮助。
参数化方法
where 语法
Julia 的参数化方法一般用 where 来写,例如
1 | identity_type(x::T) where {T} = T |
这里 T 是一个类型参数,在方法体中也可以使用。
更常见的是给类型参数加约束:
1 | my_sum(x::T, y::T) where {T <: Real} = x + y |
这表示两个参数类型必须相同,并且这个类型必须是 Real 的子类型。
不必过度使用手写类型参数
虽然 where 很强,但是不建议为了“看起来高级”而到处乱用。很多时候直接写成下面这样已经够了:
1 | my_sum2(x::Real, y::Real) = x + y |
只有在你确实需要表达“两个参数必须共享同一个类型参数”时,where 才是必要的。
构造函数
Julia 没有传统 OOP 的类构造体系,但 struct 可以定义构造函数,而且这部分设计很有特点。
默认构造函数
例如
1 | struct Point2D |
Julia 会自动生成一个默认构造函数:
1 | Point2D(1.0, 2.0) |
如果字段类型不匹配,通常会尝试调用 convert 进行转换:
1 | Point2D(1, 2) # Point2D(1.0, 2.0) |
外部构造函数
可以在 struct 外部定义额外构造函数,用来提供更方便的接口:
1 | struct PolarPoint |
不过上面这个例子存在问题:它和默认构造函数签名冲突,语义也不清楚。更合理的写法通常是提供不同名字的工厂函数,例如
1 | struct Point |
也就是说,Julia 虽然允许你把很多逻辑都塞进构造函数,但并不意味着这么做总是合适。
内部构造函数
如果希望在对象创建时强制检查某些不变量,可以在 struct 定义体内部写内部构造函数:
1 | struct OrderedPair |
使用时
1 | OrderedPair(1, 3) # ok |
内部构造函数最大的意义就是可以直接调用 new(...),从而控制对象真正被创建的条件。
参数化类型的构造
参数化类型与构造函数结合也很常见,例如
1 | struct Box{T} |
如果想限制参数类型:
1 | struct RealBox{T<:Real} |
可调用对象
在 Julia 中,不只是函数本身可以调用,某些对象也可以通过定义 call 方法变成“可调用对象”。
例如
1 | struct Polynomial |
这有点像 Python 里的 __call__,但 Julia 仍然是通过方法分派来实现的。
这种写法在数值计算中很自然,比如“一个对象既保存参数,又像函数一样被调用”。
匿名函数与闭包
基础匿名函数
Julia 支持匿名函数,写法非常数学化,例如
1 | f1 = x -> x^2 + 1 |
涉及多个参数和无参数时需要写参数列表:
1 | f2 = (x, y) -> x + y |
不建议写这种无意义的包装:
1 | x -> f(x) |
如果只是想传递 f,那就直接写 f。
闭包
匿名函数可以捕获外部变量:
1 | function make_multiplier(k) |
这就是闭包。
do 语法
考虑到某些高阶函数的第一个参数本身就是函数,如果这个匿名函数稍微复杂一点,直接写在参数列表里会很难看。
例如
1 | map(x -> begin |
Julia 为此提供了 do 语法,可以把匿名函数写到后面:
1 | map([1, 2, 3]) do x |
这个写法在文件、IO、锁、数据库连接等需要“打开后执行一段逻辑再关闭”的场景中特别常见,例如
1 | open("outfile.txt", "w") do io |
它的思想和 Python 的 with open(...) as f: 非常接近,只是 Julia 不是关键字语法,而是依靠高阶函数来实现。
参数收集与解包
不定位置参数
与 Python 的 *args 类似,Julia 提供了不定位置参数:
1 | function f(x, y, args...) |
这里 args 会被打包成元组。
位置参数解包
调用时也可以把元组、数组等解包成多个位置参数:
1 | g(x, y, z) = x + y + z |
不定关键字参数
与 Python 的 **kwargs 类似,Julia 也可以收集多余的关键字参数:
1 | function show_kwargs(; kwargs...) |
不过 Julia 收集到的不是 Dict,而是更偏静态的 Base.Pairs / 具名元组相关结构。
关键字参数解包
调用时也可以对具名元组或以 Symbol 为键的字典解包:
1 | h(; x, y, z) = x + y + z |
这里的 ; 不要省略,否则 Julia 会尝试把它当位置参数处理。
操作符也是函数
Julia 里绝大多数操作符本质上都是函数,例如
1 | 1 + 2 + 3 # 6 |
两者是等价的。还可以把操作符赋值给变量:
1 | op = + |
这也说明了为什么 Julia 在泛型数值计算里能把很多操作统一起来。
函数组合与链式调用
Julia 为函数复合提供了几种简洁写法。
函数组合
1 | f(x) = x * x |
这里的 ∘ 非常符合数学记号习惯。
管道运算
1 | sqrt(sum(1:10)) |
当处理流程是“上一层的输出作为下一层的输入”时,|> 会比括号嵌套更清晰。
和匿名函数配合也很方便:
1 | 1:5 |> x -> filter(iseven, x) |> sum |
广播与点调用
虽然下一篇会专门写数组,但这里先补一个函数角度下很重要的概念:点调用。
1 | sin.(A) |
这等价于对 f 做广播调用,也就是逐元素计算,并自动尝试对输入尺寸进行协调。
而且嵌套的点调用会自动融合成一个广播循环,例如
1 | sin.(cos.(A)) |
通常不会先构造一个完整中间数组再做第二遍计算,而是会被融合成一次遍历。
小结
Julia 的函数系统真正强的地方并不是“函数也能当变量传来传去”,这一点 Python 早就有了。Julia 的关键优势在于:
- 函数名下面可以挂很多方法;
- 分派可以同时看多个参数类型;
- 参数化方法和参数化类型结合得非常自然;
do、广播、管道这些语法让数值代码既紧凑又不算太难读。
这也是 Julia 不走传统 class-based OOP 路线,却仍然能组织大型数值代码的重要原因。
