Why implementing equals() in javascript is hard and how use Symbols to do it correctly!

A while back, we released ferrum.js, a relatively small javascript library that “brings features from rust to JavaScript” in a way that is supposed to feel native to js developers. When we first started to write the library we began by trying to answer two key questions; the first of which is a bit provocative I admit: Why is there no good library for working with es6 iterators that integrates well with javascript syntax?

We decided to tackle the problem head on: by writing just such a library!

The second question we had to address, turned out to be more complex; “How can we implement a function like equals() or hash() correctly in javascript?" Here’s how we tackled that one, too.

How to (badly) implement equals

It seems like there are a lot of implementations of these kinds of functions; lodash has one for instance. For hash() there is object-hash, which I contributed to myself a while ago! In principle, implementing a function like equals is relatively easy:

const assert = require('assert');

const equals = (a, b) => {
  if (a.constructor !== b.constructor) {
    return false;  // Implement for a variety of simple types

  } else if (a.constructor === Date) {
    return a.toString() === b.toString();  // Implement for data structures using recursion

  } else if (a.constructor === Array) {
    if (a.length !== b.length)
      return false;    for (let idx = 0; idx < a.length; idx++)
      if (!equals(a[idx], b[idx]))
        return false

    return true;
  } else if (a.constructor === Object) {
    if (Object.keys(a).length !== Object.keys(b).length)
      return false;    for (const [key, val] of Object.entries(a))

    if (!equals(val, b[key]))
      return false;

    return true;
  } else {
    // Provide a fallback for any other types
    return a === b;
  }
};

// Test our equals implementation!
const d = new Date();
const e = new Date(d.toString())

assert(equals(
  {
    foo: 42,
    baz: d,
    bar: [1, "foo", 3],
  },
  {
    foo: 42,
    bar: [1, "foo", 3],
    baz: e,
  }));

All these implementations have a drawback though: They can’t support any types they don’t know about; like this one for instance:

class Rational {
  constructor(p, q) {
    this.p = p;
    this.q = q;
  }...
};

assert(equals(new Rational(2, 2), Rational(1, 1)));

Even if the equals() implementation you are using has some support for custom types (automatically comparing each field), this example would still fail, even though 2/2 clearly equals 1/1.

Using ferrum.js to get it right

So, in order to implement equals() correctly, we need to support all the types your users might want to create; the function needs to be extensible!

Here is where ferrum.js comes in; it provides a helper class called Trait (one of the features borrowed from rust — see Rust Traits) to define extension points for functions like equals(). Ferrum already has an Equals trait and an eq() function, so we can just reuse it:

const { Equals, eq } = require('ferrum');

class Rational {
  constructor(p, q) {
    this.p = p;
    this.q = q;
  }

  // ...

  normalize() {
    // ... Normalize the rational so that 2/2 becomes 1/1
  }

  [Equals.sym](other) {
    // Tell ferrum in here how to compare your custom type
    const a = this.normalize();
    const b = other.normalize();
    return a.p == b.p && a.q == b.q;
  }
}assert(equals(new Rational(2, 2), Rational(1, 1)));

Ferrum already provides implementations of eq() for all standard types (Array, Map, Number, Date, etc.), so you just have to implement an equality function for your new type.

Internally, the library uses ES6 Symbols to find the implementation; this is the recommended way to implement generic interfaces in JavaScript. All ferrum does is wrap this in order to provide a more convenient interface to handle a lot of edge cases.

The Iterator Protocol uses Symbols in this way to implement ES6 iterators. You can even wrap existing protocols; for instance, the Sequence Trait is just a wrapper around the iterator protocol, created to make use of the advanced edge case handling of ferrum. One example: The Sequence Trait can support plain Objects, while the Iterator Protocol cannot.

Ferrum is also designed to be null/undefined safe; many functions explicitly handle null/undefined as a special edge case. Traits can even be implemented for null and/or undefined; our equals() implementation above on the other hand would simply crash. Ferrum even provides the typesafe module to safely deal with null/undefined values.

This was one of our main motivations when creating ferrum; while Rust has been designed to be safe and avoid a lot of those edge cases, JavaScript has historically had a lot of them. Ferrum is designed to take as much of the edge case load of the developer…anything that fits that description should probably be part of the Ferrum ecosystem — make JavaScript a bit safer.

What’s next?

Ferrum is currently under active development. One upcoming big feature (again, borrowed from rust) is documentation testing. Ever found that the examples in your documentation were full of bugs? This allows you test your documentation!

Other features expected to be released this year as a part of ferrum:

  • A Hash trait, and Hash tables supporting arbitrary keys.
  • A Ord trait and ordered maps supporting arbitrary keys.
  • Support for rxjs Observables and Asynchronous Iterators; all using the familiar Ferrum Sequence api!

This was originally posted on the adobe tech blog.

Date:
Sun Jan 2020 19:02 UTC
Category:
Tags:

Comments

Write a comment.