函数计算得到函数
函数式编程中把函数当成值,那么能根据函数计算得到新函数吗?当然能。
通过函数计算得到新的函数,我们便可以写更小的函数,便于测试和维护。
高阶函数、柯里化、部分应用、组合和管道等,都是由函数计算得到函数的方法。
高阶函数
接收函数作为参数或者返回函数的函数叫高阶函数。
js 里又不少高阶函数:map、find、some、forEach、 setTimeout 等。
const hof = (this, fn) => fn.bind(this) // hof 是一个高阶函数
高级函数是函数的更高级级的函数抽象和封装,可以代码更容易理解和复用。
有哪些使用场景?
React 受控组件收集表单数据。
如果不使用高阶函数 每个表单项都要写一个事件处理函数,难以阅读和难以维护。
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
或者这样写:
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 支持默认参数
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]
默认参数使得传递部分参数即可调用函数,给使用函数带来方便。
默认参数有哪些问题?
- 默认参数只能放在参数列表的后面;
- 不能动态设置默认参数值。
通过闭包改善以上的问题
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))
温习闭包
闭包:内层函数能记住外层函数作用域的特性。 闭包可访问三个作用域的变量:
- 全局作用域;
- 外部函数作用域,即外层函数的参数和局部变量;
- 自身声明的作用域,即内层函数的参数和局部变量。
闭包在外层函数执行时候创建,访问外层函数的作用域是使用闭包的目的。
部分应用、柯里化等技术,都依赖闭包。
函数式编程语言都有闭包的特性。
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 函数调用时,还是能访问它的参数和局部变量。
const a = 100
function print(fn) {
const a = 200
fn()
}
function fn() {
console.log(a)
}
print(fn) // 100
闭包、作用域都是在函数定义时确定的。
使用闭包对默认参数的限制有所改善,还是不够灵活:返回的函数只支持一个参数。
要是能有一个函数,将上面的 rangeNumber
函数自动计算得到一个支持传递部分参数的新函数,在使用时,支持 把所有参数分成任意两部分
传递,就更加灵活了,部分应用就是这种技术。
部分应用
将函数调用分成两个阶段,第一个阶段传递一部分参数,返回一个函数,第二阶段调用新的函数,传递剩余参数。 这种函数使用方式叫部分应用,可以有效拆分参数,给函数使用带来方便。
改写上面的函数:
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)
使用部分应用后,函数的参数更加灵活了。
上面的部分应用函数,第一阶段传递的参数最后调用时,是在左边的,它无法处理最终调用在右边的参数。
const delay100Ms = partial(setTimeout, 100)
// 等同于 setTimeout(100,() => {
// console.log(100)
// })
// 无法执行
delay100Ms(() => {
console.log(100)
})
编写一个处理右边参数的部分应用
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. 使用部分应用
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 向左
向右的部分应用不用改写原函数,能充分利用函数的默认参数,向左的则不能,默认情况下都是向右的才是我们希望的,向左的我们另外定义函数。
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))
}
}
柯里化
对函数多次部分应用,得到的新函数的参数越来越少。
一次性对函数的所有参数部分应用,也要再调用一次函数才能得到结果。
可以不必再调用一次函数吗?柯里化能避免再调用一次函数。
柯里化
将多参函数变成一系列单参函数的手段。
比如:
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 变成单参函数。
const curryFn = fn => a => b => c => fn(a, b, c)
上面柯里化一个三个参数的函数,任意数量的参数如何柯里化呢?
通用的柯里化函数
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)
}
}
关键代码:
if (args.length < fn.length) {
return function() {
// 实际参数一直在合并,一定会等到两者相等的时候
return curriedFn(...args.concat(Array.from(arguments)))
}
}
实参数量小于形参数量,就递归调用
。
为何不使用箭头函数呢?
因为在函数内部需要使用 arguments
获取实参。
箭头函数的特性
- 没有 this、super、arguments、new.target: 由所在的、最靠近的非箭头函数来决定;
- 不能当成构造函数:没有[[Construct]]方法;
- 没有 prototype 属性,因为 2;
- 没有 arguments 对象:既然箭头函数没有 arguments 绑定,你必须依赖于具名参数或剩余参数来访问函数的参数;
- 不允许重复的具名参数:箭头函数不允许拥有重复的具名参数,无论是否在严格模式下;而相对来说,传统函数只有在严格模式下才禁止这种重复;
- 不能作为生成器:因为不能使用
yield
操作符号;
Array.from(arguments)
将类类数组转为数组。
常见的转 arguments 为数组的方法
按照性能排序:
- 剩余参数:
const toArray=(...params)=>params
; - for 循环:arguments[i];
[].slice.call(arguments)
;Array.from(arguments)
;
将上面的改成箭头函数的写法:
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))
}
}
}
柯里化如何改善代码
有一个日志函数:
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)
还能改善吗?
以上调用在传递很多重复参数,可以使用柯里化改善。
柯里化帮助去除重复参数和样板代码
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()
再来一个例子:
const fruits = ['apple', 'mango', 'orange']
const newFruits = fruits.filter(function(name) {
return name.startsWith('a') // NOTE 希望这里的参数是动态的,即调用 filter 时才传入参数
})
改进 1:
function startsWith(text, name) {
return name.startsWith(text)
}
const newFruits = fruits.filter(fruit => startsWith('a', fruit))
达到目的了,但是我们不得不在 filter 的回调函数中再次调用 startsWith,希望 filter(startsWith('a'))
, 显然这个更加可读,如何办?
改进 2:
把 startsWith 柯里化, 参数顺序
很重要,外层函数的参数一般是固定的。
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 的前面。还可向右,不常用,不写了。
为了能使用函数的默认值,可以在提供一个参数。
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
在给定文本中出现的次数:
cat test.txt | grep 'word' | wc
一个复杂的任务,通过管道组合三个简单的命令就完成了。
unix 理念
- 每个程序只做好一件事。为了完成一个新任务,重新构建要比在复杂的旧程序中添加新功能困难。
- 每个程序的输出应该是另一个程序的输入。
理念 1:应该写职责单一的小函数,然后组合它们来处理复杂任务。
理念 2:小函数都需要输入,然后返回数据。
函数式编程中主张写小函数,复杂任务的处理通过组合小函数来完成。 因为函数越小,越容易复用和维护。
const compose = (fnA, fnB) => c => fnA(fnB(c))
形如这样,函数 fnB
的输出作为 fnA
输入的函数,叫函数组合。
先调用 fnB,再调用 fnA。也可以先调用 fnA ,再调用 fnB。
例子,给一个字符串数字进行四舍五入:
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
中单词个数
const splitSentence = str => str.split(' ')
const count = array => array.length
const str = `hello function programming`
const wordCount = compose(count, splitSentence)(str)
以上例子,都是一个参数的函数组合,而且例子太简单,多个参数还能组合吗?
多个参数的组合
通过柯里化和部分应用,可以把多参函数转为一参函数,然后组合他们。
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 进行归约
const compose = (...fns) => {
// 存在一个不是函数 立即返回
if (fns.some(fn => typeof fn !== 'function')) return
return value => fns.reverse().reduce((acc, fn) => fn(acc), value)
}
上面统计单词的例子,还向指导单词数是奇数还是偶数。
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 命令那样使用 |
来连接多个命令,操作结果依次传递,即希望函数从左到右执行。
const pipe = (...fns) => {
// 存在一个不是函数 立即返回
if (fns.some(fn => typeof fn !== 'function')) return
return value => fns.reduce((acc, fn) => fn(acc), value)
}
使用上面的例子测试:
const oddOrEvenWords = pipe(splitSentence, count, oddOrEven)
const str = `hello function programming react`
console.log(oddOrEvenWords(str)) // even
组合 vs 管道
思路一样,数据流向不同。感觉管道更符合直觉。
管道和组合都满足结合律
compose(compose(fnA, fnB), fnC) === compose(fnA, compose(fnB, fnC))
pipe(pipe(fnA, fnB), fnC) === pipe(fnA, pipe(fnB, fnC))
再添加一个返回 true 或者 false 的函数
const isOdd = str => str === 'odd'
const isOddWords = pipe(splitSentence, pipe(count, oddOrEven), isOdd)
const str = `hello function programming react`
console.log(isOddWords(str)) // false
如何调试组合函数中的错误
组合和管道都是一些函数连续执行,如何知道哪个函数执行错误呢?
可以在中间加入日志输出函数。
const identity = value => {
console.log(value)
return value
}
因为每个函数的处理的数据都不同,根根据数据输出,很容易推断出问题。
比如,想知道 splitSentence
的处理结果。
pipe(splitSentence, identity, pipe(count, oddOrEven), isOdd)