学到教学大纲这一步,你已经见过很多函数定义。通常,这些定义在 REPL 中展示时,并没有对输出做太多说明:
julia> f(x) = x^2
f (generic function with 1 method)
为什么会显示 "generic function with 1 method"?
假设我们想更具体地指定实参类型。
julia> f(x::Integer) = x^2
f (generic function with 2 methods)
julia> f(x::Real) = x^2
f (generic function with 3 methods)
或者,我们也可以改变实参的个数:
julia> f(x, y) = x * y
f (generic function with 4 methods)
看来,同一个函数的这些新定义被称为“方法”。方法会被加到一个列表里,而不是覆盖旧的定义。
我们可以很方便地列出这些方法:
julia> methods(f) # output edited for brevity
# 4 methods for generic function "f" from Main:
[1] f(x, y)
[2] f(x::Integer)
[3] f(x::Real)
[4] f(x)
当我们调用函数f时,Julia 能以某种方式知道该用哪个方法:
julia> f(2.5) # Float64
6.25
julia> f(2) # Int64
4
julia> f(2, 3) # Int64, Int64
6
根据手册,函数是一种对象,它把实参组成的元组映射为一个返回值。
我们通常会把f(2, 3)中的函数实参看作两个独立的东西,但编译器把它们看作一个元组。即使只有一个实参也是如此(不过需要加一个逗号才能看得更清楚):
julia> typeof( (2, 3) )
Tuple{Int64, Int64}
julia> typeof( (2,) )
Tuple{Int64}
把函数看作一个黑箱,我们可以说进去的是一个元组,出来的是一个返回值。更正式地说,元组被_映射_为返回值。
这通常被称为“多态”。
几十年来,这一直是计算机科学中的一个重要课题,也出现了各种不同的处理方式。
Julia 的做法很不寻常,但并非独此一家。
+运算符就是一个拥有 161 个方法的函数)。...可以部分绕过这一限制)。dispatched,并返回结果。MethodError。注意,Julia 是动态类型的,因此方法分派发生在_运行期_。
上一节文字很多,下面我们把这些想法变成代码。为简单起见,下面的函数都只有一个实参,但拥有几十个实参的函数并不少见(比如绘图包或机器学习中的函数)。
先定义几个自定义类型,用来表示几何图形。
julia> abstract type Shape end
julia> struct Circle <: Shape
radius::Float64
end
julia> struct Square <: Shape
side::Float64
end
julia> struct Rectangle <: Shape
length::Float64
width::Float64
end
接下来,我们可以定义计算各种图形面积的方法。每个方法都接收一个实参,而且各自接收的都是不同的具体用户自定义类型。
julia> area(c::Circle) = π * c.radius^2
area (generic function with 1 method)
julia> area(s::Square) = s.side^2
area (generic function with 2 methods)
julia> area(r::Rectangle) = r.length * r.width
area (generic function with 3 methods)
现在我们可以创建一些图形并计算面积,依靠 Julia 的分派机制为每个图形选用正确的公式。
julia> circle = Circle(2.3)
Circle(2.3)
julia> area(circle) # call signature Tuple{Circle}
16.619025137490002
julia> square = Square(1.6)
Square(1.6)
julia> area(square) # call signature Tuple{Square}
2.5600000000000005
julia> rectangle = Rectangle(1.4, 2.1)
Rectangle(1.4, 2.1)
julia> area(rectangle) # call signature Tuple{Rectangle}
2.94
方法签名可以接受占位的类型值,方式和参数化类型大致相同。
这种语法很有特点,需要用到where子句:
julia> same_type(v::Vector{T}, x::T) where {T} = true
same_type (generic function with 1 method)
julia> same_type([1, 2], 3)
true
julia> same_type([1, 2], "three")
ERROR: MethodError: no method matching same_type(::Vector{Int64}, ::String)
上面的例子要求两个实参使用同一个类型T,无论T具体是什么。
我们可以给T加上约束,让它更具体:
julia> same_type(v::Vector{T}, x::T) where {T<:Number} = true
same_type (generic function with 2 methods)
有了这个方法,只有完全相同的数值类型才会返回true。
也可以有多个占位变量,在where子句中用逗号分隔。
julia> myfunc(a::S, b::T) where {S, T} = true
myfunc (generic function with 1 method)
多重分派在 Julia 中非常灵活、性能也很高,因此被广泛使用。
正如+拥有 161 个方法这个例子所示,Julia 程序员会力求让方法所指定的类型尽量窄。在字节码层面,两个整数相加与两个浮点数相加差别很大,而整数与浮点数相加还会多出类型提升这一层复杂性。
类型越具体,编译器能做的优化就越多:在 Exercism 的小练习里这无关紧要,但在 Julia 所擅长的大型数值模拟中,这对性能至关重要。
如果你知道一个方法会接收到什么输入,请告诉编译器!
在本练习中,你将实现一个相遇的模拟。这会让你熟悉多重分派的基础知识,这是 Julia 的主要范式。
For this exercise, all function definitions should be a single-line declaration with no if/else logic.
一般来说,相遇指的是一个实体a遇到另一个实体b,并对其做出反应。
encounter(a, b) = "$(name(a)) meets $(name(b)) and $(meets(a, b))."
首先,我们会模拟猫和狗相遇时会发生什么。
首先添加一个抽象类型Pet。
然后定义类型Cat和Dog,作为Pet的子类型。
每个类型都有一个字段name。
实现一个函数name(),返回宠物的名字。
julia> fido = Dog("Fido")
Dog("Fido")
julia> name(fido)
"Fido"
为以下几种相遇实现meets()方法:
julia> ginger = Cat("Ginger")
Cat("Ginger")
julia> meets(ginger, fido)
"hisses"
用一个方法实现encounter()函数,用来报告两个有名字的实体相遇时会发生什么。
julia> encounter(ginger, fido)
"Ginger meets Fido and hisses."
如果它们在散步时遇到别的宠物,比如一匹马,会发生什么?
julia> bella = Horse("Bella")
Horse("Bella")
julia> encounter(fido, bella)
"Fido meets Bella and is cautious."
宠物还可能遇到很多并非宠物的其他东西:汽车、人类、植物、自然灾害、小行星……那会发生什么?
julia> colnago = Bicycle("Colnago")
Bicycle("Colnago")
julia> encounter(ginger, colnago)
"Ginger meets Colnago and runs away."
我们的模拟中还可能出现很多其他相遇。 汽车遇到狗,两个电子相遇并相互作用……
不可能提前覆盖所有相遇,所以我们要实现一个通用的兜底。
julia> γ1 = Photon("γ1")
Photon("γ1")
julia> γ2 = Photon("γ2")
Photon("γ2")
julia> encounter(γ1, γ2)
"γ1 meets γ2 and nothing happens."
# This is true, photons just pass through each other.