高阶函数(Higher-order function)

我们已经见识到了匿名函数和箭头函数的用法, 匿名的一等函数到底有什么用呢? 来看看高阶函数的应用.

高阶函数意思是它接收另一个函数作为参数. 为什么叫 高阶: 来看看这个函数 f(x, y) x(y)= 按照 lambda 的简化过程则是

f(x) => (y -> x(y))
(y) => x(y)

可以看出来调用 f 时却又返回了一个函数x.

还记得高等数学里面的导数吗, 两阶以上的导数叫高阶导数. 因为求导一次以后返回的可以求导.

概念是一样的, 如同俄罗斯套娃 当函数执行以后还需执行或者要对参数执行, 因此叫高阶函数.

高阶函数最常见的应用如 mapreduce. 他们都是以传入不同的函数来以不同的方式操作数组元素.

另外 柯里化, 则是每次消费一个参数并返回一个逐步被配置好的函数.

高阶函数的这些应用都是为函数的组合提供灵活性. 在本章结束相信你会很好的体会到函数组合的强大之处.

Higher-order function

函数在 JavaScript 中是一等公民, 因此在 JavaScript 中, 使用高阶函数是非常方便的.

函数作为参数

假设我现在要对一个数组排序, 用我们熟悉的 sort

  [1,3,2,5,4].sort( (x, y) => x - y )

如果我们要逆序的排序, 把减号左右的 x 和 y 呼唤,就这么简单, 但如果我是一个对象数组, 要根据对象的id 排序:

  [{id:1, name:'one'},
   {id:3, name:'three'}, 
   {id:2, name:'two'}, 
   {id:5, name:'five'},  
   {id:4, name:'four'}].sort((x,y) => x.id - y.id)

是不是已经能够感受到高阶函数与匿名函数组合的灵活性.

函数作为返回值

函数的返回值可以不只是值, 同样也可以是一个函数, 来看 Eweda 内部的一个工具函数 aliasFor, 他的作用是给函数 E 的一些方法起一些别名:

听起来很怪不是吗, 函数怎么有方法, 实际上 JavaScript 的 function 是一个特殊 对象, 试试在 Firefox console 里敲 console.log. 是不是看到了一些方法, 但是 typeof console.log 是 function

  var E = () => {}
  var aliasFor = oldName => {
      var fn = newName => {
        E[newName] = E[oldName];
        return fn;
      };
      return (fn.is = fn.are = fn.and = fn);
  };

这里有两个 return, 一个是 fn 返回自己, 另一个是 aliasFor 也返回 fn, 并且给 fn 了几个别名fn.is fn.are...

什么意思呢? fn 返回 fn. 很简单就是 fn() => fn, 那么 fn()()=>fn()=>fn ...以此类推, 无论调用 fn 多少次,都最终返回 fn.

这到底有什么用呢, 由于这里使用了 fn 的副作用(side affect) 来干了一些事情 E[newName]=E[oldName], 也就是给 E 的方法起一个别名, 因此每次调用 fn 都会给 E 起一个别名. aliasFor 最后返回的是 fn 自己的一些别名, 使得可以 chain 起来更可读一些:

  aliasFor('reduce').is('reduceLeft').is('foldl')

另外, 函数作为返回值的重要应用, 柯里化与闭包, 将会在在后面专门介绍. 我们先来看下以函数作为参数的高阶函数的典型应用.

柯里化 currying

还记得 Haskell Curry吗

多巧啊, 人家姓 Curry 名 Haskell, 难怪 Haskell 语言会自动柯里化, 呵呵. 但是不奇怪吗, 为什么要柯里化呢. 为什么如此重要得让 Haskell 会默认自动柯里化所有函数, 不就是返回一个部分配置好的函数吗.

我们来看一个 Haskell 的代码.

  max 3 4
  (max 3) 4

结果都是4, 这有什么用呢.

这里看不出来, 放到 高阶函数 试试. 什么? 看不懂天书 Haskell, 来看看 JavaScript 吧.

我们来看一个问题

写一个函数, 可以连接字符数组, 如 f(['1','2']) => '12'

好吧,如果不用柯里化, 怎么写? 啊哈 reduce

  var concatArray = function(chars){
    return chars.reduce(function(a, b){
      return a.concat(b);
    });
  }
  concat(['1','2','3']) // => '123'

