Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

函数计算得到函数 #109

Open
peng-yin opened this issue Mar 28, 2023 · 0 comments
Open

函数计算得到函数 #109

peng-yin opened this issue Mar 28, 2023 · 0 comments

Comments

@peng-yin
Copy link
Owner

函数计算得到函数

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

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

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

高阶函数

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

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]

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

默认参数有哪些问题?

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

通过闭包改善以上的问题

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))

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

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

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

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

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

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函数自动计算得到一个支持传递部分参数的新函数,在使用时,支持把所有参数分成任意两部分传递,就更加灵活了,部分应用就是这种技术。

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

改写上面的函数:

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))
  }
}

柯里化

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

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

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

::: tip 柯里化
将多参函数变成一系列单参函数的手段。
:::

比如:

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获取实参。

::: tip 箭头函数的特性

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

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

::: tip 常见的转 arguments 为数组的方法
按照性能排序:

  1. 剩余参数:const toArray=(...params)=>params
  2. for 循环:arguments[i];
  3. [].slice.call(arguments)
  4. 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)

还能改善吗?

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

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

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)

柯里化性能差?

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

组合

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

cat test.txt | grep 'word' | wc

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

::: tip unix 理念

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

:::

理念 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant