Hackle's blog
between the abstractions we want and the abstractions we get.
How does a Person
gain abilities? By birth?
Of course not, we say, what a terrible idea!
Although, it is very much the case... especially if Person
is a class.
Ok I am not really talking about real people, but Person
as a type in programming, think class, struct, or any "concrete" types; abilities (also called capabilities) are abstractions such as interfaces, traits, protocols or type classes, which can be implemented by / for the types.
So the question becomes: how and when are the abilities decided for a Person
, or a Parrot
, as a type?
Types in most mainstream OO languages like Java, Python or C# are granted abilities by birth.
For example, in C#, interfaces (as "abilities") must be implemented when Person
is defined, as below,
interface ICanMove
{
string Move();
}
interface ICanSpeak
{
string Speak();
}
class Person : ICanMove, ICanSpeak
{
public string Move() { return "Walk"; }
public string Speak() { return "Mumbo"; }
}
class Parrot : ICanMove
{
public string Move() { return "Fly"; }
}
The Person
type is modelled with two abilities, the Parrot
, just one yet...
But I should not have said "yet"! Because after its definition, when we realise that a Parrot
may also be able to "speak", it's not possible to add the new ability ICanSpeak
to Parrot
without changing the class definition. It's more or less set in stone. Unless we resort to ugly workarounds such as below,
class SuperParrot : Parrot, ICanSpeak
{
public string Speak() { return "Mimic"; }
}
Alas, but inheritance is bad! Or is it? After all, SuperParrot
is a Parrot
!
Never mind, let me play along and bring in the much more proper ParrotAdapter
.
class ParrotAdapter : ICanMove, ICanSpeak
{
readonly Parrot _innerParrot;
public ParrotAdapter(Parrot innerParrot)
{
this._innerParrot = innerParrot;
}
public string Move() { return _innerParrot.Move(); }
public string Speak() { return "Mimic"; }
}
My eyes! This is way more verbose, and makes for pretty poor semantics. A ParrotAdapter
has an innerParrot
?! It's the same holy parrot!
Rant aside, one way to put it - abilities are very much decided by birth; it's non-trivial to add extra abilities to a type after-the-fact. The workarounds are either ugly, or verbose AND ugly.
But let me be fair. Compared to Java or Python, C# (or Kotlin for that matter) goes a bit further to support additional abilities with extension methods.
static class ParrotExt
{
public static string Speak(this Parrot parrot)
{
return "Mimic";
}
}
This can get us pretty far, but still not quite the same as adding extra interfaces... if you do care about interfaces. Or at least care about having it as an option.
For quite a long time this problem appeared too hard to retrofit into any widely used language without major breakage, and the workarounds and design patterns ran rampant. Thankfully, more modern languages came along, without the baggage of popularity or legacy, to liberate us from the shackles of abilities by birth.
A good example is Rust with traits.
trait CanMove {
fn muve(&self) -> String;
}
trait CanSpeak {
fn speak(&self) -> String;
}
pub struct Person {}
impl CanMove for Person {
fn muve(&self) -> String {
String::from("Walk")
}
}
impl CanSpeak for Person {
fn speak(&self) -> String {
String::from("Mumbo")
}
}
struct Parrot {}
impl CanMove for Parrot {
fn muve(&self) -> String {
String::from("Fly")
}
}
impl CanSpeak for Parrot {
fn speak(&self) -> String {
String::from("Mimic")
}
}
Ah! Not a lot is decided at the time of birth (actually, this example is quite extreme, neither type has any data); instead, Person
and Parrot
can both gain more abilities as the need arises.
The same problem is also solved pretty beautifully in Swift with protocols, for our example:
extension Parrot: CanWalk, CanSpeak {
// implementation of protocols
}
However, the solution in Go may be the most astonishing. Previously we mentioned extension methods, which has become the default style in more modern languages like Kotlin, Rust or Go: the first parameter is this
, self
, or the "receiver". Whats sets Go apart, is how it leaps over the idea of implementing an "interface", and uses (compiler-checked) structural typing.
type Person struct {}
type Parrot struct {}
func (p Person) move() string { return "Walk" }
func (p Person) speak() string { return "Mumbo" }
func (p Parrot) move() string { return "Fly" }
func (p Parrot) speak() string { return "Mimic" }
type CanMove interface {
move() string
}
type CanSpeak interface {
speak() string
}
func doSpeak(p CanSpeak) string {
return p.speak()
}
func doMove(p CanMove) string {
return p.move()
}
func main() {
person := Person{}
fmt.Println("Person", doSpeak(person), doMove(person))
parrot := Parrot{}
fmt.Println("Parrot", doSpeak(parrot), doMove(parrot))
}
Let's see why this is special. These dots are connected where other languages fall short,
That's smart of Go. Extra expressive power without forcing the programmer to jump through hoops. Simplicity at its best.
To those who think the differences between the two approaches above are only syntactic and superficial, I beg to differ.
An observation that follows immediately is, the abilities system is now completely open. Previously with a closed system that requires all abilities to be decided up-front, the concrete implementation (the Person
class) has to be opened every time new abilities are needed. This is not always easy or possible, depending on the ownership of the class; even when we own the type, there can be sub-domains, bounded-context that may require abilities added to the type that are not necessarily relevant to other sub-domains.
On the other hand, with an open system, abilities can be added when needed; this can be done in sub-domains without having to bloat out the core, centralised type. Isn't that what good domain-driven design looks like?!
A less visible, but perhaps more shocking division, is how this decoupling of types and abilities enables and encourages separation of data and behaviour, in stark contrast to classical OOP teaching of combining them.
Let's see the impact of this separation with the same example. The implementation of Person.speak()
is lacking, it really should specify what language.
In the C# example, customarily, we add a new field Person.Lang
, and change the implementation of Person.Speak()
accordingly.
enum Lang { English, Chinese }
class Person : ICanMove, ICanSpeak
{
Lang Language { get; set; }
public string Speak()
{
switch (Language)
{
case Lang.English: return "Greetings";
case Lang.Chinese: return "你好";
default: throw new NotImplementedException();
}
}
// ...
}
This is all nice and good, because there is no other way to go about it: something must be added to the Person
class to make this happen, despite the possibilities that Person.Move()
does not care about Person.Lang
at all. In practice, field Person.Language
might be saved to and read from a different data table than core Person
fields such as Person.Name | Email
.
Of course, soon we also need to add another supporting field Person.RunSpeed
for Person.Move()
; and Person.SwimStyle
when another ability ICanSwim
is added; yet another for ICanCook
... you get the idea.
The story is quite different with Rust (or Swift or Haskell for that matter). The core Person
type should remain unchanged, as it's clear from the original design. To add nuance to its ability CanSpeak
, extra data should be brought in to support the implementation of speak()
, but not through a field on Person
.
enum Lang {
English,
Chinese,
}
trait PrefersLang {
fn lang(&self) -> Lang;
}
impl PrefersLang for Person {
fn lang(&self) -> Lang {
// ...getting lang for Person
Lang::English
}
}
impl <T: PrefersLang> CanSpeak for T {
fn speak(&self) -> String {
match self.lang() {
Lang::English => String::from("Greetings"),
Lang::Chinese => String::from("你好"),
}
}
}
fn main() {
let psn = Person{};
println!("{}", psn.speak());
}
Let me be clear: if deemed necessary, nothing stops me from adding a lang
field directly to Person
; however, in the case to the contrary, I have the option of keeping lang
away.
The separation of behaviour forces me to design for cleaner separation of data, therefore to avoid polluting the Person
type with supporting fields for different abilities.
Worth noting traits in Rust do much more than just enabling adding extra interfaces. For one, ad-hoc polymorphism.