Why Haskell IV: Typeclasses vs. Inheritance
Welcome to part four of our series comparing Haskell's data types to other languages. As I've expressed before, the type system is one of the key reasons I enjoy programming in Haskell. And this week, we're going to get to the heart of the matter. We'll compare Haskell's typeclass system with the idea of inheritance used by object oriented languages.
If Haskell's simplicity inspires you as well, try it out! Download our Beginners Checklist and read our Liftoff Series to get going!
Typeclasses Review
Before we get started, let's do a quick review of the concepts we're discussing. First, let's remember how typeclasses work. A typeclass describes a behavior we expect. Different types can choose to implement this behavior by creating an instance.
One of the most common classes is the Functor
typeclass. The behavior of a functor is that it contains some data, and we can map a type transformation over that data.
In the raw code definition, a typeclass is a series of function names with type signatures. There's only one function for Functor
: fmap
:
class Functor f where
fmap :: (a -> b) -> f a -> f b
A lot of different container types implement this typeclass. For example, lists implement it with the basic map
function:
instance Functor [] where
fmap = map
But now we can write a function that assumes nothing about one of its inputs except that it is a functor:
stringify :: (Functor f) -> f Int -> f String
We could pass a list of ints, an IO
action returning an Int
, or a Maybe Int
if we wanted. This function would still work! This is the core idea of how we can get polymorphic code in Haskell.
Inheritance Basics
As we saw in previous parts, object oriented languages like Java, C++, and Python tend to use inheritance to achieve polymorphism. With inheritance, we make a new class that extends the functionality of a parent class. The child class can access the fields and functions of the parent. We can call functions from the parent class on the child object. Here's an example:
public class Person {
public String firstName;
public String lastName;
public int age;
public Person(String fn, String ln, int age) {
this.firstName = fn;
this.lastName = ln;
this.age = age;
}
public String getFullName() {
return this.firstName + " " + this.lastName;
}
}
public class Employee extends Person {
public String company;
public String email;
public int salary;
public Employee(String fn,
String ln,
int age,
String company,
String em,
int sal) {
super(fn, ln, age);
this.company = company;
this.email = em;
this.sal = sal;
}
}
Inheritance expresses an "Is-A" relationship. An Employee
"is a" Person
. Because of this, we can create an Employee
, but pass it to any function that expects a Person
. We can also call the getFullName
function from Person
on our Employee
type.
public void printPerson(Person p) {
...
}
public void main {
Employee e = Employee("Michael", "Smith", 23, "Google", "msmith@google.com", 100000);
printPerson(e);
String s = e.getFullName();
}
Here's another trick. We can put items constructed as either Person
or Employee
in the same array, if that array has type Person[]
:
public void main {
Employee e = Employee("Michael", "Smith", 23, "Google", "msmith@google.com", 100000);
Person p = Person("Katie", "Johnson", 25);
Person[] people = {e, p};
}
This provides a useful kind of polymorphism we can't get in Haskell.
Benefits
Inheritance does have a few benefits. It allows us to reuse code. The Employee
class can use the getFullName
function without having to define it. If we wanted, we could override the definition in the Employee
class, but we don't have to.
Inheritance also allows a degree of polymorphism, as we saw in the code examples above. If the circumstances only require us to use a Person
, we can use an Employee
or any other subclass of Person
we make.
We can also use inheritance to hide variables away when they aren't needed by subclasses. In our example above, we made all our instance variables public
. This means an Employee
function can still call this.firstName
. But if we make them private
instead, the subclasses can't use them in their functions. This helps to encapsulate our code.
Drawbacks
Inheritance is not without its downsides though. One unpleasant consequence is that it creates a tight coupling between classes. If we change the parent class, we run the risk of breaking all child classes. If the interface to the parent class changes, we'll have to change any subclass that overrides the function.
Another potential issue is that your interface could deform to accommodate child classes. There might be some parameters only a certain child class needs, and some only the parent needs. But you'll end up having all parameters in all versions because the API needs to match.
A final problem comes from trying to understand source code. There's a yo-yo effect that can happen when you need to hunt down what function definition your code is using. For example your child class can call a parent function. That parent function might call another function in its interface. But if the child has overridden it, you'd have to go back to the child. And this pattern can continue, making it difficult to keep track of what's happening. It gets even worse the more levels of a hierarchy you have.
I was a mobile developer for a couple years, using Java and Objective C. These kinds of flaws were part of what turned me off OO-focused languages.
Typeclasses as Inheritance
Now, Haskell doesn't allow you to "subclass" a type. But we can still get some of the same effects of inheritance by using typeclasses. Let's see how this works with the Person
example from above. Instead of making a separate Person
data type, we can make a Person
typeclass. Here's one approach:
class Person a where
firstName :: a -> String
lastName :: a -> String
age :: a -> Int
getFullName :: a -> String
data Employee = Employee
{ employeeFirstName :: String
, employeeLastName :: String
, employeeAge :: Int
, company :: String
, email :: String
, salary :: Int
}
instance Person Employee where
firstName = employeeFirstName
lastName = employeeLastName
age = employeeAge
getFullName e = employeeFirstName e ++ " " ++ employeeLastName e
We can one interesting observation here. Multiple inheritance is now trivial. After all, a type can implement as many typeclasses as it wants. Python and C++ allows multiple inheritance. But it presents enough conceptual pains that languages like Java and Objective C do not allow it.
Looking at this example though, we can see a big drawback. We won't get much code reusability out of this. Every new type will have to define getFullName
. That will get tedious. A different approach could be to only have the data fields in the interface. Then we could have a library function as a default implementation:
class Person a where
firstName :: a -> String
lastName :: a -> String
age :: a -> Int
getFullName :: (Person a) => a -> String
getFullName p = firstName p ++ " " ++ lastName p
data Employee = ...
instance Person Employee where
...
This allows code reuse. But it does not allow overriding, which the first example would. So you'd have to choose on a one-off basis which approach made more sense for your type. And no matter what, we can't place different types into the same array, as we could in Java.
So while we could do inheritance in Haskell, it's a pattern you should avoid. Stick to using typeclasses in the intended way.
Comparisons
Object oriented inheritance has some interesting uses. But at the end of the day, I found the warts very annoying. Tight coupling between classes seems to defeat the purpose of abstraction. Meanwhile, restrictions like single inheritance feel like a code smell to me. The existence of that restriction suggests a design flaw. Finally, the issue of figuring out which version of a function you're using can be quite tricky. This is especially true when your class hierarchy is large.
Typeclasses express behaviors. And as long as our types implement those behaviors, we get access to a lot of useful code. It can be a little tedious to flesh out a new instance of a class for every type you make. But there are all kinds of ways to derive instances, and this can reduce the burden. I find typeclasses a great deal more intuitive and less restrictive. Whenever I see a requirement expressed through a typeclass, it feels clean and not clunky. This distinction is one of the big reasons I prefer Haskell over other languages.
Conclusion
That wraps up our comparison of typeclasses and inheritance! There's one more topic I'd like to cover in this series. It goes a bit beyond the "simplicity" of Haskell into some deeper ideas. We've seen concepts like parametric types and typeclasses. These force us to fill in "holes" in a type's definition. We can expand on this idea by looking at type families. Next week, we'll explore this more advanced concept and see what it's useful for.
If you want to stay up to date with our blog, make sure to subscribe! That will give you access to our subscriber only resources page!