letsfreezethat

3.1.0 • Public • Published

let's keep calm and freeze that

Let's Freeze That!

LetsFreezeThat is an unapologetically minimal library to make working with immutable objects in JavaScript less of a chore.

Table of Contents generated with DocToc

Installation

npm install letsfreezethat

Usage

requireing the module imports a method lets():

lets = require 'letsfreezethat'

This method is best explained by having a look at its definition which is in essence approximately three lines long: it takes an original value (a JS object or array) and an optional modifier callback function. It then thaw()s that value, which entails making a deep copy of it. Next, it calls the modifier() (if given), ignoring the return value of that call. Step 3 consists of freezing the draft version (in-place, i.e. without copying it) and returning it:

lets = ( original, modifier = null ) ->
  draft = freeze_lets.thaw original
  modifier draft if modifier?
  return deep_freeze draft

Using lets()

The way this is intended to simplify your life is as follows: you have a function that accepts and returns an object (or array). Within that function, you want to perform some computation and update the object the functional way (no side effects, no mutations). In order to be on the safe side, you want to work with deep-frozen objects (at least in development, but we'll come to that) to prevent any slipups. LetsFreezeThat gives you two styles to accomplish that goal, the 'safer' variant being lets(), like in the below:

lets = require 'letsfreezethat'

set_balance = ( account, amount ) ->
  account = lets account, ( d ) ->
    d.balance += amount
    return null # <- just for clarity but recommended to avoid accidental return value
  return account

At the point in time account is set to the return value of the lets() call, it becomes bound to a faithful copy of the value passed in to set_balance(). Whatever you name the second argument to lets() (I chose d here for draft, data or datom, whichever you prefer)—that name (binding) cannot leak out of the modifier function, so you're pretty much on the safe side here. And that's it. No new API to learn and nothing (well, less) to worry about. Keep calm and lets() freeze that!

Using thaw() and freeze()

Using lets() is fine but the act of calling a function only to get called back adds a bit of computational overhead. You can shave off a few percent (maybe 10% or so) by using thaw() and freeze() expöicitly, like so (using d as name for the business data object):

{ thaw, freeze, } = require 'letsfreezethat'

set_balance = ( d, amount ) ->
  d = thaw d
  d.balance += amount
  return freeze d

And that's it, a little bit simpler than the code for lets() if you will but also a little bit more open to accidental slips. YMMV.

get() and set()

get() and set() are available FTTB but not necessarily recommended. set() takes a data object, a key, and a value; it will produce a draft copy of the data object, set the key to the value given, freeze the data object and return it. If you have a single attribute to set, that's one way to do it:

{ thaw, freeze, get, set, } = require 'letsfreezethat'
d = freeze d
d = set d, 'key', value
w = get d, 'key'

API, and Moving to Production

LetsFreezeThat comes in two configurable flavors, one that does indeed freeze and thaw (and, thereby, implicitly copies) objects, and one that skips the freezing and thawing (but not the copying). require()ing either flavor returns a method lets() as discussed above:

  • lets = require 'letsfreezethat' which indeed deep-freezes objects and arrays, and
  • lets = ( require 'letsfreezethat' ).nofreeze which forgoes freezing (but not copying).

The nofreeze flavor is around 3 to 4 times faster on freeze().

The lets() method has a number of attributes which are callable by themselves (no JS tear-off / this-juggling here):

  • lets = ( d, modifier = null ) ->—copy of the same method.
  • assign = ( d, P... ) ->—bulk-assign, semantics like Object.assign(), returns copy of d.
  • freeze = ( d ) ->—deep-freeze in-place; a no-op with nofreeze.
  • thaw = ( d ) ->—return a deep copy of d (thereby un-freezing it).
  • get = ( d, key ) ->—return value of an attribute of d. Equivalent to d[ key ] and just there to complement set().
  • set = ( d, key, value ) ->—make a deep copy of d, set attribute key to value, and return the (frozen or unfrozen depending on flavor) copy. Prefer to use assign(), thaw()/freeze(), or lets() whenever you want to modify more than a single attribute as set() will deep-copy and deep-freeze# on each call.

Notes

  • LetsFreezeThat does not copy objects on explicit or implicit freeze(). That should be fine for most use cases since what one usually wants to do is either create or thaw a given value (which implies making a copy), manipulate (i.e. mutate) it, and then freeze it prior to passing it on. As long as manipulations are local to a single function, chances of screwing up are limited, so we can safely forgo the added overhead of making an additional copy when either freeze() is called or a call to lets d, ( d ) -> ... has finished. Observe that when being given a value d it is not necessarily safe to freeze() it since another party may still hold a reference to d and assume mutability. When in doubt, use freeze thaw d to freeze a deep copy of d.

  • Observe that the thaw() method will always make a copy even with the nofreeze flavor; otherwise it is hardly conceivable how an application could switch from the slower { freeze: true, } configuration to the faster { freeze: false, } without breaking.

  • In the case a list or an object originates from the outside and other places might still hold references to that value or one of its properties, one can use thaw() to make sure any mutations will not be visible from the outside. In this regard, thaw() could have been called deep_copy().

Implementation

The performance gains seen when going from LetsFreezeThat v2 to v3 are almost entirely due to the code used by the klona library, specifically its JSON module. The code is simple, straightforward, and fast—mostly because it's a well-written piece that does something very specific, name only concerning itself with (JSON, JS) objects and arrays.

LetsFreezeThat has a similar focus and forgoes freezing RegExps, Dates, Int32Arrays or anything but plain Objects and Arrays, so that's a perfect fit. I totally just copied the code of the linked module to avoid the dependency on whatever else it is that klona has in store (it's a lot got check it out).

