Intensive reading of Records & Tuples proposal

Immutable js, immer and other libraries have made js possible for immutable programming, but there are still some unsolved problems, that is, "how to ensure that an object is really immutable".

If it wasn't for the chest guarantee, there was really no other way now. Maybe you think frozen is a good idea, but it can still add non frozen key s.

Another problem is that when we debug application data, we see [] - > [] changes in the state, whether on the console, breakpoints, redux devtools or toString() can't see whether the reference has changed unless the variable values are obtained separately for = = = runtime judgment. But whether the reference changes or not is a big problem. It can even determine whether the business logic is correct or not.

But at this stage, we have no way to deal with it. If we can not accept the Immutablejs definition object completely, we can only make complaints about our chest changes, and ensure that our changes will be immutable. This is why js immutable programming is being tucking by many smart people. It is a very awkward thing to force immutable thinking without supporting immutable programming language.

proposal-record-tuple This problem is solved, which makes js natively support immutable data types (highlighted and bold).

Overview & Intensive Reading

js has seven primitive types: string, number, bigint, boolean, undefined, symbol, null The records & tuples proposal adds three original types! These three primitive types are completely for immutable programming environment, that is, js can open a native immutable track.

The three original types are record, tuple and box:

  • Record: the depth of class object structure is immutable, such as #{x: 1, y: 2}.
  • Tuple: the depth of class array structure is immutable, such as #[1, 2, 3, 4].
  • Box: can be defined in the above two types to store objects, such as #{prop: Box(object)}.

The core idea can be summarized as follows: because these three types are basic types, value comparison (rather than reference comparison) is used in comparison, so #{X: 1, Y: 2} = = #{X: 1, Y: 2}. This really solves the big problem! If you don't understand the pain that js doesn't support immutable, please don't skip the next section.

js does not support immutable pain

Although many people like the reactive feature of mvvm (I also wrote a lot of mvvm wheels and frameworks), but immutable data is always the best idea for developing large-scale applications. It can reliably ensure the predictability of application data without sacrificing performance and memory. It is not as convenient to use as mutable mode, but it will never encounter unexpected situations, which is very important for building stable and complex applications Yes, even more important than convenience. Of course, testability is also a very important point, which will not be expanded in detail here.

However, js does not support immutable natively, which is very troublesome and has caused a lot of problems. Let me try to explain this problem.

If you think the comparison of non original types by reference is great, you can see at a glance that the following results are correct:

assert({ a: 1 } !== { a: 1 })

But what if it's the following?

console.log(window.a) // { a: 1 }
console.log(window.b) // { a: 1 }
assert(window.a === window.b) // ???

The result is uncertain. Although the two objects look the same, the scope we get cannot infer whether they come from the same reference. If they come from the same reference, the assertion passes. Otherwise, even if the value looks the same, it will throw error.

The bigger trouble is that even if the two objects look completely different, we dare not easily draw a conclusion:

console.log(window.a) // { a: 1 }
// do some change..
console.log(window.b) // { b: 1 }
assert(window.a === window.b) // ???

Because the value of b may be modified halfway, but it does come from the same reference as a, we can't determine what the result is.

Another problem is the complexity of applying state changes. Imagine that we have developed a tree menu with the following structure:

{
  "id": "1",
  "label": "root",
  "children": [{
    "id": "2",
    "label": "apple",
  }, {
    "id": "3",
    "label": "orange",
  }]
}

If we call updatetreenode ('3 ', {id:' 3 ', Title:' Banana '}), in the immutable scenario, we only update the reference of the component with id "1" and "3", and the reference with id "2" remains unchanged, then the tree node "2" will not be re rendered, which is the pure immutable thinking logic.

However, when we save the new state and perform "state playback", we will find that the application state has changed once, and the whole description json becomes:

{
  "id": "1",
  "label": "root",
  "children": [{
    "id": "2",
    "label": "apple",
  }, {
    "id": "3",
    "label": "banana",
  }]
}

However, if we copy the above text and directly set the application status to this result, we will find that the effect is different from that of the "application playback button". At this time, id "2" is also re rendered because its reference has changed.

