Multiple Inheritance with Zero Boilerplate
Object-oriented programming is a programming paradigm found in languages notorious for verbose syntax and boilerplate code. Yet it also provides for patterns that, while often considered to be opaque and bad practice, are also useful: like multiple inheritance. Multiple inheritance is the idea that an object can inherit the properties of multiple different objects: and so be usable as a replacement for any of those. This takes many different forms: from full-fledged wretched multiple inheritance of full classes, to multiple inheritance being allowed only via interfaces, to multiple inheritance not being allowed at all and instead manually done via casting.
In a course on type systems I took this past term, we had a reading on subtyping: the idea that one type can be safely used in the context of another type, given some conditions hold. These conditions differ between structural type systems and nominal type systems. In a structural type system, the names, types, and occasionally order of complicated types (structs/unions, mostly) are all that matters when checking if a subtyping relation is valid. While in a nominal type system, they similarly must obey structural subtyping rules, but the subtype relation must be explicitly denoted (typically through use of an “extends” or “inherits” or similar keyword).
Having subtyping rules on a structural type system, alone, then, is enough to facilitate a minimal working system of multiple inheritance. And these subtyping rules are dead simple! I found this idea really quite interesting: it seemed to be obvious to some other people in the class having more extended experience with object-oriented programming languages (Java, Python, C++), but most of what I knew going in about language design came from the imperative / functionalish Nim, which lacks multiple inheritance or even a class
keyword. And so it was entirely new and interesting!
Such a system could be built on top of either a structural or a nominal system, since nominal systems extend structural systems, somewhat. I’ll not be exploring the nominal side of this here: as it adds somewhat of the boilerplate this post is focused on getting rid of, though the reader might find it interesting to think about what kind of minimum viable syntax could keep the explicitness guarantees of typical inheritance systems.
Structural Typing
Let’s first revisit what it means to have a structural type system. Such a system is a type system where type equality only relies on the names (sometimes), types, and positions (sometimes) of fields in a complicated type. There are two such types we’ll deal with here: structs and unions.
structural structs
Consider the following types:
type Foo = struct
: int, y: int
x
type Bar = struct
: int, y: int, z: int
x
type Baz = struct
: int, z: int y
Bar has all of the fields of Foo. In our structural type system, we may consider Bar to be a subtype of Foo, as it has a superset of its fields. This terminology is confusing! Bar being a subtype of Foo implies it is somehow less than Foo: but it has more fields! But this subtype relationship comes from that Bar can substitute for Foo wherever Foo is expected:
func display_xy_coords(self: Foo): str =
return "{}, {}".fmt(self.x, self.y)
let foo: Foo = { x: 1, y: 2 }
let bar: Bar = { x: 1, y: 2, z: 3 }
let baz: Baz = { y: 2, z: 3 }
print display_xy_coords(foo) # 1, 2
print display_xy_coords(bar) # 1, 2
print display_xy_coords(baz) # type error
structural unions
Now consider these union types:
type Foo[T] = union
, List[T], array[T]
str
type Bar[T] = union
List[T], array[T]
type Baz[T] = union
, List[T], array[T], int str
Foo has all the variants of Bar. In our structural type system, we may consider Bar to be a subtype of Foo, as Bar can substitute for Foo wherever Foo is expected. Foo can be a string, list, or array. Bar can only be a list or an array. Thus, Bar is safe to use where Foo is required.
Note that this behavior is opposite the behavior of structs above! These structs and unions, as we have described them, are actually formally dual: further reading on this front can be found by looking for “product types” (structs) and “sum types” (coproduct types, unions).
func get_length[T](self: Foo[T]): int =
return self.len
let foo: Foo = "hello, world!"
let bar: Bar = ['h','e','l','l','o',' ','w','o','r','l','d','!']
let baz: Baz = 1025
print get_length(foo) # 13
print get_length(bar) # 13
print get_length(baz) # type error
Multiple Inheritance
Let’s tie it all back together, and provide some nice, classic, totally-not-contrived-and-awful-pedagogically object-oriented examples.
Consider a Building. A Building has an address
. We could extend this building type to, say, an Office that additionally has a list of Employees, a House that additionally has a list of Mail, and a PostOffice that additionally has both a list of Mail and a list of Employees. (again, a contrived example.)
In Java, this presents a problem! We simply must consider a PostOffice both a House and an Office to appease the object-oriented gods. Yet there is no way to do such with classes alone: interfaces must be used, which present a much nicer structure and do not run into the deadly diamond of death. But that is beside the point: you can do multiple inheritance in Java and other languages, but it’s verbose and ugly.
In our example structural language, multiple inheritance is supported with no more than a passing thought.
type Building = struct
: str
address
type Office = struct
: str
address: List[Employees]
employees
type House = struct
: str
address: List[Mail]
mail
type PostOffice
: str
address: List[Employees]
employees: List[Mail] mail
func empty_mail(self: mut House): List[Mail] =
let mail = self.mail
.mail = []
selfreturn mail
func get_employees(self: Office): List[Employees] =
return self.employees
let foo: Office = {
: "123 Abigail Lane",
address: ["Alice", "Bob"]
employees}
let bar: House = {
: "456 Bartleby Lane",
address: mail
mail}
let baz: PostOffice = {
: "456 Bartleby Lane",
address: ["Craig", "Dan", "Erin"],
employees: mail
mail}
print get_employees(foo) # "Alice", "Bob"
print get_employees(baz) # "Craig", "Dan", "Erin"
print get_employees(bar) # type error
Conclusion
This is probably pretty obvious to anyone familiar with structural typing, or extremely familiar with object-oriented languages! But I did not fall into either of those camps, and so found it pretty neat. Perhaps because it was my first language, and I always implicitly compare everything new to it, but I find it fascinating how vast amounts of Java’s boilerplate and the arbitrary restrictions surrounding it (ex. can’t re-declare fields with a different type in extended classes) can be brushed away by simpler, more powerful programming paradigms.
Of course, such a system implemented in a real language wouldn’t be to everybody’s taste. By intention this subtyping is implicit, and if somebody down the line wanted to write really bad code taking advantage of this, there wouldn’t be any sticky webs of explicitness to stop them. In addition, if you allow function overloading, you have to contend with the deadly diamond of death: and so perhaps a better route is to only allow such structural ambiguity explicitly by way of interfaces. In general, however, I think that eschewing boilerplate and restrictiveness in favor of semantics by structure is a good thing, and so I’d like to see more languages explore more structural systems.
Further reading: Types and Programming Languages, primarily the sections on subtyping (Chapter 15) and Featherweight Java (Chapter 19)