Reputation: 137
Is the distinction between the 3 different let forms (as in Scheme's let, let*, and letrec) useful in practice?
I am current in the midst of developing a lisp-style language that does current support all 3 forms, yet I have found:
regular "let" is the most inefficient form, effectively having to translate to an immediately called lambda form and the instructions generated are nearly identical. Additionally, I haven't found myself needing this form very often.
let* (sequential binding) seems to be the most practically useful and most often used. This form can be translated to a sequence of nested "lets", each environment storing a single variable. But this again is highly inefficient, wasting space and lookup time.
letrec (recursive binding) can be efficiently implemented, given that no initializer expression refers to an unbound variable. Typically the case is that all initializers are lambda expressions and the above is true.
The question is: since letrec can be efficiently implemented and also subsumes the behavior of let*, regular let is not often used and can be converted to a lambda form with no great loss of efficiency, why not make default "let" have the behavior of the current "letrec" and be rid of the original "let"?
Upvotes: 3
Views: 390
Reputation: 9725
Think of metaprogramming. If your default let will sequentially create nested scopes, you'll have to make sure that none of the initialiser expressions are referring to the names from the wrong scopes. You have such a guarantee with a regular let. Control over name scoping is very important when you're generating code.
Letrec is even worse, it's introducing a very complicated scope rules that cannot be easily reasoned with.
Upvotes: 1
Reputation: 58627
This [
let*
] form can be translated to a sequence of nested "lets", each environment storing a single variable. But this again is highly inefficient, wasting space and lookup time.
While what you are saying here is not incorrect, in fact there is no need for such a transformation. A compiling strategy for the simple let
can handle the semantics of let*
with just simple modifications (possibly supporting both with just a flag passed to common code).
let*
just alters the scoping rules, which are settled at compile time; it's mostly a matter of which compile-time environment object is used when compiling a given variable init form.
A compiler can use a single environment object for the sequential bindings of a let*
, and destructively update it as it compiles the variable init forms, so that each successive init form sees a more and more extended version of that environment which contains more and more variables. At the end of that, the complete environment is available with all the variables, for doing the code generation for generating the frame and whatnot.
One issue to watch out for is that a flat environment representation for let*
means that lexical closures captured during the variable binding phase can capture future variables which are lexically invisible to them:
(let* ((past 42)
(present (lambda () (do-something-with past)))
(future (construct-huge-cumbersome-object)))
...))
If there is a single run-time environment object here containing the compiled versions of the variables past
, present
and future
, then it means that the lambda
must capture that environment. Which means that although ostensibly the lambda
"sees" only the past
variable, because future
is not in scope, it has de facto captured future
.
Thus, garbage collection will consider the huge-cumbersome-object to be reachable for as long as the lambda
remains reachable.
There are ways to address this, like accompanying the environmental reference emanating from the lambda
with some kind of frame index which says, "I'm only referencing part of the environment vector up to index 13". Then when the garbage collector traverses this fenced reference, it will only mark the indicated part of the environment vector: cells 0 to 13.
Anyway, about whether to implement both let
and let*
. I suspect if Lisp were being "green field" designed from scratch today, many designers would like reach for the sequentially binding version to be called let
. The parallel construct would be the one available under the special name let*
. The situations when you actually need let
to be parallel are fewer. For instance, let
allows us to re-bind a pair of variable symbols such that their contents appear exchanged; but this is rarely something that comes up in application programming. In some programming language cultures, variable shadowing is frowned up on entirely; GNU C has a -Wshadow
warning against it, for instance.
Note how in ANSI Common Lisp, which has let
and let*
, the optional parameters of a function behave sequentially, like let*
, and this is the only binding strategy supported! So that is to say:
(lambda (required &optional opt1 (opt2 opt1)) ...)
Here the value of opt2
is defaulted from whatever the value of opt1
is at the time of the call. The initialization expression of opt2
has the opt1
parameter in scope.
Also, in the same Lisp dialect, the regular setf
is sequential; if you want parallel assignment you must use psetf
, which is the longer name of the two.
Common Lisp already shows evidence of design decisions more recent than let
tend to favor sequential operation, and designate the parallel as the extraordinary variant.
Upvotes: 4