不可变对象

不变性

不可变对象(Immutable Object)指那些创建之后无法再被修改的对象,与之相对的可变对象(Mutable Object )指那些创建之后仍然可以被修改的对象。不可变性(Immutability )是函数式编程的核心思想之一,保证了程序运行中数据流的无损性。如果我们忽略或者抛弃了状态变化的历史,那么我们很难去捕获或者复现一些奇怪的小概率问题。使用不可变对象的优势在于你在程序的任何地方访问任何的变量,你都只有只读权限,也就意味着我们不用再担心意外的非法修改的情况。另一方面,特别是在多线程编程中,每个线程访问的变量都是常量,因此能从根本上保证线程的安全性。总结而言,不可变对象能够帮助我们构建简单而更加安全的代码。在 JavaScript 中,我们需要搞清楚const与不可变性之间的区别。const声明的变量名会绑定到某个内存空间而不可以被二次分配,其并没有创建真正的不可变对象。你可以不修改变量的指向,但是可以修改该对象的某个属性值,因此const创建的还是可变对象。JavaScript 中最方便的创建不可变对象的方法就是调用Object.freeze()函数,其可以创建一层不可变对象:

const a = Object.freeze({
  foo: "Hello",
  bar: "world",
  baz: "!",
});

a.foo = "Goodbye";
// Error: Cannot assign to read only property 'foo' of object Object

不过这种对象并不是彻底的不可变数据,譬如如下的对象就是可变的:

const a = Object.freeze({
  foo: { greeting: "Hello" },
  bar: "world",
  baz: "!",
});

a.foo.greeting = "Goodbye";

console.log(`${a.foo.greeting}, ${a.bar}${a.baz}`);

如上所见,顶层的基础类型属性是不可以改变的,不过如果对象类型的属性,譬如数组等,仍然是可以变化的。在很多函数式编程语言中,会提供特殊的不可变数据结构 Trie Data Structures 来实现真正的不可变数据结构,任何层次的属性都不可以被改变。Tries 还可以利用结构共享(Structural Sharing )的方式来在新旧对象之间共享未改变的对象属性值,从而减少内存占用并且显著提升某些操作的性能。JavaScript 中虽然语言本身并没有提供给我们这个特性,但是可以通过Immutable.jsMori这些辅助库来利用 Tries 的特性。我个人两个库都使用过,不过在大型项目中会更倾向于使用 Immutable.js。估计到这边,很多习惯了命令式编程的同学都会大吼一句:在没有变量的世界里我又该如何编程呢?不要担心,现在我们考虑下我们何时需要去修改变量值:譬如修改某个对象的属性值,或者在循环中修改某个循环计数器的值。而函数式编程中与直接修改原变量值相对应的就是创建原值的一个副本并且将其修改之后赋予给变量。而对于另一个常见的循环场景,譬如我们所熟知的for,while,do,repeat这些关键字,我们在函数式编程中可以使用递归来实现原本的循环需求

// 简单的循环构造
const acc = 0;
for (const i = 1; i <= 10; ++i) acc += i;
console.log(acc); // prints 55
// 递归方式实现
function sumRange(start, end, acc) {
  if (start > end) return acc;
  return sumRange(start + 1, end, acc + start);
}
console.log(sumRange(1, 10, 0)); // prints 55

注意在递归中,与变量 i 相对应的即是 start 变量,每次将该值加 1,并且将 acc+start 作为当前和值传递给下一轮递归操作。在递归中,并没有修改任何的旧的变量值,而是根据旧值计算出新值并且进行返回。不过如果真的让你把所有的迭代全部转变成递归写法,估计得疯掉,这个不可避免地会受到 JavaScript 语言本身的混乱性所影响,并且迭代式的思维也不是那么容易理解的。而在 Elm 这种专门面向函数式编程的语言中,语法会简化很多

sumRange start end acc =
    if start > end then
        acc
    else
        sumRange (start + 1) end (acc + start)

其每一次的迭代记录如下

