2020-03-30

Make python-style classes from not much javascript

It's been said many times before, objects are a poor man's closures are a poor man's objects, this is a deep and important thing. In python and javascript, the leap is really quite small. I thought I'd try drum the lesson home by implementing a limited bit of the python object system with a subset of javascript. We will:

You'll have the best time with this post if you play around with the more complicated code snippets in the developer tools console Cmd + Opt + J or F12.

Our aim

Our aim is to start with a very small language and write some code such that we can make classes similar to how we would in python, and have them behave similarly too:

class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        return "My name is " + self.name + " and I am " + self.age + " years old"

>>> const daisy = Animal("Daisy", "32")
>>> daisy.age
"32"
>>> daisy.greet()
"My name is Daisy and I am 32 years old"

Our subset of javascript

We're going to pick a small bit of javascript to use, this bit may seem obvious, but we're just demonstrating how little stuff we need to get to OO. Feel free to go quickly through this bit.

Values are numbers, strings or arrays of values:

3.14 "string" [value]

We can assign values to names:

const a = "foo"

We can get the nth element of an array with:

array[n]

We can concatenate strings/add numbers:

>>> "hello " + "oli"
"hello oli"
>>> 2 + 3
5

We can loop over arrays:

for (const n of l){do something}

If two strings are equal, we can do something:

if (a === b){do something}

We have functions that take arguments and return something:

(x, y) => {return z}

We can refer to all the arguments of a function as an array:

(...args) => {return args}

Functions are values too, we can pass them around:

const addFour = (a) => {return a + 4}
const addNine = (a) => {return a + 9}
const adders = [addFour, addNine]

>>> adders[0](10)
14
>>> adders[1](10)
19

Assignments within the curly brackets of a function are visible to inner functions within the same curly brackets (lexical scope). These values are available for the lifetime of the inner function (closures). For example:

const makeAdder = (b) => {
    const adder = (a) => {
        return a + b
    }
    return adder
}
const addThree = makeAdder(3)
const addSeven = makeAdder(7)

>>> addThree(5)
8
>>> addSeven(6)
13

Implementing dicts

Now let's make something slightly more useful:

const makeDict = (l) => {
    const getter = (getk) => {

        // for each [key, value] tuple in l,
        // if the key matches getK, we return the value

        for (const kv of l){
            if (kv[0] === getk){
                return kv[1]
            }
        }
    }

    return getter
}

const d = makeDict([
    ["foo", "1"],
    ["bar", "2"],
    ["baz", "3"],
])

>>> d("bar")
"2"

OK, looks familiar, now let's imagine that we are going to use this makeDict(...) construct again and again. The program that runs our javascript (the interpreter) is going to transform a cuter bit of syntax (shown below) into our makeDict(...) code (shown above) before it runs it (syntactic sugar):

const d = {
    "foo": "1",
    "bar": "2",
    "baz": "3",
}

>>> d["bar"]
"2"

That's a bit better, but let's make the transformation even cuter:

const d = {
    foo: "1",
    bar: "2",
    baz: "3",
}

>>> d.bar
"2"

When our interpereter sees:

const a = {b: "c"}
a.b

Before it runs it, it will transform it into:

const a = makeDict([["b", "c"]])
a("b")
I'm going to cheat a bit here and allow us to assign to these dicts too, you'll have to imagine how this might be implemented.
d["qux"] = "4"

Now for one last bit of built-in language, we're going to introduce a way to get all the keys of an object as an array:

>>> Object.keys({a: "1", b: "2"})
["a", "b"]

Implementing classes

Now, for the tough bit, we want to make an interface that looks pretty similar to python-style classes:

const Animal = ClassMaker({
    __init__: (self, name, age) => {
        self.name = name
        self.age = age
    },
    greet: (self) => {
        return "My name is " + self.name + " and I am " + self.age + " years old"
    },
})

>>> const daisy = Animal("Daisy", "32")
>>> daisy.age
"32"
>>> daisy.greet()
"My name is Daisy and I am 32 years old"

It should be easy to imagine how the interpereter would translate our original form into the above, similar to how it translates a = {b: "c"} into a = makeDict([["b", "c"]]).

Let's start by demonstrating how we can attach some functions to an instance (in this case called someInstance) using closures:

// cls is a dict of str -> function

const cls = {
    __init__: (self, name, age) => {
        self.name = name
        self.age = age
    },
    greet: (self) => {
        return "My name is " + self.name + " and I am " + self.age + " years old"
    },
}

// someInstance starts as an empty dict

const someInstance = {}

// we populate someInstance with the functions in cls,
// but we use closures to attach someInstance to the
// first argument of each function

for (const k of Object.keys(cls)){

    const f = cls[k]

    const appliedF = (...args) => {
        return f(someInstance, ...args)
    }

    someInstance[k] = appliedF
}

Now we can initialise someInstance and call .greet().

>>> someInstance.__init__("Rodney", "63")
>>> someInstance.greet()
"My name is Rodney and I am 63 years old"

Nearly there, now we wrap up the loop and the __init__ into ClassMaker such that someInstance (in this case named self) isn't in the global scope:

const ClassMaker = (cls) => {

    // it will returns a function that takes arguments that
    // we pass to __init__

    return (...initArgs) => {

        // create the self and attach the functions as above

        const self = {}
        for (const k of Object.keys(cls)){
            const f = cls[k]
            const appliedF = (...args) => {
                return f(self, ...args)
            }
            self[k] = appliedF
        }

        // call __init__ and then return the instance

        self.__init__(...initArgs)
        return self
    }
}

That's it, OO enlightenment, closures ≈ objects! Test Daisy in the console to make sure it works.




Bonus section - inheritance

Just for good measure, let's add inheritance:

const ClassMaker = (parent, cls) => {
    const combined = {}
    for (const k of Object.keys(parent)){
        combined[k] = parent[k]
    }
    for (const k of Object.keys(cls)){
        if (cls[k]){
            combined[k] = cls[k]
        }
    }

    const init = (...initArgs) => {
        const self = {}

        for (const k of Object.keys(combined)){
            self[k] = (...args) => {
                return combined[k](self, ...args)
            }
        }

        self.__init__(...initArgs)

        return self
    }

    for (const k of Object.keys(combined)){
        init[k] = combined[k]
    }

    return init
}

Now we can do:

const Animal = ClassMaker(Object(), {
    __init__: (self, name, age) => {
        self.name = name
        self.age = age
    },
    greet: (self) => {
        return "My name is " + self.name + " and I am " + self.age + " years old"
    },
})

const Dog = ClassMaker(Animal, {
    bark: (self, owner) => {
        return self.name + ": Hullo " + owner
    }
})