Skip to content

Let

Here is the implementation of our veclen function in Haskell, first using let:

veclen :: (Double, Double) -> Double
veclen (x, y) = let x2 = x * x
                    y2 = y * y
                in  sqrt (x2 + y2)

You can read this quite naturally in English: "Let x2 have the value x * x, and let y2 have the value y * y in the expression sqrt (x2 + y2)". The entire function definition is a scope. Thus, all definitions inside the let block and the expression after in can refer to all variables defined in the let block, here x2 and y2, and to all function arguments, here x and y. They can also refer to all values defined in surrounding scopes.

If you're used to programming in imperative languages, you may also be tempted to read this function definition as:

  • Assign the result of x * x to x2.
  • Assign the result of y * y to y2.
  • Finally return the result of sqrt (x2 + y2).

This sequential view of the execution of the veclen function is the wrong mental model! It may get you into trouble when reading other people's code, and will limit the code you are able to write yourself. Here's a definition of veclen that works great in Python (even though it's bad programming style):

def veclen(x, y):
    x2 = x * x
    y2 = y * y
    x2 = x2 + y2
    return sqrt(x2)

If we try the same in Haskell, it doesn't work:

GHCi
>>> :{
  | veclen (x, y) = let x2 = x * x
  |                     y2 = y * y
  |                     x2 = x2 + y2
  |                 in  sqrt x2
  | :}

<interactive>:48:21: error:
    Conflicting definitions for ‘x2’
    Bound at: <interactive>:48:21-22
              <interactive>:50:21-22

Let me explain the fundamental difference between Python (or any other imperative language) and Haskell (or any other purely functional language).

In an imperative language, variables are names for memory locations.

Variable assignment stores values in these memory locations. When using a variable in some expression, we read the variable and use the read value to calculate the value of the whole expression. This is what leads to the sequential execution model in imperative languages: If we want to use some variable's value in some expression, we better make sure that we store this value in the variable before evaluating the expression, using variable assignment.

The Python version of veclen thus makes perfect sense. We store the result of x * x in the variable x2. We store the result of y * y in the variable y2. Now we read both x2 and y2 and add their values together. We store the result in x2, thereby overwriting the old value of x2, and finally we read the new value of x2 and pass it as argument to sqrt. Since variables are names for memory locations, it is perfectly fine that they contain different values at different times during the execution of our program.

In purely functional languages, variables are names for expressions.

Note the difference: Even though the runtime system of a functional language also stores the values of these expressions in some memory locations, this concept of memory locations does not exist at all at the language level. Once again, functional languages are much closer to mathematics, where we also say things like "let \(x = \sqrt{2 + 7}\)". We define \(x\) as a shorthand for the value of the expression \(\sqrt{2 + 7}\). In particular, we generally do not define \(x\) to be one value, and then define \(x\) to be a different value at a later time, at least not within the same context. (This idea of contexts will become important in a minute. In programming languages, we call these contexts scopes.)

Now if we look at our problematic definition of veclen, translated into plain English, this definition says, "Let x2 = x * x, let y2 = y * y, and also let x2 = x2 + y2 in the expression sqrt x2." We now have two conflicting definitions of x2 within the same context, and GHCi rightly complains that it doesn't know how to interpret the expression sqrt x2 because it cannot decide for us which definition of x2 we are referring to.

So, in Haskell, "assignments" aren't really assignments as in imperative languages. Instead, they name values of expressions. Within a given scope,

  • We cannot use the same name for different expressions, and
  • We can refer to any name defined within the same or any surrounding scope.

This has an important consequence:

The ordering of the definitions in a let block or where block (next section) is irrelevant.

Therefore, both of the following are perfectly fine:

veclen :: (Double, Double) -> Double
veclen (x, y) = let x2  = x * x
                    y2  = y * y
                    sum = x2 + y2
                in  sqrt sum

and

veclen :: (Double, Double) -> Double
veclen (x, y) = let sum = x2 + y2
                    x2  = x * x
                    y2  = y * y
                in  sqrt sum

In both cases, the definition of sum refers to x2 and y2, which are defined within the same let block.

To see that this really works, here's the demonstration in GHCi:

GHCi
>>> :{
  | veclen (x, y) = let sum = x2 + y2
  |                     x2 = x * x
  |                     y2 = y * y
  |                 in  sqrt sum
  | :}
>>> veclen (3,4)
5.0

Haskell's evaluation model that allows this works roughly like this: The whole function invocation veclen (x, y) has the value sqrt sum. In order to calculate this, our program needs to figure out which value sum refers to. In this case, it finds a definition of sum right in the current scope, in the current let block. If this were not the case, it would look for an expression with the name sum in bigger and bigger surrounding scopes until it finds such an expression. Here, the definition sum = x2 + y2 tells us how to calculate sum if we know x2 and y2, so we need to find out what expressions x2 and y2 refer to. Again, we start looking for definitions of these names within the current scope and, if we don't find such definitions in the current scope, in bigger and bigger surrounding scopes. Here, we find the definitions x2 = x * x and y2 = y * y in the current let block. To calculate these, we need to know what x and y are. These are the two components of the pair that was passed to veclen as its argument. We have now found all the pieces and retrace our steps to first calculate x2 from x, y2 from y, sum from x2 and y2, and finally the return value of veclen (x, y) from sum.

I hope this is clear enough. If you think you get it but need some time to get comfortable with this departure from viewing variable definitions as a sequence of assignments, then you should probably skip the rest of this section and move on to the next section on where blocks. If you either need another way of looking at the way Haskell deals with variable definitions or you are ready for a bit of the underlying theory, then keep reading.

Computing with Functions Only

The ability to refer to variables in expressions that come before the definitions of these variables may seem strange if you're used to programming imperatively. However, there is a part of the semantics of imperative languages where the ordering of definitions doesn't matter either: function definitions.

In most imperative languages—with the notable exception of C, C++, and a few more dinosaurs in the family of programming languages—we can happily define two functions f and g, and each can call the other no matter which one is defined first. An example in Python:

def f(x):
    if x >= 10:
        return x
    else:
        return g(x + 1)

def g(x):
    if x >= 10:
        return x
    else
        return f(x * 2)

Note that f calls g even though g is defined after f.

Now, \(\lambda\)-calculus, which provides the theoretical underpinnings for functional programming, tells us that we can in fact program entirely with functions: There are no values, only functions. It is also known that this model of computation has exactly the same expressive power as Turing machines, which provide the theoretical underpinnings for imperative programming and which you should have learned about in CSCI 2115.

In Haskell, we write a one-argument function as

f :: Int -> Int
f x = x + 1

How should we interpret the expression

x :: Int
x = 5

then?

One way to look at it is as defining the value x = 5. The other way is as defining a function x with zero arguments that, when called, returns the value 5.

In Python, we would write such a function as

def x():
    return 5

We could now go back to our Haskell implementation of veclen and mimic it in Python:

import math

def veclen(x, y):
    def sum():
        return x2() + y2()
    def x2():
        return x * x
    def y2():
        return y * y
    return math.sqrt(sum())

Et voilá, we have an implementation of veclen where sum depends on x2 and y2 and, just as in Haskell, x2 and y2 are defined after sum. This may seem like a dirty little trick, but thinking about variables as functions that return their values is in fact the correct mental model to understand Haskell's evaluation model, especially once we talk about lazy evaluation, which enables some really elegant programming patterns.