Skip to content
On this page

函数计算得到函数

函数式编程中把函数当成值,那么能根据函数计算得到新函数吗?当然能。

通过函数计算得到新的函数,我们便可以写更小的函数,便于测试和维护。

高阶函数、柯里化、部分应用、组合和管道等,都是由函数计算得到函数的方法。

高阶函数

接收函数作为参数或者返回函数的函数叫高阶函数。

js 里又不少高阶函数:map、find、some、forEach、 setTimeout 等。

js
const hof = (this, fn) => fn.bind(this) // hof 是一个高阶函数

高级函数是函数的更高级级的函数抽象和封装,可以代码更容易理解和复用。

有哪些使用场景?

React 受控组件收集表单数据。

如果不使用高阶函数 每个表单项都要写一个事件处理函数,难以阅读和难以维护。

JSX
import React, { Component } from 'react'
class MyForm extends Component {
  state = {}
  render() {
    return (
      <div>
        <h2> 受控组件 </h2>
        <form>
          <label htmlFor='name'>
            用户名:
            <input
              type='text'
              id='name'
              name='myName'
              onChange={this.onChange('myName')}
            />
          </label>
          <label htmlFor='password'>
            年纪:
            <input
              type='number'
              id='password'
              name='age'
              onChange={this.onChange('age')}
            />
          </label>
        </form>
      </div>
    )
  }
  // 高阶函数
  onChange = key => event => {
    this.setState({
      [key]: event.target.value,
    })
  }
}

export default MyForm

或者这样写:

jsx
class MyForm extends Component {
  state = {}
  render() {
    return (
      <div>
        <h2> 受控组件 </h2>
        <form>
          <label htmlFor='name'>
            用户名:{' '}
            <input
              type='text'
              id='name'
              name='myName'
              onChange={event => {
                this.onChange('myName', event)
              }}
            />
          </label>
        </form>
      </div>
    )
  }
  // 高阶函数
  onChange = (key, event) => {
     this.setState({
      [key]: event.target.value,
    })
  }
}

export default MyForm

显然,高阶函数更加优雅。

部分应用

函数的默认参数

ES6 支持默认参数

js
const rangeNumber = (end, start = 0, step = 1) => {
  if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
  if (end < start) return []
  const list = []
  while (start <= end) {
    list.push(start)
    start += step
  }
  return list
}
console.log(rangeNumber(10)) // [0,1,2,3,4,5,6,7,8,9,10]
console.log(rangeNumber(10, 3)) // [3,4,5,6,7,8,9,10]
console.log(rangeNumber(10, void 0, 3)) // [0,3,6,9]
console.log(rangeNumber(10, 2, 2)) // [2,4,6,8]

默认参数使得传递部分参数即可调用函数,给使用函数带来方便。

默认参数有哪些问题?

  1. 默认参数只能放在参数列表的后面;
  2. 不能动态设置默认参数值。

通过闭包改善以上的问题

js
const rangeNumber = (start = 0, step = 1) => {
  return end => {
    if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
    if (end < start) return []
    const list = []
    // NOTE 不要直接修改 start 否则闭包会记住上次调用的值
    let begin = start
    while (begin <= end) {
      list.push(begin)
      begin += step
    }
    return list
  }
}
const numberFrom3to = rangeNumber(3)
console.log(numberFrom3to(10))
console.log(numberFrom3to(20))
const numberFrom4To = rangeNumber(4, 2)
console.log(numberFrom4To(10))

温习闭包

闭包:内层函数能记住外层函数作用域的特性。 闭包可访问三个作用域的变量:

  1. 全局作用域;
  2. 外部函数作用域,即外层函数的参数和局部变量;
  3. 自身声明的作用域,即内层函数的参数和局部变量。

闭包在外层函数执行时候创建,访问外层函数的作用域是使用闭包的目的。

部分应用、柯里化等技术,都依赖闭包。

函数式编程语言都有闭包的特性。

