当语言没有提供任何明显的实现不变性的方法时,人们如何在 JavaScript 中实现不可变数据结构?

How are people implementing immutable data structures in JavaScript when the language doesn't offer any obvious way of implementing immutability?



为什么要进行函数式编程?


我决定尝试 “函数式编程”,因为我从多个来源了解到函数式编程具有以下优点:

...谁不想这样?当然,我试了一下。




我的第一次失败尝试:


我第一次尝试实现“函数式编程”并不顺利,我不知道我现在是否对这个概念有了更好的理解。在我的脑海里,我在想 state(我的程序在任何给定时刻的状态)。我想让它达到我实现的所有内容的状态都是不可变的。我只写了 2 行代码,就很快意识到我对自己在做什么一无所知。我的第一个想法是使变量不可写,但没有达到我的预期。一切都是静态的,我不知道如何在实现不可变变量的同时创建动态程序。


显然不可变并不意味着静态,但我不太清楚当系统中的所有值都无法更改时如何实现动态系统。

为了、重申并以清晰简洁的方式提出我的问题,这不需要意见,我已经撰写了这个问题。

"How do JavaScript, TypeScipt, &/or Node.js developers implement immutable data structures for managing the state of their applications, when JavaScript doesn't offer any sort of explicit immutable data types, or support?"

Any immutable data structure 的例子就是我要找的;以及如何实现允许我使用数据结构的功能,以管理 JS 应用程序的状态。如果答案涉及使用 3rd 方库、其他语言或任何其他工具,那对我来说完全没问题。一个实际代码的例子会很棒,这样我就可以解释和理解一些东西。





Bellow is my horrible attempt at creating an immutable data structure, that I could implement.
Though its not good code, it demonstrates what I am trying to accomplish

'use strict';

const obj = {};

Object.defineProperties(obj, {
  prop_1: {
    value: (str) => {this.prop_3 = str};
    writable: false,
  },

  prop_2: {
    value: () => this.prop_3;
    writable: false,
  },

  prop_3: {
    value: '',
    writable: false,
  },
});

obj.prop_1('apples & bananas');

console.log(obj.prop_3);



/*

TERMINAL OUTPUT:

Debugger attached.
Waiting for the debugger to disconnect...
file:///home/ajay/Project-Repos/j-commandz/sandbox.js:19
      this.prop_3 = str;
                  ^

TypeError: Cannot assign to read only property 'prop_3' of object '#<Object>'
    at Object.set (file:///home/ajay/Project-Repos/j-commandz/sandbox.js:19:19)
    at file:///home/ajay/Project-Repos/j-commandz/sandbox.js:37:5

*/



欢迎使用函数式编程!

一种解决方案是使用 ES6 class。 getter returns 属性 的深拷贝,setter 抛出错误。

示例代码:

class Person {
  _name = "";
  constructor(name) {
    this._name = name;
  }

  get name() {
    return this.name;
  }

  set name(name) {
    throw new Error("Can't reassign a Person's name");
  }
}

class ImmutableArray {
  _arr = [];
  constructor(arr) {
    this._arr = [...arr];
  }
  get arr() {
    return [...this._arr];
  }
  set arr(arr) {
    throw new Error("Can't reassign a ImmutableArray");
  }
}

const aPerson = new Person("jiho");
aPerson.name = "W3Dojo"; // Error: Can't reassign a Person's name

const aImmutableArray = new ImmutableArray([1, 2, 3]);
aImmutableArray.arr = [2]; // Error: Can't reassign a ImmutableArray

const arr = aImmutableArray.arr;
arr[2] = 20;
console.log(aImmutableArray.arr[2]); // 3

在这种方法中,class 中的 属性 是不可变的。

了解更多

Private class fields 在 MDN 中,但它在第 3 阶段。

JavaScript doesn't really have a way of implementing Immutable data, at least not a way that is obvious.

您可能是 JS 的新手,但使对象不可变的明显方法是 freeze 它:

const obj = Object.freeze({
  prop_2() { return this.prop_3 }
  prop_3: '',
});

obj.prop_3 = 'apples & bananas'; // throws as expected
console.log(obj.prop_3);

如果对象是不可变的,我们需要用我们想要的新值创建新对象,而不是分配给它们的属性。对象字面量中的 Spread property syntax 帮助我们实现这一点,辅助方法也是如此:

const base = {
  withProp(newVal) {
    return { withProp: this.withProp, prop: newVal };
  },
  prop: '';
};

