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
tox2
. - Assign the result of
y * y
toy2
. - 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:
>>> :{
| 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 orwhere
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:
>>> :{
| 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.