js
const outer = (count = 0) => {
  const scopedStr = 'vue'
  return function inner() {
    console.log(scopedStr)
    return ++count
  }
}
const count = outer()
const scopedStr = 'react' // 具有迷惑性
console.log(count()) // vue 1
console.log(count()) // vue 2
console.log(count()) // vue 3

outer 调用时创建了一个闭包,闭包能记住【外层函数】的作用域,在 count 函数调用时,还是能访问它的参数和局部变量。

js
const a = 100

function print(fn) {
  const a = 200
  fn()
}

function fn() {
  console.log(a)
}
print(fn) // 100

闭包、作用域都是在函数定义时确定的。

使用闭包对默认参数的限制有所改善,还是不够灵活:返回的函数只支持一个参数。

要是能有一个函数,将上面的 rangeNumber 函数自动计算得到一个支持传递部分参数的新函数,在使用时,支持 把所有参数分成任意两部分 传递,就更加灵活了,部分应用就是这种技术。

部分应用

将函数调用分成两个阶段,第一个阶段传递一部分参数,返回一个函数,第二阶段调用新的函数,传递剩余参数。 这种函数使用方式叫部分应用,可以有效拆分参数,给函数使用带来方便。

改写上面的函数:

js
const rangeNumber = (step, start, end) => {
  if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
  if (end < start) return []
  const list = []
  while (start <= end) {
    list.push(start)
    start += step
  }
  return list
}
// 部分应用函数
const partial = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...partialArgs.concat(otherParams))
  }
}
const numberFrom3to = partial(rangeNumber, 1, 3) // 生成步长为 1,开始为 3 的函数
numberFrom3to(10) // [3,4,5,6,7,8,9,10]
const numberStep2 = partial(rangeNumber, 2) // 生成步长为 2 的函数
const numberStep2From2 = partial(numberStep2, 2) // 再次部分应用
numberStep2(2, 14) // [2,4,6,8,10,12,14]
numberStep2From2(14) // [2,4,6,8,10,12,14]
const numberFrom3to10 = partial(rangeNumber, 1, 3, 10)
numberFrom3to10() // 同 numberFrom3to(10)

使用部分应用后,函数的参数更加灵活了。

上面的部分应用函数,第一阶段传递的参数最后调用时,是在左边的,它无法处理最终调用在右边的参数。

js
const delay100Ms = partial(setTimeout, 100)
// 等同于 setTimeout(100,() => {
//   console.log(100)
// })
// 无法执行
delay100Ms(() => {
  console.log(100)
})

编写一个处理右边参数的部分应用

js
const partialRight = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...otherParams.concat(partialArgs))
  }
}
const delay100Ms = partialRight(setTimeout, 100)
delay100Ms(() => {
  console.log(100)
})

向右边的部分应用,和函数的默认参数顺序一致,不用改写函数。

ES6 的默认参数是参数列表的末尾开始的。

Number.parseInt(numberStr, radix) radix 指定 numberStr 的进制,默认 10. 使用部分应用

js
const parseDecimal = partialRight(Number.parseInt, 10)
const parseBinary = partialRight(Number.parseInt, 2)
const parseHex = partialRight(Number.parseInt, 16)
parseDecimal('10') // 10
parseBinary('10') // 2
parseHex('1110A') // 69898

向右 vs 向左

向右的部分应用不用改写原函数,能充分利用函数的默认参数,向左的则不能,默认情况下都是向右的才是我们希望的,向左的我们另外定义函数。

js
const partial = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...otherParams.concat(partialArgs))
  }
}
const prettyPrintJson = partial(JSON.stringify, null, 2)
prettyPrintJson({
  name: 'jack'
})

const partialLeft = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...partialArgs.concat(otherParams))
  }
}

柯里化

对函数多次部分应用,得到的新函数的参数越来越少。

一次性对函数的所有参数部分应用,也要再调用一次函数才能得到结果。

可以不必再调用一次函数吗?柯里化能避免再调用一次函数。

柯里化

将多参函数变成一系列单参函数的手段。

比如:

js
function sum(a, b, c) {
  return a + b + c
}

