So far, we mostly dealt with functions that operate on numbers, or larger structures built from numbers. There were a few occasions where we almost stumbled across a higher level of abstraction which is provided by all the languages of the ML family (and some more), and indeed, all the examples have been chosen in such a way as to navigate us around it. Not because the underlying concepts would be overly complex or difficult to master, but rather because they deserve being discussed separately.
While it is practically never necessary, one may explicitly specify the type of a variable rather than having it derived by OCaml's type inference. That is, one could define:
Definitions with types specified explicitly |
---|
|
Sometimes, this is useful for providing more documentation in the code (what is it actually that I am passing around?), and sometimes, it helps discovering errors. Some cryptic error message may be resolved by putting extra type specifiers into the code to discover where the system's opinions on a type differ from ours. This may be especially useful in conjunction with higher order functions and forgotten arguments.
Actually, OCaml's close relative SML does not know separate sets of
operators for integer and floatingpoint arithmetics. Rather than
"*
" and "*.
", we only have "*
",
which defaults to integer addition. Should we want floatingpoint
addition, we have to tell the system so by explicitly providing that
type, unless this can be derived by using *
in a context
where one of the arguments is known to be floatingpoint anyway:
Explicitly specified types in SML |
---|
|
So, let us just define a function that swaps the entries of a pair of integers:
Swapping a pair of integers |
---|
|
How do we define a function that swaps a pair of a string and a boolean value?
Swapping a string * bool pair |
---|
|
Now, as the types tell us, we can use our integer pair swapping functions only on pairs of integers, and our string/bool pair swapping functions only on string/bool pairs. But what would we get if we just made a definition like:
Swapping as a polymorphic function |
---|
|
If we ignore the type of this swap_pair
function for
now, we at least note that if we make the definition without
explicitly referring to a type, then we get a variant that can be used
on all sorts of pairs. Actually, while there are pairs of many
different kinds, all we have to know in order to swap the entries is
that we are dealing with a pair, any pair. We do not really
have to look into the contents of that "container" data structure for
this particular operation: this indeed also holds from the machine
perspective: the very same sequence of machine instructions will swap
any pair. This idea of "I do not have to look into it, but know how to
do it just from the structure" has far-reaching consequences and goes
by the name of parametric polymorphism.
OCaml has a funny way to express the type of a polymorphic
function: it will use so-called type jokers
. In
mathematics, one conventionally uses Greek letters for type variables,
and this funny apostrophe-letter notation is supposed to be read in
that way, "'a
" denoting "alpha", "'b
"
denoting "beta", and so on. But actually, OCaml sticks to using the
Latin alphabet 'a
to 'z
, then
'a1
to 'z1
, and so on, if there is need for
really really many jokers. Usually, there is not. Should we ever have
to specify a type joker of our own, we can use an arbitrary name that
is formed like a variable name, and prepend a "'
"
character. So, we may use meaningful joker names such as
'position
if we want to.
Whenever an expression whose type is polymorphic (that is, contains one or more type jokers) is used in some given context, OCaml checks whether it is possible to specialize the jokers in an appropriate way to match that context. If this succeeds, the original expression behaves as if it had the specialized type right from the beginning. Note that this check already occurs at compile time, not only at run time, and this is the reason why the OCaml language is called "statically typed".
There are some slight over-simplifications in that description of type inference, and actually, it is a bit difficult to describe it in a completely correct way without resorting to some mathematical formalism. However, one should know that it is easy to get used to its behaviour and develop an intuition for it by just playing around with it for some time. In a certain sense, this is very "natural".
We presumably just need a few more examples:
More examples for parametric polymorphism |
---|
|
To make things a bit more complicated, there are some functions and operators in OCaml which appear in the guise of being parametrically polymorphic, but in fact are not - far from it. The most prominent examples are the comparison operators: evidently, just comparing two integers should be in some sense much simpler than lexicographically comparing complex composite values. So, OCaml is cheating a bit here. Even worse, it pretends that something like equality comparisons existed for functions. It is important to know about these cases of mundane ad-hoc polymorphism in the guise of parametric polymorphism. Actually, the examples are quite few, and the distinction does not matter too much in most applications, but it is important to know it's there.
Examples of "Fake" parametric polymorphism in OCaml |
---|
|
It is interesting to see how polymorphism interacts with abstracting out some sub-functionality from a given function:
Why parametric polymorphism is important - an Example |
---|
|
Note that by looking at what's common among "pointwise sum",
"pointwise product", and other similar functions, and abstracting out
that which is special for every single case, we were able to find a
function that just represents the abstract idea behind "doing
something pointwise". The definition of the function
pointwise
is interesting: it does not involve any numbers
or tuples or anything else - just functions. All there is to it is
which function's value is passed on into which other function, and how
values are distributed between functions. Such a function is called a
combinator. There is quite a lot of mathematical theory on
combinators. Two other (much simpler) combinators, which we already
have seen in this lecture, are identity
and
constantly
, and indeed, one can have a lot of fun with
those three only.
In a certain sense, one may say that polymorphism just kind of solves the same problem that C++ claims to solve via templates. At least, pretty much everything that is in the C++ Standard Template Library (STL) is just parametrically polymorphic. However, the C++ approach does come with a few serious flaws. To see this, let us look at the following example:
Parametric Polymorphism and C++ |
---|
|
Analyzing the binary |
|
If we compile this and analyze the binary with utilities such as
nm
and objdump
, we see that the compiler
indeed did generate two different swap functions. However, the code is
precisely the same, even up to the number of padding NOP instructions
in both cases! So, indeed, at least the GNU C++ compiler (maybe others
as well) has the potential to generate great code bloat when it comes
to expanding templates. Actually, this was even much much worse with
earlier versions of C++ compilers, where template instantiation meant
that every single method and function that was defined for
that particular template was expanded into code - whether it was
actually used or not. Even nowadays, one can still find references to
the claim that all complaints about C++ templates generating code
bloat are out-dated, as C++ compilers will no longer compile "dead
methods" that never get called into the binary. That may well be so by
now, but still, C++ templates have a tendency to introduce quite ugly
unnecessary code bloat through repetitive expansion: there really is
no difference between swapping a pair of pointers to integers, or
swapping a pair of pointers to whatever else, so there is no reason at
all why the same code should be generated multiple times. (Actually,
having the same code more than once is not especially nice to
instruction caches and therefore CPU memory bandwidth, as well as
dynamic branch prediction.) Of course, some template authors are aware
of this problem and design their templates in such a way that the
parametrically polymorphic aspects are only compiled once, and every
instantiation of that template is just a front-end to the really
parametric code. But this inevitably requires some ugly casts, which
templates were designed to prevent. So, if we see it that way, then
templates split the C++ programmers into two classes: template users
which never are supposed to use casts, and proficient template
implementors, who may and must use casts to deal with parametric
polymorphism. A very unsatisfactory state of affairs, actually.
To be fair, the C++ template idea is somewhat more general. If we swapped a pair of a pointer and something larger, the code would be different, as the pair container would normally truly contain a copy of its entries, and not just reference them. While C++ templates are used in perhaps the overwhelming majority of cases to deal with parametric polymorphism, they are general enough to also handle ad-hoc polymorphism. Whether this actually is a good thing, in particular as this kind of prevents us to handle parametric polymorphism in a really nice and elegant way in C++, is up to the audience to decide.
One of the basic ideas of object-oriented programming is that objects are entities which satisfy certain contracts that are specified through their classes, which themselves may form a hierarchy of abstraction built on more generic contracts, base classes. Looking in the opposite direction, one can always specialize and subclass a given class.
So, from the object-oriented point of view, it would be natural to
treat the natural numbers, that is the numbers 1,2,...
without any artificial and arbitrary machine limit - as objects in a
class which we may call, say, NaturalNumbers
.
However, this approach would be conceptually flawed. The reason is
that through subclassing, one could always introduce a new class of
entities which technically speaking also are
NaruralNumbers
, through a is-a
inheritance
relationship, but which are not in the numbers
1,2,3,...
. Mathematicians like to define the natural
numbers as follows:
Zero is a natural number.
Every natural number has a successor, which again is a natural number.
Zero is not the successor of any natural number.
If two numbers have the same successor, they are the same.
Nothing else is a natural number. So, the natural numbers are the "smallest" structure that satisfies the above rules.
The important point here is the "nothing else" statement. In fact, this is equivalent to the principle of induction over natural numbers. So, if we drop that statement, as we inevitably would do by modeling natural numbers in a class, we lose the power to do stringent reasoning over the natural numbers.
This may be even clearer with boolean values: there just are two of
them: true
and false
. No one would ever
think about sub-classing a Boolean
class in order to
implement extensions to boolean values, say most likely
,
presumably
, presumably not
and such. All
hell would break loose if values like presumably
crept up
in a program expecting a boolean value to be either true
or false
.
So, there is a conceptual difference between the idea of a class, which roughly says: "I do not really know in detail what it is that I am dealing with, but it promised me to provide a particular interface, and as long as it does this, I am fine", and the idea of mathematical exclusive definitions which are made for stringent reasoning over all possible cases. Each has its place in this world, and if possible, one should perhaps not try to use the one as a substitute for the other. If a system supports exclusive definitions, it usually can also check automatically whether the case analysis is complete; when using classes for the same task, this certainly cannot be possible, as they are built for extensibility: they are abstract contracts which may be satisfied by a lot of things, and we may not know all the possibilities from the beginning. On the other hand, when using exclusive definitions instead of classes, we may end up in a situation where we eventually find out that actually, we should consider more cases than the ones we provided initially. But then, we would have to make changes all over the place: wherever we go through all the possibilities in our code, we have to add a further one.
So much about the theory. Now, let us see how this actually looks like in OCaml.
Defining new types |
---|
|
First, we introduce a so-called "variant" type
"suit
", which has four constructors: Clubs
,
Diamonds
, Hearts
, Spades
. One
may think of this as a four-valued equivalent of the two-valued
bool
type, which has "constructors" true
and
false
. As a general rule, the names of all constructors
must begin with an uppercase letter.
The suit
data type is mostly equivalent to a C-style
enum
, as those constructors just represent four different
values. The major difference to an enum
is type safety: a
C enum
value is just a number. Our constructors cannot be
accidentally confused with numbers. So, we may then proceed likewise
to define a cards' color, which is Red
or
Black
. There is an obvious function mapping a card's suit
to its color. We can either define this the old-fashioned way using
if ... then ... else
, or we can instead use the matching
operator, which allows us to just list all the cases. Conceptually,
this seems to be similar to a C switch/case
, but
actually, this will turn out to be far more powerful. The syntax may
require some getting used to, but is quite suggestive: we just list
all the cases, one after another, with the pattern at the left hand
side. Note that we may also use variables at the left hand side. Then,
the corresponding right hand side case is used when the left hand side
structure can be made to match the argument of match
by
binding variables appropriately. Here, this is quite trivial in the
suit_color_v4
example: we just use a variable to match
"any remaining cases". Note that x
does not appear at the
right hand side. With more complicated patterns, it may. There is a
convention to use the special name "_
" (underscore) for
such "don't care" variables.
Constructors play the role of "hooks" that hold data and allow us to define structural patterns on the left hand side of a matching rule. This gets much more interesting with constructors that carry arguments:
Constructors with arguments |
---|
|
Note how the structured value to the left of a matching rule
provides a pattern against which the argument to match
is
checked. When more than one rule could match, the first one is
taken. We also see this here with the default rule. If we moved this
last line to the first line, every card's value would be
"10
".
Things get even more interesting with recursive type definitions. We may use the type which we are defining right now inside the argument type definition of a constructor to define hierarchically structured values. This looks as follows:
Recursive variant types |
---|
|
Furthermore, variant types may be polymorphically parametric. We already have encountered parametric recursive types: the list is the prime example. Let us just re-invent the list with an own definition to see how polymorphic variant types are introduced and how they behave.
Polymorphic recursive variant types |
---|
|
Another kind of data structure provided by OCaml which will become very important in programs that are less mathematically inclined, but have to deal with a lot of bookkeeping, is the record. This is roughly equivalent to a C struct. Actually, with all the background we have developed so far, there is not too much to be said about records. They are defined and anonymously constructed as follows:
Records |
---|
|
One should know about records that it is not permitted to have two records defined in the same OCaml module which both have an entry with the same name. This is quite unfortunate, and considered a pretty stupid design bug of OCaml. Again, the reason is type inference, the idea being that by just mentioning a single slot, the system should be able to derive the type of the record.
This concludes our discussion of data types in OCaml for this crash course. Beginners usually need some practice to get used to programming by pattern matching and case analysis. The exercises will give ample opportunity for this.
Define a variant type that represents a binary tree with a value in every node.
Define a function on the tree of the previous exercise that returns its maximal depth.
Define another function on this tree data structure that collects the data values of all its nodes in a list.
Define a function "scan_list property list
what_if_not_found
" that scans a list for the first element on
which the function property
is true, producing the value
what_if_not_found
if no such element was found, test it,
and explain its type. Hint: use ocamlbrowser
to look up
the definitions of List.hd
and List.tl
to
see how to use the list constructor "::
" in pattern
matching.
Define a function eval_bterm term var_occupations
that
takes as arguments a boolean_term
(as defined in the
text) as well as a list of pairs
(variable_name,boolean_value)
of variable occupations,
and produces the boolean value (either true
or
false
) represented by this term which one gets by
substituting the variables with the values provided and reducing
afterwards.
Define a function that takes as argument a list and produces a pair of two lists, one containing the elements with even index in the original list, the other one containing the elements with odd index.
Look up the documentation of List.map
and
List.map2
and define a corresponding function
array_map2
on arrays. (Hint: use Array.init
)
Define a function that takes as arguments a comparison function as well as two lists, which can be assumed to be ordered with respect to this comparison function, and produces a joint list containing all the elements from both lists, also ordered with respect to the comparison function.
Use the functions from the last two exercises to define your own list sorting function.
Define a function that reverses a list. Note: if the runtime of your function scales quadratically with the length of the list, you missed something obvious. Hint: What do you get if you take a stack of cards and repetitively remove the topmost card and add it to another stack?
Define a function mapping a boolean_term
to a
string list
of all the variable names in it, without
repetition.
Define a function that tries to find an occupation of variables
for which a given boolean_term
is true. Note: this is not
required to be overly efficient. (Again, there would be an entire
mathematical theory behind that!)
(Difficult!) As briefly mentioned, there is an entire
mathematical theory behind combinators. Show that it is possible to
complete the following definition in such a way that the result
behaves just like the composition function, where the expression that
goes in the place of ???
is built exclusively out of the
functions pointwise
, identity
, and
constantly
(as well as parentheses, of course):
Some Combinatory Logic |
---|
|