{{TOC}}
在数学中,函数指的是两个非空集合之间的一种对应关系。例如,有这样一个函数:
f(x) = x + x
对此,我们输入1
就一定会得到2
,输入2
就一定会得到4
,输入3
就一定会得到6
,等等。
只要我们给予一个函数的输入值是确定的,那么它的输出值就一定也是确定的。即使我们还不知道那个输出值是什么,也是如此。所以,在使用者看来,函数就像一个黑箱。只要我们给予它一个或一组参数,它就会为我们返回一个结果。如果我们想要知道一个函数具体都做了些什么,就只能去查看和理解它的内在了。
另外,数学中的函数还有着如下特性(或者说规则):
- 只要有输入,就一定会输出。也就是说,对于每一个有效的输入值,函数都会返回一个输出值。
- 输入值与输出值之间的对应关系是固定的。我们刚刚说过,输入值确定就意味着输出值确定。而现在我们还可以进一步明确,对于同一个函数,相同的输入值总是会产生相同的输出值。
- 输入值与输出值之间可以是多对一的关系。换句话讲,多个或多组输入值可能会产生相同的输出值。但是,同一个或同一组输入值绝对不可能产生不同的输出值。你可以考虑一下
f(x,y) = x + y
这个函数。
显然,数学中的函数定义是非常严谨的。它可以保证每一个函数的有效性和稳定性。相比之下,程序设计领域对于函数这一概念的诠释就比较丰富多彩了。这主要是因为,各种编程语言都会结合自身的特点来确定“函数”的地位、用途以及功能。
比如,在一些崇尚函数式编程(functional programming)的编程语言中,尤其是那些纯粹的函数式编程语言,其函数的定义方式就非常贴近于数学中的函数。而在一些混合式的编程语言中,函数的定义可以是非常灵活的。这些函数可以有自己的副作用(side effects),甚至可以没有输出值以及输入值。此外,一些倡导面向对象编程(object oriented programming)的编程语言会弱化函数这个概念。在这种程序中的所谓函数只能被定义成对象或者类型的附属品,而不能独立的存在。
我在这里再解释一下刚刚提到的一些概念。它们对于你来说可能会比较陌生。函数的副作用是指,除了返回输出值之外,函数对外界产生的其他影响。比如,修改传入的参数值、修改某个全局的变量或状态(同时也可能会受到其影响)、向计算机的标准输出(也就是 stdout)打印内容,等等。拥有副作用的函数肯定就不能被称为纯粹的函数了。但是,在很多时候这种函数的存在也是很有必要的。另外,函数式、混合式和面向对象指的都是编程语言所支持的编程范式(programming paradigm)。其中,混合式的意思是,编程语言对函数式、面向对象甚至其他的编程范式都提供了一定程度上的支持。这样的编程语言也常常被称为多范式语言。
我早在第一章就说过,Julia 是多范式的编程语言。而且,它是明显倾向于函数式的。函数在 Julia 语言中绝对有着举足轻重的作用和地位。这也是我之前一直在陆续地讲述 Julia 函数相关知识的主要原因。
在 Julia 中,函数实际上也属于一种值,同时也是该语言中的第一等公民(first-class citizen)。因此,它们可以被赋给变量和字段,以及作为其他函数的参数值和结果值。甚至,它们还可以是匿名的。
Julia 中的函数的含义和作用都与数学中的定义比较贴近。你还记得定义函数的那种简洁形式吗?如:
greet() = print("Hello World!")
这与数学中定义函数的方式是多么的相似啊!不过,这个名叫greet
的函数并不是一个纯粹的函数。因为它有副作用,会向标准输出打印内容。
Julia 虽然允许函数拥有副作用,但是它有一个非常好的惯用法,同时也是一项很值得遵从的编程最佳实践,那就是:拥有副作用的函数的名称会以!
为后缀。反过来说,名称不以!
为后缀的函数就不会有副作用。因此,函数名称中的!
也就成为了一种标志。它向我们表明了当前函数的特殊行为方式。我们在之前已经介绍过不少这样的函数,如:append!
、broadcast!
、delete!
、get!
、merge!
、sort!
,等等。
Julia 语言的标准库以及很多第三方库都会严格地遵循这种惯用法。不过,其中也有一些拥有副作用的函数的名称并没有以!
结尾,比如我们在前面提到的print
。这种函数很少,而且都是很特别的。因为它们本身就是为副作用而存在的。并且,它们在名称上往往也有着非常鲜明的示意。这才保证了使用者不会把它们与其他的没有副作用的函数相混淆,从而避免了困惑和错用。总之,这样的惯用法是非常值得借鉴的。我们也应该尽可能地遵循它们。
另一方面,Julia 函数的用途是很广泛的。Julia 中的大多数操作符都是由函数实现的,只不过它们支持了某些特殊的语法罢了。我们在前面的章节中使用过的操作符+
、-
、*
、/
,以及==
和===
等等,其实都是函数。例如,在 Julia 的标准库中定义了这样一个名称为+
的函数:
+(x::Float64, y::Float64) = add_float(x, y)
这个函数会被应用在两个浮点数相加的操作当中:
julia> 1.2 + 2.3
3.5
julia> @which 1.2 + 2.3
+(x::Float64, y::Float64) in Base at float.jl:401
julia> +(1.2, 2.3)
3.5
julia> @which +(1.2, 2.3)
+(x::Float64, y::Float64) in Base at float.jl:401
julia>
可以看到,不论是1.2 + 2.3
还是+(1.2, 2.3)
,它们使用的实际上都是同一种操作方式。
除此之外,在索引表达式(如array[1]
)、选择表达式(如user.name
)以及数组拼接操作背后的也都是函数。我们刚刚用到的宏@which
可以让它们都原形毕露。
这里需要明确一下,我们确实可以把前面那个名称为+
的定义笼统地称为函数定义。但是,确切地说,它定义的是一个衍生方法,而且是基于泛化函数+
的且针对于Float64
类型的参数的衍生方法。
你应该已经对衍生方法这个概念不陌生了。我们在前面几章中都有所提及,而且还一起编写过几个简单的衍生方法。不过,我在本章的后面还会对衍生方法进行一次系统化的阐释,以便让你能够深入地理解它们。
下面,我们先从基本的函数编写方式讲起。
在 Julia 中,使用标准的形式编写函数需要以关键字function
和函数的签名为首行,并以单独的关键字end
为尾行。夹在这两行之间的就是函数的主体,或称函数体。函数体中可以有若干的表达式和语句,包括各种复杂的流程控制语句。下面是一个我们之前写过的例子:
function sum1(a, b)
a + b
end
在这个例子中,sum1(a, b)
就是函数的签名。它与关键字function
之间是有空格分隔的。另外,a + b
是该函数主体中的唯一的一个表达式。
函数的签名由函数名称、参数列表和结果声明组成。其中,函数名称和参数列表是必须要有的,而结果声明是可有可无的。函数签名中的参数列表负责声明当前函数需要接受的所有参数。这些参数可以是必选的,也可以是可选的。多个参数声明之间需要由英文逗号分隔。注意,即使一个函数没有任何参数,圆括号在这里也是必不可少的。
这个sum1
函数的签名还是很简单的。它的参数列表中没有携带任何的参数类型声明。从这个角度讲,它的功能是比较泛化的。当我们没有为函数定义中的某个参数指定类型时,Julia 就会把它的类型设置为Any
。由于Any
类型是 Julia 中唯一的顶层类型,所以这就相当于没有任何类型方面的约束。因此,我们在调用函数的时候可以把任意类型的值传给这样的参数。不过,对于这个sum1
函数的调用是否能够成功完成,还要取决于+
函数及其衍生方法是否对那些参数值的实际类型有所支持。
我们当然可以为sum1
函数的参数添加类型声明,以使得它的功能更加具体化。比如:
function sum1(a::Number, b::Number)
a + b
end
在这个函数定义中,参数a
和b
的类型都被声明为了Number
。这里的::Number
就是类型声明。它由符号::
和一个类型字面量组成。一个参数声明当中的参数名称和类型声明总是应该紧挨在一起的。虽然中间夹杂空格对定义的识别不会有什么实质上的影响,但是这显然会降低其可读性。
这两个sum1
函数(更确切地说,它们都是衍生方法)可以同时存在。当我们传入的两个参数值的类型都是Number
(及其子类型)时,第二个sum1
函数就会被调用。否则,第一个sum1
函数才会被调用。关于此,你同样可以使用@which
宏进行验证。
另一方面,这两个sum1
函数的定义中并没有结果声明,可它们依然可以返回一个结果值。更宽泛地讲,在这种情况下,函数可以返回任何类型的结果值。而且,Julia 的函数总是会有一个结果值的,只不过这个结果值可以是nothing
。这时就相当于函数没有结果,或者说函数返回的结果没有实际意义。我们之前展示过的函数greet
就是一个没有结果的函数。示例如下:
julia> greet() == nothing
Hello World!true
julia>
我先调用了函数greet
,紧接着用操作符==
去判断该函数返回的结果值是否等于nothing
。注意看,在 REPL 环境回显的内容中,Hello World!
是greet
函数向标准输出打印的内容,而随后的true
才是==
判断的结果。
函数greet
不会返回任何有意义的结果值。这一点我们并不难看出来。可是,sum1
函数返回的结果值又会是什么?你可能会说,肯定是表达式a + b
的求值结果。这是没错的。但是,这背后的规则是什么呢?
在缺省情况下,Julia 会把函数实际执行的流程当中的最后一个表达式的求值结果作为该函数的结果值。因此,greet
函数返回的结果值就是其中最后一个表达式print("Hello World!")
的求值结果nothing
。而sum1
函数返回的结果值则是其中最后一个表达式a + b
的求值结果。
对于如此简单的函数,即便我不说明规则,你可能也猜得出来。然而,函数中的流程越复杂,这一规则就显得越重要。到了那时,我们首先要搞清楚的就是,函数实际执行的流程到底是什么。关于这一点,我在后面还会谈到,现在先来看另一个问题。
既然函数无论如何都会返回一个结果值,那么函数定义中的结果声明起到的作用又是什么呢?这其实很简单,那就是:向我们表明该函数返回的结果值的类型。对于函数的编写者来说,这就是一种约束。当函数实际返回的结果值无法被转换成其定义中声明的结果类型的值时,Julia 就会抛出一个MethodError
类型的异常。例如:
julia> function sum2(a::Number, b::Number)::String
a + b
end
sum2 (generic function with 1 method)
julia> sum2(1, 2)
ERROR: MethodError: Cannot `convert` an object of type Int64 to an object of type String
# 省略了一些回显的内容。
julia>
而对于函数的使用者来说,结果声明有利于他们对函数的进一步认识,并且可以帮助他们更加合理地运用函数。所以说,函数的结果声明也是程序文档的一部分。类似的,周全的函数参数列表也会起到这样的作用。不过,尽管如此,它们依然不能完全取代正式的函数注释和文档。
到了这里,你应该已经知道了编写函数结果声明的方式。虽然上面这个sum2
函数的定义本身无意义,但是它可以告诉我们,函数的结果声明是紧跟在其参数列表之后的,并且它其实只是一个类型声明而已。
从定义的角度讲,函数的结果值只可能有一个。可是,从实际运用的角度说,函数实际上是可以同时返回多个结果值的。请看下面的函数定义:
function sum3(a, b)
try
a + b, nothing
catch e
0, e
end
end
这个名叫sum3
的函数中只有一条try
语句。这条try
语句由一条try
子句和一条catch
子句组成。如果a + b
不会引发任何异常,那么try
子句中的表达式a + b, nothing
就会被完全地求值。又由于,这个表达式在此时会是sum3
函数实际执行的流程当中的最后一个表达式,所以它的求值结果就会成为sum3
函数返回的那个结果值。请注意,这个表达式包含了两个由英文逗号分隔的、独立的子表达式。因此,sum3
函数在此时实际上会返回两个独立的结果值,即:a + b
的求值结果和一个表示了没有异常发生的值nothing
。
我们再来说另一种情况。如果a + b
引发了一个异常,那么catch
子句就会被执行,其中的表达式0, e
的求值结果就会被作为sum3
函数的结果值。类似的,此时的sum3
函数也会返回两个独立的结果值,即:默认的相加结果值0
和catch
子句捕获到的那个异常值。下面,我们就实际调用一下sum3
函数(假设该函数已经由 REPL 环境解析了):
julia> sum3(1, 2)
(3, nothing)
julia> typeof(ans)
Tuple{Int64,Nothing}
julia> sum3("a", "b")
(0, MethodError(+, ("a", "b"), 0x00000000000065c1))
julia> typeof(ans)
Tuple{Int64,MethodError}
julia>
在一个函数同时返回多个结果值的时候,Julia 会自动地把这些结果值包装成一个元组。如此一来,从表面上看,函数返回的依然是一个单一的结果值,并没有违反函数定义方面的规则。
sum3
函数在经过了上述的两个调用之后分别返回了符合我们预期的结果值,而且结果值的类型也都是元组。但要注意,这两个元组的类型中的第二个类型参数值是不同的。你现在可以思考一下,如果我们要为sum3
函数的定义添加结果声明,并且让该声明起到程序文档的作用,那么应该怎样去编写它呢?
我们把sum3
函数的结果声明写成::Tuple{Number, Any}
怎么样?在sum3
函数返回的元组中,第一个元素值总会是一个数值。而这些元组中的第二个元素值可能是Nothing
类型的,也可能是Exception
类型的。因此,这个结果声明确实是可以的。不过,它还可以变得更好。
你还记得我们之前讲过的底层类型Union
吗?我们可以利用它编写出一个联合类型,即Union{Nothing, Exception}
,并把它作为sum3
函数的结果声明中的第二个类型参数值。代码如下:
julia> function sum3(a, b)::Tuple{Number, Union{Nothing, Exception}}
try
a + b, nothing
catch e
0, e
end
end
sum3 (generic function with 1 method)
julia> sum3(1, 2)
(3, nothing)
julia> sum3("a", "b")
(0, MethodError(+, ("a", "b"), 0x00000000000065c1))
julia>
你或许会感觉这样写有些啰嗦。如果确实是这样,我们就想到一块去了。下面是经过我简化后的代码:
function sum3(a, b)::Union{Number, Exception}
try
a + b
catch e
e
end
end
sum3
函数不再同时返回多个结果值。不过前提是,我们使用::Union{Number, Exception}
作为该函数的结果声明。这样的话,它就可以只使用一个单一的结果值来表示两种不同的执行状态了。
一个更加重要的建议是,通过对输入的合理约束,从源头掐断引发异常的可能性。就像这样:
function sum3(a::Number, b::Number)::Number
a + b
end
我为sum3
函数的两个参数添加了类型声明。由于两个数值肯定是能够相加的,所以原来的try
语句就不再有必要了。随后,函数的结果声明也可以变得更加简单。虽然很多时候都会比这里的情况复杂得多,简化代码并不这么容易,但这正是我们努力的方向——尽量提前进行约束和检查以降低函数中主流程的复杂度和发生异常的可能性。
一旦我们能够把一个函数的函数体写得足够简单,就可以把它的定义简写成单行的代码,如:
sum3(a, b) = a + b
这与前面用到的greet
函数的定义方式是一样的,可以被叫做函数定义的简洁形式。我们在以前已经谈论过很多次这种定义方式了。
我在这里再正式地描述一下。不像标准的定义方式,函数的简洁形式没有function
,没有end
,没有换行。我们需要用符号=
把函数的签名和函数的主体分隔开。这看起来与定义变量的方式很相似,也更加贴近数学中的函数定义。
别看函数定义的简洁形式只能有一行,我们仍然可以在这一行里塞下流程控制语句(在这里也可以叫做复合表达式)。比如:
sum3(a, b) = try a+b catch e e end
请注意,在此函数定义中的catch
关键字的右边有两个标识符e
。它们担任的角色是不同的。靠近catch
的那个标识符e
代表的总是catch
子句携带的用于承载异常值的变量。而靠近end
的e
代表的则是catch
子句中的一个表达式。如果这里只写了一个e
,那么 Julia 就会把它当作catch
子句携带的那个变量。从而,当a + b
引发异常时,这个sum3
函数返回的结果值就会是nothing
。另外,在这个定义的最右边的那个end
是属于try
语句的。别忘了,函数的简洁定义并不以end
结尾。
不只是try
语句,我们还可以在其中写下if
语句,如:
sum4(a, b) = if isa(a, Number) && isa(b, Number) a+b else MethodError(+, (a, b)) end
而且,这里还可以有更复杂一些的for
语句和while
语句等。另外,如果函数体中需要包含多条语句,那么我们就必须使用英文分号和圆括号,并以此消除代码的歧义。在这里,英文分号用于分隔多条语句,而圆括号则用于包裹函数体中的所有语句。例如:
sum5(a, b) = (res = 0; err = nothing; try res = a + b catch e err = e end; (res, err))
请注意,如果函数中的流程不是非常简单的话,我们通常就不会用简洁形式去定义它。如你所见,尽管我们可以在简洁定义的函数体中塞下那些复杂的语句,可是代码的可读性也会大打折扣。在这种情况下,标准的定义方式才是更好的选择。
无论怎样,以简洁形式定义函数通常都会减少我们的工作量,并可以使一些仅包含了简单逻辑的函数看起来更加清晰。不过,这还不是最简单的函数定义方式。
我们之前在讲sort
函数的时候使用过一种没有名称的函数,也被称为匿名函数。匿名函数的定义只包含两个部分,即:参数列表和函数体。这两个部分之间需要由符号->
分隔。例如:
(a, b) -> a+b
匿名函数的参数列表在定义中位于->
的左侧。如果参数列表中包含了多个参数,那么它就必须由一个圆括号包裹。若参数只有一个,则可以省略掉圆括号。此外,匿名函数的参数列表也可以是空的。示例如下:
() -> (res = 0; for e in 1:10 res += e end; res)
在这种情况下,参数列表中的圆括号也是不能省略的。
匿名函数的函数体在定义中位于->
的右侧,用于计算和产生结果值。这里的函数体所产生的结果值可以只有一个,也可以有多个。若有多个结果值,那么我们就必须使用某种容器去包装它们。在这里,Julia 是不会自动对它们进行包装的。例如:
() -> (res = 0; max = 10; for e in 1:max res += e end; (res,max))
不过,与有名函数的简洁定义不同,匿名函数的定义可以占据多行。对于一个多行的匿名函数定义,我们需要使用关键字function
和end
来指明它的边界,就像这样:
function ()
res = 0
max = 10
for e in 1:max res += e end;
res,max
end
这显然与函数的标准定义基本相同。只不过在它的参数列表的左边并没有那个用于表明函数名称的标识符。
对于匿名函数的定义和有名函数的简洁定义,Julia 在函数体编写方面的语法规则是趋同的,只有很小的差别。而我对此的编写建议也是一样的,即:尽量保持简单。尤其在编写匿名函数的时候,我们更应该尽可能地去简化函数体。否则宁可不使用匿名函数。我这么说的原因与匿名函数的具体用途有关。
我们为一个程序定义起名字的最主要原因是,便于日后对它们的引用以及复用。反过来讲,如果一个程序定义只会在某段代码中使用一次,那么我们就没有必要为它命名。匿名函数正是为此而生的。它为程序的编写者们提供了一种相当快捷的函数定义方式,同时还避免了无用代码的出现。
由于一个 Julia 函数要想被调用就必须有名字,所以匿名函数的主要用途是作为传给其他函数的参数值,或者作为其他函数返回的结果值。比如,我们在调用sort
函数时可以这样给予它需要的参数值:
julia> sort([(1,2), (2,1), (4,0)], by=(e)->e[2])
3-element Array{Tuple{Int64,Int64},1}:
(4, 0)
(2, 1)
(1, 2)
julia>
匿名函数在被传入其他函数之后就会与相应的参数名绑定在一起,随后就可以被调用了。相似的,一个由其他函数返回的匿名函数一般也会被随即赋给某个变量或者字段。当然了,我们也可以编写一个匿名函数,并直接把它赋给一个变量或字段。
一般来说,只要不属于上述这几种情况,匿名函数就是不适用的。我们很可能需要考虑先使用其他的方式定义好函数,然后再进行引用或者调用。
好了,现在让我们来稍微总结一下。
总的来说,Julia 的函数有四种编写方式。一般的函数,即那些有名称的函数,可以使用标准形式或者简洁形式来编写。而匿名函数也有两种编写方式,分别对应于单行的定义和多行的定义。不过,对于匿名函数来说,多行的定义并不多见。因为它显得有些复杂了,与匿名函数的适用场景并不相符。
有名函数的标准定义需要多行代码。这是使用最广泛的一种定义方式,也是 Julia 函数最初的样子。而有名函数的简洁定义只能有一行代码,这也约束了它的逻辑复杂度。虽然其中可以有流程控制语句,但是在绝大多数情况下都不会出现嵌套的语句。相比之下,匿名函数的定义更应该保持简约。这与它的用途有关。匿名函数的主要用途是直接被当作普通的值来传递和赋予。一旦其逻辑趋于复杂,通常就会对代码的可读性产生明显的负面影响。
我们在前面讲过不少与函数的参数有关的知识。这包括在调用函数的时候怎样传入参数值、什么是可选的参数,以及怎样为关键字参数赋值,等等。而在本小节,我们会专注于在编写函数定义的时候怎样为其添加各种参数声明。
我们已经知道,每一个函数定义都拥有自己的参数列表,其中可以有零个、一个或多个参数声明。这些参数声明会确定参数的名称,还可以同时指定参数的类型。即使有的参数声明指定了类型,而有的没有指定类型,也是毫无问题的。
在很多时候,为了能够让调用方更加方便地使用函数,我们可以把函数的一些参数变为可选参数。比如,我们之前讲过的sort
函数就是如此。这使得我们仅向它提供一个向量就可以执行默认的排序操作。
在函数的定义中,声明可选的参数是非常容易的。我们只要在声明参数的同时为它指定默认值基本上就可以达到目的。不过,这方面也有一些小的限制。我们先来看一个示例:
function map1(vec::Vector, f=identity)
[f(e) for e in vec]
end
我在这里定义了一个名叫map1
的函数。这个函数的功能很简单,即:操作第一个参数值中的每一个元素值,并把操作所产生的新值依次地放到一个新的向量中,最后把这个新的向量作为结果值返回。
map1
函数定义中的第二个参数声明看起来就像一条赋值语句。虽然我们不能称之为赋值语句,但是它与赋值语句起到的作用是类似的。这个参数声明的含义是,若函数的调用方没有为可选的参数f
赋值,那么 Julia 就会自动地把该参数的值设置为identity
。
解释一下,所谓的identity
指的也是一个函数。这个函数会直接返回它接受的那个唯一的参数值。我把identity
作为参数f
的默认值的目的是实现一个小功能,即:当调用方没有指定具体的操作时,map1
函数只会生成一个依次包含了原有元素值的向量,并返回它。这相当于对原有的向量做了一次浅拷贝。
下面,我们就来调用一下map1
函数(假设该函数已经由 REPL 环境解析了):
julia> map1([1,2,3,4])
4-element Array{Int64,1}:
1
2
3
4
julia> map1([1,2,3,4], (e)->e*10)
4-element Array{Int64,1}:
10
20
30
40
julia>
可以看到,我们在调用map1
函数的时候给不给参数f
赋值都是可以的。这就好像是有两个map1
函数分别受理着不同的调用。没错,Julia 对函数的可选参数的支持实际上正是利用了衍生方法和多重分派机制。这里确实有两个map1
函数。你肯定也明白,它们其实都是衍生方法。
我们在 REPL 环境中很容易就能验证这一点。下面是我在一个新的环境里输入并执行的代码:
julia> function map1(vec::Vector, f=identity)
[f(e) for e in vec]
end
map1 (generic function with 2 methods)
julia> methods(map1)
# 2 methods for generic function "map1":
[1] map1(vec::Array{T,1} where T) in Main at REPL[1]:2
[2] map1(vec::Array{T,1} where T, f) in Main at REPL[1]:2
julia>
在解析了map1
函数的定义之后,REPL 环境回显了一行内容。在这行内容中有一个很关键的信息,即:2 methods
。这说明在当前的环境中已经存在了两个名叫map1
的衍生方法。我们只要调用一下methods
函数便知,这两个衍生方法都是从同一个函数定义中产生出来的。其中的一个方法没有参数f
,而另一个方法拥有参数f
。至于哪一个方法会被调用,那就要看我们在调用时是否为可选参数f
赋值了。
另一方面,从规则上讲,我们可以把一个函数定义中的所有参数都变成可选参数。但是,只要有一个参数不是可选参数,它们在编写位置上就是有要求的。每一个可选参数的声明都应该在所有必选参数声明的右侧。换句话说,在同一个函数定义中,我们必须先声明必选参数,再声明可选参数。
最后,对于我们在上面讲述的参数声明,无论它们是可选的还是必选的,我们都可以把它们统称为位置参数(positional arguments)。因此,确切地说,我们刚刚讲到的可选参数应该被称为可选的位置参数。位置参数背后的规则是,我们在为它们赋值时,放置参数值的先后顺序是要与参数声明的位置严格对应的。顺便说一句,Julia 的多重分派机制在为某个函数调用选择衍生方法的时候依据的就是位置参数。
关键字参数(keyword arguments)的关键不在于声明的位置,而在于参数的名称。这与位置参数是截然不同的。
关键字参数也是可以有默认值的。也就是说,关键字参数也可以被分为必选参数和可选参数。我们在为一个函数声明多个关键字参数的时候,并不用在意它们谁先谁后。当然了,在顺序上符合某种逻辑的参数声明可以明显降低函数的使用成本,尤其是在参数众多的时候。
虽然多个关键字参数之间的先后顺序可以是任意的,但 Julia 对关键字参数和位置参数之间的先后顺序却有着严格的规定,即:任何的关键字参数都必须在所有的位置参数之后声明。这使得同一个参数列表中的位置参数和关键字参数之间总会有一个明显的分界点。而且,这个分界点的代表并不是用于分隔两个参数声明的英文逗号,而是一个英文分号。下面的示例是在一个新的 REPL 环境中执行的:
julia> function map2(vec::Vector; f=identity)
[f(e) for e in vec]
end
map2 (generic function with 1 method)
julia> methods(map2)
# 1 method for generic function "map2":
[1] map2(vec::Array{T,1} where T; f) in Main at REPL[1]:2
julia>
你可以把这个示例与前一个示例对比起来看。函数map2
的定义与map1
的定义几乎一模一样。唯一的区别是,我把其中用于分隔参数vec
和f
的符号由英文逗号换成了英文分号。这就意味着,f
在这里是一个关键字参数,而且是一个可选的关键字参数。
这个对map2
函数的定义只产生了一个衍生方法。这是因为 Julia 的多重分派机制根本就不关心函数拥有哪些关键字参数。在此机制的眼里,这里的函数就等同于一个仅有vec
参数的衍生方法。当然了,Julia 语言还是会完整地对这个函数定义进行解析的,包括它的关键字参数。我们在调用函数的时候,它的关键字参数也是不容忽视的。
由于map2
函数的参数f
拥有一个默认值,所以我们在调用这个函数的时候可以不为f
传递参数值。但是请注意,如果我们需要为f
赋值,那么就必须带上参数名称和=
,就像这样:
julia> map2([1,2,3,4]; f=(e)->e*10)
4-element Array{Int64,1}:
10
20
30
40
julia>
请注意,从规则上讲,我们在调用函数的时候既可以用英文分号去分隔针对位置参数的赋值操作和针对关键字参数的赋值操作,也可以使用英文逗号。也就是说,map2([1,2,3,4], f=(e)->e*10)
等同于map2([1,2,3,4]; f=(e)->e*10)
。不过,使用英文分号显然可以获得更好的可读性。另外,与两种参数的声明顺序一样,我们必须先为位置参数赋值,再为关键字参数赋值。
从形式方面讲,我们为关键字参数f
赋值的方式与标准的赋值操作并没有什么两样。这也是关键字参数和位置参数的一个重要区别。Julia 会通过我们传入参数值时所展现的位置来决定哪一个参数值与哪一个位置参数绑定在一起。而对于关键字参数,由于参数值的传入顺序与参数的声明顺序无关,所以依靠我们指定的参数名称来绑定二者就是顺理成章的选择了。
另外,我们也可以定义只有关键字参数而没有位置参数的函数,比如:
function map3(;vec::Vector, f=identity)
[f(e) for e in vec]
end
请看仔细,在函数参数列表的左圆括号的右边有一个英文分号。由于位置参数声明和关键字参数声明的识别恰恰依赖于它们的分隔符,所以这里的英文分号是必须存在的。否则,Julia 就会认为vec
和f
都是位置参数。幸好这两种参数的声明顺序是固定的,这才只需要多写一个英文分号而已。
现在,让我们把map3
函数的定义放到 REPL 环境中,然后对这个函数进行解析和调用:
julia> function map3(;vec::Vector, f=identity)
[f(e) for e in vec]
end
map3 (generic function with 1 method)
julia> methods(map3)
# 1 method for generic function "map3":
[1] map3(; vec, f) in Main at REPL[1]:2
julia> map3(vec=[1,2,3,4], f=(e)->e*10)
4-element Array{Int64,1}:
10
20
30
40
julia>
我们在这里只需要关注两点。第一点,map3
函数的定义也只会产生一个衍生方法。这同样是由于它没有可选的位置参数。第二点,虽然map3
函数没有位置参数,但是我们并不需要用特别的方法调用它,不像它的声明那样还需要添加额外的符号。我们在调用时只要注意一下位置参数和关键字参数的不同赋值方式就好了。
最后,我们再来强调一下要点。在声明参数的时候,位置参数在左,关键字参数在右,两方之间的分隔符与分隔参数声明的普通符号不同。即使没有位置参数声明,这个分隔符也必须要有。位置参数声明的相对位置很重要。这关乎调用函数时的参数赋值。我们通常会把更重要的参数声明放在更靠左的位置。虽然在关键字参数声明中重要的是参数名称而不是相对位置,但我们仍然应该按照某种易懂的逻辑去排列多个参数声明。
在为位置参数赋值时,我们不需要也不能指定参数的名称,而只能依赖参数值传入的顺序进行参数绑定。但在为关键字参数赋值时,情况却恰恰相反。所以,我们可以说,位置参数可以减少函数调用的代码量,但是大量的位置参数会明显加重函数调用者的心智负担。而关键字参数恰恰可以让函数的调用更加直观和灵活。一般来说,对于很重要的参数,我建议把它们声明为位置参数,并认真考虑它们的相对位置,而把重要性一般或相互的关联性不大的参数声明为关键字参数。
可变参数的意思是数量可变的参数,英文里称为 variable arguments,可简称为 varargs 或 vararg。其含义是,声明参数的一方可以接受数量任意的同类型参数值。或者说,参数的实际数量并不固定,且会随着使用方给予的参数值的数量而动态的变化。
我们在前面专门讲过拥有可变参数的元组类型,如Tuple{Vararg{String}}
。对于这样的元组类型,其实例的长度是可变的。例如:
julia> isa((), Tuple{Vararg{String}})
true
julia> isa(("Julia",), Tuple{Vararg{String}})
true
julia> isa(("Julia", "Python", "Golang"), Tuple{Vararg{String}})
true
julia>
函数也可以拥有可变参数。我们通常把这样的函数简称为变参函数(varargs function)。下面是一个简单的示例:
julia> function map4(vec::Vector...; f=identity)
[f(e...) for e in zip(vec...)]
end
map4 (generic function with 1 method)
julia> methods(map4)
# 1 method for generic function "map4":
[1] map4(vec::Array{T,1} where T...; f) in Main at REPL[1]:2
julia>
不同于前面的map1
、map2
和map3
,map4
可以接受任意多个位置参数值。更重要的是,Julia 会把这些位置参数值全部与参数名称vec
绑定到一起。下面,我们就一起来看一看它是怎么做到的。
我们已经知道了,那个名叫vec
的位置参数就是可变参数。在这个参数的声明中,除了类型声明::Vector
之外,还有一个特殊的符号...
。而后者正是函数的可变参数的唯一标志。
我们在前面已经接触过...
几次了。这个符号在不同的上下文中有着不同的作用。在这里,它的作用就是把紧挨在它左边的那个参数变成可变参数。我们在调用包含了可变参数的函数的时候,处在与这个参数对应的位置上以及更靠右的位置上的那些位置参数值都会被绑定到该参数的名称上。例如:
julia> map4([1,2,3,4], [10,20,30,40], [100,200,300,400]; f=+)
4-element Array{Int64,1}:
111
222
333
444
julia>
在这个例子中,数组[1,2,3,4]
、[10,20,30,40]
和[100,200,300,400]
都会与vec
进行绑定。正是由于这种特殊的绑定操作,Julia 对于可变参数的声明位置是有着严格的规定的。可变参数只能是函数的最后一个位置参数,否则参数的声明就是不合法的。
更具体地说,Julia 会先把相应位置上的那些参数值都放到一个元组中,然后再让这个元组与可变参数的名称绑定在一起。请注意,在这种情况下,可变参数的实际类型会与我们为它声明的类型有所不同。
依然以map4
函数为例,我为参数vec
声明的类型是Vector
,但由于它是一个可变参数,所以它的实际类型就会变成Tuple
。这个Tuple
类型的类型参数值肯定都是Vector
,但具体是什么,就取决于我们为vec
实际传入的参数值了。
如果我们像上例那样传入了三个整数向量,那么vec
的实际类型就会是
Tuple{Array{Int64,1},Array{Int64,1},Array{Int64,1}}
而如果我们只传入了一个整数向量,那么vec
的实际类型就会是
Tuple{Array{Int64,1}}
以此类推。对于这个可变参数而言,有参数值["a","b","c"]
和["d","e","f"]
就会有参数类型Tuple{Array{String,1},Array{String,1}}
,而有参数值['u','v','w']
和["x","y","z"]
就会有参数类型Tuple{Array{Char,1},Array{String,1}}
,等等。但是,无论怎样,我们为vec
传入的多个参数值都必须是向量,因为该参数的类型已被声明为了Vector
。这个基本的类型约束是不会有变化的。
在看明白了map4
函数中的可变参数vec
之后,我们再来关注该函数的函数体。其中只有一行代码,即:
[f(e...) for e in zip(vec...)]
还记得吗?这是一个数组推导式。其中的函数zip
可以把多个可迭代对象压缩成(或者说组合成)一个可迭代对象。它的具体做法是,把各个可迭代对象中的、在对应位置上的元素值分别包装成一个个类型相同的元组,然后再把这些元组按照原有的顺序放到一个新的可迭代对象中。示例如下:
julia> zip([1,2,3,4], [10,20,30,40], [100,200,300,400])
Base.Iterators.Zip{Tuple{Array{Int64,1},Array{Int64,1},Array{Int64,1}}}(([1, 2, 3, 4], [10, 20, 30, 40], [100, 200, 300, 400]))
julia> zip(['u','v','w'], ["x","y","z"])
Base.Iterators.Zip{Tuple{Array{Char,1},Array{String,1}}}((['u', 'v', 'w'], ["x", "y", "z"]))
julia>
你在这里不用太深究类型Base.Iterators.Zip
的内部机制,只要知道它的实例都是可迭代对象就可以了。我们或许可以用数组来模拟此类实例的内部结构。上面的调用表达式zip([1,2,3,4], [10,20,30,40], [100,200,300,400])
所产生的结果值就类似于:
[(1,10,100), (2,20,200), (3,30,300), (4,40,400)]
这种模拟虽然并不严谨,但是应该能够让你领悟到这类实例被迭代的时候将会发生什么。
一旦了解了zip
函数的功用,我们就可以进一步解释map4
的函数体了。调用表达式zip(vec...)
的含义是,把与vec
绑定在一起的那个元组中的所有元素值都平铺开来,并让它们中的每一个都成为传入zip
函数的独立参数值。而f(e...)
的作用也是类似的,它会把迭代变量e
包含的元素值都拿出来,然后相继传入到函数f
中。不要忘了,变量e
中的每一个元素值都是由多个位置参数值在对应位置上的元素值组合而成的。
由此可见,我们传入map4
函数的参数值应该是彼此呼应的。比如,若与参数vec
绑定在一起的参数值都是整数向量,那么参数f
代表的那个函数就应该能够同时接受多个整数作为其参数值。再比如,如果与参数vec
绑定在一起的参数值既有字符向量也有字符串向量,那么f
代表的就应该是一个可以同时接受字符和字符串的函数。示例如下:
julia> map4(['u','v','w'], ["x","y","z"]; f=*)
3-element Array{String,1}:
"ux"
"vy"
"wz"
julia>
到了这里,我想你应该已经对map4
函数的定义和用法都了如指掌了。我讲了这么多是想告诉你,与可变参数绑定的实际参数值在数量上几乎没有限制。因此,我们可以说,变参函数的调用方可以自由决定传入多少个参数值。所以,我们在定义变参函数的时候必须对此做好妥帖的应对方案。另外,由于符号...
的特殊性,它不但是可变参数声明的必要组成部分,而且还是可以帮助我们更好地实现变参函数的一把利器。合理地利用好这个符号可以让我们事半功倍。
我在前面已经讲了函数结果的生成和返回。我们再一起来简单地回顾一下。
Julia 程序中的函数总会有结果值。不过,当一个函数的结果值为nothing
时,我们一般会忽略掉它,并认为该函数没有返回(具有实际意义的)结果值。Julia 函数的结果值有且只能有一个。但是,我们却可以从函数中同时返回多个独立的结果值。在这种情况下,Julia 会用一个元组把这些结果值包装起来,并把这个元组返回给函数的调用方。这显然是一种很好的变通。也正因为如此,函数的结果声明变得非常简单。我们如果要为一个函数添加结果声明,那么只需要编写一个类型声明以指定其结果的类型就可以了。对于实际上会有多个结果值返回的函数,我们可以把其结果的类型声明为Tuple
。当然,若为了灵活性,我们也可以不加结果声明,但最好在函数的注释中对其结果加以解释。
在缺省情况下,Julia 会把函数实际执行的流程中的最后一个表达式的求值结果作为这个函数的结果值。在这时,函数实际执行的流程是什么就显得尤为重要了。如果一个函数中的流程比较复杂,比如拥有很多分支和一些特殊的情况,那么它的可读性就一定会变得很差。这时,我通常就会显式地指明结果值,而不会让 Julia 自己去识别。这倒不是为 Julia 对代码的解析提供方便,而是为了代码的阅读者和维护者考虑。
要想显式地为函数指明结果值,就需要使用return
语句。return
语句以return
关键字为起始,后面可以携带需要返回的一个或多个结果值。Julia 在执行函数的过程中一旦碰到return
语句就会马上结束对当前函数的执行,并将流程的控制权返还给函数的调用方。也就是说,Julia 会从调用这个函数的表达式那里继续执行后续的代码。
如果return
语句携带了结果值,那么结果值也会随着控制权返回到函数的调用方。当然了,函数的结果值也可以由表达式代表。如果是这样,那么 Julia 就会先对这里的表达式进行求值,然后再将计算出来的结果值和控制权一并返回给函数的调用方。
我们在前面几章其实已经用到过return
语句,比如在cmp
函数的新衍生方法中,又比如在get_bmi
函数中。下面,我们就以cmp
函数的新衍生方法为例:
function cmp(A::Array, B::Array)
for (a, b) in zip(A, B)
if !isequal(a, b)
return isless(a, b) ? -1 : 1
end
end
return cmp(length(A), length(B))
end
你还记得吗?cmp
函数会比较参数A
和参数B
的值,并返回-1
、0
或1
以表明比较的结果。
这里的cmp
方法先调用了zip
函数,把在A
和B
中的处在对应位置上的元素值都两两组对,并按照原有的顺序放到一个新的可迭代对象中。因此,for
语句中的迭代变量a
和b
会依次地代表源于参数A
和B
的一对对元素值。
显然,这个cmp
方法要做的事情是依次地比较每一对元素值。一旦发现某对元素值不相等,它就会立即通过return
语句把比较结果返回给它的调用方。这里有两点需要注意。第一点,这条return
语句携带的正是一个表达式。所以,Julia 会先去对这个表达式进行求值,也就是判断a
和b
谁大谁小并依此给出一个整数,然后再中止函数的执行并返回。第二点,虽然这条return
语句处在一个条for
语句之中,但是不论这条for
语句正在进行第几次迭代,只要条件满足 Julia 就会中止它的执行。也就是说,return
语句的执行是与流程的上下文无关的,正所谓快刀斩乱麻。也正因为如此,函数中的流程越是复杂,return
语句所起到的作用就越是明显。
我们再来看处于cmp
方法中的那条最后的return
语句。其中的关键字return
其实是可有可无的。因为如果两个参数值在对应位置上的元素值都两两相等,那么即便等到for
语句的执行完全结束了也肯定得不出一个比较结果。这时,这条return
语句就是cmp
方法实际执行的流程当中的最后一条语句。因此,即使我们不写return
关键字,表达式cmp(length(A), length(B))
的求值结果也会被作为这个cmp
方法的结果值。
好了,如果你理解了我在前面所讲述的这些内容,那么就应该可以掌握return
语句的用法以及与函数的结果有关的知识了。顺便说一句,return
语句只能够用在函数里。
我们已经知道,当一个函数被调用的时候,Julia 会通过多重分派(multiple dispatch)机制去决定实际调用这个函数的哪一个衍生方法。这种决定依据的是函数定义中所声明的位置参数。更具体地说,决定的因子有位置参数的数量以及各个位置参数的类型。
我们一直在说的多重分派机制的含义是,通过多个决定因子来确定将要执行的代码块,或者说确定将流程的控制权委派给哪一个代码块。在 Julia 中,这样的代码块指的就是衍生方法,也可以简单地称之为方法。相应的,还存在一种被叫做单一分派(single dispatch)的机制。顾名思义,单一分派机制只会依据一个决定因子。这个决定因子往往是被调用函数所属的对象的类型,或者被调用函数的第一个参数的类型。像 C++、Java、Python、JavaScript 等编程语言都具备单一分派机制的某种实现。很显然,多重分派机制是更加灵活和强大的。
我们在之前讲过的参数化类型也可以被称为泛化类型。因为它们代表着一种对数据结构的泛化定义。对于函数,Julia 也有一个比较相近的概念,叫做泛化函数(generic function)。我们都知道,一个函数定义的编写就意味着对某种功能的实现。然而,一个泛化函数的意义却在于对某种功能的命名和定义,与具体的实现无关。
Julia 中的泛化函数是一个抽象的概念,它无需落实在应用程序的代码上。不过,如果我们非要把泛化函数写在代码里,也是可以做到的。示例如下:
function sum1 end
这行代码很特别,它定义了一个名为sum1
的泛化函数。与通常的函数定义有着鲜明的区别,它根本就没有参数列表和结果声明,更没有函数体。这样的定义让泛化函数变得可见,也让程序的阅读者更加明确了某个泛化函数的存在,从而可以在一定程度上增强代码的可读性。然而,只要我们编写了相应的衍生方法(也就是通常的函数定义),它就是可有可无的。并且,单独的泛化函数定义是没有任何实质上的功能的。所以,泛化函数的定义仅仅属于一种文档化的代码。正是由于这些特点,只要没有特别说明,我们所说的函数定义指的就肯定不是泛化函数的定义,而是那种一般的函数定义。
我们定义的每一个函数(确切地说是衍生方法)都与泛化函数脱不了干系。在默认的情况下,当 Julia 解析一个函数定义的时候,如果在当前的模块下还没有同名的函数被解析过,那么它就会创建一个与之同名的泛化函数,并把这个正在被解析的函数作为该泛化函数的第一个衍生方法。这里只有一个例外,那就是:这个函数的定义代表的是为其他模块中的泛化函数编写的衍生方法。我们稍后会解释这个例外。
下面是一个在新的 REPL 环境中执行的示例:
julia> function sum1(a, b)
a + b
end
sum1 (generic function with 1 method)
julia> methods(sum1)
# 1 method for generic function "sum1":
[1] sum1(a, b) in Main at REPL[1]:2
julia>
虽然这个名为sum1
的定义是以关键字function
开头的,并且我们也可以称之为函数,但是,它实质上是一个衍生方法。它衍生自那个 Julia 刚刚创建的、名字也叫sum1
的泛化函数。这一点从 REPL 环境在解析函数定义之后回显的内容那里就可以得到验证。开头的sum1
是泛化函数及其衍生方法共用的名称。括号中的generic function
说明泛化函数sum1
已经被创建(有时也表示已经存在),而之后的with 1 method
则说明该泛化函数目前只拥有 1 个衍生方法(也就是我们刚刚定义的那一个)。这与调用表达式methods(sum1)
返回的结果是一致的。
下面,我们再来定一个sum1
函数:
julia> function sum1(a::Number, b::Number)
a + b
end
sum1 (generic function with 2 methods)
julia> methods(sum1)
# 2 methods for generic function "sum1":
[1] sum1(a::Number, b::Number) in Main at REPL[3]:2
[2] sum1(a, b) in Main at REPL[1]:2
julia>
我们这次定义的sum1
函数与之前的不同。它的位置参数a
和b
都拥有了确定的类型。实际上,Julia 正是利用函数定义中位置参数的声明来区分它们的。这个新的函数定义使得泛化函数sum1
又多了 1 个衍生方法。
你可能已经有所察觉,sum1
这个名称好像就代表着那个泛化函数。没错,泛化函数只有一个唯一的标志,那就是它的名称。在同一个模块内,同名的函数定义一定会属于同一个泛化函数。不过,处于不同模块的多个同名函数定义却可能属于不同的泛化函数。关于这一点,我们在后面就会讲到。
到这里,我想你应该已经搞清楚了几个基本的问题。首先,泛化函数是什么,它与我们通常所说的函数有什么不同。其次,泛化函数是怎样产生的,它与我们定义的函数之间有什么样的关联。最后,我们一般怎样去判断多个函数定义是否是属于同一个泛化函数的衍生方法。
为了避免混淆,我在这里再对几个看起来很相似的概念做一下解释。函数,是一个很笼统的概念。我们编写的以function
关键字开头的程序定义都可以被称为函数定义。不过,Julia 中的函数又可以被分为两种,即:泛化函数和衍生方法。其中,泛化函数是抽象的。它常常只体现为 Julia 内部的一种对象,而无需落实在应用程序的代码上。相对的,衍生方法是具体的,并且一定会出现在应用程序之中。因此,我们常常会直接把程序中出现的一般函数定义称为方法。当然了,我们称呼它们为函数也没有错。所以,本教程里提到的“函数”和“方法”在 Julia 程序的上下文中指的都是那种一般的函数定义。最后,衍生方法一定不是独立存在的,它肯定会与某个泛化函数关联在一起。
我们其实已经在前面展示过不少衍生方法的定义了。而且,我们也已经知道,同属于一个泛化函数的衍生方法一定拥有着相同的名称,同时拥有着不同的位置参数列表。我们刚刚讲过的那两个名为sum1
的方法就是如此。
我在前面也讲了,Julia 在选择衍生方法的时候会把它们的所有位置参数都考虑在内。这不仅涉及到了位置参数的数量,还涉及到了每一个位置参数声明中的类型信息。另一方面,虽然我们定义的函数也可以包含关键字参数,但是这种参数却不会在多重分派的过程中发挥任何作用。
下面,我们就再编写一个带有关键字参数的sum1
方法:
julia> function sum1(a::Number, b::Number; print::Bool)
res = a + b
if print
println("$a + $b = $res")
end
res
end
sum1 (generic function with 2 methods)
julia>
这个方法定义除了拥有位置参数a
和b
之外,还有一个名为print
的关键字参数。这个sum1
方法看上去没有任何的问题,并且与前两个sum1
方法有着明显的不同。可是,REPL 环境回显的内容却显示,泛化函数sum1
仍然只有两个衍生方法。你能想到这是为什么吗?
我们在前面已经讲过,Julia 的多重分派机制只会关心同名函数定义的位置参数列表,而不会在意它们的关键字参数。如果只看名称和位置参数列表,那么我定义的第三个sum1
方法和第二个sum1
方法就是一样的。
又由于它们直接所属的作用域是相同的,都是Main
模块,所以它们在 Julia 看来就是重复的函数定义。对于重复的函数定义,Julia 总是会以最后解析的那一个为准。也就是说,这里的第三个sum1
方法会覆盖掉第二个sum1
方法。如果我们通过调用methods
函数去查看的话,就可以验证这一点:
julia> methods(sum1)
# 2 methods for generic function "sum1":
[1] sum1(a::Number, b::Number; print) in Main at REPL[5]:2
[2] sum1(a, b) in Main at REPL[1]:2
julia>
顺便说一下,Julia 的多重分派机制同样也不会去理会函数定义中的结果声明。无论我们是否声明了结果的类型,以及声明的结果类型是什么,都不会干扰 Julia 对重复函数定义的判断和处理。下面是相应的例子:
julia> function sum1(a::Number, b::Number; print::Bool=false)::String
res = a + b
if print
println("$a + $b = $res")
end
"$res"
end
sum1 (generic function with 2 methods)
julia> methods(sum1)
# 2 methods for generic function "sum1":
[1] sum1(a::Number, b::Number; print) in Main at REPL[7]:2
[2] sum1(a, b) in Main at REPL[1]:2
julia>
至此,泛化函数sum1
的衍生方法只剩下我们刚刚定义的第四个方法,以及最初定义的第一个方法。虽然从methods
函数返回的结果中看不到方法定义的结果声明,但是我们还是能够依据相应的位置信息(如REPL[7]:2
)做出判断的。
以上就是为泛化函数定义衍生方法的一般方式。在两者处于同一个模块的情况下,这种方式肯定是有效的。但是,如果你想为处于其他模块中的泛化函数定义衍生方法,那么就需要先在当前的作用域中导入这个函数。比如,导入语句import Base.cmp
会把处在Base
模块中的泛化函数cmp
导入到当前的作用域中。
在这里需要注意的是,在不同的模块中是可以存在同名的函数定义的。如果确实存在这种情况,那么这些模块就会包含名称相同的泛化函数。这样的话,我们在定义方法之前,就要先搞清楚我们要衍生的是哪一个泛化函数,应该先导入哪一个模块中的标识符。
对于此,我就不再举例了。因为我们在前面已经一起编写过不少这样的函数定义了。比如,在讲数值类型的提升的时候,我们编写过Base.promote_rule
函数的衍生方法。又比如,我们在讲标准字典的实例化的时候,一起编写过Base.==
函数和Base.hash
函数的衍生方法。还有,我们在讲数组的比较时还为Base.cmp
函数编写过衍生方法。这些都是很好的参考。如果你忘记了,可以再翻回去看一看。
现在,我想你已经很清楚如何正确地编写衍生方法了。请记住,衍生方法定义的关键就在于它的名称和位置参数列表。它的名称会告诉 Julia,你在为哪一个泛化函数定义衍生方法。而它的位置参数列表则会让 Julia 知道,你定义的衍生方法与已经存在的方法定义在表面上有哪些不同。请注意,不同的位置参数列表就意味着新方法的加入,而相同的位置参数列表则会导致方法的覆盖。
我们在前面已经讲过了,Julia 的多重分派机制在选择衍生方法的时候会使用一些决定因子,即:所有位置参数的类型以及它们的数量。在本小节,我们会介绍一些更加详细的规则。
已知,我们编写的函数定义最终都会被 Julia 解析为衍生方法。如果我们想为某个泛化函数定义多个衍生方法,那么只需要编写更多的名称相同但位置参数列表不同的函数定义就可以了。由于泛化函数只有函数名称这么一个标志,所以在同一个模块中的同名函数定义都会被解析为同一个泛化函数的衍生方法。
当我们调用某个泛化函数的时候,Julia 首先会识别出我们给予的各个位置参数值的类型,然后连同函数的名称一起合成一个期望的函数签名对象。随后,Julia 会拿着这个函数签名对象到相应的方法表中去查找,并选择一个签名与之最相似的方法。
下面,我将用一些示例来说明。首先,我们定义两个名为sum2
的方法:
# 第 1 个方法。
function sum2(a::Integer, b::Integer)
a + b
end
# 第 2 个方法。
function sum2(a::Integer, b::Integer, c::Integer)
a + b + c
end
还记得吗?Julia 中所有的整数类型都是Integer
的子类型,包括有符号的整数类型、无符号的整数类型,以及布尔类型。
当 Julia 执行sum2(1, 2)
的时候,它会选择第 1 个方法,而不是第 2 个方法。因为第 1 个方法中的位置参数的数量与这个调用表达式给予的位置参数值的数量是一致的。如果我们再定义第 3 个sum2
方法:
# 第 3 个方法。
function sum2(a::Integer, b::Int)
a + b
end
那么,sum2(1, 2)
就一定会被分派给第 3 个方法。原因是,第 3 个方法中的位置参数的类型更加匹配。更具体地说,这个调用表达式给予的位置参数值都是Int
类型的,而第 3 个方法中的参数b
也是这个类型的。我们再来看第 4 个sum2
方法:
# 第 4 个方法。
function sum2(a::Int, b::Integer)
a + b
end
与第 3 个方法恰恰相反,第 4 个方法中的参数a
的类型是Int
,而参数b
的类型则是Integer
。对于上述的调用表达式,第 4 个方法在位置参数类型的匹配度方面与第 3 个方法难分高下。Julia 在这种情况下会怎样去选择呢?请看下面的执行结果:
julia> sum2(1, 2)
ERROR: MethodError: sum2(::Int64, ::Int64) is ambiguous. Candidates:
sum2(a::Integer, b::Int64) in Main at REPL[3]:3
sum2(a::Int64, b::Integer) in Main at REPL[4]:3
Possible fix, define
sum2(::Int64, ::Int64)
Stacktrace:
[1] top-level scope at REPL[5]:1
julia>
可以看到,Julia 报错了。对于sum2
函数的第 3 个方法和第 4 个方法,Julia 认为它们的定义是模棱两可的,从而无法做出很清晰的选择。所以,即使它们都是当前最匹配的方法,Julia 也不会选择它们中的任何一个,而是把这当作一个程序定义方面的错误暴露出来。
我们在遇到这种错误的时候,一定要认真地反思一下,仔细斟酌并修正函数定义中存在的歧义。实际上,Julia 已经给我们提供了一个建议,即:也许应该把函数的签名修正为sum2(::Int64, ::Int64)
。
顺便说一下,这对于我们来说其实是一个警示。虽然 Julia 的衍生方法和多重分派机制可以给程序带来极大的灵活性,但是同时也会给程序的设计者带来很大的挑战。尤其是在一个泛化函数需要许多衍生方法的时候,我们仍然要尽力地保证它们在签名和功能方面都满足正交(orthogonality)设计原则,即:相互独立、没有重复且只有单向的依赖关系。这显然不是一件容易的事情,往往需要我们花很多时间进行方法研究和经验积累才能够优雅地达到目的。
回到原先的话题。当存在比上述两个含糊不清的方法更加匹配的方法时,Julia 就会直接去选择那个方法。这也是它提出前面那个建议的原因。相应的代码如下:
# 第 5 个方法。
function sum2(a::Int, b::Int)
a + b
end
对于sum2(1, 2)
来说,第 5 个sum2
方法显然是更加适合的。从原理上讲,Julia 总是会选择位置参数的类型最具体的那一个方法。
现在,让我们来换一个调用表达式,使用sum2(2, 3.2)
。下面是执行它的结果:
julia> sum2(2, 3.2)
ERROR: MethodError: no method matching sum2(::Int64, ::Float64)
Closest candidates are:
sum2(::Int64, ::Int64) at REPL[6]:3
sum2(::Integer, ::Int64) at REPL[3]:3
sum2(::Int64, ::Integer) at REPL[4]:3
...
Stacktrace:
[1] top-level scope at REPL[7]:1
julia>
Julia 又报错了。这次是因为它没有找到一个能够与sum2(2, 3.2)
相匹配的方法。到目前为止,我们还没有定义出可以接受浮点数的sum2
方法。从表面上看,整数值和浮点数值很相近,而且相互转换应该也很容易。但请记住,Julia 语言本身在任何情况下都不会对一个值进行隐式的类型转换。所有的类型转换要么是通过调用某个函数(如trunc
函数、convert
函数等)完成的,要么是使用操作符::
做到的。
所以说,我们在这时就不得不再添加一个方法来匹配上面的这个调用表达式:
# 第 6 个方法。
function sum2(a::Number, b::Number)
a + b
end
虽然距离类型Int64
和Float64
最近的共同超类型是Real
,但是为了做到更大程度的泛化,我还是把所有参数的类型都声明为了Number
。如此一来,任意的数值就都可以作为该方法的参数值了。又由于+
函数本身就支持所有的数值,所以这并不会带来任何问题。
顺便说一下,不知道你是否还记得,+
函数也拥有一个参数类型都为Number
的衍生方法:
+(x::Number, y::Number) = +(promote(x,y)...)
正因为有了这个+
方法,我们才能够把两个任意类型的数值相加在一起。该方法会先通过调用promote
函数把两个数值都转换为公共类型的值。别忘了,promote
函数能够这样做完全得益于 Julia 的类型提升系统。在这之后,这个+
方法会依据上述的公共类型把转换后的值传给其他相应的+
方法,如+(x::Float64, y::Float64)
等。
你可能也看出来了,这个衍生方法同时担任着两个角色,即:调用入口和功能适配器。它是+
函数下的一个很重要的方法。而且,它与相关的+
方法都是满足正交设计原则的。我们在设计一个泛化函数的衍生方法群的时候可以以此为鉴。不过在这里,由于我们定义的所有sum2
方法都直接使用了+
函数,所以就没有必要再自己去做功能适配了。
最后,我在这里再提示一个很容易被忽略的问题,那就是:当我们定义的函数包含了可选的位置参数的时候,一定要当心衍生方法之间的覆盖现象。因为,Julia 会把这样的函数定义解析为多个衍生方法。例如,若我们有如下的函数定义:
# 第 7 个方法。
function sum2(a::Integer, b::Integer, c::Integer=0)
a + b + c
end
那么,它会被 Julia 同时解析为sum2(a::Integer, b::Integer)
方法和sum2(a::Integer, b::Integer, c::Integer)
方法。如此一来,Julia 之前解析的第 1 个sum2
方法和第 2 个sum2
方法就都会被覆盖掉。在这之后,诸如sum2(1, 2)
和sum2(1, 2, 3)
这样的调用表达式就都会导致第 7 个方法的执行。一旦第 7 个方法与前面那两个方法的行为不完全一致,后续相应的调用表达式的求值结果就很可能会与之前的不同,从而导致程序功能的不稳定。
好了,到目前为止,我们已经讲述了很多关于衍生方法的内容,包括泛化函数、衍生方法的定义方式,以及 Julia 在选择衍生方法时所遵循的规则。接下来,我们会讲解函数的参数化定义方式。
我们在定义一个类型的时候可以使其包含参数,并以此实现极大的灵活性和扩展能力。这样的定义不止可以表示单个的类型,还可以表示一个完整的类型族群。如此定义出来的类型也被称为参数化类型。在 Julia 语言中,到处都充斥着大量的参数化类型。不但如此,针对函数的参数化也是相当普遍的。
对于函数来说,参数化的意义主要在于,确定其结果与其参数之间在类型约束方面的对应关系。我们下面就以之前编写的一个sum3
方法作为开始,讲解函数的参数化。我们先来回顾一下这个方法的定义。
sum3(a, b) = a + b
我们可以看到,这个函数定义对于参数的类型没有任何的限制。同时,它也没有对结果进行声明。
尽管函数+
的衍生方法众多,但终归还是有不适用的类型存在的,如Char
类型和String
类型等等。所以,我们还是应该对这个sum3
方法有所约束,不能让它对什么类型的参数值都可以接受。下面是我对它的改写,以及新的 REPL 环境对它的解析:
julia> sum3(a::T, b::T) where {T<:Number} = a + b
sum3 (generic function with 1 method)
julia>
在对函数进行参数化定义的时候,我们需要把where
关键字以及相关的内容写在函数签名的右侧。如果函数的定义是用简洁形式编写的,那么它们还应该处于符号=
的左边。
还记得吗?我们在讲参数化类型的时候介绍过针对这种类型的值化表示法。这种表示法使用where
关键字来引领针对类型参数的范围约束,如Drawer{T} where T<:Jewelry
。很显然,函数定义的参数化方式与之是类似的。
由于改造后的sum3
方法的两个参数类型都被声明为了占位符T
,因此它现在只能够接受类型相同的两个参数值。又由于存在T<:Number
,所以我们传给它的两个参数值的类型还必须都是Number
类型的某个子类型。否则,Julia 就会立即抛出一个MethodError
类型的错误,并会告诉我们它没有找到与之相匹配的衍生方法。例如:
julia> sum3(1, 2.3)
ERROR: MethodError: no method matching sum3(::Int64, ::Float64)
Closest candidates are:
sum3(::T, ::T) where T<:Number at REPL[1]:1
Stacktrace:
[1] top-level scope at REPL[2]:1
julia> sum3('1', '2')
ERROR: MethodError: no method matching sum3(::Char, ::Char)
Stacktrace:
[1] top-level scope at REPL[3]:1
julia>
同样的类型约束也可以被用在函数的结果声明上。不过,在这种情况下,我们就不能使用简洁形式去定义函数了。以下是我对前述sum3
方法进行的第二次改造:
julia> function sum3(a::T, b::T)::T where {T<:Number}
a + b
end
sum3 (generic function with 1 method)
julia>
这一版的定义可以明确地告诉使用者,它返回的结果值会与它接受的参数值拥有相同的类型。请注意,sum3
函数至此仍然只有一个衍生方法。因为 Julia 的多重分派机制并不会依据函数定义中的结果声明去识别和分辨衍生方法。这使得这一版的sum3
方法覆盖掉了上一版的sum3
方法。
除此之外,参数化的函数参数类型不仅可以是T
,还可以是Vector{T}
、Array{T,N}
、Dict{K, V}
等等。当然了,如果这里存在多个类型参数占位符,那么我们还需要在函数签名的右侧追加多个where
以及针对相应的类型参数的范围约束。例如:
julia> op1 = Dict("a"=>1, "b"=>2, "c"=>3); op2 = 10;
julia> function add_value!(d::Dict{K,V}, v::V)::Dict{K,V} where {K} where {V<:Number}
for (key, num) in d
d[key] = num + v
end
d
end
add_value! (generic function with 1 method)
julia> add_value!(op1, op2)
Dict{String,Int64} with 3 entries:
"c" => 13
"b" => 12
"a" => 11
julia>
关于函数定义的参数化,我已经把所有的基本方式都展示在这里了。怎么样?还是很简单的吧?至于更高级的玩法,你或许可以在“参数化类型”一章里的相关内容之中找到灵感。
我们自从在讲BigFloat
的时候提了一下do
代码块,之后就再也没有谈论到它了。但实际上,do
代码块是一个很棒的语法糖。它是建立在“函数是 Julia 语言中的第一等公民”这一特性的基础之上的。
就像我们在先前所展示的那样,当一个函数需要另一个函数作为其第一个参数值的时候,do
代码块就能够派上用场了:
julia> setprecision(35) do
BigFloat(1.01) + parse(BigFloat, "0.2")
end
1.2099999999
这里调用的函数Base.MPFR.setprecision
的签名是这样的:
setprecision(f::Function, [T=BigFloat,] precision::Integer)
它的第一个参数是Function
类型的,即代表函数的数据类型。
为了完整的演示,我们需要先稍微改造一下之前定义过的函数map1
。如下所示:
julia> function map1(f::Function, vec::Vector)
[f(e) for e in vec]
end
map1 (generic function with 1 method)
julia>
在这里,f
变成了map1
函数的第一个参数。又由于可选的位置参数只能被排在位置参数列表的最后,所以f
现在是必选的参数。另外,我还为f
参数声明了类型。
现在,我们可以像下面这样调用改造后的map1
函数:
julia> map1(e->e*10, [1,2,3,4])
4-element Array{Int64,1}:
10
20
30
40
julia>
或者,在调用它的时候携带一个do
代码块:
julia> map1([1,2,3,4]) do x
x*10
end
4-element Array{Int64,1}:
10
20
30
40
julia>
我们这次一共向 REPL 环境输入了三行代码。第一行代码包括针对map1
函数的调用表达式map1([1,2,3,4])
、关键字do
以及标识符x
。实际上,后两者与第二行的表达式x*10
和第三行的关键字end
共同组成了一个do
代码块。
请注意,虽然map1
函数的必选参数有两个,但我们只在调用表达式中传给了它一个参数值。你应该也看出来了,被传入的这个参数值是给该函数的第二个参数的。那么,第一个参数值在哪里呢?
答案是,我们这次传给map1
函数的第一个参数值就是那个do
代码块。我们可以把do
代码块看成函数定义的一种变体。一个do
代码块就代表了一个匿名函数。在这里,处于do
关键字右边的标识符x
就相当于函数定义中的一个参数声明。
不过,这有一个限制,那就是:do
代码块代表的匿名函数只能有一个参数。当然了,我们可以通过一些手段突破这个限制。为了加以说明,我们再来定义一个map1
方法:
julia> function map1(f::Function, vec::Vector, extra)
[f((e, extra)) for e in vec]
end
map1 (generic function with 2 methods)
julia>
这个map1
方法有三个参数。参数extra
代表了额外的附加值。另外,这个方法传给f
的参数值是元组(e, extra)
,而不是之前的单一变量e
。相应的,我们调用该方法时所携带的do
代码块也需要有所变化:
julia> map1([1,2,3,4], 1) do (x, y)
x*10+y
end
4-element Array{Int64,1}:
11
21
31
41
julia>
可以看到,我们在这里的调用表达式中向这个map1
方法传入了第二个参数值[1,2,3,4]
和第三个参数值1
,而第一个参数值仍然由后面的do
代码块代表。但不同的是,在do
关键字右边的是由一个圆括号包裹的两个标识符。你也可以把这一小段代码看成一个元组。因为它表示的只是一个参数,而不是两个。这也正是这个map1
方法在调用f
时传入(e, extra)
而非e
和extra
的原因。
do
代码块的意义在于,当我们需要临时定义一个函数并把它作为第一个参数值传入另一个函数的时候,使用do
代码块会让代码变得非常的清晰。因为它看起来就是(事实上也是)一个独立的代码块。我们可以在这样的代码块中写入各种复杂的逻辑,而丝毫不会对前面的调用表达式以及周边的代码造成视觉上的干扰。如果在这种情况下不使用do
代码块,那么就很可能会降低相关代码的可读性,甚至会间接地导致一些代码编写方面的错误。由此看来,do
代码块在特定的场景下是很有用处的。
Julia 中的函数是很有特色的。它用一种很惊艳的方式——多重分派——达成了对多态性调用的支持。不仅如此,函数还可以被参数化,以满足我们对多态性参数及结果的要求。这使得一个函数定义能够自动地同时向着多个维度进行扩展,从而可以大大减少我们的代码量,也在很大程度上提高了我们编码的效率。
在本章,我们先回顾了数学中的函数,然后延伸到了程序中的函数。Julia 语言中的函数与数学中的函数很相近。我们可以用一种简洁形式在 Julia 程序中定义函数。这样的话,它们看起来就更像数学函数了。当然了,我们在一般情况下会使用标准的形式来定义函数。标准的函数定义会包含关键字function
和end
,而且其函数体还可以占据多行。另外,我们不但可以为一个函数的任何参数声明类型,还可以把它的参数声明为位置参数或关键字参数,以及必选参数或可选参数。相比之下,函数的结果声明就简单多了。因为它只能有一个类型声明。虽然 Julia 函数的结果原则上只能有一个,但我们却可以让它同时返回多个结果值。这时,Julia 会自动地把多个结果值包装成一个元组。
除此之外,我们还讲述了非常重要的一点,那就是:Julia 中的函数又可以被分为泛化函数和衍生方法。我们通常定义的函数其实都是基于某个泛化函数的衍生方法。同一个泛化函数下的衍生方法的名称一定是相同的。然而,这些衍生方法的位置参数列表肯定是不同的,或是参数的数量不一,或是参数的类型各异,或是兼而有之。另外,它们所属的模块也可以是不同的。
当我们对一个泛化函数进行调用时,Julia 会利用它的多重分派机制选择最匹配的那个衍生方法去承接调用。如果存在那样的衍生方法,那么函数调用就将是非常顺滑的。否则,Julia 就会立即报错,并告知我们找不到匹配的衍生方法。另外还要注意,包含了可选的位置参数的函数定义会被 Julia 同时解析为多个衍生方法。这可能会导致一些不符合我们预期的方法覆盖。
一旦搞懂了本章所讲的这些知识,我们就可以去编写逻辑任意复杂的程序了。要知道,在 Julia 中,函数无疑是最主要的程序载体。绝大部分的表达式、语句和代码块都可以被放置在函数体里面。另外,函数也是非常重要的代码块和作用域。
通常,正规的程序都会有一系列函数负责把整个流程串起来。因为这样做可以让流程中的每一个步骤都清晰、易懂。毫不夸张地讲,会用函数、善用函数是我们成为合格的程序开发者的必要条件。我希望你能通过对本章内容的理解,向着优秀开发者的阵营大步挺进。