Array slices and membranes using ES6 proxies
JavaScript’s dynamic nature, first-class functions and prototypes could always be used for elegant metaprogramming. Proxies let programmers further blur the line between the native, user defined objects and the host objects.
Some of the examples below require native implementation of either harmony:proxies
(obsolete standard, Chrome 37, using a polyfill) or harmony:direct_proxies
(current standard, Firefox 31). In Chrome it is necessary to enable Experimental JavaScript at chrome://flags/#enable-javascript-harmony
.
Mimicking an array
One interesting example of the difference between built-in and user-defined objects is the emulation of a built-in object (in this case an array) by the user-defined object.
The code below treats the array as a queue, adding elements at the end of this structure and removing them from the beginning.
var arr = [17, 29, 51];
arr.push(99);
print('Length: ' + arr.length);
print(arr);
print('First element: ' + arr.shift());
print(arr);
Because functions in the Array’s prototype are generic they work on any array-like object.
var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
// borrow push and shift functions from arrays
obj.push = [].push;
obj.shift = [].shift;
obj.push(99);
print('Length: ' + obj.length);
print(obj);
print('First element: ' + obj.shift());
print(obj);
But the length
property is special — writing to it shortens or expands the array.
var arr = [17, 29, 51];
arr.length = 1;
print('Length: ' + arr.length);
print(arr);
The same does not work on plain objects — the length
value is updated but the numbered properties are not deleted.
var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
obj.length = 1;
print('Length: ' + obj.length);
print(obj);
The reason is that when the length
property is modified on an array instance a built-in function (the setter) updates other properties.
Getters and setters in ES5
One of the features of ECMAScript 5 is the ability to add getters and setters to the user-defined objects. These execute user-provided functions when the property is accessed or set hiding the fact whether the property is computed or not.
The following code defines both a getter and a setter for the length
property. The setter implements an algorithm similar to the one used in Array’s length
— removing elements with keys higher or equal to given length. The explicit, initial value of length
is no longer needed as it is always computed by the getter.
To run the example your browser has to support:
- Getters and setters
var obj = {
0: 17,
1: 29,
2: 51,
push: [].push,
shift: [].shift,
get length() {
function isNumber(n) {
return !isNaN(parseInt(n, 10)) && isFinite(n);
}
var numericKeys = Object.getOwnPropertyNames(this).
filter(isNumber).map(Number);
if (typeof this._length === 'number') {
numericKeys.push(this._length - 1);
}
return Math.max.apply(null, numericKeys) + 1;
},
set length(value) {
var length = this.length;
for (var i = value; i < length; i++) {
delete this[i];
}
if (value > length) {
this._length = value;
} else {
delete this._length;
}
}
};
print('Length: ' + obj.length);
print(obj);
obj.length = 1;
obj.push(42);
print('Length: ' + obj.length);
print(obj);
The complexity of the length
getter stems from the fact that the numbered properties can be set at any time and that should also be reflected by the value of length
.
- Getters and setters
var arr = [1, 2, 3];
arr[10] = 10;
print('Length of the array: ' + arr.length);
obj[10] = 10;
print('Length of the object: ' + obj.length);
Altering object’s prototype in ES6
ECMAScript 6 brings several new functions that can be used to change the behavior of an object after it is constructed.
The Object.setPrototypeOf
function allows modifying the prototype chain by altering the internal [[Prototype]]
property of objects.
To run the example your browser has to support:
- ES6 setPrototypeOf
var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
Object.setPrototypeOf(obj, Array.prototype);
obj.push(44);
print(obj);
print('Length: ' + obj.length);
The prototype chain works and all array methods are accessible from the custom object but unfortunately setting the element at a high index does not modify the length
property as it does for arrays. This behavior is unique to built-in array objects and cannot be emulated by the modifications to the prototype alone.
- ES6 setPrototypeOf
var obj = { 0: 17, 1: 29, 2: 51, length: 3 };
Object.setPrototypeOf(obj, Array.prototype);
obj[10] = 10;
print(obj);
print('Length: ' + obj.length);
Proxies
Another approach to this problem involves using the proxy object. This proxy object has special methods called traps that are invoked when a property is being read or written to. It is similar to how getters and setters work but the property name does not have to be known in advance as the traps are invoked on each property access.
To run the example your browser has to support:
- Proxies
function emulateArray(obj) {
var length = obj.length || 0;
return new Proxy(obj, {
get: function(target, property) {
if (property === 'length') {
return length;
}
if (property in target) {
return target[property];
}
if (property in Array.prototype) {
return Array.prototype[property];
}
},
set: function(target, property, value) {
if (property === 'length') {
for (var i = value; i < length; i++) {
delete target[i];
}
length = value;
return;
}
target[property] = value;
if (Number(property) >= length) {
length = Number(property) + 1;
}
}
});
}
var obj = {
0: 17,
1: 29,
2: 51,
length: 3
};
obj = emulateArray(obj);
print('Length: ' + obj.length);
print(obj);
obj.length = 1;
obj.push(42);
print('Length: ' + obj.length);
print(obj);
print('Last element: ' + obj.pop());
The get
trap supports reading the special length
property, properties from the target object (indexed by numbers) and the functions in the Array prototype (for array methods — simulating the prototype chain). The set
trap supports truncating properties and updating the internal length
property.
Array slices
The same characteristic of proxies — intercepting calls to non-existent properties — can be used to implement array slices in JavaScript that looks similar to Python’s slices or even to add support to negative indices to arrays.
This proxy extends the property access to a given array by allowing the use of ranges and multiple indices.
To run the example your browser has to support:
- Proxies
var p = new Slice([1, 3, 6, 10, 15, 21, 28, 36, 45, 55]);
// get properties 0 to 4 then 6 and 7
var slice = p['0..4,6,7'];
print(slice);
// overwrite properties from 0 to 4 with values from 5 to 10
p['0..4'] = p['5..10'];
print(p);
The implementation uses simple notation to indicate ranges (start..end
) and groups of indices (x,y,z
).
All properties that are numeric or start with a symbol or a letter are forwarded to the original array (the target).
Additionally the toJSON
instance function is supported so that the proxy object can be serialized like a regular array.
var Slice = (function() {
var parts = new Map();
parts.set(/^\d+$/, function(matches) {
return [ matches[0] ];
});
parts.set(/^([0-9]+)\.\.([0-9]+)$/, function(matches) {
var lower = +matches[1];
var upper = +matches[2];
var indices = [];
for (var i = lower; i <= upper; i++) {
indices.push(i);
}
return indices;
});
function getIndices(spec) {
var indices = spec.split(',').map(function(part) {
var indices = [];
parts.forEach(function(value, regexp) {
var matches = part.match(regexp);
if (matches) {
indices.push.apply(indices, value(matches));
}
});
if (indices.length === 0) {
throw new Error('Unknown spec: ' + part);
}
return indices;
});
return Array.prototype.concat.apply([], indices);
}
var nativeArrayProperty = /^\d+$|^[^\d-]/;
return function Slice(target) {
return new Proxy(target || [], {
get: function(target, property) {
if (property === 'toJSON') {
return function() {
return target;
};
}
if (property.match(nativeArrayProperty)) {
return target[property];
}
var indices = getIndices(property, target);
return indices.map(function(index) {
return target[index];
});
},
set: function(target, property, value) {
if (property.match(nativeArrayProperty)) {
return target[property] = value;
}
var indices = getIndices(property, target);
indices.forEach(function(rangeIndex, index) {
target[rangeIndex] = value[index];
});
}
});
};
}());
function print(msg) {
var special = false;
if (typeof msg === 'undefined') {
msg = 'undefined';
special = true;
}
if (msg === null) {
msg = 'null';
special = true;
}
var li = document.createElement('LI');
var text = msg;
if (typeof msg === 'object') {
if (!Array.isArray(msg)) {
text = JSON.stringify(msg, null, 2);
} else {
text = JSON.stringify(msg);
}
}
li.textContent = text;
li.className = special ? 'primitive' : '';
output.appendChild(li);
}
function clear() {
output.innerHTML = '';
}
clear();
Membranes
Membrane is a mechanism used to limit access to the entire object graph. While Object.freeze
function can be used to prevent modifications of an object a membrane is used to also restrict read access to an object.
This pattern prevents unsafe, third-party code from holding the object reference and reading its properties later.
- Proxies
var user = {
profile: {
avatarUrl: 'avatar-1.jpg'
}
};
function getAbsoluteAvatarPath(user) {
var profile = user.profile;
var path = profile.avatarUrl;
setTimeout(function() {
try {
print(profile.avatarUrl);
} catch (e) {
print('Error while reading: ' + e);
}
}, 10);
return 'https://example.com/avatars/' + path;
}
var membrane = createMembrane(user);
var url = getAbsoluteAvatarPath(membrane.target);
print(url);
membrane.revoke();
The getAbsoluteAvatarPath
holds the reference to the user’s profile via the function’s scope even after it returns. After the membrane is revoked reading properties from the user
object is impossible.
var createMembrane = (function() {
var objectPool = new WeakMap();
function Membrane(target, controller) {
function checkAccess() {
if (controller.revoked) {
throw new Error('Access has been revoked.');
}
}
return new Proxy(target || [], {
get: function(target, property) {
checkAccess();
var value = target[property], membrane;
if (typeof value === 'object') {
membrane = objectPool.get(value);
if (!membrane) {
membrane = new Membrane(value, controller);
objectPool.set(value, membrane);
}
return membrane;
}
return value;
},
set: function(target, property, value) {
checkAccess();
target[property] = value;
},
delete: function(target, property) {
checkAccess();
delete target[property];
},
getOwnPropertyDescriptor: function(property) {
checkAccess();
return Object.getOwnPropertyDescriptor(target, property);
},
getPropertyDescriptor: function(property) {
checkAccess();
return Object.getPropertyDescriptor(target, property);
},
getOwnPropertyNames: function() {
checkAccess();
return Object.getOwnPropertyNames(target);
},
getPropertyNames: function() {
checkAccess();
return Object.getPropertyNames(target);
},
defineProperty: function(property, desc) {
checkAccess();
Object.defineProperty(target, property, desc);
},
has: function(property) {
checkAccess();
return property in target;
},
hasOwn: function(property) {
checkAccess();
return ({}).hasOwnProperty.call(target, property);
},
enumerate: function() {
checkAccess();
var result = [];
for (var name in target) {
result.push(name);
};
return result;
},
keys: function() {
checkAccess();
return Object.keys(target);
}
});
}
return function(target) {
var controller = {
revoked: false
};
return {
target: new Membrane(target, controller),
revoke: function() {
controller.revoked = true;
}
};
};
}());
The implementation checks if the access has been revoked in all traps and then forwards the operation to the real object (the target).
The get
trap additionally checks if the value retrieved is an object and if so wraps that inner object in a membrane too to secure the access to the entire object graph. A WeakMap
object is needed so that for each original object the same membrane always is returned. That keeps the condition user.profile === user.profile
true if the user
variable is an object wrapped in the membrane.
Calling remote services
Another use of proxies is to provide straightforward access to remote resources when the exact names of remote resources is not known in advance.
The code below provides programmatic access to annotations for articles.
To run the example your browser has to support:
- Proxies
- Promises
var api = new Proxy({ key: null }, {
get: function(target, property) {
if (property in target) {
return target[property];
}
function getAnnotations() {
var auth = 'Basic ' + btoa(target.key + ':');
var url = property.replace(/([a-z])([A-Z])/g, '$1-$2').
toLowerCase() + '/annotations';
var success, error;
var xhr = new XMLHttpRequest();
xhr.onload = function() {
if (xhr.status === 200) {
success(JSON.parse(xhr.responseText));
} else {
error(new Error('Status: ' + xhr.status));
}
};
xhr.onerror = error;
xhr.open('GET', url);
xhr.setRequestHeader('Authorization', auth);
xhr.send();
return new Promise(function(resolve, reject) {
success = resolve;
error = reject;
});
}
return {
getAnnotations: getAnnotations
};
}
});
api.key = localStorage['curiosity-driven.annotations.username'];
api.arraySlices.getAnnotations().then(function(annotations) {
print('Annotations: ');
print(annotations);
}, function(e) {
print('Error: ' + e);
});
Change arraySlices
to bitcoinContracts
in the code above to see annotations for a different article.
Proxies can also be used to create objects that throw exceptions when an undefined property is accessed or limit access to functions.
Comments
Revisions
- Initial version.