There is definitely an efficiency argument between let
and let*
. But the main reason why we have let
is historic, due to the relationship with lambda
.
let
is easier, simpler and more efficient to implement in a code-walking Lisp interpreter. This is particularly true if there is some half decent data structure for environments, rather than just an assoc
list.
Suppose that the interpreter implements environments as a chain of objects. So for isntance (let (a b) (let (c d) (let (e f))))
will add three environment nodes to the environment chain. Each of these new nodes contains two bindings (in an individual list, or hash table or whatever).
When we interpret the let
form, we can evaluate all of the initializing expressions in the incoming environment chain. We can create a single environment node for all of the new bindings, in a single operation, and populate the bindings with the values.
When we interpret the let*
form, we cannot do this. For each successive binding mentioned in the let*
, we have to call make-environment
, populate it, and add it to the environment chain, so that we then interpret the next initialization form in the extended environment.
This leads to a degenerate run-time environment structure. Whereas (let (a b c d ...))
produces one environment object with a nice hash table in it, (let* (a b c d ...))
produces an inefficient chain that requires O(n) traversal to find a binding.
We can erase the difference between the interpreter performance of let
and let*
, but only by dragging the performance of let
down to let*
. If we represent the environment chain as a naive assoc
list, then this issue doesn't matter; all variable lookups are a linear search. In fact let*
is then easier to implement: evaluate each init expression, and push a new binding onto the current environment.
Now, enter compilation into the picture. A Lisp compiler can use a devilish trick to implement let*
by just making a few tweaks to the compilation strategy for let
. To compile let*
, we can allocate a single environment for all the bindings (a move which would result in incorrect scoping in the interpreter). We leave that environment empty, and add it to the compile-time environment chain. We thus compile the init expressions in the scope of that new environment. As we iterate over the init expressions to compile them, we add each corresponding variable to that environment one by one, so that the compilation of subsequent init expressions will have that variable in scope.
let*
is a simple hack that becomes obvious when you have a Lisp compiler that handles let
.
A Lisp compiler easily produces efficient representations of environments, regardless of scoping rules, which is not necessarily true of an interpreter.
Since interpreters came first, that explains why let
is parallel. At least partially. The other reason is that let
was realized as a syntactic sugar over lambda
. But lambda
(originally) oes not have initializing expressions at all in its own scope; it just specifies variables. The lambda
expression produces a run-time object, such that the binding of the values to the parameters happens at run time, when the function is invoked. The evaluation of the argument expressions is in a totally different scope.
Now in the immediately-called lambda
, this is still true: the scope of the expressions is entirely outside of the lambda
:
((lambda (a b) (+ a b)) 1 2)
The expressions 1
and 2
have nothing to do with the lambda
; they are not enclosed in it.
So it is obvious that if we want a let
sugar notation which corresponds to the above, we must carefully preserve this property:
(let ((a 1) (b 2))
(+ a b))
If we want this let
to be the same as the previous lambda
, we must make it look as if a
and b
are function parameters, and 1
and 2
are argument expressions.
If you're a researcher working with a language that has lambda
and no let
, longing for a nicer way to write immediately-called lambda
s, it is unlikely that you will invent let*
binding semantics. You will invent something that has a clear translation strategy to the existing construct you are using all over your code, so that you can refactor the code to use it without surprises.
Note that the modern lambda
in dialects like Common Lisp does have embedded expressions in it: namely optional and keyword arguments!
(lambda (a &optional (b x) (c y) ...))
These default value expressions x
and y
are evaluated in the surrounding lexical scope, whenever the argument is missing, every time time the function is called. And, so, what scoping discipline do these expression use? Why, serial, not parallel!
[1]> (defun foo (x &optional (y (1+ x)) (z (1+ y)))
(list x y z))
FOO
[2]> (foo 10)
(10 11 12)
So, things went full circle. In the beginning, there was LAMBDA
. LAMBDA
begat LET
. LET
begat LET*
, and LET*
begat newer LAMBDA
with sequential binding for optional argument init-forms. :)
The result is that translating a modern immediately-called lambda into let
is quite complicated. For instance:
(funcall (lambda (x y &optional (z x) (w (1+ z))) a b c)
can compile into:
(let ((x a) (y b)) ;; we put the fixed params into a let
(let* ((z c)) ;; z arg present, so refer to c, not x
(w (1+ z))) ;; w arg absent, use (1+ z)
...))
let
is needed?' is a bit like asking 'is there a case where functions with more than one argument are needed?'.let
&let*
don't exist because of some notion of efficiency they exist because they allow humans to communicate intent to other humans when programming. – user5920214