配置对象修改问题之浅见
引子
前些天落落与我讨论一个问题:
- 对于通用库,能不能直接修改用户传入的 object。
- 不修改的话,怎么处理。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 | let a = 1; |
b === 1
说明这里1
的值没有改变,改变的是a的指向。
再以string类型举例,不知道你有没有发现,所有字符串的内置方法都会返回新值:
1 | const a = "hello"; |
而对于对象而言,其本身是可变的,如:
1 | const a = { |
其内置方法存在可变和不可变两种,以array的方法举例:
- 对于
concat()
,filter()
,map()
,each()
,reduce()
,slice()
等方法,其不会改变原有的数组 - 对于
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 | const { Map } = require('immutable'); |
这里我们改变了originalMap
中的值,得到了一个新的map,新的map改变的只是a
的值,其余的对象并没有改变。一个更容易的理解方式是,把immutable的对象也看作是值(或者说是原始类型)。
线性类型和内存管理
藏在所有权背后的哲学是线性类型。线性类型Linear Type Systems是真实世界的映射,其规定了值被使用有且只有一次。线性类型保证了内存里的数据用后即焚,不必额外的内存分配和垃圾回收。
用一种不严谨的方式理解,即如果a
能推出b
,a
能推出c
,那如果要得到b & c
需要有两个a
来推断。放在配置对象的上下文里,如果我需要两个实例,或者做两件事情,那我一定要两个配置才可以。
堆分配则了解js中如何管理内存的:
我们知道js会自动分配和回收内存(MDN JavaScript内存管理),且如JavaScript内存机制所言,在js中,
栈 stack 存放原始数据类型
堆 heap 存放引用数据类型( Array、Object、Function)
对于原始类型而言,其本身是不可变的,copy的成本是很小的,我们可以不用在意它,我们主要需要对堆中的数据做所有权的定义。在js中即复杂数据类型的所有权。
如何知道js对象已经被使用了呢?
实现
在打包器大行其道的今天,我们可以对js的值/对象进行一系列的约束或检查。比如我们可以用eslint规定这种行为:对没用到的对象报错,多次使用的对象报错。
我们也可以用一个runtime库来解决。如果我们要实现runtime js中的所有权,则需要明确我们所有权的职责(对于资源的分配,js中已有实现,我们要考虑资源销毁的情况):
- 如果仅仅是使用后销毁,用proxy get即可实现,对象中的每一个子对象,子值都仅可读一次,一个不严谨(没有考虑子对象,没有考虑原型)的写法是
1
2
3
4
5
6
7
8
9const handler1 = {
get: function(target, prop, receiver) {
if (typeof target[prop] !== "undefined") {
const tmp = target[prop];
delete target[prop];
return tmp;
}
}
}; - 如果还要保证值/对象未被读的情况,则需要在适当的时候check已读的情况,要考虑异步场景下check的结果,则适当的时候是什么时候呢?一定是要增加生命周期的,如果生硬的调用生命周期钩子是可以用,更自然的方式可能就需要从语言层面实现了。这里还没有想通。
总结
文章从配置对象的修改问题扯到不可变性和线性类型,其本质还是讲创造对象的人还是修改对象的人应该对对象clone的问题,这里面还涉及用户体验,api设计等问题,本人才疏学浅无法想的、讲的透彻,多多包涵。