配置对象修改问题之浅见

引子

前些天落落与我讨论一个问题:

  1. 对于通用库,能不能直接修改用户传入的 object。
  2. 不修改的话,怎么处理。deepclone?会不会影响效率?

我的第一反应是不能修改,即使影响效率也要 clone。遂回复如下:

一般来说不能直接修改,
不知道数据是否正在被用
不知道数据的修改是不是做了劫持
你不知道用户后面会不会继续用作它用

你永远也不知道用户会怎么做,所以 deepclone 在普遍意义上是需要的,我理解 deepclone 主要是占内存,clone 的范围也可以是 bounded 的,那么用一个 function partlyDeepClone(obj, [key1, {key2: key3...}]) {}?对原数据一定要改的部分做 clone,可以减少不必要的内存占用?

还能怎么提高效率呢?计算如果是瓶颈的话,worker?其实还是 specify 究竟哪些必须要改吧,主线程跑不改的,worker 跑改的。

随后她又说,如果这个 object 很大很大呢?

一定要 clone 吗?

当然不是,一方面来说,作为一个库,clone 为的是少给用户造成不必要的麻烦,库的开发者有权为自己库的演化方向负责,如果认为其收益大于损失,强制用户接受这种设计也是可行的。另一方面来说,clone 是在效率与造成麻烦上做trade off。事实上,这种trade off很常见,不只是在前端里,更多的是在涉及内存的底层编程上。在内存安全的问题上,固然可以采取拷贝的方式解决,也可以采用转移所有权+堆分配的方法解决。

rust里也有所有权转移概念,陈天一篇文章讲这个,这里就不多加赘述。堆分配的目的是当所有权或指针转移时,其内容的内存位置不会改变。这二者保证了内存不会被复制也不会被移动,而且对象仅能被所有者使用。用户创建了对象,把所有权转移给库,库始终持有所有权直到生命周期结束。这样,clone的工作应该是未持有所有权的人来做。

除了trade off,这两种方式存在着不同的哲学:immutable和线性类型。

哲学与原理

immutable

immutable看起来有些陌生,但在js里随处可见:

js内置值、对象和方法的不可变性

对于原始类型类型而言,其本身就是不可变的,我们以最简单的赋值举例:

1
2
3
4
let a = 1;
let b = a;
a = 2;
console.log(b); // 1

b === 1说明这里1的值没有改变,改变的是a的指向。

再以string类型举例,不知道你有没有发现,所有字符串的内置方法都会返回新值:

1
2
3
const a = "hello";
a.concat(" world"); // 相似的还有repeat, slice, ...
console.log(a); // "hello"

而对于对象而言,其本身是可变的,如:

1
2
3
4
5
6
const a = {
hello: "world"
};

a.hello = "universe";
console.log(a); // { hello: "universe" }

其内置方法存在可变和不可变两种,以array的方法举例:

  1. 对于concat(), filter(), map(), each(), reduce(), slice()等方法,其不会改变原有的数组
  2. 对于fill(), pop(), push(), shift(), sort(), splice(),其会改变原有的数组

虽然不知道这样设计的原因是什么,但可以看出,es6的方法均为不可变的。这种不可变性的意义是什么呢?

immutable的意义

对于immutable,《Designing Data-Intensive Applications里这样写道

Part of what makes Unix tools so successful is that they make it quite easy to see what is going on:
The input files to Unix commands are normally treated as immutable. This means you can run the commands as often as you want, trying various command-line options, without damaging the input files.
As with most Unix tools, running a MapReduce job normally does not modify the input and does not have any side effects other than producing the output.

这里说的主要是不变性对函数式编程很友好,但数据依然是要变的,变就还得需要clone,而且得用最小的代价clone,这就是为什么说clone后面的哲学是immutable。这里就不得不提更为前端人所知的是immutable.js,其以不变性为基础,通过对“改变”操作的副作用,间接实现了partlyDeepClone,下面的代码可以加以说明:

1
2
3
4
5
const { Map } = require('immutable');
const originalMap = Map({ a: 1, b: 2, c: {t: 2} });
const updatedMap = originalMap.set('a', 0);

console.log(updatedMap.c === originalMap.c) // true

这里我们改变了originalMap中的值,得到了一个新的map,新的map改变的只是a的值,其余的对象并没有改变。一个更容易的理解方式是,把immutable的对象也看作是值(或者说是原始类型)。

线性类型和内存管理

藏在所有权背后的哲学是线性类型。线性类型Linear Type Systems是真实世界的映射,其规定了值被使用有且只有一次。线性类型保证了内存里的数据用后即焚,不必额外的内存分配和垃圾回收。

用一种不严谨的方式理解,即如果a能推出ba能推出c,那如果要得到b & c需要有两个a来推断。放在配置对象的上下文里,如果我需要两个实例,或者做两件事情,那我一定要两个配置才可以。

堆分配则了解js中如何管理内存的:

我们知道js会自动分配和回收内存(MDN JavaScript内存管理),且如JavaScript内存机制所言,在js中,

栈 stack 存放原始数据类型
堆 heap 存放引用数据类型( Array、Object、Function)

对于原始类型而言,其本身是不可变的,copy的成本是很小的,我们可以不用在意它,我们主要需要对堆中的数据做所有权的定义。在js中即复杂数据类型的所有权。

如何知道js对象已经被使用了呢?

实现

在打包器大行其道的今天,我们可以对js的值/对象进行一系列的约束或检查。比如我们可以用eslint规定这种行为:对没用到的对象报错,多次使用的对象报错。

我们也可以用一个runtime库来解决。如果我们要实现runtime js中的所有权,则需要明确我们所有权的职责(对于资源的分配,js中已有实现,我们要考虑资源销毁的情况):

  1. 如果仅仅是使用后销毁,用proxy get即可实现,对象中的每一个子对象,子值都仅可读一次,一个不严谨(没有考虑子对象,没有考虑原型)的写法是
    1
    2
    3
    4
    5
    6
    7
    8
    9
    const handler1 = {
    get: function(target, prop, receiver) {
    if (typeof target[prop] !== "undefined") {
    const tmp = target[prop];
    delete target[prop];
    return tmp;
    }
    }
    };
  2. 如果还要保证值/对象未被读的情况,则需要在适当的时候check已读的情况,要考虑异步场景下check的结果,则适当的时候是什么时候呢?一定是要增加生命周期的,如果生硬的调用生命周期钩子是可以用,更自然的方式可能就需要从语言层面实现了。这里还没有想通。

总结

文章从配置对象的修改问题扯到不可变性和线性类型,其本质还是讲创造对象的人还是修改对象的人应该对对象clone的问题,这里面还涉及用户体验,api设计等问题,本人才疏学浅无法想的、讲的透彻,多多包涵。

阅读更多