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

函数式编程 #1

Open
draculapile opened this issue Feb 23, 2022 · 0 comments
Open

函数式编程 #1

draculapile opened this issue Feb 23, 2022 · 0 comments
Labels
curry Functional Programming Currying FP JavaScript

Comments

@draculapile
Copy link
Owner

draculapile commented Feb 23, 2022

函数式编程

本篇由一个常见的面试题引入,尝试了解下函数式编程在前端的应用

add 函数

实现 add 函数,支持如下调用和返回:

add(1)(2)            // 3
add(1)(2)(3)         // 6
add(1)(2)(3)(4)      // 10
// ...

实现思路

  1. 每次传入一个参数
  2. add(1) 的结果需要跟 2 一起作为 add(2) 的输入,依此类推...

利用闭包的知识,容易想到最笨的实现:

// 1. add(1) 有一个参数;
// 2. add 函数返回的是一个函数,这样才能实现连续调用
function add(x) {
  let sum = x
  return function(y) {
    sum += y
    return function(z) {
      // ...
      // 最终 return sum
    }
  }
}

// 简化一下:
function add(x) {
  return function(y) {
    return x + y
    // ...return more
  }
}

// 甚至再简化一下
const add = x => y => z => x + y + z // ... more

显然这是不现实的,因为随着累加次数增大,无法维护

继续观察,容易想到:对重复性的操作(累加)可以使用递归(一个函数通过名称调用自己)来实现,而递归则需要终止条件,结合题目,先试一下把『不再有参数输入』作为终止条件:

function add(x) {
  let sum = x || 0
  return function addSum(y) {
    if (y === undefined) { // 不再有参数输入
    	return sum
    } else {
      sum += y
      return addSum
    }
  }
}

// 调用方式:
add(1)(2)(3)() 			// 6
// ...

那么 y === undefined 怎么成立呢?自然是 add(1)(2)...(),最后必须进行一次不传参数的调用,这貌似不符合题目要求

如果不判断『是否有参数输入』呢?代码如下:

function add(x) {
  // console.log(x)
  let sum = x || 0 
  const addSum = (y) => {
    // console.log(y)
    sum += y
    // console.log(sum)
    return addSum
  }
  return addSum
}

执行 add(1)(2)(3),显然它的输出结果是一个函数,而且没有把计算结果返回,这次递归的终止条件是『不再有函数调用』,打印可发现 sum 是已经计算完毕的,如何将 sum 与返回的函数绑定?

函数是需要调用的,既然不考虑显式调用,那么将值储存起来,需要的时候,进行隐式调用。隐式调用让人联想到隐式转换:

function add(x) {
  let sum = x || 0 
  const addSum = (y) => {
    sum += y
    return addSum
  }
  // 改写函数的 toString 方法,存储 sum 值
  addSum.toString = () => {
    return sum
  }
  
  return addSum
}

// 调用时始终需要隐式转换
console.log('%s', add(1)(2)(3))        // 6
add(1)(2)(3) + 2                       // 8

隐式转换的扩展:

由于函数也是一个对象,当调用隐式转换时,会遵循对象的转换规则

  1. 如果部署了 Symbol.toPrimitive 方法,优先调用再返回;
  2. 调用 valueOf(),如果转换为基础类型,则返回;
  3. 调用 toString(),如果转换为基础类型,则返回;

扩展

对题目要求进行扩展

// what if?

add(1, 2)            // 3
add(1)(2)            // 3
add(1, 2)(3)         // 6
// ...

这时就需要从 arguments 上考虑:

function add(...args) { // 收集参数放到 args 上, args 为 Array 实例
  let allArgs = [...args]
  function addSum(...innerArgs) {
    // 利用递归,收集所有的参数
    allArgs.push(...innerArgs)
    return addSum
  }
  
  // 存储参数,隐式调用时,进行参数的求和计算
  addSum.toString = () => {
    return allArgs.reduce((pre, cur) => pre + cur, 0)
  }
  
  return addSum
}

// 调用时始终需要隐式转换
console.log('%s', add(1, 2)(3)(4)) 		// 10
add(1, 2)(3)(4) + 2 					// 12

柯里化

容易写出求和函数如下,它有『多个参数』

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

对比上面的题目,虽然结果一样,但是 add 函数拥有『较少参数』

计算机科学中,柯里化(英語:Currying),又译为卡瑞化或加里化,是把接受多个参数函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凱爾·加里命名的,尽管它是Moses Schönfinkel戈特洛布·弗雷格发明的。
——维基百科

柯里化函数通常由以下步骤动态创建:调用另一个函数并为它传入要柯里化的函数和必要参数
——《JavaScript 高级程序设计(第三版)》