const obj1 = base.withProp('apples & bananas');
console.log(obj1.prop);
const obj2 = {...base, prop: obj1.prop + ' & oranges'};
console.log(obj2.prop);

有了足够的自我约束(或练习,或代码审查,或像类型检查器和 linters 这样的工具),这种克隆对象的编程风格变得自然,你不会再因意外赋值而犯错误。

当然,用更复杂的(嵌套)结构来做这件事很麻烦,所以有相当多的库提供辅助函数,也有更高级的实现 purely functional data structures 比每次都克隆你的整个数据。

虽然JavaScript缺少不可变的内置数据结构,但不可变的状态仍然是可能的。

如您所知,变量存储程序的状态。像 Lisp 这样的函数式语言通常通过将当前状态作为输入并 return 将新的更新状态作为输出(用作另一个函数的输入;重复)来更改程序状态。

JavaScript 程序通常通过改变变量来改变程序状态,但也可以使用上面描述的 Lisp 使用的方法。不要编写改变变量的函数,只需编写输入当前状态的函数和 return 新的输出状态而不修改任何输入。

在 JavaScript 中以不可变风格编程时,您可能 运行 遇到一些缺点:

  • JavaScript 针对突变而非不变性进行了优化。所以可能会有 memory/performance 的惩罚。不可变范式更喜欢生成新值而不是改变现有值。 (另一方面,类似 Lisp 的语言针对不变性而非突变的偏见进行了优化。)
  • 如果您使用许多 JavaScript 改变数据的原语中的任何一个(例如 Array.sort()),改变可能会“泄漏”到您的程序中。

有助于在 JS 中使用不可变范式的库

沉浸

Immer 是一个有助于在 JS 中使用不可变状态的 JS 库:

The basic idea is that you will apply all your changes to a temporary draftState, which is a proxy of the currentState. Once all your mutations are completed, Immer will produce the nextState based on the mutations to the draft state. This means that you can interact with your data by simply modifying it while keeping all the benefits of immutable data.

Immutable.js

Immutable.js 是另一个有助于在 JS 中实现不可变状态的 JS 库:

Immutable.js provides many Persistent Immutable data structures including: List, Stack, Map, OrderedMap, Set, OrderedSet and Record.

These data structures are highly efficient on modern JavaScript VMs by using structural sharing via hash maps tries and vector tries as popularized by Clojure and Scala, minimizing the need to copy or cache data.

Mori 提取 ClojureScript 的优化不可变数据结构,以便您可以在 vanilla JS 中使用它们。 (ClojureScript 是一种编译为 JavaScript 的 Lisp。)

JavaScript 个不可变库列表

这个list of immutable libraries分为两大类:

  • 持久数据结构w/structural共享
  • 不可变助手(简单的浅拷贝 JS 对象)

JavaScript

中函数式编程的更长资源列表

https://project-awesome.org/stoeffel/awesome-fp-js

最后,ProseMirror 是 JavaScript 中不可变状态的真实示例:

ProseMirror 是一个用 JavaScript 编写的编辑器,它使用 persistent data structure to store the document data.

  • 请注意,按照惯例,此数据结构是不可变的。开发人员必须确保数据结构不发生变化。可以改变数据结构,但结果是未定义的,很可能是不希望的。所以 ProseMirror 提供了文档和函数来支持不变性。
  • 可以使用 Object.freeze() 强制此数据结构的不变性,但这通常会被避免,因为它会导致巨大的性能损失。
  • 另请注意,不变性不是“全有或全无”。主要数据结构是不可变的,但在更有意义的地方使用可变变量(如循环变量)。

(很多)一个想法可能是将对象包装在 Proxy 中。类似于片段:

See also...

function createObject(someObject) {
  const localScores = {value: {history: [], current: []}, writable: true};
  const proxyHandler = {
    get: (target, prop) => {
      if (!(prop in target)) {
        console.log(`'${prop}' is a non existing property`);
        return null;
      }
      return target[prop].value;
    },
    set: (obj, prop, value) => {
      if (obj[prop] && obj[prop].writable) {
        obj[prop].value = value;
        return value;
      }
      console.log(`'${prop}' is not writable, sorry`);
    },
  };  
  return new Proxy(
    {...someObject, ...{ local: localScores } }, 
    proxyHandler
  );
}
const obj = createObject({
  prop1: {value: 'some string 1', writable: false}, 
  prop2: {value: '', writable: true}, 
  prop3: {value: 42, writable: false},
});