Benchmarks

where to find the code—The code that produced the below benchmarks is available in 𐌷𐌴𐌽𐌲𐌹𐍃𐍄 (which is my workbench of sorts to develop, test and benchmark my software). In each case, thousands of small-ish JS objects were frozen, manipulated, and thawed, as the case may be, using a number of approaches and a number of software packages.

how to read the tagsletsfreezethat_v{2|3}_f{0|1} is to be read as: 'letsfreezethat using { legacy v2.2.5 | code for upcoming v3 in the present state } with freezing turned { off | on }'.

how to understand the numbers—Absolute numbers are cycles per second (Hz) where mulling through the tasks for a single object is counted as one cycle, and the number and nature of tasks is identical for all libraries tested, as far as possible. To obtain a baseline for comparison, JavaScript's Object.freeze() have been used for freezing and Object.assign() for thawing, but keep in mind that both methods are shallow in the sense that neither method would affect the nested list in a value like { x: [ 1, 2, 3, ], }. LetsFreezeThat does do deep freezing and deep thawing, though (and some of the other libraries do so too; others don't), so the comparison is slightly in favor of JavaScript native methods (because they get as much credit for each cycle although less gets done).

why native JS looks slow in comparison—One would fully expect JS native methods to be always on top of the scores but this is not the case. For one thing letsfreezethat.nofreeze.freeze() does not actually do anything, its literally just the id() function: nofreeze_lets.freeze = ( me ) -> me, bam. Deep freezing without the part where you deep-freeze is indeed faster than shallow freezing, of course. Also, although care has been taken to run garbage collection explicitly and to perform any computation that is external to each test such that it does not affect the timings, there's always an observable and, sadly, unavoidable jitter in performance which can add up to as much as 10 or even 20 per cent of the figures shown. Each test case has been run with hundreds or thousands of values and a few (3 to 5) repeated runs, some of them in shuffled order, to minimize such effects. I hope to provide error bars in future editions but for now please understand that 100,00Hz means something close to between 80,000Hz and 120,000Hz and 50% is really maybe something around 40% to 60% of the best performing solution.

