Functors for Polymorphism
An important motivation for typed and functional programming is support for so-called type-polymorphism: some types (e.g. lists, arrays, …) depend on other types that should be treated as parameters (e.g. data type, device, shape…).
Type Polymorphism
It is a very common problem in programming to design data structures and interfaces whose low-level behaviour may depend on the specific types of the objects being handled, although all the implementations share a common logic. A single source code definition which adapts behaviour to a range of input types (type parameters in the source) is called polymorphic.
Polymorphism is a powerful feature of C++, accessible via its so-called templates. This is for instance very handy if trying to implement a linear algebra backend supporting different numerical types and floating point precisions.
Python came rather late to type polymorphism, with a support mostly limited to type annotations and not so satisfying yet:
>>> list[int] is list[int]
False
>>> issubclass(list[int], list) or isinstance(list[int], list)
False
>>> x = list[int](x for x in (0, 1, 2, "nan", 3.141592))
>>> isinstance(x, list[int])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: isinstance() argument 2 cannot be a parameterized generic
>>> x
[0, 1, 'nan', 3.141592]
In fp, polymorphic types are explicitly created by calling a type constructor.
A constructor’s __new__ method is wrapped within functools.cache,
in order to always return the same type instance when called with identical
(hashable) arguments.
This way, parameterized list types start behaving like we want them to:
>>> List(Int) is List(Int)
True
>>> List(Int) == List(Str)
False
>>> isinstance(List(Int), List) and issubclass(List, Type)
True
Covariant Functors
In category theory, functors are transformations of categories, i.e. things that transforms both their objects (e.g.
Typeinstances) and their morphisms (e.g. typed programsA -> Bbetween any twoTypeinstances). These transformations must map any source-composition of morphisms to the target-composition of image morphisms.
The List type constructor is the canonical example of a
functor in functional programming. As an instance of the Functor metaclass,
it implements the following Method instances:
new:Type -> Typeimage of type objectsfmap:(A -> B) -> List A -> List Bimage of type morphisms
Image objects
Functors inherit their type construction logic from the Constructor metaclass,
which means they will look for a class attribute Object from which every image object
will inherit. This special Type object is referred to as top-type throughout the documentation.
It might be thought of as a kind of “abstract base class” (although in practice it has nothing to do
with the abc module).
As a canonical example, consider the following minimal implementation of the
List functor:
class List(Monoid, metaclass=Functor):
src = Type
tgt = Type
class Object(list):
def __init__(self, xs):
"""Cast inputs to type argument."""
# NOTE: List(A)._head_ = List
# List(A)._tail_ = (A,)
A = self._tail_[0]
super().__init__(fp.io.cast(x, A) for x in xs)
@classmethod
def fmap(cls, f):
...
We first see that as a functor, List has two class attributes src and tgt, both referring here
to the Type category (these attributes are used later to determine how morphisms should be
mapped, see below).
The List.Object class acts as a template class from which all child instances of the form
List(A) inherit, and one can therefore check that xs is a typed list with
isinstance(xs, List.Object).
The type arguments with which a functor or constructor was called
can be retrieved in the tuple class attribute _tail_, while the constructor itself is
stored as the class attribute _head_. These two attributes provide the symbolic description
of fp’s typesystem which allows for convenient generic programming.
In some cases, the functor might construct types which do more than only inheriting
from the top-type with a specific _tail_ attribute. In that case, the new class method
should be overriden, although this requires a deeper understanding of how python metaclasses
work.
Image morphisms
To complete the definition of List as a functor (and not only a constructor), there only
remains to fill in the definition of the fmap classmethod.
Remember from above that List.fmap should take a typed function f : A -> B as input
and return a mapped function of signature List A -> List B.
class List(Monoid, metaclass=Functor):
...
@classmethod
def fmap(cls, f):
"""
Map a function on lists.
"""
@Type.Hom(cls(f.src), cls(f.tgt))
def mapf(xs):
return (f(x) for x in xs)
mapf.__name__ = f"map {f.__name__}"
return mapf
The decorated mapf returns a generator to save the overhead of creating an intermediate list,
while the returned typed callable List.fmap(f) will take care of casting its output
to List(f.tgt).
Examples
Trees
As a popular simple example, consider the Tree functor which maps:
any type
ato the (recursive) typeTree a = Branch a [Tree a]. This Haskell notation instructs that trees are defined by a root node carrying a valuex: aand a list of subtrees of same node type.any function
f : a -> bto the tree transformationTree.fmap(f) : Tree(a) -> Tree(b)recursively defined by
Tree.fmap(f) =
lambda Tree.branch(x, subtrees) :
Tree.branch(f(x), [Tree.fmap(f)(t) for t in subtrees])
In the above pseudo-python code, a classmethod Tree.branch is assumed to take care
of type inference to construct an object of type Tree(type(x)): remember that as a
functor, Tree itself must accept type arguments, in order to return tree types such
as Tree(Int) or Tree(Tensor).
By imitating the List functor implementation outlined above, i.e.
declaring
fp.Functoras metaclass,defining an
Objecttop-type as class attribute,implementing the
fmapclass method,
functors will automatically cache their __new__ type constructor, so that
>>> List(A) is List(A)
True
always. More advanced type construction methods will be covered in the chapter on
the Constructor metaclass, from which Functor, Applicative and Monad inherit
their type construction logic.
Covariant Hom Functors
Given any category C and an object X of C, the covariant hom-functor Hom(X, ...) below X
maps C into the category of sets (types), by:
mapping any object A of C to the set (type)
Hom(X, A)mapping any morphism (function) f : A → B to a function:
Hom.fmap(f) : Hom(X, A) → Hom(X, B) ϕ ↦ f ∘ ϕ
called push-forward by f, which simply means appending f at the end of a pipe from a programmer’s view.
Contravariant Functors
An important class of functors actually swap the source and target objects when transforming morphisms. These functors are called contravariant, but they are really otherwise just ordinary functors, as the direction of arrows is important yet arbitrary (what we choose to call source and target can be globally interchanged). This fundamental symmetry from say, left to right, is called duality. Dual notions are usually named with the “co-” prefix (e.g. vector, covector).
Opposite Category
Given a category C, its dual or opposite category C*
has the same objects as C,
has a morphism
f* : B -> Afor every morphismf : A -> Bof Chas composition
f* @ g* : C -> Aequal to(g @ f)* : C -> Afor every pair of composable morphismsf : A -> Bandg : B -> Cof C.
This defines C* as a category for any category C, although its (sometimes abstract)
swapped morphisms may not always be thought of as functions on sets
(that can be evaluated with __call__), as is the case for the many
common concrete categories (groups, vector spaces, algebras, manifolds, posets…)
that can be embedded in Set.
In the case of vector spaces, duality still
carries a concrete interpretation.
Given a linear map f : fp.Linear(A, B) its dual is the adjoint or
transposed map, f.t() : fp.Linear(B, A) in finite dimension
(for topological vector spaces, the dual space A’ of
continuous linear forms on A may be quite different from A).
Contravariance
A contravariant functor F from C to C’ is a covariant functor from C* to C’.
In fp, contravariant functors are instances of the Cofunctor
metaclass. They must implement a class method cofmap with signature:
F.cofmap : Hom(A, B) -> Hom(F(B), F(A))
There are many examples of contravariant functors in fp. They mostly
arise from bivariate functors, described in more details below.
Examples
Real Functions
Ubiquitous contravariant functors link geometry and algebra in mathematics, which:
associate to any base space X (finite set or topological space, differentiable or algebraic manifold…) an algebra of real-valued functions C(X), satisfying some regularity condition (continuous, continuously differentiable, polynomial or rational…)
map any morphism ϕ : X → Y to the pull-back ϕ* : C(Y) → C(X):
ϕ* : C(Y) → C(X) f ↦ f ∘ ϕ
The restriction of real functions to a subspace X ⊂ Y is a particular case of pull-back. Their invariant extension along a regular projection or quotient map π : Z → Y are another particular case of pull-back.
These abstract but general notions have very practical counterparts when viewing numerical array types as a special case of cofunctors of real functions above.
Tensors
As a particular case, assume that X is a finite set, with an additional torus structure:
X = (Z/n₀Z) × … × (Z/nₖ₋₁Z)
~= fp.Torus((n₀, …, nₖ₋₁))
Then C(X) = R X denotes the space of real values functions on X.
This space is described by the type fp.Tens((n₀, …, nₖ₋₁)).
The contravariant functor Tens is the object of the next chapter.
Contravariant functors of real functions are usually a particular case of the following more general example, where the line (and algebra) of real numbers R plays the role of a particular object in the category.
Contravariant Hom Functors
Given any category C and an object Y of C, the contravariant hom-functor
Hom(..., Y) above Y
maps C into the category of sets (types), by:
mapping any object B of C to the set (type)
Hom(B, Y)mapping any morphism (function) f : A → B to a function:
Hom.cofmap(f) : Hom(B, Y) → Hom(A, Y) ψ ↦ ψ ∘ f
called pull-back by f. From a programmer’s view, it just prepends f at the entrance of a pipe.
Multivariate Functors
From a very abstract perspective, many computer data structures could also be thought of as special Hom bifunctors:
Dictionaries map finite sets of keys to a value type,
Tensors map index ranges (hypercubes) to a numerical value type