sumRange 1 10 0 =      -- sumRange (1 + 1)  10 (0 + 1)
sumRange 2 10 1 =      -- sumRange (2 + 1)  10 (1 + 2)
sumRange 3 10 3 =      -- sumRange (3 + 1)  10 (3 + 3)
sumRange 4 10 6 =      -- sumRange (4 + 1)  10 (6 + 4)
sumRange 5 10 10 =     -- sumRange (5 + 1)  10 (10 + 5)
sumRange 6 10 15 =     -- sumRange (6 + 1)  10 (15 + 6)
sumRange 7 10 21 =     -- sumRange (7 + 1)  10 (21 + 7)
sumRange 8 10 28 =     -- sumRange (8 + 1)  10 (28 + 8)
sumRange 9 10 36 =     -- sumRange (9 + 1)  10 (36 + 9)
sumRange 10 10 45 =    -- sumRange (10 + 1) 10 (45 + 10)
sumRange 11 10 55 =    -- 11 > 10 => 55
55

在实际编程中,多个不可变对象之间可能会共享部分对象:

image

函数操作库

Immutable.js

Immutable 对象一旦被创建之后即不可再更改,这样可以使得应用开发工作变得简化,不再需要大量的保护性拷贝,使用简单的逻辑控制即可以保证内存控制与变化检测。Immutable.js 虽然和 React 同期出现且跟 React 配合很爽,但它可不是 React 工具集里的(它的光芒被掩盖了),它是一个完全独立的库,无论基于什么框架都可以用它。意义在于它弥补了 Javascript 没有不可变数据结构的问题。不可变数据结构是函数式编程中必备的。前端工程师被 OOP 洗脑太久了,组件根本上就是函数用法,FP 的特点更适用于前端开发。

Javascript 中对象都是参考类型,也就是a={a:1}; b=a; b.a=10;你发现a.a也变成 10 了。可变的好处是节省内存或是利用可变性做一些事情,但是,在复杂的开发中它的副作用远比好处大的多。于是才有了浅 copy 和深 copy,就是为了解决这个问题。举个常见例子:

const defaultConfig = {
  /* 默认值 */
};

const config = $.extend({}, defaultConfig, initConfig); // jQuery用法。initConfig是自定义值

const config = $.extend(true, {}, defaultConfig, initConfig); // 如果对象是多层的,就用到deep-copy了

const stateV1 = Immutable.fromJS({
  users: [{ name: "Foo" }, { name: "Bar" }],
});
const stateV2 = stateV1.updateIn(["users", 1], function () {
  return Immutable.fromJS({
    name: "Barbar",
  });
});
stateV1 === stateV2; // false
stateV1.getIn(["users", 0]) === stateV2.getIn(["users", 0]); // true
stateV1.getIn(["users", 1]) === stateV2.getIn(["users", 1]); // false

如上,我们可以使用 === 来通过引用来比较对象,这意味着我们能够方便快速的进行对象比较,并且它能够和 React 中的 PureRenderMixin 兼容。基于此,我们可以在整个应用构建中使用 Immutable.js。也就是说,我们的 Flux Store 应该是一个具有不变性的对象,并且我们通过 将具有不变性的数据作为属性传递给我们的应用程序。

Immer

Immer 是 MobX 作者开源的 JavaScript 中不可变对象操作库,不同于 ImmutableJS,其基于 Proxy 提供了更为直观易用的操作方式。

import produce from "immer";

const baseState = [
  {
    todo: "Learn typescript",
    done: true,
  },
  {
    todo: "Try immer",
    done: false,
  },
];

const nextState = produce(baseState, (draftState) => {
  draftState.push({ todo: "Tweet about it" });
  draftState[1].done = true;
});

immer 同样可以简化 Reducer 的写法:

import produce from "immer";

const byId = produce(
  (draft, action) => {
    switch (action.type) {
      case RECEIVE_PRODUCTS:
        action.products.forEach((product) => {
          draft[product.id] = product;
        });
        return;
    }
  },
  // 传入默认初始状态
  {
    1: { id: 1, name: "product-1" },
  }
);

或者直接在 React setState 中使用:

onBirthDayClick2 = () => {
  this.setState(
    produce((draft) => {
      draft.user.age += 1;
    })
  );
};