Forbidden Transitions

Subscribe
Archives
June 6, 2023

Subclasses and Typeclasses

Today, I'd like to focus on some software development ideas by expanding on something I shared on the fediverse recently; namely, that the concept of subtypes necessarily couples the idea of types with that of typeclasses. In doing so, I'll assume some working knowledge of object-oriented programming (OOP); though I'll use examples in a variety of different languages, none of them should require substantial experience in those languages beyond common OOP concepts. I'll also take a somewhat high-level view, necessarily glossing over some important details in favor of simplicity; this is an introduction, not a complete treatise on the topic.

In any case, it helps to start by revisiting what types actually are. When we write software, we typically work by assigning values to variables. For instance, suppose that in a fictional language, we write something like the following:

let x = 42;

We might read this as assigning the value 42 to a variable named x. The value 42 happens to represent a natural number, but often, languages will let us assign different kinds of values as well:

let y = "hello";
let z = false;

Where this can cause problems is if we write a program that assumes something about what values a given variable can hold. Those assumptions may or may not hold in practice, leading to logic errors. For example, in Python:

>>> def square(x):
...     return x * x
...
>>> print(square("circle"))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in square
TypeError: can't multiply sequence by non-int of type 'str'

(If you don't have Python installed, you can run this example in your browser by using the PyScript REPL example. Just copy and paste the code above in, without the >>> and ... prompts, and you should get your error.)

Here, square looks like a perfectly reasonable function, but it implicitly assumes that its argument is a value that can be multiplied by itself — when we violate that assumption, we get an error at runtime.

The concept of types helps us prevent that kind of error by allowing us to reason about what kinds of programs may or may not be valid based on what assumptions they make about values.

For example, we might write a similar program in TypeScript:

let square = (x) => x * x;

console.log(square("circle"))

If we do, the TypeScript compiler immediately tells us there might be a problem, before we run the program:

Parameter 'x' implicitly has an 'any' type.

If we fix that by telling TypeScript that x is a number, we get a new error where we call square:

let square = (x: number) => x * x;

console.log(square("circle"))
// Argument of type 'string' is not assignable to parameter of type 'number'.

Here, x: number tells TypeScript that only numbers are valid values for the parameter x. That is, x has as number as its type. Formally, we can think of types as sets of values. In pseudocode, we might write something like number ≔ {-42, 0, 1.1, 3.14, 29104062225468, ...} to indicate that number can include values representing positive numbers, negative numbers, floating-point numbers, integers, and so forth. Critically, though, number doesn't include "circle", and so we get an error.

This might seem very different than how you may have first encountered types, as we're often taught in programming to think of types as specifying behavior, rather than values. For example, we implicitly assumed that * is something that makes sense for the number type. When we change the type of x to string, TypeScript helpfully points out that that assumption doesn't hold for the type string:

let square = (x: string) => x * x;
// The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
// The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.

Thinking of types as behavior makes a lot of sense in languages like C++, Java, C#, and Python where you can define new types by specifying the structure and behavior of those types. It's common to see examples like this in intro programming classes (sorry):

class Drawable():
    @abstractmethod
    def draw(self, surface):
        pass

class Circle(Drawable):
    def __init__(self, radius):
        self.radius = radius

    def draw(self, surface):
        # TODO: draw a circle onto surface

class Rectangle(Drawable):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def draw(self, surface):
        # TODO: draw a rectangle onto surface

Here, the type Drawable tells us only about some behaviour, namely that Drawable instances can be drawn to some surface. We need to make concrete subclasses to understand what kind of data we need to draw each different shape.

Thinking of types this way, we can easily work with Drawable instances without knowing the exact sort of values — only knowing that they have some desired behavior. Even the name Drawable suggests that interpretaion! For example,

def draw_all(drawables, surface):
    for drawable in drawables:
        drawable.draw(surface)

Here, we've implicitly required that the drawables argument has as its type some collection of Drawable instances, but we can also be more explicit — in many languages, we have to be. In C#, for example, we could write out the same draw_all function as a method that takes Drawable as a type:

void DrawAll(IEnumerable<Drawable> drawables, Surface surface)
{
    foreach (var drawable in drawables)
    {
        drawable.Draw(surface);
    }
}

This implicitly uses a key idea behind object-oriented programming, namely that instances of a class are also instances of all of its superclasses. In particular, instances of Circle are also instances of Drawable. Thinking of types as classes of values, we might write something like Circle ⊆ Drawable. That is, Circle is a subtype of Drawable.

As an aside, Python is a bit of an odd duck when it comes to defining types, since all Python types are also values in the language. In particular, type is the type of all types, leading to such fun edge cases as type(type) returning type. All types in Python are also values, but not all values are types. There's good reasons Python is that way, but they make this discussion significantly more confusing, so I'll largely set Python aside for the rest of the post.

Anyway, going back to thinking of types as collections of values rather than specifications of behavior, the situation gets somewhat odd when we consider subtypes — effectively they both play the role of constraining behavior and specifying the data structures for various values, but those two roles are quite different in practice. Consider the problem of specifying behavior of the form "can be added to other values of the same type." In C#, we might write this as an interface that specifies behavior without specifying data:

interface IAdd
{
    IAdd Add(IAdd other);
}

If we do this, though, we quickly hit limits when we try to implement that interface for a particular data structure. What should happen if we try to add a 32-bit integer to a 64-bit integer? String concatenation is a form of addition, so can we add a floating point number to a string?

All of these limitations hit us smack in the face if we try to write something like a method to sum various IAdd instances:

IAdd Sum(IEnumerable<IAdd> summands)
{
    var sumSoFar = ?????; // ← how do we define zero?
    for (var summand in summands)
    {
        // What happens if we pass a list containing
        // both strings and integers?
        sum = summand.Add(sumSoFar);
    }
}

Thankfully, C# lets us solve that by introducing type parameters for our method:

T Sum<T>(IEnumerable<T> summands)
where T: IAdd
{
    var sumSoFar = ?????; // ← how do we define zero?
    for (var summand in summands)
    {
        sum = summand.Add(sumSoFar);
    }
}

Here, the bound on the type parameter T, namely where T: IAdd tells us that whatever specific type we assign to T, it must implement the IAdd interface. There's still some problems, such as finding the zero value for a given type T, but the concept of a type parameter bound allows us to express that a type parameter can be assigned to any type satisfying some constraint.

If that sounds a lot like the idea of types themselves, you're completely correct. In particular, a typeclass is a set of types in precisely the same sense that a type is a set of values. What makes this seem a bit complicated at first is that when we define something like IAdd or Drawable we're defining both a type and a typeclass. For example, as a typeclass, Drawable is the set containing Rectangle, Circle, and whatever other types someone might define as inheriting from Drawable.

Similarly, in the example of where T: IAdd above, IAdd is acting as a set of types — as a typeclass — rather than any specific type. C# doesn't really have a concept of typeclasses as distinct from types, so when we define IAdd as a typeclass, we also define a type with the same name whose values are all values of every type in the IAdd typeclass. On the face of it, it's a bit silly to have both integers and strings in the same summation just because they're both values of types in the IAdd typeclass, but that's precisely what it means to declare a value as having type IAdd.

Not all languages couple types and typeclasses by giving them the same name, though. In Rust, typeclasses are a fundamental part of the language, known in Rust as traits. For example, the same concept as IAdd is expressed in the Rust standard library as a trait:

pub trait Add<Rhs = Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

(This is the actual code from the standard library, by the way, with only some metadata and comments removed.)

Basically, this trait says that a type T is in the typeclass Add<Rhs> if it can be added to a type Rhs to get some output. The trait on its own doesn't say anything about what members of that typeclass look like, but we can use impl blocks to tell the compiler that a particular type is in a given typeclass:

impl Add<u64> for u64 {
    type Output = u64;

    fn add(self, other: u64) -> u64 { self + other }
}

This says that a u64 value (Rust syntax for the type of 64-bit unsigned integers) can be added to another u64 value to get a u64 value as output. Declaring that the type u64 is in the Add<u64> typeclass then lets us use u64 in functions that generalize over the entire Add<T> typeclass:

use std::ops::Add;

fn double<T: Copy + Add<T>>(x: T) -> T::Output {
    x.add(x)
}

fn main() {
    println!("2 + 2 = {}", double(2));
    println!("3.1 + 3.1 = {}", double(3.1));
}

(If you don't have Rust installed, you can use the Rust Playground to run this example in your browser.)

Here, we also had to specify that T also satisfies the typeclass Copy, since Rust doesn't assume it can make copies of arbitrary values, but for the most part, the double function acts like you'd expect. The + operator acting on typeclasses let us easily express the intersection of Add<T> and Copy (that is, the typeclass of types that are in both the typeclass Copy and Add<T>). In C#, we can do similar by adding multiple classes or interfaces to a where clause, but that breaks the correspondence between types and typeclasses, making it confusing to reason about when a type is acting to constrain values and when it's acting to constrain other types.

This works because we've made a strict separation between types, that specify data structures and values, and typeclasses that specify behaviors. Since behaviors compose nicely, it makes perfect sense for a single type to be in multiple typeclasses (in Rust parlance, to implement multiple traits), while the idea of a value simultaneously being of multiple different types is more difficult to work with. For example, a 32-bit unsigned integer is not also a 64-bit signed integer, even though both kinds of integer support addition — they have entirely different sizes in memory, and the implementation of that addition behavior is entirely different.

My fediverse post earlier was making the point that whenever you define a class in an object-oriented language, you also implicitly also define a typeclass comprised of all that classes' subclasses, inextricably linking the specification of data structures with types to the specification of behavior with typeclasses. That in turn can often overconstrain software design by reducing expressiveness, impacting how we develop and organize code.

For example, if a data structure admits some kind of behavior, we have to know about that behavior when we define the data structure. In our C# example, the IAdd interface has to exist before we implement it, while in our Python example, we couldn't define how to make something like namedtuple("x", "y") Drawable since that class didn't exist when the standard library defined namedtuple.

That restriction makes perfect sense for extending and manipulating data structures, but the corresponding restriction on defining behaviors comes about only because of types and typeclasses are often conflated with each other in object-oriented languages. Separating the two apart can make it significantly easier to reason about how behavior generalizes over types, and can help us dramatically simplify our code.


Postscript: You may have caught me sliding a little something under the rug, namely that void DrawAll<T>(IEnuerable<T> drawables) where T: Drawable would be far less useful than void DrawAll(IEnumerable<Drawable> drawables), as it would impose that everything we try to draw must be of the same shape. That a collection can have values of various types so long as they support the same behavior is difficult to express using typeclasses, since that behavior could be implemented differently for every element of the collection. Effectively, we need each element to be paired with a pointer telling us where to find the right implementation — in many languages, that pointer is called a vtable. The virtual and abstract keywords in C# add methods to a type's vtable, while in Rust, the dyn keyword turns a typeclass into a type by constructing a vtable for each different part of the behavior specified by that typeclass. We might write something like draw_all in Rust as:

fn draw_all<T: Iterator<Item = Box<dyn Drawable>>>(drawables: T, surface: &Surface) {
    for ref drawable in drawables {
        drawable.draw(surface)
    }
}

Here, Box is needed to make each vtable something we can store in a list, since not all vtables will be of the same size in memory. In C#, vtables are implicit as a part of reference types, such that Box<dyn Drawable> in Rust is similar to Drawable in C# when Drawable is used as a type and not as a typeclass.

Don't miss what's next. Subscribe to Forbidden Transitions:
Web
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.