核心的点在于:参数数量的变化。变化不意味着丢失,所以每次调用 curried 函数时,需要进行『参数收集』,直到收集了非柯里化函数的所有参数

根据定义,首先尝试完成 sum(a, b, c)add(a)(b)(c) 的转换:

function curry(fn) {
  // 1.返回一个函数
  // 2.返回的函数只接收一个参数 a
  return function(a) {
    // 1.再次返回一个函数
    // 2.接收参数 b
    return function(b) {
      // ...同上
      return function(c) {
        // a, b, c 都拿到了,接下来传递 fn 的调用
        return fn(a, b, c)
      }
    }
  }
}

// 调用
const add = curry(sum)

console.log(add(1)(2)(3))

同样,基于闭包和递归的知识进行抽象。一个通用的用于柯里化转换的函数实现如下:

// ES5 版本
function curry(fn, args) {
  var args = args || []
  return function () {
    var newArgs = args.concat(Array.prototype.slice.call(arguments))
    if (newArgs.length < fn.length) {
      return curry.call(this, fn, newArgs)
    } else {
      return fn.apply(this, newArgs)
    }
  }
}

// 调用
const add = curry(sum)

console.log(add(1)(2)(3))    // 6

为了方便理解,它的流程图:

注意:我们使用了 fn.length 取到了函数参数(形参)的个数,来终止递归。因此,在进行柯里化时,我们必须要能确定待柯里化函数参数的个数!

使用函数参数扩展和箭头函数的 ES6 版本的实现

// 尽量保留可读性
const curry = (fn, ...args) => 
  args.length >= fn.length
    ? fn(...args)
    : (...newArgs) => curry(fn, ...args, ...newArgs)

// 调用方式同上

柯里化的应用

固化参数

Vue 的 patch 方法

Vue 源码在 src/core/vdom/patch.js 中,定义了一个 createPatchFunction 方法:

// 定义
export function createPatchFunction (backend) {
  // ...
  return function patch(/* 参数 */) {
    
  }
}

// 调用
// nodeOps 为操作 dom 的方法,modules 为不同平台的模块
export const patch = createPatchFunction({ nodeOps, modules })

这样在将数据渲染到视图的 __patch__ 过程中,就可以直接调用最终导出的 patch 方法,而不用每次都重复传递 nodeOpsmodules 参数:

Vue.prototype.__patch__ = inBrowser ? patch : noop
// ...
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)

提前确认

map 映射

查找一个值是否在一个较长的集合中,或许可以这么编写:

// val: string, map: string
function isInMap(val, map) {
  const arr = map.split(,)
  let flag
  for (let i = 0; i < arr.length; i++) {
    if (val === arr[i]) {
      flag = true
      break
    }
  }
  return flag
}

试想:对于 map 很长的情况,每次调用都需要传递 map 参数。还是以 Vue 为例,判断字符串是否为 HTML 标签,它的 map 是这样的:

const tagMap = 
  'html,body,base,head,link,meta,style,title,' +
  'address,article,aside,footer,header,h1,h2,h3,h4,h5,h6,hgroup,nav,section,' +
  'div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,' +
  'a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,rtc,ruby,' +
  's,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,' +
  'embed,object,param,source,canvas,script,noscript,del,ins,' +
  'caption,col,colgroup,table,thead,tbody,td,th,tr,' +
  'button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,' +
  'output,progress,select,textarea,' +
  'details,dialog,menu,menuitem,summary,' +
  'content,element,shadow,template,blockquote,iframe,tfoot'


// 查询是否是 HTML 标签
const isHTMLTag = isInMap('div', tagMap)

在多次调用的情况下,每一次都需要 for 循环遍历,会增大开销。而 Vue 中为了减少开销,是这么做的:

function makeMap(str) {
  const map = Object.create(null)
  const list = str.split(',')
  for (let i = 0; i < list.length; i++) {
    map[list[i]] = true
  }
  return map[val]
}

const isHTMLTag = makeMap(tagMap)

// 每次调用时只需要:
isHTMLTag('div')
isHTMLTag('span')
// ...

ES5 bind 的实现

利用函数柯里化可以实现 ES5 的 bind

function bind(fn, context) {
  var args = Array.prototype.slice.call(arguments, 2)
  return function() {
    var innerArgs = Array.prototype.slice.call(arguments)
    var finalArgs = args.concat(innerArgs)
    return fn.apply(context, finalArgs)
  }
}

函数式编程

函数式编程(Functional Programming)区别于面向对象编程(Object-Oriented Programming)

面向对象编程注重过程和抽象,本质是利用抽象(值或方法)来访问数据(对象)

函数式编程,顾名思义,需要正确理解函数并运用函数。在 ES 中:

  1. 函数的参数 arguments 是一个类数组对象;
  2. 函数可以作为参数传递

