JavaScript 中的柯里化

不过多介绍柯里化的定义和应用场景,直接看实现。


柯里化实现

如果我们有以下加法函数:

1
const add = (a, b) => a + b

要怎么实现柯里化呢?


手工柯里化

我们可以选择直接手写柯里化的 add:

1
2
3
4
5
6
7
// 手工柯里化 
const curryingAdd = a => b => a + b

// 使用
const inc = curryingAdd(1)
inc(1) // 2

通过闭包的嵌套,我们可以实现每次只接受一个参数的手工柯里化的函数。

但是这种方式,对于每个需要柯里化的函数,都需要侵入其实现,非常麻烦。因此并没有什么实用价值。我们需要能一种通用的方案来自动化做这个事情。


自动柯里化

柯里化的实现,核心在于根据参数数量选择返回接受剩余参数的新函数,或者返回最终结果。

在 JavaScript 中,具备实现这一切的条件,我们可以通过函数的 length 得知形参的数量,通过 arguments 对象得知实参的信息,通过闭包返回新函数也不在话下。下面开始尝试实现,先从简单的两个参数的情况开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 单一参数函数的柯里化
const curry1 = (fn) => {
  // 生成一个柯里化的版本
  return function currying(a) {
    // 检查实参数量
    // 此处不能简单地用 undefined 判断,
    // 因为 fn 本身可能也能接受并处理 undefined
    switch (arguments.length) {
      // 没有传入任何参数,返回柯里化后的函数本身
      case 0: return currying

      // 传入了任意大于 0 个数量的参数,则调用应用它们
      default: return fn.apply(void 0, arguments)
    }
  }
}

// 两个参数的函数柯里化
const curry2 = (fn) => {
  // 生成一个柯里化的版本
  return function currying(a,b) {
    // 同 curry1,检查实参数量
    switch (arguments.length) {
      // 没有传入任何参数,返回柯里化后的函数本身
      case 0: return currying
 
      // 传入了 1 个参数,则记录下该参数,
      // 创建一个接受剩余参数的新函数并柯里化
      // 相当于简化了函数的参数规模
      case 1: {
        const arg = arguments[0]
        
        // 自动柯里化接受剩余参数的函数
        // 剩余参数只有 1 个,因此使用 curry1
        return curry1(function() {
          // 合并外层函数已经部分应用的参数          
          const args = [arg]
          args.push.apply(args, arguments)

          return fn.apply(void 0, args)
        })
      }

      // 传入了足够的参数时,直接应用这些参数
      default: return fn.apply(void 0, arguments)
    }
  }
}

// 使用
const add = (a, b) => a + b
const curryingAdd = curry2(add)
curryingAdd(1, 1) // 2
curryingAdd(1)(1) // 2
curryingAdd()(1)()(1) // 2
// ...

READ MORE...

λ 演算(λ-calculus)笔记

文中涉及到代码的地方,除了 λ 表达式,都尽量提供 Scheme 以及 JavaScript 代码做对照,以便加深理解。

先从基本概念开始。λ 演算包含构建 λ 项和对 λ 项执行操作。

READ MORE...

Scheme 中的 continuation

continuation 是一个非常抽象晦涩的概念,为了理解这个概念,翻阅了大量的资料。 下面记录一些对 continuation 的粗浅的理解。

continuation 代表了程序于某一点接下来将要执行的 “后续部分”。

在 scheme 中,操作符 all‑with‑current‑continuation(下文开始使用缩写 call/cc) 提供了使用 continuation 的方法。call/cc 会捕获调用处的 continuation,然后将该 continuation 传入其参数(一个 procedure)中进行处理。

call/cc 接受一个 procedure(过程/函数)作为参数,call/cc 调用处的 continuation 将作为该 procedure 的参数传入。

观察这个例子:

1
(+ 1 (call/cc (lambda (k) (+ 2 (k 3)))))

例子中,

  1. 首先,我们先把 call/cc 所处位置部分当作一个“空洞”
  2. 然后,call/cc 所处位置的 continuation,就是 “空洞” 之外的程序后续将执行的过程
1
(+ 1 空洞)

具体点,就是 “一个将对该(空洞)位置做加一的过程”,相当于:

1
(lambda (hole) (+ 1 hole))

再来观察上述例子中 call/cc 的参数部分,

1
(lambda (k) (+ 2 (k 3)))

其中形参 k 绑定的即为 continuation。执行时,该 continuation 会应用在 3 上。换句话说,3 充当了上述的“空洞”的值,继续走完 “下半程”(即 +1 操作)。

1
(+ 1 3) 

结果为 4

escaping continuation

