-
-
Notifications
You must be signed in to change notification settings - Fork 414
Thinking Functionally: How types work with functions
Now that we have some understanding of functions, we'll look at how types work with functions, both as domains and ranges. This is just an overview.
First, we need to understand the type notation a bit more. We’ve seen that the arrow notation "->" is used to show the domain and range (domain -> range
which is the same as Func<domain, range>
)
A function that takes other functions as parameters, or returns a function, is called a higher-order function (sometimes abbreviated as HoF). They are used as a way of abstracting out common behaviour. These kinds of functions are extremely common in language-ext; most of the types use them. You may have already experienced them from using LINQ functions like Select
and Where
.
Consider a function EvalWith5ThenAdd2
, which takes a function as a parameter, then evaluates the function with the value 5
, and adds 2
to the result:
static int EvalWith5ThenAdd2(Func<int, int> fn) =>
fn(5) + 2;
You can see that the domain is (int -> int)
(Func<int, int>
) and the range is int
. What does that mean? It means that the input parameter is not a simple value, but a function, and what's more is restricted only to functions that map ints to ints. The output is not a function, just an int
.
Let’s try it:
Func<int, int> add1 = x => x + 1; // define a function of type (int -> int)
int y = EvalWith5ThenAdd2(add1); // y == 8
add1
is a function that maps ints to ints, as we can see from its signature. So it is a valid parameter for the EvalWith5ThenAdd2
function. And the result is 8
.
Here’s another one:
Func<int, int> times3 = x => x * 3; // a function of type (int -> int)
int y = EvalWith5ThenAdd2(times3) // y == 17
times3
is also a function that maps ints to ints, as we can see from its signature. So it is also a valid parameter for the EvalWith5ThenAdd2
function. And the result is 17
.
Note that the input is sensitive to the types. If our input function uses floats rather than ints, it will not work. For example, if we have:
Func<float, float> times3float = x => x * 3.0; // a function of type (float->float)
EvalWith5ThenAdd2(times3float);
Evaluating this will give an error, meaning that the input function should have been an int->int function (Func<int, int>
).
A function value can also be the output of a function. For example, the following function will generate an “adder” function that adds using the input value.
static Func<int, int> AdderGenerator(int numberToAdd) =>
x => x + numberToAdd;
The signature is:
int -> (int -> int)
which means that the generator takes an int, and creates a function (the “adder”) that maps ints to ints. It can be represented as Func<int, Func<int, int>>
. Let’s see how it works:
var add1 = AdderGenerator(1);
var add2 = AdderGenerator(2);
This creates two adder functions. The first generated function adds 1 to its input, and the second adds 2. Note that the signatures are just as we would expect them to be.
add1 = int -> int
add2 = int -> int
And we can now use these generated functions in the normal way. They are indistinguishable from functions defined explicitly
int x = add1(5); // x == 6
int y = add2(5); // y == 7
When programming, we sometimes want a function to do something without returning a value. Consider the function PrintInt
, defined below. The function doesn’t actually return anything. It just prints a string
to the console as a side effect.
void PrintInt(int x)
{
Console.WriteLine(x);
}
So what is the signature for this function?
We can't put void
into a Func
in C#. That is because in type-theory void
represents a type with no possible values (it has no domain). And therefore it can't be instantiated. This has lead to the disaster zone which is Action
and Func
, where Action
is a Func
that doesn't return a value. If we go back to the core concepts of mathematical functions from an earlier episode, we must have a "range" or a "codomain" for our "domain". i.e. domain -> range
.
So what is the range
when we have nothing to return? That's where Unit
comes in. Unlike void
it can be instantiated. It is a type that can have one possible value, itself: unit
. If you think of how bool
can have two possible values: true
and false
, then Unit
is a type that has only one possible value: unit
.
Even if a function returns no output, it still needs a range. There are no "void" functions in mathematics-land. Every function must have some output, because a function is a mapping, and a mapping has to have something to map to!
So in language-ext, functions don't return void
they return Unit
. You should get used to using Unit
instead of void
as a matter of 'good hygiene'. You will find that it is very useful as you become more experienced at functional programming in general.
Unit WhatIsThis() => unit;
The signature of this should be Unit -> Unit
. But if we want to represent that as a Func
then it will be Func<Unit>
. So what's going on? In functional languages the unit type is often represented as ()
. So when you see WhatIsThis()
with the trailing ()
you can read that as "pass the unit value to the function WhatIsThis
". Unfortunately C# interprets that slightly differently, and considers ()
as an invocation with zero values. This is one of the areas where C# method signatures don't quite match our functional ideals.
To represent this fully we'd need to write:
Unit WhatIsThis(Unit _) => unit;
But that's overkill and doesn't help anybody. However when working with Func<domain, range>
, it can sometimes be useful for the domain to be a Unit
. This happens rarely, but shouldn't be ignored. The most useful aspect of Unit
is when representing the range, and returning a concrete codomain rather than an empty one.
In some cases the compiler requires a unit type and will complain. For example, both of the following will be compiler errors:
Unit DoSomething() => 1 + 1;
You can use the ignore
function in the Prelude
to ignore the result of computation whilst maintaining the expression:
Unit DoSomething() => ignore(1 + 1);
The types discussed so far are just the basic types. These types can be combined in various ways to make much more complex types. A full discussion of these types will have to wait for another series, but meanwhile, here is a brief introduction to them so that you can recognise them in function signatures.
-
The “tuple” types. These are pairs, triples, etc., of other types. For example
("hello", 1)
is a tuple made from astring
and anint
. The comma is the distinguishing characteristic of a tuple – if you see a comma in C#, it is almost certainly part of a tuple! (parameter lists can be seen as tuples if you think about it). -
The immutable collection types. The most common of these are lists (
Lst
), sequences (Seq
), arrays (Arr
), maps (Map
), and sets (Set
). Lists and arrays are fixed size, while sequences come in two flavours:IEnumerable
andSeq
.IEnumerable
are potentially infinite whereasSeq
has many of the behaviours ofIEnumerable
without the potential downside of multiple evaluations (although this rules it out of infinite sequences).
Lst<int> list = List(1, 2, 3);
Lst<string> list = List("a", "b", "c");
Seq<int> seq = Seq(Range(1, 10));
Arr<int> arr = Array(1, 2, 3);
- The option type. This is a simple wrapper for objects that might be missing. There are two cases:
Some
andNone
.
Option<int> option = Some(1);
Option<int> option = None;