Subtypes#
In programming language theory, subtyping (also called subtype polymorphism or inclusion polymorphism) is a form of type polymorphism. A subtype is a datatype that is related to another datatype (the supertype) by some notion of substitutability (read: Liskov substitution principle), meaning that program elements (typically subroutines or functions), written to operate on elements of the supertype, can also operate on elements of the subtype[1].
Types are Sets#
For people coming from a mathematical background, it may be useful to think of types as sets. Indeed, a type in the context of type theory, is a set of values[2]. In essence, a type defines a collection—or set—of values that share certain characteristics.
(Integer Type as a Set)
To illustrate, consider the Integer (int
) type in many programming
languages. You can think of this type as a set that includes all whole numbers
from negative infinity to positive infinity. Each number in this set ranging
from \(-\infty\) to \(\infty\) is an element of the Integer type.
Nominal vs. Structural Subtyping#
In type theory, a crucial distinction is made between two primary subtyping schemes: nominal subtyping and structural subtyping. This distinction is fundamental in understanding how different programming languages conceptualize and implement subtype relationships. Nominal subtyping bases the subtype relationship on explicit declarations (like class inheritance), while structural subtyping determines it based on the actual structure (methods and properties) of the types.
This distinction is particularly important for static type checkers, which checks the types at compile time, and rely on the subtyping schemes to determine if one type, \(\mathcal{A}\), is a subtype of another type, \(\mathcal{B}\).
In nominal subtyping, the static type checker searches for explicit
declarations of inheritance (e.g., class A
extends B
), clearly indicating
that A
is a subtype of B
. This establishes a formal, name-based relationship
between types at the time of declaration which means that this schema relies
more on the declared hierarchy and naming of the types rather than their
inherent structure or functionalities. Conversely, structural subtyping
involves the checker assessing whether a potential subtype possesses all
necessary structural features, such as methods and properties, to fulfill
the requirements of its supertype, without requiring any explicit declaration
of this relationship. For instance, the checker would examine if the subtype
implements all the methods present in the supertype, ensuring compatibility
based solely on structural characteristics.
Declaration, Compile, and Run Time
Nominal subtype relationships are established at declaration time (i.e.,
when a new subclass is declared), and checked at compile time, whereas
structural subtype relationships are established at the point of use,
and checked at runtime. However, when defining via mypy
’s Protocol
,
the structural subtyping is actually checked at compile time. We will see
the difference later.
Nominal Subtyping - Class Hierarchy Determines Subtypes#
Given the backdrop in the previous section, we would condense out the key concepts of nominal subtyping below, and end it off with a python example.
What is Nominal Subtyping?#
Nominal subtyping is a type system concept where a type is considered a subtype of another only if it is explicitly declared as such. This mechanism is rooted in explicit declarations of type relationships, typically through class inheritance in object-oriented programming languages.
Why Nominal Subtyping?#
Nominal subtyping provides a controlled environment for polymorphism, where the relationships between types are well-defined and restricted according to the class hierarchy. Consequently, the explicitness of such declaration provides clarity to developers. Furthermore, nominal subtype relationships need to be planned in advance, and hence it might be easier to ensure that certain principles (e.g, the Liskov substitution principle) hold for subtypes.
How to Implement Nominal Subtyping?#
In languages that utilize nominal subtyping, subclassing or interface implementation are the primary means to establish subtype relationships. For instance, a class must explicitly extend another class or implement an interface to be considered its subtype. This approach relies on the lineage of type declarations to determine subtype relationships, focusing on names and declarations rather than the structural content of the types.
In Java for instance, if class Dog extends Animal
, Dog is a nominal
subtype of Animal because it explicitly extends Animal
. We see a
similar implementation in Python below, detailing how Dog
and Cat
are both
subtypes of their parent class Animal
through inheritance.
1class Animal:
2 def describe(self) -> str:
3 return str(self.__class__.__name__)
4
5 def make_sound(self) -> str:
6 return "Generic Animal Sound!"
7
8
9class Dog(Animal):
10 def make_sound(self) -> str:
11 return "Woof!"
12
13 def fetch(self) -> str:
14 return "Happily fetching balls!"
15
16
17class Cat(Animal):
18 def make_sound(self) -> str:
19 return "Meow"
20
21 def how_many_lives(self) -> str:
22 return "I have 9 lives!"
23
24class Robot:
25 def describe(self) -> str:
26 return str(self.__class__.__name__)
27
28 def make_sound(self) -> str:
29 return "Generic Robot Sound!"
30
31cat = Cat()
32dog = Dog()
33rob = Robot()
34print(isinstance(cat, Animal)) # True, Cat is a nominal subtype of Animal
35print(isinstance(dog, Animal)) # True, Dog is a nominal subtype of Animal
36print(isinstance(rob, Animal)) # False, Robit is not a nominal subtype of Animal
True
True
False
In this example, Dog
and Cat
are nominal subtypes of Animal
because they
explicitly inherit from the Animal
class. However, Robot
which has the exact
same methods as Animal
, is not a subclass of Animal
and therefore do not
qualify as a subtype of Animal
under the nominal subtyping framework. Note
that python allows unsafe overriding of attributes and methods, so we really
want static type checker to ensure we do not violate any rules such as
Liskov Substitution Principle.
Structural Subtyping#
What is Structural Subtyping?#
Structural subtyping is a type system strategy where a type is considered a subtype of another based on its structure — specifically, if it possesses all the members (properties and methods) required by the supertype. This approach contrasts with nominal subtyping by focusing on the capabilities of types rather than their explicit declarations or lineage. It aligns with the concept of “duck typing” in dynamically typed languages: if an object behaves like a duck (implements all the duck behaviors), it can be treated as a duck
Why Structural Subtyping?#
The flexibility of structural subtyping allows for novel and unintended uses of existing code by enabling objects that do not share a common inheritance path to interact seamlessly as long as they fulfill the structural criteria. Sometimes you would like to enable loose coupling and subclass (nominal) may just add unwanted complexity.
Consider a toy example below, where we construct Dataset
to hold a Sequence
containing elements of type T
. The current implementation does not have any
subtyping schemes to it, and therefore, if we try to check if this Dataset
is
an instance of
Sized
,
we would get False
.
1T = TypeVar("T")
2
3
4class Dataset:
5 def __init__(self, elements: Sequence[T]) -> None:
6 self.elements = elements
7
8dataset = Dataset([1, 2, 3, 4, 5])
9print(isinstance(dataset, Sized))
False
However, once we add __len__
to the example, then Dataset
is now an instance
of the Sized
. The Sized protocol requires just one thing: a __len__
method
that returns the size of the container. Despite Dataset not inheriting from any
specific class that implements Sized, the mere presence of the said method
adheres to the structural expectations of being “sizable”.
1class Dataset:
2 def __init__(self, elements: Sequence[T]) -> None:
3 self.elements = elements
4
5 def __len__(self) -> int:
6 """Returns the number of elements in the collection."""
7 return len(self.elements)
8
9dataset = Dataset([1, 2, 3, 4, 5])
10print(isinstance(dataset, Sized))
True
It is worth noting that the Sized
protocol is not really the Protocol
we
know of, instead they use __subclasshook__
for the structural typing dark
magic to happen.
1class Sized(metaclass=ABCMeta):
2
3 __slots__ = ()
4
5 @abstractmethod
6 def __len__(self):
7 return 0
8
9 @classmethod
10 def __subclasshook__(cls: Type[Sized], C: Type) -> bool:
11 if cls is Sized:
12 return _check_methods(C, "__len__")
13 return NotImplemented
To this end, the Dataset
class is now a structural subtype of the Sized
class, as it implements the __len__
method required by the Sized
“protocol”.
The check is done at runtime via the __subclasshook__
method, which
verifies if the class implements the necessary methods for the protocol.
How to Implement Structural Subtyping?#
In languages supporting structural subtyping, subtype relationships are
established through the implementation of the required members, without the need
for explicit inheritance or interface implementation. This method focuses on the
actual implementation of the required properties and methods. More concretely,
if type A
defines all the methods of type B
(and B
is usually a
Protocol
), then A
is a subtype of B
, irrespective of their inheritance
relationship.
For pedagogical purposes, we can illustrate structural subtyping by implementing
it manually. Our is_flyable
function checks if an object has a fly
attribute, and if that attribute is callable so we know that this attribute is a
method or function, and not a data attribute.
1def is_flyable(obj: Any) -> bool:
2 return hasattr(obj, "fly") and callable(obj.fly)
3
4class Bird:
5 def fly(self) -> str:
6 return "Bird flying"
7
8class Airplane:
9 def fly(self) -> str:
10 return "Airplane flying"
11
12class Car:
13 def drive(self) -> str:
14 return "Car driving"
15
16print(is_flyable(Bird())) # True, because Bird implements a callable fly method
17print(is_flyable(Airplane())) # True, Airplane also implements a callable fly method
18print(is_flyable(Car())) # False, Car does not implement a callable fly method
19
20objects = [Bird(), Airplane(), Car()]
21for obj in objects:
22 if is_flyable(obj):
23 print(f"{obj.__class__.__name__} can fly: {obj.fly()}")
24 else:
25 print(f"{obj.__class__.__name__} cannot fly.")
True
True
False
Bird can fly: Bird flying
Airplane can fly: Airplane flying
Car cannot fly.
While manual checks like the one above illustrate the core idea of structural
subtyping, Python offers a more streamlined approach through the typing
module. By defining a protocol via the
Protocol
class, you can specify the required methods and properties for a
type,
1from typing import Protocol
2
3class Flyable(Protocol):
4 def fly(self) -> str:
5 ...
6
7def can_we_fly(obj: Flyable) -> None:
8 ...
9
10bird = Bird()
11airplane = Airplane()
12car = Car()
13
14can_we_fly(bird) # No error, Bird is a structural subtype of Flyable
15can_we_fly(airplane) # No error, Airplane is a structural subtype of Flyable
16can_we_fly(car) # Error, Car is not a structural subtype of Flyable
Here, both Bird
and Airplane
are considered structural subtypes of the
Flyable
protocol because they implement the required fly
method, even though
they don’t explicitly inherit from Flyable
. The Car
class, on the other
hand, does not implement the fly
method and is not considered a structural
subtype of Flyable
.
It is worth noting that mypy
is a static type checker, and hence if you run
mypy
on the above code, the code is checked at compile time to ensure that
the Bird
and Airplane
classes are structural subtypes of the Flyable
protocol and that the Car
class is not.
If you want to ensure that the check is done at runtime with isinstance
, you
can use the decorator runtime_checkable
to enable runtime instance
checks[3] (you cannot call isinstance
on Flyable
without
this decorator):
1from typing import Protocol, runtime_checkable
2
3@runtime_checkable
4class Flyable(Protocol):
5 def fly(self) -> str:
6 ...
7
8print(isinstance(bird, Flyable)) # True, Bird is a structural subtype of Flyable
9print(isinstance(airplane, Flyable)) # True, Airplane is a structural subtype of Flyable
10print(isinstance(car, Flyable)) # False, Car is not a structural subtype of Flyable
True
True
False
Pros and Cons of Nominal and Structural Subtyping#
In the nominal subtyping example, the subtype relationship is established
through explicit class inheritance. In the structural subtyping example, the
subtype relationship is based on the implementation of a specific interface
(defined by a Protocol
), regardless of the inheritance relationship.
In the context of structural subtyping, a nuanced issue arises from the application of the Liskov Substitution Principle (LSP). The LSP asserts that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. Structural subtyping, however, evaluates type compatibility based on the presence and signature of methods, not on the inherent relationship or semantic compatibility between the types. This leads to scenarios where a class might unintentionally become a subtype of another by merely implementing the same method signatures, potentially violating the LSP due to semantic discrepancies.
Consider the same example from nominal subtyping, but with an added
__subclasshook__
method to the Animal
class. This method is used to check if
a class is a structural subtype of Animal
by checking if it implements the
describe
and make_sound
methods.
1def _check_methods(C: Type, *methods: str) -> bool:
2 mro = C.__mro__
3 for method in methods:
4 for B in mro:
5 if method in B.__dict__:
6 if B.__dict__[method] is None:
7 return NotImplemented
8 break
9 else:
10 return NotImplemented
11 return True
12
13class Animal:
14 def describe(self) -> str:
15 return str(self.__class__.__name__)
16
17 def make_sound(self) -> str:
18 return "Generic Animal Sound!"
19
20 @classmethod
21 def __subclasshook__(cls: Type[Animal], C: Type) -> bool:
22 if cls is Animal:
23 return _check_methods(C, "describe", "make_sound")
24 return NotImplemented
25
26class Dog(Animal):
27 def make_sound(self) -> str:
28 return "Woof!"
29
30 def fetch(self) -> str:
31 return "Happily fetching balls!"
32
33
34class Cat(Animal):
35 def make_sound(self) -> str:
36 return "Meow"
37
38 def how_many_lives(self) -> str:
39 return "I have 9 lives!"
40
41class Robot:
42 def describe(self) -> str:
43 return str(self.__class__.__name__)
44
45 def make_sound(self) -> str:
46 return "Generic Robot Sound!"
In this code, Robot
implements the make_sound
method, which according to the
__subclasshook__
in Animal
, qualifies it as a subtype of Animal
from a
structural subtyping perspective. However, from a semantic standpoint,
classifying a Robot
as a subtype of Animal
is incorrect because they belong
to fundamentally different categories of entities.
In practice, this can be avoided by adhering to good design patterns for your type protocols or interfaces. Golang is a famous language that relies almost exclusively on structural subtyping, here’s a good post that summarizes some of thees rules.
Inclusive vs. Coercive Implementations#
While nominal and structural subtyping focus on how type relationships are defined, inclusive and coercive implementations concern themselves with what happens when types interact in a program, specifically how values of one type are treated or converted to another type within the subtype relationship[4].
Inclusive Implementations#
The basic idea of inclusive implementations is that the internal representation of a value from a subtype is compatible with that of its supertype. This means that the value itself doesn’t need to change or be converted when treated as a value of the supertype.
Think of this as direct “plug-and-play”. A subtype is a special case of the supertype and can directly be used in any context where the supertype is expected. No transformation or special handling is needed; the system inherently understands that the subtype fits because its representation includes all necessary aspects of the supertype.
In other words, if you have a subtype \(\mathcal{A}\) and a supertype \(\mathcal{B}\), any value that belongs to \(\mathcal{A}\) is also valid as a value of \(\mathcal{B}\). The representation of the value in \(\mathcal{A}\) is sufficient to represent it in \(\mathcal{B}\) as well.
(Inclusive Implementations in Object-Oriented Languages)
Consider a class hierarchy where Dog
extends Animal
. A Dog
object has all
the characteristics (fields, methods) of an Animal
and possibly more. When you
treat a Dog
object as an Animal
(for example, passing it to a function that
expects an Animal
), no conversion is needed - the Dog
object ‘fits’ into the
Animal
type because it already includes all aspects of an Animal
. This very
much sounds like the nominal subtyping we discussed earlier.
Coercive Implementations#
On the other hand, coercive implementations involve automatically converting a value from one type to another when necessary. This conversion is typically needed when the internal representations of the types are different. Imagine this as needing an “adapter” to make one type fit into the context of another. The system recognizes that while the two types are not the same, there’s a known way to convert from one to the other so that interactions can proceed smoothly.
To formalize the concept of coercive implementations in the context of subtypes and supertypes with mathematical logic, we consider two types, \(\mathcal{A}\) and \(\mathcal{B}\), not necessary subtype of each other. The coercive implementation implies that values of type \(\mathcal{A}\) may need to be converted to type \(\mathcal{B}\) to be treated as values of \(\mathcal{B}\).
Let \(\mathcal{A}\) and \(\mathcal{B}\) be two types within a type system. We define a coercive conversion process as a function \(f\) that maps values from \(\mathcal{A}\) to \(\mathcal{B}\):
This function \(f\) takes a value \(\mathcal{V}_{\mathcal{A}}\) of type \(\mathcal{A}\) and converts it into a value \(\mathcal{V}_{\mathcal{B}}\) of type \(\mathcal{B}\), such that:
(Coercive and Primitive Types)
A common example is numeric types, like integers and floating-point numbers. In
many languages, an integer can be used where a floating-point number is
expected. The integer (type A
) is automatically converted (coerced) into a
floating-point number (type B
). For instance, in a language like Python, if
you have a function that expects a float but you pass an integer, the integer
will be automatically converted to a float.
1# Integer (type A)
2int_value = 5
3
4# Floating-point number (type B)
5float_value = 2.5
6
7# Adding an integer to a floating-point number
8result = int_value + float_value # 7.5
Even though int_value
is an integer and float_value
is a float, the
programming language automatically converts int_value
to a float during the
addition operation. This is coercive implementation: the integer is
automatically converted to a float (a related but different type) to make the
operation possible.
In this context:
\(\mathcal{A}\) corresponds to the type
int
.\(\mathcal{B}\) corresponds to the type
float
.The function \(f\) represents the implicit conversion process that Python performs to convert
int
tofloat
before performing the addition.
So, when int_value
(of type int
, or \(\mathcal{A}\)) and float_value
(of
type float
, or \(\mathcal{B}\)) are added, Python implicitly applies the
conversion function \(f\) to int_value
to convert it into a float
(type
\(\mathcal{B}\)) before the addition. This can be thought of as:
\(\mathcal{V}_{\mathcal{A}} = \text{int_value}\)
\(\mathcal{V}_{\mathcal{B}} = f(\text{int_value}) = \text{float_value}\)
Here, \(f(\text{int_value})\) effectively “coerces” or converts the integer value
to a floating-point value, ensuring that the addition operation occurs between
two values of the same type (\(\mathcal{B}\), or float
). The result of this
operation is a float
, demonstrating how the coercive conversion aligns with
the formalism.
References and Further Readings#
References