function curriedSum(a) {
  return function(b) {
    return function(c) {
      return a + b + c
    }
  }
}
sum(1, 2, 3)
curriedSum(1)(2)(3)

希望编写一个函数,自动把 sum 变成单参函数。

js
const curryFn = fn => a => b => c => fn(a, b, c)

上面柯里化一个三个参数的函数,任意数量的参数如何柯里化呢?

通用的柯里化函数

js
const curry = fn => {
  if (typeof fn !== 'function') {
    throw new Error('no function provided!')
  }
  // NOTE 为何不使用箭头函数?因为需要递归调用
  return function curriedFn(...args) {
    // 实参数数量小于形参数量
    // NOTE fn.length 必需参数的数量
    if (args.length < fn.length) {
      return function() {
        // 实际参数一直在合并,一定会等到两者相等的时候
        return curriedFn(...args.concat(Array.from(arguments)))
      }
    }
    return fn(...args)
  }
}

关键代码:

js
if (args.length < fn.length) {
  return function() {
    // 实际参数一直在合并,一定会等到两者相等的时候
    return curriedFn(...args.concat(Array.from(arguments)))
  }
}

实参数量小于形参数量,就递归调用

为何不使用箭头函数呢?

因为在函数内部需要使用 arguments 获取实参。

箭头函数的特性

  1. 没有 this、super、arguments、new.target: 由所在的、最靠近的非箭头函数来决定;
  2. 不能当成构造函数:没有[[Construct]]方法;
  3. 没有 prototype 属性,因为 2;
  4. 没有 arguments 对象:既然箭头函数没有 arguments 绑定,你必须依赖于具名参数或剩余参数来访问函数的参数;
  5. 不允许重复的具名参数:箭头函数不允许拥有重复的具名参数,无论是否在严格模式下;而相对来说,传统函数只有在严格模式下才禁止这种重复;
  6. 不能作为生成器:因为不能使用yield操作符号;

Array.from(arguments) 将类类数组转为数组。

常见的转 arguments 为数组的方法

按照性能排序:

  1. 剩余参数:const toArray=(...params)=>params
  2. for 循环:arguments[i];
  3. [].slice.call(arguments)
  4. Array.from(arguments)

将上面的改成箭头函数的写法:

js
const curry = fn => {
  if (typeof fn !== 'function') {
    throw new Error('no function provided!')
  }
  // 因为要递归,使用箭头函数会不方便
  return function curriedFn(...args) {
    // 递归出口放在前面,更加好理解
    if (args.length === fn.length) {
      return fn(...args)
    }
    // 箭头函数没有 arguments 需要显示给出参数
    return (...params) => {
      return curriedFn(...args.concat(params))
    }
  }
}

柯里化如何改善代码

有一个日志函数:

js
const loggerHelper = (mode, initialMessage, errorMessage, lineNO) => {
  switch (mode) {
    case 'DEBUG':
      console.debug(initialMessage, `${errorMessage} at line ${lineNO}`)
      break
    case 'ERROR':
      console.error(initialMessage, `${errorMessage} at line ${lineNO}`)
      break
    case 'WARN':
      console.warn(initialMessage, `${errorMessage} at line ${lineNO}`)
      break
    default:
      throw new Error('Wrong mode!')
  }
}

// 习惯样调用
loggerHelper('ERROR', 'Error at index.js', '报错了', 10)
loggerHelper('ERROR', 'Error at main.js', '报错了', 13)

还能改善吗?

以上调用在传递很多重复参数,可以使用柯里化改善。

柯里化帮助去除重复参数和样板代码

js
const errorLog = curry(loggerHelper)('ERROR')
errorLog('Error at index.js', '报错了', 10)
errorLog('Error at index.js')('报错了', 10)
errorLog('Error at index.js')('报错了')(13)
// 柯里化时传递所有参数,得到最后的调用结果
curry(loggerHelper)('ERROR', 'Error at main.js', '报错了', 130)
// 部分应用所有参数,需要再调用一次函数才能得到结果
const errorLog2 = partialLeft(loggerHelper, 'ERROR', 'Error at index.js', '报错了', 130)
errorLog2()

