Imitating Classes
In this post, we are going to explore how you might imitate classes in a language that doesn’t have them, to get a better feel for how various object-oriented language features actually work. We will use JavaScript, but ban ourselves from using
- the class keyword (obviously),
- the new keyword (we will construct all of our own objects),
- the this keyword,
- prototypal inheritance (we will build our own inheritance mechanism).
The main language features we will be using are functions (including higher-order functions), closures, and object literals (including some of the nice new syntactic sugar for constructing objects).
To start off with, let’s create a really basic collection class.
// Create a new collection, initially with the given items. function collection(items = []) { const _items = [...items]; // Clear the collection, removing all items. function clear() { _items.splice(0); }; // Determine whether the given item is in the collection. function contains(item) { return _items.includes(item); }; // Add the given item to the collection. function add(item) { _items.push(item); }; // Remove the given item from the collection. function remove(item) { const index = _items.indexOf(item); if (index >= 0) _items.splice(index, 1); }; return { clear, contains, add, remove }; };
Each time we call the collection function, we get a new collection
object with its own copy of the four methods, clear
,
contains
, add
, and remove
,
that each close over the private array of items. The
_items
array is private because it isn’t included as a
member of the returned object.
It’s also worth mentioning that although we haven’t made use of this, the fact that methods are defined as local functions means that they can all refer to each other.
Next, we’ll create a class which extends collection. Let’s add some methods for adding and removing items transactionally.
// Create a new collection, initially with the given items. function transactionalCollection(...args) { const _added = []; const _removed = []; const base = collection(...args); // Add an item with the current transaction. function addOnSubmit(item) { _added.push(item); }; // Remove an item with the current transaction. function removeOnSubmit(item) { _removed.push(item); }; // Submit the current transaction function submit() { _added.forEach(base.add); _removed.forEach(base.remove); _added.splice(0); _removed.splice(0); }; // Abandon the current transaction. function abandon() { _added.splice(0); _removed.splice(0); }; return { ...base, addOnSubmit, removeOnSubmit, submit, abandon }; };
Here we have added four additional methods—addOnSubmit
,
removeOnSubmit
, submit
, and
abandon
, and the additional methods are able to
reference the exising methods. It’s also worth noting that aside from
assuming that collection
has an add
and a
remove
method, transactionalCollection
makes no other assumptions about collection
, it will
continue to work if the constructor is changed or other methods are
added, and importantly it will not remove such methods either.
Next, let’s consider method overriding. We’ll create another class,
localStorageCollection
, which also persists the
collection of items to local storage so that the collection can be
used across sessions. Unfortunately we’re going to want access to the
private _items
member of collection
, so
what we really want is to be able to make it protected. More
unfortunately still, since public members are just those added to the
returned object and private members are those which aren’t, there’s
no mechanism left for us to define protected members.
We will instead use convention to define protected members, as those
whose name starts with an underscore (_
). If we want to
enforce this, we can define a helper function which removes protected
members from a class, as below.
// Create a new collection, initially with the given items. function collectionBase(items = []) { // Array of items in the collection. const _items = [...items]; // Clear the collection, removing all items. function clear() { _items.splice(0); }; // Determine whether the given item is in the collection. function contains(item) { return _items.includes(item); }; // Add the given item to the collection. function add(item) { _items.push(item); }; // Remove the given item from the collection. function remove(item) { const index = _items.indexOf(item); if (index >= 0) _items.splice(index, 1); }; return { _items, clear, contains, add, remove }; }; // Return a copy of the given object with only its public members. function publics(obj) { const newObj = { ...obj }; Object.keys(newObj) .filter(m => m.startsWith('_')) .forEach(m => delete base[m]); return newObj; }; // Seal a class by removing protected members. function seal(cls) { return function (...args) { return publics(cls(...args)); } }; const collection = seal(collectionBase);
The defintion of transactionalCollection
from above is
fully compatible with this new defintion.
We can now define localStorageCollection
using
collectionBase
.
// Create a new collection which persists to local storage, initially with the // given items. function localStorageCollection(keyName, items) { const storage = window.localStorage; items = items || JSON.parse(storage.getItem(keyName) || '[]'); const base = collectionBase(items); function _save() { storage.setItem(keyName, JSON.stringify(base._items)); } // Clear the collection, removing all items. function clear() { base.clear(); _save(); }; // Add the given item to the collection. function add(item) { base.add(item); _save(); }; // Remove the given item from the collection. function remove(item) { base.remove(item); _save(); } return { ...publics(base), clear, add, remove }; }
The obvious question now is, can we make a transactional collection
that also persists to local storage—transactionalCollection
and
localStorageCollection
, you’ll notice that they “know”
what they inherit from—
// Create a new class which adds transactions to a base collection class. function makeTransactionalCollection(collection) { return function (...args) { const _added = []; const _removed = []; const base = collection(...args); // Add an item with the current transaction. function addOnSubmit(item) { _added.push(item); }; // Remove an item with the current transaction. function removeOnSubmit(item) { _removed.push(item); }; // Submit the current transaction. function submit() { _added.forEach(base.add); _removed.forEach(base.remove); _added.splice(0); _removed.splice(0); }; // Abandon the current transaction. function abandon() { _added.splice(0); _removed.splice(0); }; return { ...base, addOnSubmit, removeOnSubmit, submit, abandon }; }; }; const transactionalCollection = makeTransactionalCollection(collection); const transactionalLocalStorageCollection = makeTransactionalCollection( localStorageCollection);
Notice that the core of the body of
makeTransactionalCollection
is identical to that of
transactionalCollection
from above. The only difference
is that now the variable collection
no longer refers to
the existing collection
class defined in the global
scope, but to the collection
parameter which refers to
whatever argument we choose to supply. This makes it a far more
powerful construct.