很简单,对吧.

现在我要其中所有数字加1, 然后在连接

  var concatArray = function(chars, inc){
    return chars.map(function(char){
      return (+char)+inc + '';
    }).reduce(function(a,b){
        return a.concat(b)
    });
  }
  console.log(concatArray(['1','2','3'], 1))// => '234'

所有数字乘以2, 再重构试试看

  var multiple = function(a, b){
    return +a*b + ''
  }
  var concatArray = function(chars, inc){
    return chars.map(function(char){
      return multiple(char, inc);
    }).reduce(function(a,b){
        return a.concat(b)
    });
  }
  console.log(concatArray(['1','2','3'], 2)) // => '246'

是不是已经看出问题了呢? 如果我在需要每个数字都减2,是不是很麻烦呢.需要将 map 参数匿名函数中的 multiple 函数换掉. 这样一来 concatArray 就不能同时处理加, 乘和减? 那么怎么能把他提取出来呢? 来对比下柯里化的解法.

柯里化函数接口

  var multiple = function(a){
    return function(b){
      return +b*a + ''
    }
  }

  var plus = function(a){
    return function(b){
      return (+b)+a + ''
    }
  }
  var concatArray = function(chars, stylishChar){
    return chars.map(stylishChar)
      .reduce(function(a,b){
        return a.concat(b)
    });
  }
  console.log(concatArray(['1','2','3'], multiple(2)))
  console.log(concatArray(['1','2','3'], plus(2)))

有什么不一样呢 1. 处理数组中字符的函数被提取出来, 作为参数传入 2. 提取成柯里化的函数, 部分配置好后传入, 好处显而易见, 这下接口非常通畅 无论是外层调用

concatArray(['1','2','3'], multiple(2))

还是内部的 map 函数

chars.map(stylishChar)

这些接口都清晰了很多, 不是吗

这就是函数式的思想, 用已有的函数组合出新的函数, 而柯里化每消费一个参数, 都会返回一个新的部分配置的函数, 这为函数组合提供了更灵活的手段, 并且使得接口更为流畅.

自动柯里化

在 Haskell 语言中, 函数是会自动柯里化的:

  max 3 4

其实就是

(max 3) 4

可以看看 max 与 max 3 函数的 类型

  ghci> :t max
  max :: Ord a => a -> a -> a

看明白了么, Ord a => 表示类型约束为可以比较大小的类型, 因此 max 的类型可以翻译成: 当给定一个a, 会得到 a -> a, 再看看 max 3 的类型就好理解了

ghci> :t max 3
(Num a, Ord a) => a -> a

左侧表示类型约束 a 可以是 Ord 或者 Num, 意思是 max 3 还是一个函数,如果给定一个 Ord 或者Num 类型的参数 则返回一个 Ord 或者 Num.

现在是不是清晰了, 在 Haskell 中每给定一个参数, 函数如果是多参数的, 该函数还会返回一个处理余下参数的函数. 这就是自动柯里化.

而在 Javascript(以及大多数语言) 中不是的, 如果给定多参函数的部分参数, 函数会默认其他参数是undefined, 而不会返回处理剩余参数的函数.

  function willNotCurry(a, b, c) {
      console.log(a, b, c)
      return a*b-c;
  }
  willNotCurry(1)
  // => NaN
  // => 1 undefined undefined

如果使用自动柯里化的库 eweda, 前面的例子简直就完美了

var multiple = curry(function(a, b){
  return +b*a + ''
})
var plus = curry(function(a, b){
  return (+b)+a + ''
})

函数组合 function composition

通过前面介绍的高阶函数, map, fold 以及柯里化, 其实已经见识到什么是函数组合了. 如之前例子中的 map 就是 由 fold 函数与 reverse 函数组合出来的.

这就是函数式的思想, 不断地用已有函数, 来组合出新的函数.

如图就是函数组合,来自 Catgory Theory(Funtor 也是从这来的,后面会讲到), 既然从 A到B 有对应的映射f,B到 C有对应的映射g, 那么 (g.f)(x) 也就是 f 与 g 的组合 g(f(x)) 就是 A到 C 的映射。上一章实现的 map 函数就相当于 reverse.fold.

Compose

我们可以用 Eweda 非常方便的 compose 方法来组合函数

  var gf = E.compose(f, g)