再来一个例子:

js
const fruits = ['apple', 'mango', 'orange']
const newFruits = fruits.filter(function(name) {
  return name.startsWith('a') // NOTE 希望这里的参数是动态的,即调用 filter 时才传入参数
})

改进 1:

js
function startsWith(text, name) {
  return name.startsWith(text)
}
const newFruits = fruits.filter(fruit => startsWith('a', fruit))

达到目的了,但是我们不得不在 filter 的回调函数中再次调用 startsWith,希望 filter(startsWith('a')) , 显然这个更加可读,如何办?

改进 2:

把 startsWith 柯里化, 参数顺序 很重要,外层函数的参数一般是固定的。

js
function startsWith(text) {
  return function(name) {
    return name.startsWith(text)
  }
}
const newFruits = fruits.filter(startsWith('a'))

部分应用 vs 柯里化

目的相同

通过部分应用和柯里化,能把多参函数变成参数更小、行为更加具体的函数,方便使用。

参数更少:调用时更加方便,只需要关注变化的参数。

行为更加具体:反映到函数名称上,让函数更加自文档化。

使用方式不同

柯里化时更加彻底的部分应用,使用部分应用,还需要再调用一次函数。

fn.length vs arguments.length

函数有一个 length 属性,表示 必需 的参数数量, arguments.length 表示实际参数数量,当有默认参数和剩余参数时,两者可能不等。

我们的柯里化函数用到了 fn.length ,因此无法处理默认值。

curry 函数对参数的处理是向左的,即先传递的而参数,放在 fn 的前面。还可向右,不常用,不写了。

为了能使用函数的默认值,可以在提供一个参数。

js
const curry = (fn, argsSize = fn.length) => {
  if (typeof fn !== 'function') {
    throw new Error('no function provided!')
  }
  return function curriedFn(...args) {
    if (args.length === argsSize) {
      return fn(...args)
    }
    return (...params) => {
      return curriedFn(...args.concat(params))
    }
  }
}

const rangeNumber = (end, start = 1, step = 1) => {
  if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
  if (end < start) return []
  const list = []
  while (start <= end) {
    list.push(start)
    start += step
  }
  return list
}

console.log(curry(rangeNumber)(10)) // start = 1 step = 1
console.log(curry(rangeNumber, 2)(10)(5)) // step = 1
console.log(curry(rangeNumber, 3)(10, 5)(2)) // start = 5 step 2

curry(
  setTimeout,
  2 // 不传递,会是 5
  //  TODO 为何是 5 ? 没查到相关资料
)(() => {
  console.log('1000毫秒')
})(1000)

柯里化性能差?

使用频繁的函数不要柯里化

由于柯里化层层嵌套,当参数很多,嵌套会很深,频繁执行的函数,柯里化后比不柯里化的函数慢。

组合

Unix 或者 Linux 命令,想要计算单词 word 在给定文本中出现的次数:

bash
cat test.txt | grep 'word' | wc

一个复杂的任务,通过管道组合三个简单的命令就完成了。

unix 理念

  1. 每个程序只做好一件事。为了完成一个新任务,重新构建要比在复杂的旧程序中添加新功能困难。
  2. 每个程序的输出应该是另一个程序的输入。

理念 1:应该写职责单一的小函数,然后组合它们来处理复杂任务。

理念 2:小函数都需要输入,然后返回数据。

函数式编程中主张写小函数,复杂任务的处理通过组合小函数来完成。 因为函数越小,越容易复用和维护。

js
const compose = (fnA, fnB) => c => fnA(fnB(c))

形如这样,函数 fnB 的输出作为 fnA 输入的函数,叫函数组合。

先调用 fnB,再调用 fnA。也可以先调用 fnA ,再调用 fnB。

例子,给一个字符串数字进行四舍五入:

