Subscribe to get more articles about programming languages

Keyword of the day: LET

zx spectrum LET key

The let keyword is used in many programming languages to declare identifiers as constants or expressions. It appeared over 50 years ago and became quite widespread since then.

First appearance

LET keyword appeared in the original version of the BASIC programming language from 1964. The keyword was used to declare and assign variables like so:

10 LET PI=3.13
20 LET R=5
30 LET L=2*PI*R

This little program would print a length of a circle L with radius R equal to 5.

As time passed the BASIC language interpreters became smarter and made it possible to omit writing “LET”. For example in Visual Basic one would write just R=5 without LET.

In functional programming

Around 1970s the let keyword appeared in functional programming languages. In contrast to BASIC the meaning here is a bit different: the names declared with “let” are permanently bound in the adjacent scope. This type of expression is sometimes called a “let binding” as opposed to the variable declaration, because the declared identifier is not changeable inside the scope.

In imperative programming world this is similar to local scope constant declarations in languages like C++ or Java. People would still sometimes refer to such names as “variables” although they are “read-only”, “immutable” or “final”, i.e. do not vary.

This is how it looks like:

In MLs

let pi = 3.14
    in let r = 5.0
           in 2.0 * pi * r

This is F# syntax, but this would look very similar in OCaml and Haskell. Here in the scope of the bottom expression identifiers “pi” and “r” are bound to values above.

With the F# light syntax the same code can be written like this:

let pi = 3.14
let r = 5.0
let l = 2.0 * pi * r
printfn "%f" l

Since functions are first-class citizens “let” can also be used to declare functions. “let rec” must be used in F# for recursive functions or mutual recursion when one function depends on another and vice versa:

let rec visit tree =
    match tree with
    | Composite(subtrees) -> visitSubtrees subtrees
    | Leaf(text) -> visitLeaf text
and visitSubtrees subtrees =
    for tree in subtrees do
        visit tree
and visitLeaf text = printfn "%s" text

This example is a depth-first tree traversal where each type of tree nodes is handled by a separate function potentially recursively calling back to visit for a subtree.


(let [pi 3.14
      r 5]
     (* 2 pi r))

This example is in Clojure, but they have a similar syntax in Scheme, Common Lisp, Racket and Emacs Lisp. Scheme language had this expression by 1978. Note the absence of equality “=” sign after the names which is different from the rest examples listed here. Another feature here is that “let” can create more than one variable at once: a list of pairs is interpreted as the names and values.

Clojure also features “destructuring” assignment inside let declarations where the code above can be rewritten as:

(let [[pi r] [3.14 5]]
     (* 2 pi r))

Here a vector on the right side [3.14 5] provides values to 2 new variables at once.


In the similar manner let keyword can be used inside the LINQ query syntax to name intermediate results of subqueries:

from book in books
let title = book.title
let filmVersions = (
    select movie in movies
    where movie.plotOrigin == book
    select movie
where filmVersions.Length > 0
select title;

This code supposes that we have 2 collections: books and movies, and returns all the book titles that were filmed and thus have a corresponding movie entry. Although this sort of query could be rewritten using a JOIN (which might be more optimal) this example is illustrating the abilities of “let” to give explicit names in the SQL context.

Mathematically speaking

In classical lambda calculus this let expression is a syntax sugar for a lambda abstraction applied to a value:

((λpi.λr.(2 * pi * r))3.14)5

In this expression a function with 2 arguments gets passed 2 values on the right: the first value (3.14) is bound to “pi” inside the body, the second value (5) is bound to “r”.

However in the typed lambda calculus (Hindley–Milner type system) the let expression plays an important role. Expressions which need to be annotated with types have the same form as the classical lambda calculus expressions, plus a let-expression in the form:

let x = expr1 in expr2

where expr2 supposedly has one or more occurrences of “x” to be substituted with its given value - expr1.

The reason to add the let expression to the syntax is to allow “let polymorphism”. The rules of the Hindley-Milner type inference involving an argument of an application expression only allow “monotypes”, but they allow “polytypes” for an argument of a let expression.

“monotypes” are primitive types and type constructors applied to monotypes, in other words fully specialized generic types, for example: Map<string, List<int>> is a monotype. “polytypes” can also include not completely specialized generic types like: Map<string, List<T>> or maybe Map<TKey, List<TContent>>.

So in the form (λx.expr2)expr1, which represents an application expression, “expr1” and “x” can only have a monotype, and so all occurrences of “expr1” in “expr2” after substitution must have surroundings compatible with that particular fully specialized type. On the contrary in the form let x = expr1 in expr2 the variables “expr1” and “x” can have polytypes, so that different replacements of “x” in “expr2” might get different inferred types depending on surroundings, i.e. behave polymorphically.

Let’s write some pseudo-Swift to demonstrate how this could look. Imagine that we have written 2 competing generic list reversal functions (for example one of them uses some parallel optimizations), and we want to test it on lists of different types. A test method sketch could look like so:

func parallel_reverse(_: List<T>) -> List<T> { ... }

func test() {
  let reverse = parallel_reverse in {
    let inp_int_list = [1, 2, 3]
    let out_int_list = reverse(inp_int_list)
    // out_int_list type is: List<Int>

    let inp_str_list = ["a", "b", "c"]
    let out_str_list = reverse(inp_str_list)
    // out_str_list type is: List<String>

The point here is to show that if let polymorphism semantics took place the inferred return types would be different yet correct. Each call would become “specialized” according to a given input type.

Using the let keyword

Swift and Rust vs JavaScript

In both Swift, Rust and JavaScript ES6 the let keyword can be used to declare local scope variables. Moreover it has value decomposition (aka “destructuring”) superpowers that allow one-line assignment of internal content to variables:

let name = ("John", "Doe")
let (first, last) = name

The key difference is that in Swift the let keyword declares immutable names, but in JavaScript they are mutable:

    let pi = 3.14;
    pi = 3.1415;

In Rust it would also be immutable, but it’s possible to opt-in for mutability like so:

let mut pi = 3.14;
pi = 3.1415;

Without “mut” a compilation error happens on trying to reassign a new value.

Standard ML meets Scala

In Standard ML binding with a let keyword looks similar to F#, but requires an extra “val” keyword:

    val pi = 3.14
    val r = 5
    2 * pi * r

Scala might have been influenced by this as its syntax uses “val” instead of “let” to declare immutable variables.

Does the let keyword make sense?

The let keyword is short and easy to type. Having a short keyword is important, because the name declaration ability is one of the most used abstraction and decomposition features in programming languages.

But does this particular word let make sense? Is it human readable? Probably not. It’s easy to propose words that are more meaningful to the operation of declaring names: define, declare, assign, bind and so on. The “let” word carries almost no meaning related to the identifier declaration, so it’s mostly conventional.

Is it really a requirement to have it in a programming language? Not really, because in almost all cases the equality sign follows. That “=” sign should be enough of a hint to the parser in order to understand the code. Yet having an extra keyword like “let” simplifies parsing and makes it a little bit easier to create syntax analysis tools.

P.S. Bash magic

In Bash and compatible shells a “let” builtin command can be used to evaluate integer arithmetic expressions:

let 'pi = 314' 'r = 5' 'l = 2 * pi * r / 100'
echo $l

The passed strings are evaluated as expressions and the resulting variables are placed into the global scope.


Subscribe to get more articles about programming languages