The problem is that as like as two peas are used, we can not see whether the reference is changed. Even if the two structures are identical, they can not guarantee whether the reference is the same, which leads to inability to infer whether the application is consistent. If there is no artificial code quality control, unexpected reference updates are almost inevitable.

This is the background of the problems to be solved by the records & tuples proposal. We can learn better by looking at its definition with this understanding.

Records & tuples are consistent with objects and arrays in usage

The records & tuples proposal states that immutable data structures are no different from ordinary objects and arrays except that they need to be declared with # symbols when defined.

The usage of Record is almost the same as that of ordinary object s:

const proposal = #{
  id: 1234,
  title: "Record & Tuple proposal",
  contents: `...`,
  // tuples are primitive types so you can put them in records:
  keywords: #["ecma", "tc39", "proposal", "record", "tuple"],
};

// Accessing keys like you would with objects!
console.log(proposal.title); // Record & Tuple proposal
console.log(proposal.keywords[1]); // tc39

// Spread like objects!
const proposal2 = #{
  ...proposal,
  title: "Stage 2: Record & Tuple",
};
console.log(proposal2.title); // Stage 2: Record & Tuple
console.log(proposal2.keywords[1]); // tc39

// Object functions work on Records:
console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"]

The following example shows that there is no difference between Records and object in function processing. This is a very important feature mentioned in the FAQ, which can fully integrate immutable into the current js Ecology:

const ship1 = #{ x: 1, y: 2 };
// ship2 is an ordinary object:
const ship2 = { x: -1, y: 3 };

function move(start, deltaX, deltaY) {
  // we always return a record after moving
  return #{
    x: start.x + deltaX,
    y: start.y + deltaY,
  };
}

const ship1Moved = move(ship1, 1, 0);
// passing an ordinary object to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

Tuple is used almost the same as a normal array:

const measures = #[42, 12, 67, "measure error: foo happened"];

// Accessing indices like you would with arrays!
console.log(measures[0]); // 42
console.log(measures[3]); // measure error: foo happened

// Slice and spread like arrays!
const correctedMeasures = #[
  ...measures.slice(0, measures.length - 1),
  -1
];
console.log(correctedMeasures[0]); // 42
console.log(correctedMeasures[3]); // -1

// or use the .with() shorthand for the same result:
const correctedMeasures2 = measures.with(3, -1);
console.log(correctedMeasures2[0]); // 42
console.log(correctedMeasures2[3]); // -1

// Tuples support methods similar to Arrays
console.log(correctedMeasures2.map(x => x + 1)); // #[43, 13, 68, 0]

When processing in a function, there is no special difference between getting an array or Tuple:

const ship1 = #[1, 2];
// ship2 is an array:
const ship2 = [-1, 3];

function move(start, deltaX, deltaY) {
  // we always return a tuple after moving
  return #[
    start[0] + deltaX,
    start[1] + deltaY,
  ];
}

const ship1Moved = move(ship1, 1, 0);
// passing an array to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

Since ordinary objects (such as immutable objects defined as # tags) cannot be defined in the Record, if ordinary objects must be used, they can only be wrapped in the Box, and. unbox() needs to be called to unpack when obtaining the value. Even if the object value is modified, it will not be considered to have changed at the Record or Tuple level:

const myObject = { x: 2 };

const record = #{
  name: "rec",
  data: Box(myObject)
};

console.log(record.data.unbox().x); // 2

// The box contents are classic mutable objects:
record.data.unbox().x = 3;
console.log(myObject.x); // 3

console.log(record === #{ name: "rec", data: Box(myObject) }); // true

In addition, you cannot use any ordinary object or new object instance in records & tuples unless it has been converted to ordinary object:

const instance = new MyClass();
const constContainer = #{
    instance: instance
};
// TypeError: Record literals may only contain primitives, Records and Tuples

const tuple = #[1, 2, 3];

tuple.map(x => new MyClass(x));
// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples

// The following should work:
Array.from(tuple).map(x => new MyClass(x))

grammar