obj.nothing;
obj.prop1 = 'no dice';
obj.prop2 = 'apples & bananas!';
obj.local = { 
  history: [...obj.local.history, obj.local.current], 
  current: [...obj.local.current, ...[1,2,3]] };
obj.local = { 
  history: [...obj.local.history, obj.local.current], 
  current: [...obj.local.current, obj.prop3] };
obj.local = { 
  history: [...obj.local.history, obj.local.current], 
  current: [...obj.local.current, ...[123, 321]] };
console.log(`obj.prop1: ${obj.prop1}`);
console.log(`obj.prop2 has a new value: ${obj.prop2}`);
console.log(`obj.local: ${JSON.stringify(obj.local)}`);

你是对的,Javascript(不像 Haskell & co.) does not provide first-class support for Immutable Data Structures (In Java you'd have the keyword final)。 并不意味着您不能以不可变的方式编写代码或推理程序。

正如其他人提到的,您仍然有一些本机 javascript API 可以帮助您实现不变性(大概),但正如您已经意识到的那样,none 确实解决了问题(Object.freeze 只在浅层工作,const 阻止你重新分配一个变量,但不能改变它,等等)。


那么,如何实现 Immutable JS?

我想提前道歉,因为这个答案可能主要基于个人观点,并且不可避免地因我自己的经验和思维方式而存在缺陷。所以,请对以下内容持保留态度,因为这只是我对这个主题的两分钱。

我想说不变性主要是一种思想状态,然后您可以在此基础上构建支持它(或使其更易于使用)的所有语言 API。

我之所以说“这主要是一种心态”,是因为您可以(在某种程度上)弥补第三方库中第一class语言结构的不足(并且有一些令人印象深刻的成功案例)。

但是不可变性是如何工作的?

好吧,它背后的想法是任何变量都被视为固定的,任何突变都必须在新实例中解决,而原始 input 保持不变。

好消息是,所有 javascript 个原语都已如此。

const input = 'Hello World';
const output = input.toUpperCase();

console.log(input === output); // false

所以,问题是,我们怎样才能把一切都当作它是一个原始

...好吧,答案很简单,接受函数式编程的一些基本原则,让第三方库填补这些语言空白。

  1. state 与其 transition 逻辑分开:
class User {
  name;

  setName(value) { this.name = value }
}

只是


const user = { name: 'Giuseppe' };

const setUserName = (name, user) => ({ ...user, name });
  1. 避免命令式方法并利用第 3 方专用库
import * as R from 'ramda';

const user = { 
  name: 'Giuseppe',
  address: {
    city: 'London',
  }
};


const setUserCity = R.assocPath(['address', 'city']);

const output = setUserCity('Verbicaro', user);

console.log(user === output); // recursively false

也许是关于我喜欢的一些库的注释

  1. Ramda provides immutability as well as enriching the js api with all those declarative goodies that you'd normally find in any f language (sanctuary-js and fp-ts也是伟大的成功案例)
  2. RxJS 使用序列实现不可变和无副作用的编程,同时还提供惰性评估机制等
  3. Redux and XState提供不可变状态管理的解决方案。

最后一个例子

const reducer = (user, { type, payload }) => {
  switch(type) {
    case 'user/address/city | set':
      return R.assocPath(['address', 'city'], payload, user);
  
    default:
      return user;
  }
}

const initial = {
  name: 'Giuseppe',
  address: {
    city: 'Verbicaro',
  },
};

const store = Redux.createStore(reducer, initial);
console.log('state', store.getState());

store.dispatch({ 
  type: 'user/address/city | set', 
  payload: 'London',
});

console.log('state2', store.getState());
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.27.1/ramda.js" integrity="sha512-3sdB9mAxNh2MIo6YkY05uY1qjkywAlDfCf5u1cSotv6k9CZUSyHVf4BJSpTYgla+YHLaHG8LUpqV7MHctlYzlw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.1.0/redux.js" integrity="sha512-tqb5l5obiKEPVwTQ5J8QJ1qYaLt+uoXe1tbMwQWl6gFCTJ5OMgulwIb3l2Lu7uBqdlzRf5yBOAuLL4+GkqbPPw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

最后重申你自己的例子

const obj = {
  prop_1(value) { 
    return { ...this, prop_3: value }
  },
  prop_2: () => this.prop_3,
  prop_3: '',
}

console.log(obj);

const obj2 = obj.prop_1('Apple & Banana');

console.log(obj2);