Js 函数式编程

上篇讲了 js 的 this 指向, 核心在于分析它的调用位置和方式进行动态分析, 这和作用域是定义阶段确定的逻辑, 恰好相反(this 是作用域的一部分, 但指向是运行时), 因此 this 指向就具有不确定性, 从而用总结出一些通用的 this 绑定规则, 如 默认绑定(独立调用), 隐式绑定 (对象.函数()), 显示绑定 (call/apply), new 绑定, 内置函数绑定, 箭头函数等各类场景. 算是 js 比较难理解的地方吧.

函数式编程介绍

本篇来学习这个 js 函数式编程. 它是一个编程范式, 将计算视为数学函数求值, 并避免改变状态和可变数据, 强调 表达式求值 而非 语句执行, 就和数学中的 函数 是类似概念.

  • 函数是一等公民: 函数可以作为变量, 参数, 返回值, 数据结构
  • **纯函数: **相同输入, 永远有相同的输出, 没有副作用 (Side Effects
  • 不可变性: 数据创建不可修改, 任何修改操作都会返回新对象, 而 不改变原对象
  • **函数组合: ** 可将多个函数组合为一个新函, 数据流清晰
  • **声明式表达: ** 关注做什么, 而非怎么做

函数式变编程的优势在于其能让代码更简洁, 可读性强 (数学逻辑), 纯函数无副作用, 天然适合并行 / 并发编程, 因为无共享状态嘛.

而缺点则是其学习曲线陡峭 (数学逻辑不行的就退吧), 性能开销大, 要频繁创建/销毁大量的对象.

我主要是做数据类工作的, 数据分析会用到 python / pandas 库, 里面各种 df 搞来搞去, 也相对于是函数式的, 数据开发会用到 scala / spark, 需要不断对数据源进行 步骤迭代 处理 ETL, 先用函数式编程有大用, 但如果转向用 java 来面向对象方式处理的话, 这个代码就变成又臭又长了, 反复输入输出类型变换, 还有迭代计算 .... 绝对崩溃.

举个简单的用函数式编程来实现 单词计数, 体验一下

// scala / spark 函数式编程

val spark = SparkSession.builder.appName("WordCount").master("local[*]").getOrCreate()
import spark.implicits._

val text = "hello world hello spark functional programming spark"
val wordCount = text
  .split("\\s+")
  .toSeq
  .toDF("word")
  .groupBy("word")
  .count()
  .show()
+------------+-----+
|        word|count|
+------------+-----+
|   functional|    1|
|       world|     1|
|      spark|     2|
|       hello|    2|
|programming|     1|
+------------+-----+

因此不论是面向对象, 还是函数式编程, 还是面向关系 (sql), 面向过程等, 都是得看具体场景来分析.

js 实现 apply, call, bind

内部 js 引擎实现是通过 c++ , 这里我们更多是用 js 来模拟其过程, 体验一下函数式编程的思想而已啦, 同时也练习一下 js 函数, 调用关系, this 绑定等, 不会过度考虑边界 (edge case) 情况哦.

框架代码: 核心逻辑往往并不复杂, 但至少 80% 的时间都在考量各种边界情况

数据分析: 分析报告也是并不复杂, 但至少 80% 的时间都在处理各种杂乱数据

call 实现

  • 在函数原型上添加一个 cjCall(thiArg, ..args) 的方法, 使其能被所有函数共用
  • 对将要 this 指向的对象 thisArg 简单校验, 让其必须为对象
  • 执行函数
  • 返回函数执行后的结果
// 01: 给所有函数添加一个 cjCall 的方法, 在构造函数上

// function 的原型是 内置的 Function 对象
Function.prototype.cjCall = function() {
  console.log('cjCall 被调用')
}

function foo() {
  console.log('foo 函数被执行')
}

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

foo.cjCall()  // cjCall 被调用
sum.cjCall()  // cjCall 被调用

cjCall 函数加到原型对象中就能被所有函数共用了.

接下来, 对于 foo.cjCall() 来说, 需要知道调用者是谁, 这里是 foo.

var fn = this // 此时的 this -> foo

接着要进行函数调用, 且要让 this 指向传入的对象 thisArg, 则可将 fn 作为一个属性, 先绑定到 thisArg 上,

通过 thisArg.fn() 来实现函数调用, 同时实现 this -> thisArg, 妙呀!

Function.prototype.cjCall = function(thisArg) {
  
  // 确定是哪个函数执行了 cjCall
  var fn = this 

  // 这两行秒呀! 同时实现函数调用和 this 绑定转移
  thisArg.fn = fn  
  thisArg.fn()
  
  delete thisArg.fn // 调完就删
}

补充一下, 这里要对参数 thisArg 进行校验一下, 保证其是一个对象

thisArg = thisArg? Object(thisArg): window

最后一步是返回函数执行结果就好啦, 整体如下:

// 01: 给所有函数添加一个 cjCall 的方法, 在构造函数上

// function foo 的原型是 内置的 Function 对象
Function.prototype.cjCall = function(thisArg, ...args) {
  
  // 1. 获取需要被执行的函数 foo.cjCall -> foo 
  var fn = this 

  // 2. 对 thirArg 转为对象, 不传就默认 window
  thisArg = thisArg? Object(thisArg): window

  // 3. 调用函数 foo, 同时让 this -> thisArg
  thisArg.fn = fn 
  var result = thisArg.fn(...args)
  delete thisArg.fn // 调完就删

  // 4. 返回函数执行的结果
  return result 

}


// 验证 
function foo() {
  console.log('foo 函数被执行', this)
}

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

foo.cjCall() // 不传则默认 window

ret = sum.cjCall('abc', 10, 20)
console.log(ret) 

因此 call 方法实现的关键点在于, 先通过 存下最初的 this 来记录调用函数是谁, 然后接收到将要被指向的 thisArg 对象后, 将调用函数作为属性绑定到 thisArg 对象, 然后通过 隐式绑定, thisArg.fn() 的巧妙方式, 既执行了函数调用, 同时也改变了 this 指向, 最后将函数执行结果返回即可.

// 补充下剩余参数 和 展开运算符

// 都是 ...arg 形式, 出现在函数定义, 则剩余
// 出现在数组中, 函数调用中, 则, 展开

function foo(name, ...args) {

  console.log(name, args) // 剩余: [1, 2, 3]

  console.log(...args) //    展开:  1, 2, 3
}

foo(1, 2, 3)

apply 实现

它和 call 的唯一区别在于参数的传递方式:

  • Foo.call('xxx', a, b, c)
  • Foo.apply('xxx', [a, b, c])

在实现 call 方法的时候, 传入参数用的是 剩余参数 ...arg, 然后调用时将参数展开 ...arg, 那

// apply 实现 

Function.prototype.cjApply = function(thisArg, argArray) {
  // 1. 获取将要被执行的函数
  var fn = this 

  // 2. 对 thisArg 保证其是个对象, 方便添加 fn 属性
  thisArg = (thisArg !== null && thisArg !== undefined)? Object(thisArg): window 

  // 3. 调用函数, 并将 this -> thisArg 
  thisArg.fn = fn

  // var result 
  // if (!thisArg) {
  //   // 没有传参数, 直接调用即可
  //   result = fn()
  // } else {
  //   // 正常参数, 将参数展开, 执行函数
  //   result = thisArg.fn(...argArray)
  // }

  // 考虑 thisArg 是否有参数情况
  argArray = argArray || []
  var result = thisArg.fn(...argArray)

  delete thisArg.fn

  // 4. 返回函数执行结果
  return result

}


// 验证
function sum(a, b) {
  return a + b 
}

var ret = sum.cjApply('xx', [10, 20])
console.log(ret) // 30

大致就是这样的, 但其实还有很多边界情况要考虑的, 比如说上面的 fn 是硬编码的, 当实际调用时, 传入的对象中也有叫 fn 的属性, 那这就不好搞了. 当然也是有解决办法的, 比如 fn 这个硬编码, 改为用 es6 中的 Symbol 就可以了, 它是全局独立唯一的.

所以, 写一个通用的东西非常复杂, 因为各种情况都要考虑呀, 写框架代码和写业务代码, 完全是不一样的思路呢.

bind 实现

在 js 中, bind 方法用于 创建一个新函数, 当这新函数被调用时候, 它的 this 关键字会被设置为此前绑定的值.

常用场景在于 固定 this 的值, 进行柯里化, 预设参数等.

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

// bind 方法会返回一个新函数, this 指向传入值
var newSum = sum.bind('cj')
console.log(newSum(1, 2)) // 3

// 也可以先传
var newSum2 = sum.bind('cj', 10, 20)
console.log(newSum2())

模拟实现 bind 方法如下:

  • 在函数原型上添加 cjBind (thisArg, ...argArray) 的方法, 使所有函数能共用
  • 通过 this 获取到真实要调用的函数 fn
  • 返回一个新函数, 里面将 thisArg 转为对象, 让 fn 添加在 thisArg 中, 并进行调用
  • 返回新函数执行结果
Function.prototype.cjBind = function(thisArg, ...argArray) {
  // 1. 获取到真实需要调的函数
  var fn = this 

  // 2. 绑定this 
  thisArg = (thisArg !== null && thisArg !== undefined)? Object(thisArg): window 

  // 3. 将真实函数要绑定到, 返回新函数的内部
  return function(...args) {
    thisArg.fn = fn 
    // 两拨参数
    var finalArgs = [...argArray, ...args]
    var result = thisArg.fn(...finalArgs)

    delete thisArg.fn 
    // 4. 返回新函数执行结果
    return result
  }
}


// 验证
function sum(a, b) {
  console.log('foo 被调用', this)
  return a + b
}

// 分别两次传参
var bar = sum.cjBind('cj', 1)
var ret = bar(2)

console.log(ret)  // 3

总结来看, 关于 call / apply / bind 函数的实现思路, 核心就是解决两个问题:

  • 如何 获取到调用函数: 通过 this
  • 如何 改变 this 绑定 并执行函数: 隐式绑定 + 将 函数添加到传入对象中并执行

如果前面的 this 理解清楚了, 那这些问题应该是迎刃而解了, 也不是太难的 bro.

Arguments

在 js 中 arguments 是一个对应于 传递给函数参数类数组 array-like 对象.

// arguments 基础使用

function foo(a, b, c) {
  // 所有参会放在一个类数组对象中, arguments

  // 1. 获取参数数组长度
  console.log(arguments.length) // 动态

  // 2. 根据索引值, 获取某个参数
  console.log(arguments[0])
  console.log(arguments[2])
  console.log(arguments[100]) // 越界 undefined

  // callee 获取当前 arguments 所在的函数
  // console.log(arguments.callee)

  // arguments 是类数组而非数组, 不能使用数组的 map/filter/forEach 方法

  // 转为真数组 es6
  var argArr = Array.from(arguments)
  argArr.forEach(arg => console.log(arg))
  
}

// 函数定义要求传入 3个参数, 但实际可以传 n 个参数 
foo(1, 2, 3, 4, 5)

在 es6 之后, 就很少推荐使用 arguments 啦, 包括箭头函数直接就没有这个东西, 而对应实现的方式则更推荐使用 剩余参数 的方式.

// es6 不推荐用 argument, 推荐用 剩余参数

var foo = (n1, n2, ...args) => {
  console.log(n1, n2)
  
  console.log(args) // [3, 4, 5]
  console.log(...args) // 3 4 5
}

foo(1, 2, 3, 4, 5)

js 纯函数

函数式编程中有一个非常重要的概念叫做 纯函数 Pure Fnction, js 也是符合函数式编程范式的, 因此也有纯函数的概念.

在 react 中纯函数是用的比较多的, 如 react 组件要求像是一个纯函数, 在 redux 中有一个reducer 概念, 也是要求必须是一个纯函数.

我本来就有用 scala 的, 因此, 纯函数理解也相对清晰, 对于 js 来说, 理解它则对很多的框架设计的实现原理则很有帮助. 纯函数的最明显特点是:

  • 函数的相同输入总是产生相同的输出
  • 函数执行中无副作用, 就和很多数学函数一样的.

副作用

副作用 side effect 是一个医学的概念, 如我们吃某种要是为了治病, 但这个同时会产生别的一些影响, 则称为有一些副作用.

在编程世界中, 副作用表示为在执行一个函数时, 除了返回值之后, 还对调用函数产生了附加的影响, 如修改了全局变量, 修改了参数或者改变外部存储等

对纯函数来说, 觉得不允许产生这样的副作用, 而往往很多的 bug 就是来自于一些非纯函数的副作用啦.

js纯函数举例

// js 纯函数举例

var names = ['a', 'b', 'c', 'd', 'e']

// 1. 数组.slice(start, end), 对同数组来说, 一定会返回确定的值
// 2. 无副作用, 不会影响原数组

console.log(names.slice(0, 3)) // 左闭右开
console.log(names.slice(0, 3)) 

// splice 不是一个纯函数
var names2 = names.splice(2)
console.log(names2)

// 它有副作用, 会修改调原数组 
console.log(names) // [a, b]


// foo 是一个纯函数

// 相同输入, 产生相同输出; 执行过程不产生副作用
function foo(num1, num2) {
  return num1 * 2 + num2 * num2
}
// bar 函数不是纯函数, 调用过程中改掉了外部区域的变量

var name = 'youge'

function bar() {
  console.log('bar 函数被调用')
  // 修改全局变量
  name = 'cjj'
}

bar()
console.log(name)  // cjj
// foo 不是一个纯函数, 它修改了传入的参数

function foo(info) {
  info.age = 30
}

var obj = { name: 'youge', age: 20 }
foo(obj)

console.log(obj) // { name: 'youge', age: 30 }

纯函数在函数式编程中的重要性体现在:

  • 安心编写, 安心使用而不产生副作用
  • 写的时候保障了纯度, 只需专注实现自己的业务逻辑, 无需关系传入内容是如何获得和不关系外部依赖
  • 用的时候, 内容不会被篡改, 相同的输入一定会有确定的输出

在 react 中要求不论是函数还是 class 声明一个组件, 都要求必须吃像纯函数一样, 保护他们的 props 不被修改.

js 函数柯里化

在函数式编程中, 柯里化 Currying 也是非常重要的概念, 或者一种技术.

将一个接收多个参数的函数, 变成接收一个单一参数的函数, 并且返回接余下参数, 且返回结果的新函数的技术

如果固定某些参数, 将会得到接收余下参数的一个函数. 如一个函数 foo(a, b, c, d) 接收 4个参数, 首次调用的时候接收 a, 然后返回一个新函数, 接收 b, 然后又返回一个新函数接收 c ... 这样最终结束. 这个转换的过程, 就叫做柯里化.

// 柯里化过程

function add(x, y, z) {
  return x + y + z
}

var res1 = add(10, 20, 30)
console.log(res1) // 60

// 对这个 add 函数进行柯里化 
function sum(x) {
  return function (y) {
    return function (z) {
      return x + y + z 
    }
  }
}

// add 函数 柯里化 后变成了 sum 函数, 调用方式变了
var res2 = sum(10)(20)(30)
console.log(res2) // 60

当然这个柯里化的 sum 函数可以用箭头函数来简写一把:

// 柯里化函数简写 

var sum = x => y => z => {
  return x + y + z
}

var res = sum(10)(20)(30)
console.log(res) // 60

Currying 让函数职责单一

在函数式编程中, 通常希望一个函数的处理问题尽可能单一, 而非将一大堆的过程都交给一个函数来处理.

我们可以将每次传入的参数, 在单一的函数中进行处理, 处理完后, 继续在下一个函数中在使用处理后的结果. 就类似上面写的 spark 函数过程:

val text = "hello world hello spark functional programming spark"
val wordCount = text
  .split("\\s+")
  .toSeq
  .toDF("word")
  .groupBy("word")
  .count()
  .show()

// 一步步来迭代计算, 当前的输出, 作为下阶段的输入 ...
// 感觉 sql 也是这样的

继续我们上面的 add(x, y, z) 案例修改: 传入的函数需要分别被进行如下处理: 第一个参数 +2 ; 第二个参数 *2; 第三个参数 **2

function add(x, y, z) {
  x = x + 2 
  y = y * 2
  z = z * z 
  return x + y + z 
}

console.log(add(10, 20, 30)) // 952

这样看似能实现, 但真实情况下, 里面的每一步, 对应的逻辑, 可能是几段长的代码, 因此这样写的话, 在编写和阅读上体验就很差. 这时候进行柯里化, 让函数实现单一职责就很清晰了.

function sum(x) {
  // 这里大段逻辑 01
  x = x + 2

  return function(y) {
    // 这里大段逻辑 02
    y = y * 2 

    return function(z) {
      // 这里大段逻辑 03
      z = z * z 

      return x + y + z 
    }
  }
}

var res = sum(10)(20)(30)
console.log(res) // 952

Currying 让逻辑复用

体现在函数参数复用, 延迟计算, 提升组合性等, 比如之前的 adder 函数.

function sum(m, n) {
  return m + n 
}

// 假如需要经常将一个数字, 与另外一个数字相加
console.log(sum(10, 1))
console.log(sum(10, 2))
console.log(sum(10, 3))

// 用柯里化则会简化逻辑
function adder(count) {
  return function(num) {
    return count + num 
  }
}

// currying
var res = adder(10)(1)
var res2 = adder(10)(2)

// 简化在这里
var adder10 = adder(10)
adder(10)
adder(20)
adder(30)

再来补充一个案例, 打印日志的柯里化吧:

// 打印日志 - 普通做法

function log(date, type, message) {
  console.log(`[${date.getHours()}: ${date.getMinutes()}] [${type}]: [${message}]`)
}

// 重复调用, 时间可能是不同的
log(new Date(), 'DEBUG', '轮播图有 bug')
log(new Date(), 'DEBUG', '菜单栏有 bug')
log(new Date(), 'DEBUG', '数据上有 bug')

// 柯里化
var log = date => type => message => {
  console.log(`[${date.getHours()}: ${date.getMinutes()}] [${type}]: [${message}]`)
}

// 如果我们需要打印的, 都是相同时间 
var newLog = log(new Date())
newLog('DEBUG')('轮播图有 bug')
newLog('DEBUG')('菜单栏有 bug')
newLog('DEBUG')('数据上有 bug')

// 还能扩展
var nowAndDebugLog = log(new Date())('DEBUG')
nowAndDebugLog('轮播图 bug')
nowAndDebugLog('数据表 bug')

// 继续扩展
var nowAndFeatureLog = log(new Date())('FEATURE')
nowAndFeatureLog('添加新功能~')

柯里化函数实现

前面的函数柯里化过程都是手动实现的, 现在要来写一个通用的 cjCurrying() 函数, 让转化过程自动化.

// 柯里化函数实现

// 输入一个函数
// 返回一个柯里化之后的函数

function cjCurrying(fn) {
  function curried(...args) {
    // 判断当前已接收参数个数, 是否和传入的参数一样多吗
    if (args.length >= fn.length) {
      // 参数接收够了, 则调即可, fn(...args),但考虑 this 情况
      return fn.apply(this, args)
    } else {
      // 参数不够, 则返回一个新的函数, 继续来接收参数
      return function(...args2) {
        return curried.apply(this, [...args, ...args2])
      }
    }
  }

  return curried
}

// 验证
function sum(x, y, z) {
  return x + y + z 
}

var currySum = cjCurrying(sum)
console.log(currySum(10)(20)(30)) // 60

这个实现过程还是有点麻烦的, 主要是有点绕, 必须理解这个过程才行呢.

js 组合函数

在 js 中, 组合函数 Compose 是一个对函数使用的高级小技巧. 即将函数自动组合起来, 自动调用.

比如现在需要对某个数据进行函数调用, 执行两个函数 fn1, fn2, 这俩函数是一次执行的. 正常操作是我们一次编写代码让其自动调用, 但这样就有点重复, 那考虑, 能否将两个函数组合起来, 以此自动调用呢, 当然是可以的, 这个偶过程就是实现 组合函数.

其实就是, 层层嵌套函数, 类似这样的, 头的搞晕了: f5(f4(f3(f2(f1(x)))))

// 实现简单组合函数

function double(num) {
  return num * 2
}

function square(num) {
  return num ** 2
}

var num = 10
// 嵌套调用
var res1 = square(double(num))
console.log(res1) // 400

// 如果 num = 20 ... 这样重复调用就有点冗余了. 
// 考虑将 double, square 组合到一起
function composeFn(f1, f2) {
  return function(num) {
    return f2(f1(num))
  }
}

// 这样组合
var newFn = composeFn(double, square)
console.log(newFn(10)) // 400


继续来实现一个通用的组合函数

// 实现通用组合函数

// 输入是1或多个普通函数
// 输出是组合函数
function cjCompose(...fns) {
  // 参数验证: 必须都为 函数
  var length = fns.length
  for (var i = 0; i < length; i++) {
    if (typeof fns[i] !== 'function') {
      throw new TypeError("需要传入参数, 是函数类型哦!")
    }
  }
  // 实现嵌套调用
  function compose(...args ) {
    var index = 0
    var result = length? fns[index].apply(this, args): args 
    // 从左到右继续执行函数
    while(++index < length) {
      var result = fns[index].call(this, result)
    }
    return result
  }

  return compose
}

// 验证
function double(num) {
  return num * 2
}

function square(num) {
  return num ** 2
}

//组合一把
var newFn = cjCompose(double, square)
console.log(newFn(10)) // 400

至此, 关于 js 中的函数式编程的内容就差不多了, 重点是理解函数式编程的思想, 如纯函数, 柯里化等. 尤其是纯函数的理解, 即函数可以当做变量, 输入, 输出使用; 而纯函数是没有副作用的, 然后手动模拟实现了一下 call / apply / bind 的实现, 以及柯里化实现, 组合函数实现等, 这些内容还是有点难度的, 但是一旦学会就彻底理解呀.

posted @ 2025-08-03 23:57  致于数据科学家的小陈  阅读(11)  评论(0)    收藏  举报