This is the sixth article from the series "Theory of Categories for Programmers." Previous articles have already been published on Habré:0. Category Theory for Programmers: Preface1. Category: essence of composition2. Types and functions3. Categories, large and small4. Categories Claisley5. Works and CopiesIn the previous article the basic operations on types were considered: product and coproduct. We now show that combining these mechanisms allows us to construct many of the everyday data structures. Such a construction has significant applied value. For example, if we are able to check basic data types for equality, and also know how to reduce the equality of work and coproduct to equality of components, then the equality operators for composite types can be inferred automatically. In Haskell, for an extensive subset of complex types, the equality and comparison operators, conversion to and from the string, and many other operations are automatically output.
Let us take a closer look at the place of creation and type creation in programming.
')
Type product
The canonical implementation of a work of types in programming languages is a pair. In Haskell, a pair is a primitive type constructor, and in C ++ it is a relatively complex template from the standard library.

Strictly speaking, a product of types is not commutative: it is impossible to substitute a pair of type
(Int, Bool)
instead of
(Bool, Int)
, although they contain the same data. However, the product is commutative up to an isomorphism defined by the
swap
function, which is inverse to itself:
swap :: (a, b) -> (b, a) swap (x, y) = (y, x)
Couples can be viewed as different storage formats for the same information, like big endian and little endian.
By investing one pair into another, you can combine as many types as you like into your work. The same can be obtained more easily if you notice that nested pairs are equivalent to tuples. This is a consequence of the fact that different orders of imbedding of pairs are isomorphic to each other. There are two possible combining order in the product of the three types
a
,
b
and
c
(in a given sequence). Namely,
((a, b), c)
or
(a, (b, c))
These types are different in the sense that a function that expects an argument of the first type cannot pass the argument of the second, but the values of the types are in one-to-one correspondence. Here is the function that sets this display in one direction:
alpha :: ((a, b), c) -> (a, (b, c)) alpha ((x, y), z) = (x, (y, z))
But back to her:
alpha_inv :: (a, (b, c)) -> ((a, b), c) alpha_inv (x, (y, z)) = ((x, y), z)
So, there is an isomorphism of types; these are just different ways of repacking the same data.
Considering the product of types as a binary operation, we see that the isomorphism above is very similar to the associative law in monoids:
(a * b) * c = a * (b * c)
The only difference is that in a monoid both products are absolutely identical, and for types they are equal up to isomorphism.
If we consider this distinction unimportant, then we can extend the analogy with monoids further and show that Singleton
()
is a neutral element with respect to the multiplication of types, just as 1 is neutral with respect to the multiplication of numbers. Indeed, attaching
()
to an element of type
a
does not add any information. Type of
(a, ())
isomorphic to
a
, where isomorphism is given by functions
rho :: (a, ()) -> a rho (x, ()) = x
rho_inv :: a -> (a, ()) rho_inv x = (x, ())
Our observations can be formalized as a statement that the category of the set
Set is
monoidal , that is, a category that is also a monoid with respect to the multiplication of objects (in this case with respect to the Cartesian product). A strict definition will be given below.
Haskell has a more general way of defining works, especially convenient, as we will soon see when they are combined with type sums. We use named constructors with several arguments. For example, an alternative definition of a pair looks like this:
data Pair ab = P ab
Here,
Pair ab
is the name of the type constructor, parametrized by two other types,
a
and
b
, and
P
is the name of the data constructor. We define a specific type, passing two types to the
Pair
type constructor, and create a pair of this type, passing the appropriately typed values to the
P
constructor. For example, we define the variable
stmt
as a pair from
String
and
Bool
:
stmt :: Pair String Bool stmt = P "This statement is" False
The first line is a type declaration. It consists of the
Pair
type constructor with
String
and
Bool
instead of
a
and
b
. The second line defines the value of a variable, obtained by applying the data constructor
P
to a specific line and a logical value. Once again: type constructors are used to construct types, data constructors are used to construct values.
Since the namespaces of the types and data in Haskell do not overlap, often the same name is used for both constructors. For example,
data Pair ab = Pair ab
If you look at the built-in type of a pair with Lenin's squint, then it is recognized that it is in fact a variation on the topic of the last declaration, only the
Pair
constructor is replaced with a binary operator
(,)
. You can use
(,)
in the same way as any other named constructor, in prefix notation:
stmt = (,) "This statement is" False
Similarly,
(,,)
constructs triples, etc.
Instead of using generic pairs or tuples, you can enter a separate name for the product of specific types. For example,
data Stmt = Stmt String Bool
is a product of
String
and
Bool
, but has its own name and constructor. The advantage of this definition is that you can create many types with the same content, but with different semantics and functionality that the type system will not allow to mix with each other.
Programming on tuples and multi-argument constructors often leads to confusion and errors, because you have to keep track of which component is responsible for what. It would be better to be able to give the components proper names. The product of types with named fields is called a Haskell entry and a
struct
in C.
Records
Consider a simple example. We will describe the chemical elements of a single structure consisting of two lines (Latin name and symbol) and an integer corresponding to the atomic mass. To do this, you can use a tuple
(String, String, Int)
and keep in mind what component is responsible for what. To extract components from a tuple, we will apply pattern matching. The following function checks whether the character of a chemical element is a prefix of its Latin name (for example,
He is the
Helium prefix):
startsWithSymbol :: (String, String, Int) -> Bool startsWithSymbol (name, symbol, _) = isPrefixOf symbol name
In such code, it is easy to make a mistake, it is difficult to read and maintain. Much better to define instead of a tuple entry:
data Element = Element { name :: String , symbol :: String , atomicNumber :: Int }
These representations are isomorphic, as can be seen with the help of the following reciprocal transformations:
tupleToElem :: (String, String, Int) -> Element tupleToElem (n, s, a) = Element { name = n , symbol = s , atomicNumber = a }
elemToTuple :: Element -> (String, String, Int) elemToTuple e = (name e, symbol e, atomicNumber e)
Note that the field names of the record are simultaneously accessor functions. For example,
atomicNumber e
returns the
atomicNumber
field of the
atomicNumber
record. Thus, the
atomicNumber
function has the type:
atomicNumber :: Element -> Int
Using
Element
type entries, the
startsWithSymbol
function becomes more readable:
startsWithSymbol :: Element -> Bool startsWithSymbol e = isPrefixOf (symbol e) (name e)
In Haskell, you can crank a trick that turns the
isPrefixOf
function into an infix operator, framing it with reverse apostrophes. This makes the code more readable:
startsWithSymbol e = symbol e `isPrefixOf` name e
We were able to omit the brackets due to the fact that the priority of the infix operator is lower than the priority of the function call.
Sum of Types
In the same way that a product in the category of sets induces the products of types, coproduction produces sums of types. The canonical implementation of the sum of types in Haskell is:
data Either ab = Left a | Right b
Like pairs,
Either
commutative (up to isomorphism) can be nested, and the order of nesting is not important (up to isomorphism). For example, the sum of three types looks like this:
data OneOfThree abc = Sinistral a | Medial b | Dextral c
It turns out that
Set forms a (symmetric) monoidal category with respect to the coproduct operation. The binary operation takes the disjoint sum, and the neutral element is the initial object. In terms of types,
Either
is a monoidal operation, and an uninhabited
Void
type is its neutral element. Consider that
Either
is addition, and
Void
is zero. Indeed, adding
Void
to the sum of types does not change the set of values of the type. For example,
Either a Void
isomorphic to
a
. Indeed, since the
Void
type is not populated, there is no way to construct the
Right
value. Hence, the only inhabitants of
Either a Void
are
Left
values, which are simply a wrapper over a value of type
a
. Symbolically, this can be written as
a + 0 = a
.
Sums of types are very common in Haskell. In C ++, their analogs (combinations or variants) are used much less frequently for a number of reasons.
First, the simplest sums of types are enums that are implemented in C ++ using
enum
. By equivalent
data Color = Red | Green | Blue
in C ++ will
enum { Red, Green, Blue };
An even simpler sum of types.
data Bool = True | False
in C ++ it is a primitive type
bool
.
Further, the sums of types encoding the presence or absence of a value are implemented in C ++ using various tricks with "impossible" values, such as empty strings, negative numbers, null pointers, etc. In Haskell, the explicit, intentional optional values are written using
Maybe
:
data Maybe a = Nothing | Just a
The type of
Maybe
is the sum of two types. Mentally turn its designers into separate types. The first will take the form:
data NothingType = Nothing
This is an enumeration with a single value of
Nothing
. In other words, it is a singleton equivalent to a type
()
. The second part of
data JustType a = Just a
is a wrapper over type
a
. We could write
Maybe
as
data Maybe a = Either () a
More complex sums of types are fabricated in C ++ using pointers. A pointer can either be null or point to a value of a particular type. For example, in Haskell the list is defined as a (recursive) sum of types:
List a = Nil | Cons a (List a)
In C ++, the same type is written as:
template<class A> class List { Node<A> * _head; public: List() : _head(nullptr) {}
The null pointer here encodes an empty list.
Note that the Haskell
Nil
and
Cons
constructors have turned into two overloaded constructors of the
List
class with similar arguments: no arguments in the
Nil
case, a value and a list for
Cons
. The
List
class does not need a label that would distinguish the components of the sum of types; instead, it uses the special value
nullptr
for
_head
to represent
Nil
.
An important distinction between types in Haskell and in C ++ is that Haskell has data structures that are immutable. If an object was created using a specific constructor, it will remember forever which constructor was used with which arguments. So an instance of the class
Maybe
, created as
Just "energy"
, will never turn into
Nothing
. Similarly, an empty list will always remain empty, and a three-element list will always store the same three elements.
Immunity makes constructors reversible: an object can always be disassembled into its component parts used in its creation. Such deconstruction is carried out by comparison with a sample, which is a particular designer. Constructor arguments are replaced with variable names (or other patterns).
The
List
type has two constructors, so the deconstruction of an arbitrary
List
consists of two corresponding pattern matching. The first sample matches the empty
Nil
list, the second one with the list created with
Cons
. For example, we define a simple function:
maybeTail :: List a -> Maybe (List a) maybeTail Nil = Nothing maybeTail (Cons _ t) = Just t
The first part of the
maybeTail
definition uses the
Nil
constructor as a pattern for matching and returns
Nothing
. The second part uses the
Cons
constructor as a sample. The first argument of the sample is represented by a dash, since we are not interested in the value contained in it. The second argument,
Cons
associated with the variable
t
(hereinafter, we will talk about variables, although, strictly speaking, they are unchanged: the variable that is once associated with the value never changes). The value of the function on this sample is
Just t
. So, depending on how the value of the
List
type is created, it will coincide with one of the samples. If it was created with
Cons
, the function will get both arguments used (the first of which will be ignored).
More complex sums of types are implemented in C ++ by a hierarchy of polymorphic classes. A family of classes with a common ancestor can be interpreted as a sum of types in which the table of virtual functions serves as an implicit label of the component. What Haskell serves as pattern matching is implemented in C ++ by calling the dispatch function.
The reader is rarely seen in C ++ using
union
as a sum of types due to its excessive limitations. You can't even put
std::string
in a
union
, because this class has a copy constructor.
Type algebra
Separately, the product and the sum of the types allow you to define many useful data structures, but the real power comes from their combination.
Let's summarize the above. We considered two commutative monoidal structures underlying the type system. These are the sum of types with a neutral element
Void
and the product of types with a neutral element
()
. It is convenient to imagine them as addition and multiplication. In this case,
Void
zero, and
()
is one.
Let's see how far this analogy goes. For example, is it true that multiplying by zero gives zero? In other words, is any product isomorphic to a
Void
type
Void
?
Are there pairs of, say,
Int
and
Void
? To create a pair, both values are needed. The value of the
Int
type is not a problem, but there is a catch with
Void
: this type is not populated (there is not a single value of this type). Thus, for any type
a
type
(a, Void)
also not populated and, therefore, equivalent to
Void
. In other words,
a*0 = 0
.
Addition and multiplication of numbers are connected by the distribution law:
a * (b + c) = a * b + a * c
Is it for sum and product types? Yes, up to isomorphism. The left part of the identity corresponds to the type
(a, Either bc)
and right -
Either (a, b) (a, c)
Let's present functions which will transform types there and back:
prodToSum :: (a, Either bc) -> Either (a, b) (a, c) prodToSum (x, e) = case e of Left y -> Left (x, y) Right z -> Right (x, z)
sumToProd :: Either (a, b) (a, c) -> (a, Either bc) sumToProd e = case e of Left (x, y) -> (x, Left y) Right (x, z) -> (x, Right z)
The
case of
construction is used for pattern matching within a function. The arrow separates the sample and its corresponding expression. For example, when calling
prodToSum
with an argument
prod1 :: (Int, Either String Float) prod1 = (2, Left "Hi!")
the variable
e
in
case e of
will be equal
Left "Hi!"
. It will match the
Left y
pattern, substituting
"Hi!"
instead of
y
. Since the variable
x
previously associated with
2
, the result of the
case of
construction (and the entire function) will be, as expected,
Left (2, "Hi!")
.
The proof that the functions above are mutually inverse will be left to the reader as an exercise. They only repack the same data from one format to another.
Two monoids connected by distributive law are called
semi-rings in mathematics. This is not a complete
ring , since we cannot determine the subtraction of types. The set of statements true for the generators of the semiring of natural numbers can be transferred to types. Here are some examples:
Numbers
| Types
|
0
| Void
|
one
| ()
|
a + b
| Either ab = Left a | Right b
|
a * b
| (a, b) or Pair ab = Pair ab
|
2 = 1 + 1
| data Bool = True | False
|
1 + a
| data Maybe = Nothing | Just a
|
The list type is of particular interest because it is defined as a solution to the equation. The type defined is found on both sides of the equality:
List a = Nil | Cons a (List a)
Making the usual substitutions and replacing
List a
with
x
, we get
x = 1 + a * x
This equation cannot be solved by traditional algebraic methods, since types cannot be subtracted or divided. Let us recursively substitute the expression
(1 + a*x)
instead of
x
on the right and expand the brackets by distributivity. Will get
x = 1 + a*x x = 1 + a*(1 + a*x) = 1 + a + a*a*x x = 1 + a + a*a*(1 + a*x) = 1 + a + a*a + a*a*a*x ... x = 1 + a + a*a + a*a*a + a*a*a*a...
In the end, we come to an infinite sum of products (tuples), which can be interpreted as follows: the list is either empty,
1
; either consists of a single element,
a
; either consists of a pair,
a*a
; either from a triplet,
a*a*a
, and so on. The formal definition obtained fully corresponds to the intuitive notion of the list as a line, where instead of letters are values of type
a
.
We will return to lists and other recursive structures further, after studying functors and fixed points.
Solving equations with symbolic variables is an algebra! Therefore, these data types are called algebraic (ADT).
Summing up, we give one very important interpretation of type algebra. Note that the product of types
a
and
b
must contain both a value of type
a
and a value of type
b
, which implies a population of both types. On the other hand, the sum of types contains either a value of type
a
,
or a value of type
b
, so it is enough that at least one of them is populated. The logical operations of
conjunction and
disjunction form a semiring, which is in accordance with the following type algebra:
Logics
| Types
|
false
| Void
|
true
| ()
|
a || b
| Either ab = Left a | Right b
|
a && b
| (a, b)
|
This analogy can be deepened and is the basis of the Curry-Howard isomorphism between logic and type theory. We will return to this issue when considering functional types.
Exercises
- Show that
Maybe a
and Either () a
is isomorphic.
- Consider the following sum of types in Haskell:
data Shape = Circle Float | Rect Float Float
To define the area
function on the Shape
type, we use the pattern matching:
area :: Shape -> Float area (Circle r) = pi * r * r area (Rect dh) = d * h
Implement Shape
in C ++ or Java as an interface and create two classes: Circle
and Rect
. Then write the area
as a virtual function.
- (Continued) It’s easy to add a new
circ
function that calculates the perimeter of a Shape
. In Haskell, the definition of the Shape
type will remain unchanged; The following code can be added anywhere in the program:
circ :: Shape -> Float circ (Circle r) = 2.0 * pi * r circ (Rect dh) = 2.0 * (d + h)
Add circ
to your C ++ or Java program. What parts of the original program had to be modified?
- (Continued) Add a new Shape
Square
Shape
type and make the appropriate updates to the rest of the code. What had to change in Haskell? What about C ++ or Java? (Even if you don't know Haskell, the changes should be pretty obvious.)
- Show that the formal identity
a + a = 2 * a
holds for types (up to isomorphism). Recall that 2
in the type language corresponds to Bool
(see the table above).
Thanks
The author is grateful to Gershom Bazerman for reviewing the post and helpful comments.