There's a fairly "standard" way of encoding sum types into an object-oriented language.
Here's two examples:
type Either<'a, 'b> = Left of 'a | Right of 'b
In C#, we could render this as:
interface Either<A, B> {
C Match<C>(Func<A, C> left, Func<B, C> right);
}
class Left<A, B> : Either<A, B> {
private readonly A a;
public Left(A a) { this.a = a; }
public C Match<C>(Func<A, C> left, Func<B, C> right) {
return left(a);
}
}
class Right<A, B> : Either<A, B> {
private readonly B b;
public Right(B b) { this.b = b; }
public C Match<C>(Func<A, C> left, Func<B, C> right) {
return right(b);
}
}
F# again:
type List<'a> = Nil | Cons of 'a * List<'a>
C# again:
interface List<A> {
B Match<B>(B nil, Func<A, List<A>, B> cons);
}
class Nil<A> : List<A> {
public Nil() {}
public B Match<B>(B nil, Func<A, List<A>, B> cons) {
return nil;
}
}
class Cons<A> : List<A> {
private readonly A head;
private readonly List<A> tail;
public Cons(A head, List<A> tail) {
this.head = head;
this.tail = tail;
}
public B Match<B>(B nil, Func<A, List<A>, B> cons) {
return cons(head, tail);
}
}
The encoding is completely mechanical. This encoding produces a result that has most of the same advantages and disadvantages of algebraic data types. You may also recognize this as a variation of the Visitor Pattern. We could collect the parameters to Match
together into an interface that we could call a Visitor.
On the advantages side, this gives you a principled encoding of sum types. (It's the Scott encoding.) It gives you exhaustive "pattern matching" though only one "layer" of matching at a time. Match
is in some ways a "complete" interface for these types and any additional operations we may want can be defined in terms of it. It presents a different perspective on many OO patterns such as the Null Object Pattern and State Pattern as I indicated on Ryathal's answer, as well as the Visitor Pattern and the Composite Pattern. The Option
/Maybe
type is like a generic Null Object Pattern. The Composite Pattern is akin to encoding type Tree<'a> = Leaf of 'a | Children of List<Tree<'a>>
. The State Pattern is basically an encoding of an enumeration.
On the disadvantages side, as I wrote it the Match
method puts some constraints on what subclasses can meaningfully be added, especially if we want to maintain the Liskov Substitutability Property. For example, applying this encoding to an enumeration type would not allow you to meaningfully extend the enumeration. If you did want to extend the enumeration, you would have to change all callers and implementors everywhere just as if you were using enum
and switch
. That said, this encoding is somewhat more flexible than the original. For example, we can add an Append
implementor of List
that just holds two lists giving us constant-time append. This would behave like the lists appended together but would be represented in a different manner.
Of course, many of these problems have to do with the fact that Match
is somewhat (conceptually but intentionally) tied to the subclasses. If we use methods that aren't so specific, we get more traditional OO designs and we regain the extensibility, but we lose the "completeness" of the interface and thus we lose the ability to define any operation on this type in terms of the interface. As mentioned elsewhere, this is a manifestation of the Expression Problem.
Arguably, designs like the above can be used systematically to completely eliminate the need for branching ever achieving an OO ideal. Smalltalk, for example, uses this pattern often including for Booleans themselves. But as the preceding discussion suggests, this "elimination of branching" is fairly illusory. We've just implemented branching in a different manner, and it still has much of the same properties.