Skip to content

Thinking Functionally: How types work with functions

Aydın ERAYDIN edited this page Jan 5, 2020 · 5 revisions

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>)

Function types as parameters

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>).

Functions as output

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

The Unit type

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.

Forcing unit types with the ignore function

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);

Other types

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 a string and an int. 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 and Seq. IEnumerable are potentially infinite whereas Seq has many of the behaviours of IEnumerable 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 and None.
    Option<int> option = Some(1);
    Option<int> option = None;

NEXT: Currying