only temporal, no spatial benchmarks—So far I have not looked at RAM consumption figures for the various test cases. This is in part because the intended use case for LetsFreezeThat is in passing around lots of small-ish objects that are not very deeply nested (datoms to be more precise). I do not expect any copy-on-write (COW) implementation to be very space- and time-efficient in JavaScript for this particular use cae except for the hypothetical case where we have something like Hash Array Mapped Tries (HAMTs) built right into the language like Clojure has. The story might well be different in the case where you have deeply nested, larg-ish objects where once in while you want to modify-but-not-mutate this or that attribute in a tree. I did not test for that in the current iteration. Since the memory consumption of each individual piece of data is so small, just making a copy as fast as you can without asking questions turns out to be quite efficient time-wise, and I just assume that it will be somehow-acceptable space-wise, too, because garbage collection. It would still be nice to have some memory consumption for the various libraries, so maybe sometime.

what to learn from the benchmarks—The overall trend is clear. Barring any dumb blunders in my benchmarking code what clearly stands out is that structural sharing (as provided by immutable.js, immer, HAMT, and mori) does not pay out in terms of time costs and provided you have many small-ish, flat-tish objects. It's just not worth the trouble. These are well thought-out, tested and honed libraries that go a long way to prevent unwarranted duplication of data, yet their demands in terms of CPU cycles is non-trivial when compared to stupid copying.

# hengist/dev/letsfreezethat/src/lft-deepfreeze.benchmarks.coffee

  thaw_____shallow_native                          829,171 Hz   100.0 % │████████████▌│
  thaw_____klona                                   347,483 Hz    41.9 % │█████▎       │
█ thaw_____letsfreezethat_v3_f0                    330,089 Hz    39.8 % │█████        │
█ thaw_____letsfreezethat_v3_f1                    242,111 Hz    29.2 % │███▋         │
  thaw_____fast_copy                               176,418 Hz    21.3 % │██▋          │
  thaw_____letsfreezethat_v2                        93,441 Hz    11.3 % │█▍           │
  thaw_____deepfreezer                              50,249 Hz     6.1 % │▊            │
  thaw_____deepcopy                                 31,608 Hz     3.8 % │▌            │
  thaw_____fast_copy_strict                         17,539 Hz     2.1 % │▎            │
———————————————————————————————————————————————————————————————————————————————————————————
█ freeze___letsfreezethat_v3_f0                    745,781 Hz    89.9 % │███████████▎ │
  freeze___shallow_native                          665,340 Hz    80.2 % │██████████   │
█ freeze___letsfreezethat_v3_f1                    201,651 Hz    24.3 % │███          │
  freeze___letsfreezethat_v2                        70,091 Hz     8.5 % │█            │
  freeze___deepfreeze                               59,320 Hz     7.2 % │▉            │
  freeze___deepfreezer                              37,352 Hz     4.5 % │▋            │
# hengist/dev/letsfreezethat/src/usecase1.benchmarks.coffee

  plainjs_mutable                                    8,268 Hz   100.0 % │████████████▌│
  plainjs_immutable                                  4,933 Hz    59.7 % │███████▌     │
█ letsfreezethat_v3_thaw_freeze_f0                   4,682 Hz    56.6 % │███████▏     │
  letsfreezethat_v2_standard                         4,464 Hz    54.0 % │██████▊      │
█ letsfreezethat_v3_lets_f0                          4,444 Hz    53.8 % │██████▊      │
█ letsfreezethat_v3_lets_f1                          4,213 Hz    51.0 % │██████▍      │
█ letsfreezethat_v3_thaw_freeze_f1                   4,034 Hz    48.8 % │██████▏      │
  letsfreezethat_v2_nofreeze                         2,143 Hz    25.9 % │███▎         │
  immutable                                          1,852 Hz    22.4 % │██▊          │
  mori                                               1,779 Hz    21.5 % │██▊          │
  hamt                                               1,752 Hz    21.2 % │██▋          │
  immer                                              1,352 Hz    16.3 % │██           │
# hengist/dev/letsfreezethat/src/main.benchmarks.coffee