需要注意的是,上述过程中,3 “走了不寻常的路” 穿梭到 k 这个 continuation 中去了。

而原本 (k 3) 处的 continuation(即 (+ 2 空洞))将被抛弃!就是说,开头的例子中,最终的运行结果就是 4

1
2
(+ 1 (call/cc (lambda (k) (+ 2 (k 3)))))
=>  4

这是个很有用的特性,叫做 escaping continuation,可以用于退出一个计算过程,在这个例子中就是退出 + 2 的运算过程。可以用下面的代码加深理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
;; 返回
(+ 1 (call/cc (lambda (return) (+ 2 (return 3)))))
=> 4

;; 退出
(call-with-current-continuation
   (lambda (exit)
     (for-each (lambda (x)
                 (if (negative? x)
                     (exit x)))
               '(54 0 37 -3 245 19)) #t))
=> -3

作为比较:

1
2
(+ 1 (call/cc (lambda (k) (+ 2 3)))))
=> 6

该例子中,没有使用 call/cc 处的 continuation,因此不会有逃逸现象,(+ 2 3) 的值作为 call/cc 的返回值,然后 + 1,最终结果为 6

scheme 的 continuation 还能回到上一次离开的上下文,因为 continuation 里面,一直持有程序的“后续”,我们可以不限制的多次执行它们,用下面的例子来说明:

1
2
3
4
5
6
7
(define r #f)

(+ 1 (call/cc (lambda (k)
                ;; 将 `continuation` 保存在 `r` 中
                (set! r k)
                (+ 2 (k 3)))))
=> 4

这次的例子中,我们额外使用全局变量 r 来记录这个 continuation,该例子此时跟之前一样,仍然返回运算结果 4。 然后,我们再通过全局变量 r 来再次“重放”该 continuation

1
2
(r 5)
=> 6

再次强调,需要注意伴随 r 的逃逸现象,如下面的例子:

1
2
(+ 3 (r 5))
=>  6

其中 r 被应用在 5 时,(r 5) 本身的 continuation 就会被抛弃。

READ MORE...

使用 MutationObserver 监视 DOM 变化

MutationObserver 可以用来监控 DOM 的变动。 这个 API 定义在 DOM4 中,被设计来替代已经废弃的 DOM3 事件:Mutation Events。

该 API 与事件不同的是,它并不会在每个 DOM 节点变化后立即执行回调函数,而是在 DOM 操作都完成后,将所有变化记录存储在数组中,在事件循环的 microtask 阶段执行回调函数,一次性处理这些存储的变化。

注: 一次事件循环,从 macrotask quene 中取出一个任务执行,执行完毕后,执行整个 microtask quene 中的任务。

构造函数:

1
MutationObserver(callback)

callback 在每个 DOM 变动中被调用,这个回调函数接受两个参数:

  1. mutationRecords 改变记录列表
  2. observer 即 MutationObserver 实例

MutationRecord

MutationRecord 用以描述一个 DOM 变动的详细信息,它拥有如下字段:

  • type {String}
    返回描述改变的类型,值可能为:
    • attributes 变化的为节点的 attribute
    • characterData 变化的为节点的文本值
    • childList 变化的为节点增删

    注:CharacterData 是一个抽象接口,表示node节点包含的文本数据。如文本节点、注释节点等实现了这个接口。

  • target {Node}
    type 字段的不同有不同结果:
    • attributes:值为被改变的节点
    • characterData:值为 CharacterData 节点
    • childList:值为包含这些改变子节点的父节点
  • addedNodes {NodeList}
    返回新增的节点,无新增则返回空节点列表。

  • removedNodes {NodeList}
    返回移除的节点,无移除则返回空节点列表。

  • previousSibling {Node}
    返回新增/移除的节点的上一个相邻节点,若没有,则为 null

  • nextSibling {Node}
    返回新增、移除的节点的下一个相邻节点,若没有,则为 null

  • attributeName {String}
    返回被改变的 attribute 的本地名称,或 null

  • attributeNamespace {String}
    返回被改变的attribute的命名空间,或 null

  • oldValue {String}
    type 字段的不同有不同结果:
    • attributes:返回attribute被改变前的值
    • characterData:返回改变前的 data
    • childList:返回null

MutationObserver 实例方法

  • observe(target, initOptions) {void}
    这个方法为 MutationObserver 实例注册需要观察的节点。
    参数 target 为被观察的节点。
    参数 initOptions 为初始化选项,用以指定需要观察哪些改变(mutation)。
    可用的选项有:

    • childList {Boolean}
      true,表示需要观察目标节点的子节点(包括文本节点)的增删。

    • attributes {Boolean}
      true,表示要观察目标节点的 attributes。

    • characterData {Boolean} 若 true,表示需要观察目标节点的 data。

    • subtree {Boolean}
      true,表示需要观察目标节点以及其后代节点。

    • attributeOldValue {Boolean} 若 true,表示需要记录 attribute 改变前的值。

    • characterDataOldValue {Boolean}
      true,表示需要记录 data 改变前的值。

    • attributeFilter {String[]} 如果只是部分 attribute 需要观察,可以设置这个属性,只需将这些需要观察的 attribute 的名字(无需命名空间)用数组列出来即可。

    注意,childListattributescharacterData 三种选项中,必须最少有一个为 true,否则抛An invalid or illegal string was specified错误。

READ MORE...

JavaScript 闭包

闭包(词法闭包)的定义

闭包是「函数」以及该函数相关的「引用环境」组合而成的实体。

这个定义中,包含了两个核心的要素:

  1. 函数:一段可以执行的代码
  2. 引用环境:生成闭包时的词法环境

观察以下这段 JavaScript 代码:

1
2
3
4
5
6
7
8
9
10
11
function outer() {
  var _private = 'private'

  return function inner() {
    return _private
  }
}

var getPrivate = outer()
console.log(getPrivate.name) // 'inner'
getPrivate() // 'private'

函数 outer 调用后,会将在其内部定义的函数 inner 暴露出来并赋值给 getPrivate,在下一行代码可以确认这点。
再继续下一行,我们调用 getPrivate,发现成功得到了 outer 的本地变量 _private,尽管此时 outer 已经返回(我们学习过的编程知识告诉我们,函数执行时在栈上分配的变量会在离开函数执行环境时被销毁)。这个例子的实际结果说明 outer 的本地变量 _private 被保存在某个地方(堆上分配)继续可用。

根据这些表现,我们可以确认这就是一个典型的闭包。

实际上,这个闭包在 outer 被调用的时候创建。它在某个地方保存了函数 inner 所需要的引用环境,使得离开了创建闭包的环境(outer)时,对自由变量的引用依旧有效(直至闭包的生命周期结束才一并被回收)。

注:自由变量是指在函数之外的定义的变量,它既不是本地变量,也不是参数。


闭包的实例

按照上面的定义,闭包不能简单等同于函数(除非语言对函数的实现符合上面的定义),因为它还包含了跟函数组合的词法环境。 同一个函数,跟不同的环境组合,能得到不同的闭包实例。

继续看代码:

1
2
3
4
5
6
7
8
9
10
function inc(x) {
  return function() {
    x += 1
    return x
  }
}
var incA = inc(0) // 闭包实例1
var incB = inc(0) // 闭包实例2
incA() // 1
incB() // 1

在这段代码中,函数 inc 每进入一次,就形成一个新的环境,内部函数跟这个环境结合,生成了不同的闭包的实例。
两次调用 inc 返回了两个不同的闭包实例,这两个实例的函数相同,但环境不同。两个环境拥有各自的 x 的绑定,互不干扰。


闭包的实现原理

理解了闭包的定义和特点后,就能理解闭包的实现方式了。 闭包可以表示为一个数据结构,这个数据结构中,保存了函数地址指针,也保存了闭包创建时的函数所处的词法环境。典型的闭包实现方式就是这么做的。

JavaScript 的函数,正好实现了上述的数据结构。

在 JavaScript 中,函数本身也是对象。所以,虽然罕见,但是函数确实是可以用下面这种创建对象的方式来创建的:

1
var f = new Function('x', 'return x')

这相当于:

1
2
3
var f = function(x) {
  return x
}

既然函数是一个对象,那么就可以往这个对象身上保存信息。
函数对象拥有一系列内部属性或内部方法,在 ES 规范中,一般用双重方括号来表示内部属性和内部方法。 在这里我们需要关注的属性不多:

  1. [[Scope]] 内部属性:函数在创建的时候,会把当前「执行环境」的「词法环境」赋给这个内部属性(函数声明和函数表达式细节上略有差异,但不必深究)。换句话说,函数对象会记忆它被创建时所处的词法环境,用以查找函数引用的自由变量的绑定。这行为就像创建了一个封闭的上下文,将所用到的变量都绑定起来纳入其中(闭包名字的由来…)。
  2. [[Code]] 内部属性:保存函数的 ECMAScript 代码。
  3. [[Call]] 内部方法:这是函数对象区别于其它对象的一个接口,实现这个接口的对象称为可调用对象。

从这种实现方式可以发现,JavaScript 的函数本身就是一种闭包。

READ MORE...