严格来说,Higher Order Function就是一个函数,只要它满足以下至少一条:
在函数式编程的世界里,这个词的用法往往更窄。它通常指 filter、map和reduce这类函数,它们会把传入的函数应用到集合的各个元素上。
学到课程体系的这个阶段,我们已经见过多种把某个操作应用到可迭代集合(比如 Vector)所有元素上的方式:
本概念将重点介绍高阶函数(在 Haskell 或 F# 这类函数式语言里都能见到)。
其他可能的方式包括:
filter()函数接收一个返回布尔值的传入函数,并把它应用到集合上。只有返回 true的元素才会包含在返回值中,返回值的_基本_类型与输入相同(见下文)。
julia> filter(iseven, 1:6)
3-element Vector{Int64}:
2
4
6
# String is a collection of Chars, so String in -> String out
julia> filter(!isascii, "Hrōðgār")
"ōðā"
# tuple input -> tuple output
julia> filter(iseven, (1, 2, 3, 4, 5))
(2, 4)
对于多维数组,filter会把输入的维度展平,返回一个 Vector:这是“输出类型与输入类型保持一致”这一规则最主要的例外。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> filter(isodd, m)
3-element Vector{Int64}:
1
5
3
上面的例子用的都是内置函数,但在这种场景下,匿名函数用得也非常多。
julia> filter(x -> x % 3 == 0, 1:20)
6-element Vector{Int64}:
3
6
9
12
15
18
还有一个原地版本 filter!(),本概念中的许多函数都有这样的版本。
map()函数通过对每个元素应用一个函数来转换集合。在简单的情况下,这和广播很相似,输出的形状与输入一致。
julia> map(√, [1, 4, 9])
3-element Vector{Float64}:
1.0
2.0
3.0
julia> map(x -> x^2 + 1, 1:4)
4-element Vector{Int64}:
2
5
10
17
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> map(√, m)
2×3 Matrix{Float64}:
1.0 1.41421 1.73205
2.0 2.23607 2.44949
map()也可以对多个集合逐元素操作。
julia> map(*, [1, 2], [3, 4])
2-element Vector{Int64}:
3
8
从概念上讲,这相当于先对多个输入集合运行 zip(),再对中间结果的每个元素运行 map()。这只是一个粗略的类比,并不代表实现上也是如此!
和 zip()一样,形状不匹配的集合会被截断到最小者的维度。
有时只需要传入函数的副作用,比如写数据库,或者向数组 push!。这时可以使用高阶函数 foreach(),它总是返回 nothing。
reduce()函数接收一个有两个实参的函数,把它应用到集合上,从而降低维度。
抽象地听起来可能有点绕,但想想 sum()或 prod()这类函数,它们接收一个集合,返回单个值。
julia> sum(1:4) # add
10
julia> prod(1:4) # multiply
24
这些专用函数经过了高度优化,只要能用就应该始终使用。 其他例子包括 maximum()和 minimum()、逻辑函数 all()和 any(),以及许多统计函数。
仅为说明起见,看看用更通用的 reduce()实现同样的功能(回想一下,中缀运算符 +和*在内部其实就是函数)。
julia> reduce(+, 1:4) # add
10
julia> reduce(*, 1:4) # multiply
24
和 sum()及其他聚合函数一样,reduce()可以接收一个可选的关键字参数 dims,用来指定要归约的维度。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> reduce(+, m; dims=1)
1×3 Matrix{Int64}:
5 7 9
这些例子很简单,因为加法和乘法都满足交换律(1+2 == 2+1)和结合律((1+2)+3 == 1+(2+3))。
但这远不是普遍情况!就连减法和除法这样常见的运算也不满足结合律。
还有一个问题:浮点数误差会在大型集合上累积,所以从左到右的归约结果可能和从右到左略有不同。
Julia 的 reduce函数的方向取决于具体实现,并没有保证。
要显式控制方向,可以用 foldl()和 foldr()函数,名义上分别从“左”和“右”开始(对 Vector 而言,实际上是顶部和底部)。
julia> foldl(-, 1:3) # (1 - 2) - 3
-4
julia> foldr(-, 1:3) # 1 - (2 - 3)
2
注意,这两个函数是为可以当作一维来看待的集合设计的,返回标量结果。foldl和 foldr不支持 dims实参,只有 reduce支持。
把 map操作和 reduce结合起来,在各种编程领域都非常常见。
我们可以依次运行 map,再对中间集合运行 reduce。但这样充其量效率不高,而且随着集合变大,扩展性会非常差。
因此_强烈_建议改用合并后的 mapreduce()函数。它可以实现性能高得多的算法,把 map 和 reduce 操作交织在一起。
第一个实参是用来映射的函数,第二个实参是归约运算符。
julia> mapreduce(x -> x^2 + 1, +, 1:3)
17
# equivalent to (2 + 5 + 10)
julia> sum(map(x -> x^2 + 1, 1:3))
17
如我们所料,对于方向很重要的场景,Julia 也有 mapfoldl()和 mapfoldr()函数。
我们正在创办一个奶酪俱乐部,它将利用机器学习,根据顾客的历史记录和口味,挑选出新的奶酪,提供给热爱奶酪的顾客。
新会员需要填写一份初始调查问卷,以便我们先收集一些基础数据。 通过这份问卷,我们发现有一小部分顾客的评价非常极端,缺少层次。 因为这可能会不可逆地让更精细的算法产生偏差,所以我们单独设置了一套算法来处理他们的需求。 请你提供一些辅助函数,来整理他们的数据。
解决下面这些任务可能有多种方式,但每个任务都可以用一个不同的高阶函数来完成。
评分系统以五星为基准,也就是由整数1:5构成。
极端顾客只会给出1或5的评分,我们想知道某位顾客是否表现出这种行为。
实现all_15(),它接收一个评分向量,如果所有评分都是1或5,就返回true,否则返回 false。
julia> ratings = [2, 3, 4, 4, 1];
julia> all_15(ratings)
false
julia> ratings = [1, 5, 5, 1, 5];
julia> all_15(ratings)
true
我们需要把评价更极端的顾客和其他顾客分开。
实现emphatics(),它接收一个顾客与评分的字典。
返回一个类似的字典,其中只包含那些仅给出1星或5星评分的顾客。
julia> ratings = ([2, 3, 5, 1, 1], [1, 1, 5, 5, 1], [4, 5, 5, 3, 2], [5, 5, 1, 1, 5]);
julia> names = ("c1", "c2", "c3", "c4");
julia> customers = Dict(zip(names, ratings))
Dict{String, Vector{Int64}} with 4 entries:
"c2" => [1, 1, 5, 5, 1]
"c1" => [2, 3, 5, 1, 1]
"c3" => [4, 5, 5, 3, 2]
"c4" => [5, 5, 1, 1, 5]
julia> emphatics(customers)
Dict{String, Vector{Int64}} with 2 entries:
"c2" => [1, 1, 5, 5, 1]
"c4" => [5, 5, 1, 1, 5]
由于极端顾客只用1和5评分,把这些评分改成0和1在计算上会方便得多。
实现tobinary(),它接收一个极端评分的向量。
返回二进制评分,其中1已被改为0,5已被改为1。
julia> ratings = [1, 1, 5, 5, 1];
julia> tobinary(ratings)
5-element Vector{Int64}:
0
0
1
1
0
我们的算法使用Matrix作为输入,所以需要把数据转换成矩阵。
实现tobinarymatrix(),它接收一个由极端评分向量组成的向量。
返回转换后数据的Matrix,其中每个评分向量都是矩阵中的一行。
julia> customersratings = [[1, 1, 5, 5, 1],[5, 5, 1, 1, 5]];
julia> tobinarymatrix(customersratings)
2×5 Matrix{Int64}:
0 0 1 1 0
1 1 0 0 1