█ letsfreezethat_v3_f0_freezethaw                  116,513 Hz   100.0 % │████████████▌│
█ letsfreezethat_v3_f1_freezethaw                   97,101 Hz    83.3 % │██████████▍  │
█ letsfreezethat_v3_f0_lets                         93,101 Hz    79.9 % │██████████   │
█ letsfreezethat_v3_f1_lets                         76,045 Hz    65.3 % │████████▏    │
  plainjs_mutable                                   28,035 Hz    24.1 % │███          │
  letsfreezethat_v2_f0_lets                         22,410 Hz    19.2 % │██▍          │
  letsfreezethat_v2_f0_freezethaw                   16,854 Hz    14.5 % │█▊           │
  letsfreezethat_v2_f1_freezethaw                   16,443 Hz    14.1 % │█▊           │
  letsfreezethat_v2_f1_lets                         13,648 Hz    11.7 % │█▌           │
  immutable                                          8,359 Hz     7.2 % │▉            │
  mori                                               7,845 Hz     6.7 % │▉            │
  hamt                                               7,449 Hz     6.4 % │▊            │
  immer                                              4,943 Hz     4.2 % │▌            │

Other Libraries

Libraries that do deep freezing and/or deep copying and/or provide copy-on-write semantics that are available on npm include immer, HAMT, mori, immutable.js, fast-copy, deepfreeze, and deepfreezer (a.k.a. DeepFreezerJS).

immer provided the inspiration—The key idea of immer is that in order to achieve immutability in JavaScript, instead of inventing one's own data structures and APIs, it is much simpler to just recursively make use of Object.freeze() and Object.assign() and give the programmer a convenience function—in LetsFreezeThat: lets(); in immer: produce()—that allows to perform mutation within the confines of a callback function. immer aims at reducing memory usage by providing structural sharing. I have not looked into its implementation and did not collect any figures on RAM consumption, so I'll leave the reader with the benchmarks.

mori is tempting, but not convincing for my use casemori is a standalone library that brings some ClojureScript goodness to JS programs. Its API is a bit un-JS-ish but does provide some interesting functionality. On the downside, it cannot initialize HashMaps from plain JS objects, only from sequence of key/value pairs, and when doing so, must explicitly take care of nested objects and lists. What you then get is data structures that internally look very unlike plain JS objects so even to get a meaningful ouput when debugging you can never just console.log( myvalue ), you must always convert back to plain JS. These two considerations pretty much precluded using mori under the hood; also, the benchmarks.

most deep-copy algos too slow—In search for a fast solution that would only provide deep-copying (i.e. no copy-on-write / structural sharing) and/or deep-freezing capabilities I found klona, fast-copy, deepfreeze, and deepfreezer (a.k.a. DeepFreezerJS). Of these, benchmarks convinced me that only klona was likely to bring speedups to the next version of LetsFreezeThat so I did not consider the rest any more. Deep-freezing nested compound values in-place is almost exactly the same as deep-copying nested compound values so I used klona's approach for both chores. Be it said though that I did not evaluate other possibly interesting aspects of any of these packages, so if your use cases involves copying or freezing JS Date objects, Int32Arrays, RegExps, I encourage you to have a second look at any of these.

Should I COW?—Copy-On-Write is a technique to eschew 'speculative', avoidable memory consumption. Phil Bagwell suggested how to efficiently leverage structural sharing for trees of data in a paper titled Ideal Hash Trees (Lausanne, 2000); subsequentially, his approach was implemented by the Clojure community to get more memory-efficient and performant COW semantics into the language. Alas, according to my benchmarks HAMT is still not fast enough in JS to justify the effort when your data items are small as you'll only get 5%—25% of the performance that you'd get with naive copying.

To Do

Package Sidebar

Install

npm i letsfreezethat

Weekly Downloads

30

Version

3.1.0

License

MIT

Unpacked Size

41.2 kB

Total Files

8

Last publish

Collaborators

  • loveencounterflow