In this previous post, we defined abstractions that could be used to build boolean and number systems from pure functional programming, and then applied it to implement the factorial function. This pure system can become difficult to write with and often struggles to generalize complex data types, whereas languages such as Java and Python support more complex abstractions combining data and functionality as objects that can be interfaced with, composed, and inherited.

In this post, we will rederive all of our favorite object-oriented principles from scratch using the Google Sheets ecosystem. This means that we have to reïnvent method calls, constructors, inheritance, error propagation, dynamic dispatch, and even self references via a this keyword using only pure functions. While functional programming and object-oriented programming might seem fundamentally incompatible, the two paradigms at their core (as we will demonstrate) are actually very closely related.

The Original Object-Oriented Language

Smalltalk approached the problem of easy abstraction by introducing a brand-new programming paradigm, object-oriented programming, in the 1970s. However, unlike other (and later!) languages such as C++ which evolved from the notion of simply binding functions to existing struct pointer data types, Smalltalk presented object-oriented programming as a pure message-passing system, where everything is an object which is programmed to respond to different messages which implement its methods.

The idea of passing messages to objects is actually an extremely functional concept — you give the name of the method you want to invoke by simply using it as the parameter while applying the function. The idea of invoking a method on an object in Java syntax would be rectangle.getWidth(), but in Smalltalk, it would look simply like rectangle getWidth, borrowing the structure from Lisp function application. (Unfortunately, since Google Sheets requires the use of brackets, the best we can do is rectangle("getWidth").) None of our code needs to have any side effects if we stick to a pure message-passing framework, which strictly binds data values to their implementations.

Number Abstractions

To start, we can define some number objects which wrap their data in an interface that responds to messages such as "*", "+", which allow us to transparently define a contract rather than relying on built-in operators which can only work on built-in types:

=LET(
    message_match, LAMBDA(expected, LAMBDA(actual,
        IFERROR((actual = expected), FALSE)
    )),
    handle_unknown_message, LAMBDA(message, "unknown value"),

    DEFINE_CLASS_NUMBER, "object wrapper for builtin numbers",
    make_number_bootstrap, LAMBDA(f, LAMBDA(raw,
        LAMBDA(message,
            IF(message_match("rawNumVal")(message),
                raw,
            IF(message_match("+")(message),
                LAMBDA(rhs, f(f)(raw + rhs("rawNumVal"))),
            IF(message_match("*")(message),
                LAMBDA(rhs, f(f)(raw * rhs("rawNumVal"))),
            IF(message_match("factorial")(message),
                f(f)(FACT(raw)),
            IF(message_match("log")(message),
                f(f)(LOG(raw)),
            IF(message_match("displayString")(message),
                CONCAT("", raw),
            handle_unknown_message(message) 
            ))))))
        )
    )),
    make_number, (make_number_bootstrap)(make_number_bootstrap),

    RETURN_VALUE, "the output of this program cell",
    make_number(3)("factorial")("factorial")("log")("displayString")
)

This demonstration of unary message chaining was taken directly from the Wikipedia page for Smalltalk, and as expected, yields the number 2.85733.... This format of applying functions by passing the right-hand-side message to the object yields a syntax that feels surprisingly imperative — in Java, this code might equivalently be written this way:

new NumWrapper(3).factorial().factorial().log().toString()

We just used message passing notation to transform the infamously indirect functional programming style into an active, imperative sequence, with a well-defined order of operations. No nested parentheses required. When we need to, however, we can use parentheses to alter the default left-to-right parsing of operations, for example evaluating 3 + (4 * 5) = 23:

three, make_number(3),
four,  make_number(4),
five,  make_number(5),

RETURN_VALUE, "the output of this program cell",
(three)("+")((four)("*")(five))("displayString")

That’s right — this programming paradigm offers both the “subject-verb-object” notation offered by Java syntax and the intuitive mathematical expression form! Theoretically, we could continue implementing all of our data classes like this.

Self-Context References

Although our current system is technically sufficient for implementing all sorts of data records, we run into slight inconveniences when building more advanced behavior. Consider, for example, this rectangle implementation:

DEFINE_CLASS_RECTANGLE, "rectangle with length and width",
make_rectangle, LAMBDA(l, w,
    LAMBDA(message,
        IF(message_match("length")(message),
            l,
        IF(message_match("width")(message),
            w,
        IF(message_match("area")(message),
            (l)(*)(w),
        IF(message_match("name")(message),
            "Rectangle",
        IF(message_match("description")(message),
            CONCATENATE(
                "Rectangle",
                " [length=", l("displayString"),
                ", width=", w("displayString"), "]",
                " has area=", (l)("*")(w)("displayString")
            ),
        handle_unknown_message(message) 
        )))))
    )
),

While this implementation is technically correct, we’ve had to duplicate all of our code in order to implement the “description” method. Not only is this inconvenient for large chunks of code, but it harms overall reusability when we need to change a certain method’s implementation. In Java, this issue is solved with a this keyword, which can be used for an object to access its own members.

We can take a similar approach by requiring the class declaration to pass in a lambda reference to its own implementation, which should respond to messages exactly the same way as the original object would’ve:

DEFINE_CLASS_RECTANGLE, "rectangle with length and width",
rectangle_class, LAMBDA(this, LAMBDA(l, w,
    LAMBDA(message,
        IF(message_match("length")(message),
            l,
        IF(message_match("width")(message),
            w,
        IF(message_match("area")(message),
            (this("length"))("*")(this("width")),
        IF(message_match("name")(message),
            "Rectangle",
        IF(message_match("description")(message),
            CONCATENATE(
                this("name"),
                " [length=", this("length")("displayString"),
                ", width=", this("width")("displayString"), "]",
                " has area=", this("area")("displayString")
            ),
        handle_unknown_message(message) 
        )))))
    )
)),
new_rectangle_bootstrap, LAMBDA(f,
    LAMBDA(l, w, rectangle_class(LAMBDA(message, f(f)(l, w)(message))) (l, w))
),
new_rectangle, (new_rectangle_bootstrap)(new_rectangle_bootstrap),

Admittedly, this solution is a bit more complicated in a pure functional system (using combinatory logic and recursion rather than references), but this way, we don’t need to repeat any code, and objects can transparently interact with its own implementations, enabling more advanced abstractions.

Inheritance

This structure for dynamic self-invocations using this actually paves the way for a new object-oriented abstraction. By simply overriding the "name" method to return "Square" instead of "Rectangle", and wrapping the constructor, we can actually reuse all of the other functions our rectangle class already defined. In effect, since all objects are functions that handle method names, their implementations are themselves vtables. With the basic fall-through logic of if-else statements, we can implement methods which will take priority over the superclass’s functions. Similarly, since this is not explicitly required to be bound to the exact same identity class, we could pass the original vtable this from the subclass to the superclass, so that it also receives the overrides. This allows a class to be used both as-is, and as part of an inheritance hierarchy:

DEFINE_CLASS_SQUARE, "subclass with a single side length parameter",
square_class, LAMBDA(this, LAMBDA(s,
    LAMBDA(message,
        IF(message_match("name")(message),
            "Square",
        rectangle_class(this)(s, s)(message)
        )
    )
)),
new_square_bootstrap, LAMBDA(f, LAMBDA(s, square_class(LAMBDA(message, f(f)(s)(message))) (s))),
new_square, (new_square_bootstrap)(new_square_bootstrap),

Just like Java code, our square implementation comes with an explicit call to the superclass, forwarding the single constructor parameter s to both the length and width parameters. This pattern can also be used to create abstract classes! I won’t include a demonstration here, but an abstract class works the exact same way — an abstract class invokes methods on this which it expects to be implemented by a subclass, accessed using dynamic dispatch.

Error Propagation

By this point, we’ve definitely implemented enough features to prove that object-oriented programming can exist even within a functional landscape. Just as a challenge to bolster the idea of control flow with message passing, let’s try to implement some error handling so that rather than returning a generic eval failure in Google Sheets, we can see a rough stack trace of what happened. We can use handle_unknown_message to generate a special object that accumulates traces of everything that happens to it as the result of an invalid method call gets used throughout a system:

as_err_string, LAMBDA(message, fallback_message,
    IFERROR(
        CONCAT("", message),
        IFERROR(
            IF(is_nil(message),
                "[nil]",
                CONCAT(CONCAT("Object[ ", message("displayString")), " ]")
            ),
            fallback_message
        )
    )
),

next_error_message, LAMBDA(original_err_message, message,
    CONCAT(CONCAT(
        as_err_string(original_err_message, "[Invalid Trace]"),
        " | "),
        as_err_string(message, "[Unknown Argument]")
    )
),

DEFINE_CLASS_INVALID_METHOD_TRACE, "object that accumulates traces of invalid method calls",
make_invalid_method_trace_bootstrap, LAMBDA(f, LAMBDA(err_message,
    LAMBDA(message,
        IF(message_match("displayString")(message),
            CONCAT(CONCAT("InvalidMethodTrace[ Object | ", as_err_string(err_message, "Undefined")), " ]"),
            f(f)(next_error_message(err_message, message))
        )
    )
)),
make_invalid_method_trace, (make_invalid_method_trace_bootstrap)(make_invalid_method_trace_bootstrap),

We can make a similar object to track the usages of a “nil” value throughout the code. As an extreme example of mangled statements, this is the result my framework produces from evaluating this code:

nil("cope")("invalidmethod")("+")((nil)("*")(nil))
    (make_rectangle(nil, nil)("nonExistentMethod"))("displayString")
NilTargetTrace[ [nil] | cope | invalidmethod | + | Object[ NilTargetTrace[ [nil] | * | [nil] ] ]
     | Object[ InvalidMethodTrace[ Object | nonExistentMethod ] ] ]

The overall code for this blog can be accessed here.

Conclusion

While functional programming may seem very unpermissive to other programming styles, and object-oriented programming may seem incompatible with more “pure” implementations, we demonstrated that all of our object-oriented ideas, such as method calls, dynamic dispatch, method overriding, inheritance are possible in the Google Sheets environment. We even introduced some helpful debugging functions that can help detect errors rather than ominously crashing. Overall, I don’t think that functional and object-oriented programming are necessarily opposites — the same design principles for clear thinking apply to any programming paradigm.