js
const num = '4.56'
// 常规写法
const data = Number.parseFloat(num)
const round = Math.round(data)
// 或者
const round2 = Math.round(Number.parseFloat(num))
// 组合函数的写法
const number = compose(Math.round, Number.parseFloat)
const result = number(num)

再看一个例子:统计 hello function programming 中单词个数

js
const splitSentence = str => str.split(' ')
const count = array => array.length
const str = `hello function programming`
const wordCount = compose(count, splitSentence)(str)

以上例子,都是一个参数的函数组合,而且例子太简单,多个参数还能组合吗?

多个参数的组合

通过柯里化和部分应用,可以把多参函数转为一参函数,然后组合他们。

js
const books = [{
    name: 'vue',
    price: 45.5,
    author: 'Even You',
    rate: 9.4
  },
  {
    name: 'react',
    price: 50.5,
    author: 'facebook',
    rate: 9.7
  },
  {
    name: 'angular',
    price: 60.5,
    author: 'google',
    rate: 3.7
  },
  {
    name: 'jquery',
    price: 40.5,
    author: 'apache',
    rate: 4.7
  },
]
const filterGoodBooks = book => book.rate > 5

const projectNameAndAuthor = book => ({
  name: book.name,
  author: book.author
})
const projectName = book => ({
  name: book.name
})

const queryGoodBooks = partial(filter, filterGoodBooks)
const mapTitleAndAuthor = partial(map, projectNameAndAuthor)

const nameAndAuthorForGoodBooks = compose(mapTitleAndAuthor, queryGoodBooks)
nameAndAuthorForGoodBooks(books)

const nameForGoodBooks = compose(partial(map, projectName), queryGoodBooks)
nameForGoodBooks(books)

任意函数的组合

目前 compose 只能组合两个函数,想要组合两个以上函数如何写?

使用 reduce 进行归约

js
const compose = (...fns) => {
  // 存在一个不是函数 立即返回
  if (fns.some(fn => typeof fn !== 'function')) return
  return value => fns.reverse().reduce((acc, fn) => fn(acc), value)
}

上面统计单词的例子,还向指导单词数是奇数还是偶数。

js
const oddOrEven = count => (count % 2 === 0 ? 'even' : 'odd')
const oddOrEvenWords = compose(oddOrEven, count, splitSentence)
const str = `hello function programming react`
console.log(oddOrEvenWords(str)) // even

为何要反转参数?

fns.reverse() ,反转参数,希望从最后一个函数开始调用。

compose(oddOrEven, count, splitSentence) splitSentence 最开始调用。

管道

希望能像 linux 命令那样使用 | 来连接多个命令,操作结果依次传递,即希望函数从左到右执行。

js
const pipe = (...fns) => {
  // 存在一个不是函数 立即返回
  if (fns.some(fn => typeof fn !== 'function')) return
  return value => fns.reduce((acc, fn) => fn(acc), value)
}

使用上面的例子测试:

js
const oddOrEvenWords = pipe(splitSentence, count, oddOrEven)
const str = `hello function programming react`
console.log(oddOrEvenWords(str)) // even

组合 vs 管道

思路一样,数据流向不同。感觉管道更符合直觉。

管道和组合都满足结合律

js
compose(compose(fnA, fnB), fnC) === compose(fnA, compose(fnB, fnC))
pipe(pipe(fnA, fnB), fnC) === pipe(fnA, pipe(fnB, fnC))

再添加一个返回 true 或者 false 的函数

js
const isOdd = str => str === 'odd'
const isOddWords = pipe(splitSentence, pipe(count, oddOrEven), isOdd)
const str = `hello function programming react`
console.log(isOddWords(str)) // false

如何调试组合函数中的错误

组合和管道都是一些函数连续执行,如何知道哪个函数执行错误呢?

可以在中间加入日志输出函数。

js
const identity = value => {
  console.log(value)
  return value
}

因为每个函数的处理的数据都不同,根根据数据输出,很容易推断出问题。

比如,想知道 splitSentence 的处理结果。

js
pipe(splitSentence, identity, pipe(count, oddOrEven), isOdd)

Released under the MIT License.