The new EcmaScript standard, ES6, contains specifications for Map
and Set
built-in classes. Each of these collections treats an object-valued key as opaque and unique, e.g.:
var foo = new Set();
foo.add({bar: "baz"}).has({bar: "baz"}) // == false
However, there are cases in which one might want to treat similar objects as the same key. For this purpose, I wrote the following HashMap
and HashSet
classes. The objectives:
- High quality code (readable and maintainable)
- Two different objects with identical enumerable properties and corresponding values are treated as identical
- The value of a circular reference is considered identical if it refers to the same relative path from the object used as a key
- With the exception of the previous two objectives, instances of
HashMap
andHashSet
should be fully interchangeable with instances ofMap
andSet
respectively. In other words, where not conflicting with the above, the ES6 standard for instances of those classes (http://www.ecma-international.org/ecma-262/6.0/#sec-map-objects) should apply to instances of the corresponding classes as well.
Code below uses ES6 features supported by Firefox at present. Sadly that does not include class
or super
.
// This is for replacing cyclic references with relative names.
// as long as the reference is internally the same relative to
// the hashed object, they will hash equal.
const HASH_PATH = Symbol("hash path");
function hashVal(thing, name = "this") {
var keys, hash;
if (thing instanceof Array) {
hash = "[" +
thing.map((el, idx) =>
hashVal(el, name + "[" + idx.toString() + "]")
).join(",") + "]";
} else if (typeof thing === "object") {
if (thing[HASH_PATH]) {
hash = thing[HASH_PATH];
} else {
keys = Object.keys(thing);
thing[HASH_PATH] = name;
keys.sort((k1, k2) => k1 > k2 ? 1 : -1);
hash = "{" +
keys.map(key =>
key + ":" +
hashVal(thing[key], name + "." + key)
).join(",") + "}";
delete thing[HASH_PATH];
}
} else if (typeof thing === "string") {
hash = '"' + thing + '"';
} else {
hash = thing.toString();
}
return hash;
}
function HashMap(els, hash = this.defHash) {
this.hash = hash;
var elByHash = this.elByHash = new Map();
var keyByHash = this.keyByHash = new Map();
var el, key, hashKey;
if (els)
for ([key, el] of els.entries()) {
hashKey = this.hash(key);
keyByHash.set(hashKey, key);
elByHash.set(hashKey, el);
}
}
HashMap.prototype = {
get size() {
return this.elByHash.size;
},
clear() {
this.elByHash.clear();
this.keyByHash.clear();
return this;
},
delete(key) {
var hash = this.hash(key);
return this.keyByHash.delete(hash) && this.elByHash.delete(hash);
},
entries() {
return function*() {
var keys = this.keyByHash.entries();
var hash, key;
for ([hash, key] of keys) {
yield [key, this.elByHash.get(hash)];
}
};
},
keys() {
return this.keyByHash.values();
},
values() {
return this.elByHash.values();
},
set(key, val) {
var hash = this.hash(key);
this.keyByHash.set(hash, key);
this.elByHash.set(hash, val);
return this;
},
get(key) {
return this.elByHash.get(this.hash(key));
},
has(key) {
return this.keyByHash.has(this.hash(key));
},
forEach(cb, cx = this) {
var key, val;
for ([key, val] of this.entries()) {
cb.call(cx, val, key, this);
}
}, [Symbol.iterator]() {
return this.entries();
},
defHash: hashVal
};
// spec says set.values, set.keys, set[@@iterator] are same object
function hashSetValIterator() {
return this.elements.values();
}
function HashSet(els, hashFn = hashVal) {
var hash = this.hash = hashFn;
var elements = this.elements = new Map();
var key, val;
if (els)
for (val of els) {
elements.set(this.hash(val), val);
}
}
HashSet.prototype = {
defHash: hashVal,
get size() {
return this.elements.size;
},
add(el) {
this.elements.set(this.hash(el), el);
return this;
},
clear() {
this.elements.clear();
},
delete(el) {
return this.elements.delete(this.hash(el));
},
entries() {
var vals = this.values();
return function*() {
for (var v of vals) yield [v, v];
}
},
forEach(cbFn, thisArg = this) {
for (var v of this.values()) {
cbFn.call(thisArg, v, v, this);
}
},
has(el) {
return this.elements.has(this.hash(el));
},
values: hashSetValIterator,
keys: hashSetValIterator,
[Symbol.iterator]: hashSetValIterator,
defHash: hashVal
};
Does this code meet the stated objectives? How so or why not?
I elected to not subclass Map
or Set
. While I intended to reproduce their interfaces to ensure maximum portability into code currently using the built-in classes and zero cost to learn, the new classes do not fulfill exactly the same contract as the built-ins. In addition, they don't need to borrow any methods from the built-ins, and there are no opportunities for them to exploit a superclass relationship via super
as far as I know. Thus, subclassing would be a violation of substitution principle with no practical payoff. If you disagree, I would like to hear about it.
PRE-REVIEW REVISION (no review exists at the time of this revision, so CR policy of forbidding edits to code is not applicable)
The use of the HASH_PATH
symbol in the hashVal
function to temporarily associate an object with its relative path within the key object is a subtle but important mistake. The following are consequences:
- An object may be frozen or inextensible; the creation of a privately scoped property via adding a closured-key property will throw an error in this case.
Proxy
obiects are now widely available, and assignment anddelete
to aProxy
-handled object's property may have unpredictable consequences.- The value of
HASH_PATH
could be leaked to code in the scope that defines theProxy
handler, violating the assumption of locality. Proxy
handler methods for these operations are permitted to have side effects with no desirable contract with theHashMap
orHashSet
.
- The value of
- If the object is
null
, adding and deleting properties will cause an unwanted exception (although this can be avoided without abandoning a privateSymbol
-valued key).
Tentative solution (pending review) is
- Add a parameter to hashVal
, ns = new WeakMap
which maps object-valued properties to name paths
- The type of the name path is open for suggestion. To consider:
- Strings formed by concatenation of keys and appropriate punctuation are temptingly simple for objects whose keys are all strings following the grammatical production identifierName, but this is far from guaranteed and overcomplicated to detect.
- A key whose value is a Symbol
(widely implemented feature) had no accessible String
representation that is different from non-equal Symbol
values, leading to identical object path strings for paths that actually differ. Open to suggestion, but one possible mitigation is introduction of a second object-specific local Map
of Symbol
-valued keys to unique String
identifiers. Measures should be taken to ensure that non-identifierName keys are escaped or bracketed or both, whereas path substrings are marked with a distinguishing token remaing unescaped and unbraceket. Again suggestions are welcome.
hash = thing.toString();
, doing(null).toString()
(or withundefined
) would error, so I would recommend instead doinghash = "" + thing
; \$\endgroup\$