在写 JS 代码时,可以适当利用函数式编程的一些思想

组合函数技巧

以 Node.js 库 koa 和 redux 为例

koa 中间件执行机制的实现

  1. Koa 通过 node.js 实现了一个十分具有表现力的 HTTP 中间件框架,力求让 Web 应用开发和 API 使用更加地愉快。Koa 的中间件之间按照编码顺序在栈内依次执行,允许您执行操作并向下传递请求(downstream),之后过滤并逆序返回响应(upstream);
  2. 注册中间件:use(),传递请求:next()

koa 应用中,以下示例代码的输出和执行顺序图示:

const Koa = require('koa')
const app = new Koa()

// mw1
app.use(async (ctx, next) => {
  console.log('mw1')
  await next()
  console.log('mw1 ended')
})
// mw2
app.use(async (ctx, next) => {
  console.log('mw2')
  await next()
  console.log('mw2 ended')
})
// mw3
app.use(async (ctx, next) => {
  console.log('mw3')
})

app.listen(3000)

// mw1
// mw2
// mw3
// mw2 ended
// mw1 ended

执行顺序图示:

    +---------------------------------+
    |#1: 1                      #1: 1 |
    |      +-------------------+      |
    |      |#2: 2         #2: 2|      |
    |      |     +-------+     |      |
    |      |     | #3: 3 |     |      |
    |      |     |       |     |      |
+--执行顺序---------------------------------->
    |      |     |       |     |      |
    |      |     |       |     |      |
    |      |     +-------+     |      |
    |      |                   |      |
    |      +-------------------+      |
    |                                 |
    +---------------------------------+

koa 通过 koa-compose来实现这一机制:

// compose 函数核心逻辑

// middleware 是一个函数数组 [mw1, mw2, ...]
function compose (middleware) {
  // ...

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        // core
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

koa 使用compose

const fn = compose(this.middleware)

结合示例代码分析一下函数的调用顺序:

  1. 中间件是一个 async 函数,它返回一个 Promise
  2. Promise.resolve(value) 方法返回一个以给定值解析后的 Promise 对象。如果这个值是一个 promise ,那么将返回这个 promise
  3. JS 的函数调用栈遵循 FILO(先进后出)的原则
// 		application 				     内部
// --------------------------------------------------------------
// 1. app.use(fn) 		=> 		middleware.push(fn)
//    ...
// 2. app.listen(port) 				
// 3. localhost:port 		=> 		callback()
// 4. 				=> 		const fn = compose(middleware)
// 5. 				=> 		执行 fn(ctx).then(handleResponse).catch(onerror)

// 6. 				=> 		dispatch(0)
// 7. 				=> 		执行 mw1(context, dispatch.bind(null, 0 + 1))
// 8. console.log('mw1')	 		
// 9. await next() 		=> 		await dispatch(1) 
//                                              等待 dispatch(1) 执行;console.log('mw1 finished') 入栈

// 10. 				=> 	        dispatch(1)
// 11. 				=> 		执行 mw2(context, dispatch.bind(null, 1 + 1))
// 12. console.log('mw2')	 		
// 13. await next() 		=> 		await dispatch(2) 
//                                              等待 dispatch(2) 执行;console.log('mw2 finished') 入栈

// 14. 				=> 	        dispatch(2)
// 15. console.log('mw3')	 		
// 16. next === undefined 	=>              fn === undefined
// 17. 				=>		Promise.resolve()
// 18. 				=>		dispatch(2) 为 fulfilled,从栈取出并执行
// 19. console.log('mw2 ended')	
// 20. console.log('mw1 ended')

// 21. 				=> 		handleResponse

redux compose

同样的,在 redux 中,也有组合中间件的 compose 函数实现:

function compose(...funcs) {
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

不可变性和无副作用的思想

这两者更偏向于理论或者说约定的原则。比如 Vue 的父子组件规定了单向数据流;在 React 中,强调 props 的只读性

函数式编程缺点

  1. 牺牲可读性
  2. 容易写出性能差的代码

参考资料

前端开发js函数式编程真实用途体现在哪里? - 知乎
简明 JavaScript 函数式编程——入门篇 - 云音乐前端技术团队的文章 - 知乎
编程的宗派 ——王垠
React世界的函数式编程(Functional Programming)
Immutable 详解及 React 中实践

@draculapile draculapile added curry Functional Programming Currying FP labels Feb 24, 2022
@draculapile draculapile added JavaScript curry Functional Programming Currying FP and removed curry Functional Programming Currying FP labels Mar 10, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
curry Functional Programming Currying FP JavaScript
Projects
None yet
Development

No branches or pull requests

1 participant