Intro
In this article, I want to introduce you to different types of polymorphism. I'll try to answer questions about what it is, why, and how to use it with different examples. I wrote this article, to sum up, and structure my knowledge after reading On understanding types, data abstraction, and polymorphism paper. I'll use PHP, Go, and Typescript for code examples.
What is polymorphism?
Simply put, polymorphism allows your functions to have values of multiple types. It's opposed to monomorphism, where functions only work on one type.
For example, consider the following function:
func length(list []int) int {}
This function is monomorphic. It allows you to pass list
only as only type []int
.
On the other hand:
func length(list []interface) int {}
is polymorphic. Your list can be []int
, []string
, and etc.
How many types of polymorphism are there?
According to the paper, there are four. We will discuss only two of them in this article, which the paper names as "true polymorphism".
Inclusion
Let's start with the one that we all know about. Inclusion polymorphism says that your types can have subtypes, and your function will work with any subtype as well as with the supertype.
For example:
interface Writer {
write(value: string): void;
}
class ConsoleWriter implements Writer {
public write(value: string): void {
console.log(value);
}
}
class FileWriter implements Writer {
public write(value: string): void {
// write to file here
}
}
function write(writer: Writer, value: string): void {
writer.write(value);
}
Although this is a bad example for real-world programming (composition over inheritance), it's good for demonstrating the idea.
You can pass any object of type Writer
, and its subtypes to the write
function. What does it give to us? It simply allows you to program by contract and not to care about the implementation. You always know that the $writer
object has a defined set of methods that you can run.
Subclasses and interfaces are not the only way to achieve this type of polymorphism. There's also a concept that's called duck typing which says that if an object has a set of methods that are defined in type A
, then it's type A
. If it behaves like a duck, then it's a duck.
A common example is Go:
type Writer interface {
Write(p []byte) (n int, err error)
}
type FileWriter struct {}
func (w FileWriter) Write(p []byte) (n int, err error) {}
func write(w Writer, data []byte) {}
For those who are not familiar with Go, the code above defines an interface Writer
and the struct(think about this as a class) FileWriter
with the method Write
. Here, the FileWriter
is a subtype of Writer
, although it doesn't implement any interface or anything.
In Go, any type is a subtype of the type named interface
which is an inclusion kind of polymorphism, but when generics are released, the language will support parametric as well. Let's jump to this one.
Parametric
Parametric polymorphism allows you to define generic functions that will work with any type. A common technique that allows you to achieve this is generic programming. Consider this method in Typescript:
function length<T>(list: T) int {}
Users of your function can pass a list of any type here.
As I wrote above, Go doesn't not support this yet. You still can create a function like this:
func length(list []interface) {}
But this is inclusion polymorphism because any type in the Go is a subtype of the type interface
.
I won't dive into the details about why generics are better, but you can watch this video if you're interested.
What does it give to us?
One type, many implementations
Polymorphism gives you IoC(Inversion Of Control) power. To simplify, you can create some complicated function that accepts two values:
type Writer interface {
func Write(data string) string
}
func transformAndWriteData(w Writer, data string) string {
// transform data
w.Write(data);
return data
}
Inside that function, you can do whatever you want and then call w.Write
. You don't care how Write
behaves. It could write to some remote file, disk, or stdout.
Unit testing
This concept actually allows you to unit test your program. This is a common technique when you test only one part of the system at a time. It would be hard and unnecessary to use the implementation of FileWriter
when you want to test transformAndWriteData
. So you can create a mock:
package main
import (
"testing"
"reflect"
"strings"
)
type Writer interface {
Write(data string)
}
func transformAndWriteData(w Writer, data string) string {
data = strings.ToUpper(data) // let's pretend this is useful
w.Write(data);
return data
}
type MockWriter struct {
Data string
}
func (w *MockWriter) Write(data string) {
w.Data = data
}
func TestTransformAndWrite(t *testing.T) {
data := "abc"
writer := &MockWriter{}
expected := "ABC"
actual := transformAndWriteData(writer, data);
if !reflect.DeepEqual(actual, expected) {
t.Errorf("expected %q, got %q", expected, actual)
}
}
Generic programming
What about parametric polymorphism? It also gives you the power of IoC. For example, you want to implement your own map
function in TypeScript:
function map<T, K>(list: Array<T>, f: ((_: T) => K)): Array<K> {}
Or, you want to build a library that provides some sort of authentication:
interface User {
id: int;
username: string;
}
function authenticate<T extends User>(user: T) {
//...
}
Now, programmers who use your tiny auth library can create their own implementation of user and then use the authenticate
method. This is a super-simplified example, but you get the idea.
Summary
We've defined two types of polymorphism: inclusion and parametric. Both could be used separately, or together, and allow you to define beautiful abstractions which give you the power of IoC. And both make more sense in statically typed languages, where types could be checked on compile, which will save you time debugging errors during runtime.
Top comments (0)