说到了函数组合, 柯里化, 我想现在终于可以解释清楚为什么在这里选用 Eweda/Ramda 而不是 Underscore 了.

举个例子🌰 如果我现在想要 tasks 列表中所有属性为 completed 为 true 的元素, 并按照 id 排序.

underscore 里会这样写:

  _(tasks)
      .chain()
      .filter( task => task.completed===true)
      .sortBy( task => task.id)
      .value();

这种方式怎么看都不是函数式, 而是以对象/容器为中心的串联,有些像 jquery 对象的链式调用, 或者我们可以写的函数式一些, 如

  _.sortBy(_.filter(tasks, task => task.completed===true), task => task.id)

恩恩, 看起来不错嘛, 但是有谁是这么用 underscore的呢. 一般都会只见过 链式调用才是 underscore 的标准写法。

来对比一下用 Eweda/Ramda 解决的过程 :

  compose(sortBy(task=>task.id), filter(task=>task.completed===true))(tasks)

好像没什么区别啊? 不就是用了 compose 吗?

区别大了这, 看见 tasks 是最后当参数传给 E.compose() 的吗? 而不是写死在filter 的参数中. 这意味着在接到需要处理的数据前, 我已经组合好一个新的函数在等待数据, 而不是把数据混杂在中间, 或是保持在一个中间对象中. 而 underscore 的写法导致这一长串 _.sortBy(_.filter()) 其实根本无法重用。

好吧如果你还看不出来这样做的好处. 那么来如果我有一个包含几组 tasks的列表 groupedTasks, 我要按类型选出 completed 为 true 并按 id 排序. 如我现在数据是这个:

groupedTasks = [
  [{completed:false, id:1},{completed:true, id:2}],
  [{completed:false, id:4},{completed:true, id:3}]
]

underscore:

  _.map(groupedTasks,
     tasks => _.sortBy(_.filter(tasks, task => task.completed===true), task => task.id))

看见我们又把 _.sortBy(_.filter()) 这一长串原封不动的拷贝到了 map 里。 因为 underscore 一开始就要消费数据,使得很难重用,除非在套在另一个函数里:

  function completedAndSorted(tasks){
    return _.sortBy(_.filter(tasks, task => task.completed===true), task => task.id))
  }
  _.map(groupedTasks, completedAndSorted)

只有这样才能重用已有的一些函数。或者虽然 underscore 也有 _.compose 方法,但是 几乎所有 underscore 的方法都是先消费数据(也就是第一个参数是数据),使得很难放到 compose 方法中,不信可以尝试把 filter 和 sortBy 搁进去,反正我是做不到。

来看看真正的函数组合

  var completedAndSorted = compose(sortBy(task=>task.id),
                                   filter(task=>task.completed===true))
  map(completedAndSorted, groupedTasks)

看出来思想完全不一样了吧.

由于 Eweda/Ramda 的函数都是自动柯里化,而且数据总是最后一个参数, 因此可以随意组合, 最终将需要处理的数据扔给组合好的函数就好了. 这才是函数式的思想. 先写好一个公式,在把数据扔给 公式。而不是算好一部分再把结果给另一个公式。

而 underscore 要么是以对象保持中间数据, 用 chaining 的方式对目标应用各种函数(书上会写这是Flow-Base programming,但我觉得其实是 Monad,会在下一章中介绍), 要么用函数嵌套函数, 将目标一层层传递下去.

pipe

类似 compose, eweda/ramda 还有一个方法叫 pipe, pipe 的函数执行方向刚好与 compose 相反. 比如pipe(f, g)f 会先执行, 然后结果传给 g, 是不是让你想起了 bash 的 pipe

find / | grep porno

实际上就是 pipe(find, grep(porno))(/)

没错,他们都是一个意思. 而且这个函数执行的方向更适合人脑编译(可读)一些.

+

 

如果你已经习惯 underscore 的这种写法

  _(data)
    .chain()
    .map(data1,fn1)
    .filter(data2, fn2)
    .value()

那么转换成 pipe 是很容易的一件事情,而且更简单明了易于重用和组合。

  pipe(
    map(fn1),
    filter(fn2)
  )(data)
posted @ 2017-07-26 20:12  天涯海角路  阅读(1378)  评论(0)    收藏  举报