Only Record, Tuple and Box can be used in records & tuples:

#{}
#{ a: 1, b: 2 }
#{ a: 1, b: #[2, 3, #{ c: 4 }] }
#[]
#[1, 2]
#[1, 2, #{ a: 3 }]

Empty array entries are not supported:

const x = #[,]; // SyntaxError, holes are disallowed by syntax

In order to prevent references from being traced back to the upper layer and destroy the immutability, the definition of prototype chain is not supported:

const x = #{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntax

const y = #{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property.

You cannot define methods in it:

#{ method() { } }  // SyntaxError

At the same time, some features that destroy the immutable and stable structure are also illegal. For example, the key cannot be a Symbol:

const record = #{ [Symbol()]: #{} };
// TypeError: Record may only have string as keys

You cannot directly use an object as value unless wrapped in Box:

const obj = {};
const record = #{ prop: obj }; // TypeError: Record may only contain primitive values
const record2 = #{ prop: Box(obj) }; // ok

Judgment, etc

Judgment is the core. Records & tuples proposal requires = = and = = = native support for immutable judgment, which is an important manifestation of js native support for immutable. Therefore, its judgment logic is very different from ordinary object judgment:

First, if the values appear to be equal, they are really equal, because the basic type only compares the values:

assert(#{ a: 1 } === #{ a: 1 });
assert(#[1, 2] === #[1, 2]);

This is completely different from the object judgment. After the Record is converted into an object, the judgment follows the rules of the object:

assert({ a: 1 } !== { a: 1 });
assert(Object(#{ a: 1 }) !== Object(#{ a: 1 }));
assert(Object(#[1, 2]) !== Object(#[1, 2]));

In addition, the judgment of Records has nothing to do with the order of keys, because there is an implicit key sorting rule:

assert(#{ a: 1, b: 2 } === #{ b: 2, a: 1 });

Object.keys(#{ a: 1, b: 2 })  // ["a", "b"]
Object.keys(#{ b: 2, a: 1 })  // ["a", "b"]

Whether the boxes are equal depends on whether the internal object references are equal:

const obj = {};
assert(Box(obj) === Box(obj));
assert(Box({}) !== Box({}));

For the comparison between + 0 and - 0, NaN and NaN can be safely determined to be equal, but object Because is is the judgment logic for ordinary objects, it will think that #{a: -0} is not equal to #{a: +0}, because it thinks that - 0 is not equal to + 0, which needs special attention here. In addition, records & tulpes can also be used as the key of Map and Set, and can be found according to the same value:

assert(#{ a:  1 } === #{ a: 1 });
assert(#[1] === #[1]);

assert(#{ a: -0 } === #{ a: +0 });
assert(#[-0] === #[+0]);
assert(#{ a: NaN } === #{ a: NaN });
assert(#[NaN] === #[NaN]);

assert(#{ a: -0 } == #{ a: +0 });
assert(#[-0] == #[+0]);
assert(#{ a: NaN } == #{ a: NaN });
assert(#[NaN] == #[NaN]);
assert(#[1] != #["1"]);

assert(!Object.is(#{ a: -0 }, #{ a: +0 }));
assert(!Object.is(#[-0], #[+0]));
assert(Object.is(#{ a: NaN }, #{ a: NaN }));
assert(Object.is(#[NaN], #[NaN]));

// Map keys are compared with the SameValueZero algorithm
assert(new Map().set(#{ a: 1 }, true).get(#{ a: 1 }));
assert(new Map().set(#[1], true).get(#[1]));
assert(new Map().set(#[-0], true).get(#[0]));

How does the object model handle records & tuples

Object model refers to the object model. In most cases, all methods that can be applied to ordinary objects can be seamlessly applied to records, such as object Key or in can be the same as dealing with ordinary objects:

const keysArr = Object.keys(#{ a: 1, b: 2 }); // returns the array ["a", "b"]
assert(keysArr[0] === "a");
assert(keysArr[1] === "b");
assert(keysArr !== #["a", "b"]);
assert("a" in #{ a: 1, b: 2 });

It is worth mentioning that if the wrapper Object is in Record or Tuple, the proposal also prepares a complete implementation scheme, that is, Object(record) or Object(tuple) will freeze all attributes and point the prototype chain to Tuple Prototype, for cross-border access to arrays, you can only return undefined instead of tracing along the prototype chain.

Standard library support for records & tuples

After performing native array or object operations on Record and Tuple, the return value is also immutable:

assert(Object.keys(#{ a: 1, b: 2 }) === #["a", "b"]);
assert(#[1, 2, 3].map(x => x * 2), #[2, 4, 6]);

You can also use record Fromentries and tuple The from method converts an ordinary object or array into a Record, Tuple:

const record = Record({ a: 1, b: 2, c: 3 });
const record2 = Record.fromEntries([#["a", 1], #["b", 2], #["c", 3]]); // note that an iterable will also work
const tuple = Tuple(...[1, 2, 3]);
const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also work

assert(record === #{ a: 1, b: 2, c: 3 });
assert(tuple === #[1, 2, 3]);
Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to Record
Tuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple

This method does not support nesting, because the standard API only considers one layer, and recursion is generally implemented by business or library functions, such as object Like assign.

Record and Tuple are also iterative:

const tuple = #[1, 2];

// output is:
// 1
// 2
for (const o of tuple) { console.log(o); }

const record = #{ a: 1, b: 2 };

// TypeError: record is not iterable
for (const o of record) { console.log(o); }

// Object.entries can be used to iterate over Records, just like for Objects
// output is:
// a
// b
for (const [key, value] of Object.entries(record)) { console.log(key) }

JSON.stringify will convert record & tuple into ordinary objects:

JSON.stringify(#{ a: #[1, 2, 3] }); // '{"a":[1,2,3]}'
JSON.stringify(#[true, #{ a: #[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]'

However, it is also recommended to implement JSON Parseimmutable directly converts a JSON to record & tuple type, and its API is the same as JSON Parse is no different.

Tuple. The prototype method is very similar to Array, but there are some differences. The main difference is that it does not modify the reference value, but creates a new reference. See the details appendix.

Since three original types are added, typeof will also add three return results:

assert(typeof #{ a: 1 } === "record");
assert(typeof #[1, 2]   === "tuple");
assert(typeof Box({}) === "box");

Record, tuple and box all support to be the key of Map and Set, and judge according to their own rules, i.e

const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const map = new Map();
map.set(record1, true);
assert(map.get(record2));
const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const set = new Set();
set.add(record1);
set.add(record2);
assert(set.size === 1);

However, WeakMap and WeakSet are not supported:

const record = #{ a: 1, b: 2 };
const weakMap = new WeakMap();

// TypeError: Can't use a Record as the key in a WeakMap
weakMap.set(record, true);
const record = #{ a: 1, b: 2 };
const weakSet = new WeakSet();

// TypeError: Can't add a Record to a WeakSet
weakSet.add(record);

The reason is that the immutable data does not have a predictable garbage collection time, so if it is used in the break series, it will not be released in time, so the API does not match.

Finally, the proposal is also attached with theoretical basis and FAQ chapters, which are also briefly introduced below.

theoretical basis

Why create a new primitive type instead of processing it at the upper level like other libraries?

In other words, if js natively supports immutable, it must be used as the original type. If it is not used as the original type, it is impossible for the = =, = = = operators to natively support the specific judgment of this type, which will also make immutable syntax and other js codes seem to be under two sets of logic systems, hindering the unity of ecology.

Will developers be familiar with this syntax?

Because it ensures the consistency with ordinary objects, array processing and API s to the greatest extent, it should be easier for developers to get started.

Why not use Immutablejs get .set method operation?

This will lead to ecological fragmentation. The code needs to pay attention to whether the object is immutable or not. One of the most vivid examples is that when Immutablejs is matched with ordinary js operation library, you need to write code similar to the following:

state.jobResult = Immutable.fromJS(
    ExternalLib.processJob(
        state.jobDescription.toJS()
    )
);

It has a very strong sense of separation.

Why not use the global record and tuple method instead of # declaration?

Two comparisons are given below:

// with the proposed syntax
const record = #{
  a: #{
    foo: "string",
  },
  b: #{
    bar: 123,
  },
  c: #{
    baz: #{
      hello: #[
        1,
        2,
        3,
      ],
    },
  },
};

// with only the Record/Tuple globals
const record = Record({
  a: Record({
    foo: "string",
  }),
  b: Record({
    bar: 123,
  }),
  c: Record({
    baz: Record({
      hello: Tuple(
        1,
        2,
        3,
      ),
    }),
  }),
});

Obviously, the latter is not as concise as the former, and it also breaks the developer's understanding of objects and arrays Like.

Why use # [] / #{} syntax?

Using existing keywords may lead to ambiguity or compatibility problems. In addition, there are {|} [|] proposal But at present # the winning side is relatively large.

Why is the depth immutable?

The proposal was sprayed freeze:

const object = {
   a: {
       foo: "bar",
   },
};
Object.freeze(object);
func(object);

Since only one layer is guaranteed, object A is still variable. Since js should support immutable natively, we certainly hope that the depth is immutable, rather than only one layer.

In addition, because this syntax supports immutable verification at the language level, deep immutable verification is very important.

FAQ

How to create a new immutable object based on an existing immutable object?

Most grammars can be used, such as deconstruction:

// Add a Record field
let rec = #{ a: 1, x: 5 }
#{ ...rec, b: 2 }  // #{ a: 1, b: 2, x: 5 }

// Change a Record field
#{ ...rec, x: 6 }  // #{ a: 1, x: 6 }

// Append to a Tuple
let tup = #[1, 2, 3];
#[...tup, 4]  // #[1, 2, 3, 4]

// Prepend to a Tuple
#[0, ...tup]  // #[0, 1, 2, 3]

// Prepend and append to a Tuple
#[0, ...tup, 4]  // #[0, 1, 2, 3, 4]

For Tuple of class array, you can use the with syntax to replace and create a new object:

// Change a Tuple index
let tup = #[1, 2, 3];
tup.with(1, 500)  // #[1, 500, 3]

However, there is also a problem that we can't get around when making in-depth modifications. At present, there is one proposal In discussing this matter, an interesting grammar is mentioned here:

const state1 = #{
    counters: #[
        #{ name: "Counter 1", value: 1 },
        #{ name: "Counter 2", value: 0 },
        #{ name: "Counter 3", value: 123 },
    ],
    metadata: #{
        lastUpdate: 1584382969000,
    },
};

const state2 = #{
    ...state1,
    counters[0].value: 2,
    counters[1].value: 1,
    metadata.lastUpdate: 1584383011300,
};

assert(state2.counters[0].value === 2);
assert(state2.counters[1].value === 1);
assert(state2.metadata.lastUpdate === 1584383011300);

// As expected, the unmodified values from "spreading" state1 remain in state2.
assert(state2.counters[2].value === 123);

counters[0].value: 2 looks quite novel.

And Readonly Collections Your relationship?

Complementary.

Can I create a Record instance based on Class?

Not considered at this time.

TS also has Record and Tuple keywords. What is the relationship between them?

Students familiar with TS know that it's just the same name.

What are the performance expectations?

This problem is very critical. If the performance of this proposal is not good, it can not be used in actual production.

There are no performance requirements at this stage, but the best practices will be optimized for the manufacturer before Stage4.

summary

If this proposal is passed together with the nested update proposal, the use of immutable in js will be guaranteed at the language level. Libraries including immutable js and immerjs can really be laid off.

The discussion address is: Intensive reading of records & tuples proposal · Issue #384 · DT Fe / weekly

If you want to participate in the discussion, please click here , there are new themes every week, published on weekends or Mondays. Front end intensive reading - help you filter reliable content.

Focus on front-end intensive reading WeChat official account

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

Copyright notice: free reproduction - non commercial - non derivative - keep signature( Creative sharing 3.0 License)

Keywords: Javascript Front-end

Added by altergothen on Wed, 29 Dec 2021 00:21:28 +0200