`let` syntax weirdness

let is great and very important to have, but the syntax is very unusual.

Before running them, try to guess what these do:


function f1()
    a = 1
    b = 2
    c = let a = 3
        b = 4
        a+b
    end
    (a,b,c)
end


function f2()
    a = 1
    b = 2
    c = let 
        a = 3
        b = 4
        a+b
    end
    (a,b,c)
end

function f3()
    a = 1
    b = 2
    c = let begin
            a = 3
            b = 4
        end
        a+b
    end
    (a,b,c)
end

f1 is the standard usage of let. But the only new binding is for a! This is very surprising for anyone coming from languages with let...in syntax. There, we’d expect the value of the expression to be the last line in the block. This is not the case.

f2 looks like it ought to be equivalent, but it’s not! This is one of the very few cases where Julia is whitespace-sensitve.

f3 seems like it would be a sensible way around this limitation, but it doesn’t even parse!

As a result of this syntax, there are lots of examples in the wild of people using let where it doesn’t do anything let-like.

Why is let designed this way? Can it change for Julia 2.0?

4 Likes

This is intended and I believe is the normal behavior of let from lisp: the let block introduces a new scope, but only the let “arguments” introduce new bindings. Describing this as white space sensitive is a bit misleading: new lines are often very impactful to meaning. I agree it’s a bit confusing though.

9 Likes

Thanks @StefanKarpinski. Maybe part of my confusion is from coming from Ocaml and Haskell. But I think my previous misunderstanding may be very common, and the use case is an important one.

If you need lots of bindings before the return value, do you just have one giant line? Why can’t begin..end work in this context?

2 Likes

Maybe most importantly… If we have a way to introduce a block of bindings, it’s easy to reduce this to just one. But the reverse is very awkward. Worst of all, the current let is very often misused, which may lead to lots of subtle bugs in the ecosystem.

1 Like

For example, here’s some code from @shashi and @Mason, both of whom I consider to be top-tier developers:

No shame here, the point is that if it’s throwing them, it’s throwing many others.

While I think that’s a very weird use of a let block, I’m not sure it’s showing a misunderstanding. Could you elaborate on what you see as wrong with it?

2 Likes

Maybe I’m misunderstanding how you’re using it, in which case I owe you an apology.

When I see someone use let, I assume they intend to introduce local bindings. As I now understand let, it’s only the let line itself that’s special, otherwise it just acts like a begin. So as it’s used here, it seems to make no difference at all and could just be removed entirely.

I’d guess this is the case for the vast majority of uses out there. We usually put things in blocks with newline separations, but there’s this one weird exception and no clean way around it that I can see.

Or… maybe I’m wrong? That’s fine too, I’d just like to understand if that’s the case.

If I may add an additional point of confusion to me, is that the bindings behave differently if inside a function or in the global scope:

julia> function f1()
         a = 1
         b = 2
         c = let a = 3 
           b = 4
           a+b
         end
         (a,b,c)
       end
f1 (generic function with 1 method)

julia> f1()
(1, 4, 7)

julia> a = 1
       b = 2
       c = let a = 3
         b = 4
         a+b
       end
       (a,b,c)
(1, 2, 7)

julia>

In one case b is local to the function, in the other case it is local to the let block only.

No, let introduces a local scope through the whole block. The first line has different special properties though. What you observed above was that nesting local scopes does’t separate them unless you take advantage of the first line of the let block to tell julia you’re rebinding variables.

Perhaps this is helpful:

julia> let
           a = 1
       end;

julia> a
ERROR: UndefVarError: a not defined

julia> begin
           a = 1
       end;

julia> a
1

julia> let
           let
               a = 1
           end
           a
       end
1
9 Likes

I have to say that I was able to guess correctly the results of f1 and f2. Only for f3 I had no idea about what would happen, and this seem to not be a problem, as this case is a parsing error.

This is wrong. let introduces a local scope. begin ... end does not introduce a new local scope. Therefore, let is a useful tool to take out things of global scope without putting them inside a function.

3 Likes

In your example, the source of the difference is not the let itself, but the difference between global and non-global scope, you could be using a for or any other block that introduces local scope.

For your second example to be compatible with the first it should be written as:

julia> a = 1
       b = 2
       c = let a = 3
         global b = 4
         a+b
       end
       (a,b,c)
(1, 4, 7)
2 Likes

Well, that difference was removed in 1.5 for for loops (of course you know that already):

julia> a = 1
       b = 2
       for i in 1:1
         b = 4
       end
       (a,b)
(1, 4)

julia>

So the new scope introduced by let behaves differently than the new scope of for when used from the global scope now.

Just to be clear: I do not think that that should be different by any reason, just pointing out that this might be a source of confusion because a copy/paste of a block of code can result in different behaviors.

Yes, and no. I had forgot about this distinction (at the moment I am locked at 1.4.2). However, even in Julia 1.5+, this distinction only happens in the REPL. What I said is the correct behavior if your are executing the code from a script. We did not consider this distinction until now (if the code executes from the REPL or a script), so I would say that both our comments are correct from the right perspective and wrong in general. The relevant section of the manual is this one.

1 Like

Well this just get weirder and weirder. Especially since you get

But I get (Julia 1.5)

julia> let
           let
               a = 1
           end
           a
       end
ERROR: UndefVarError: a not defined
Stacktrace:
 [1] top-level scope at REPL[2]:5
 [2] run_repl(::REPL.AbstractREPL, ::Any) at /build/julia/src/julia-1.5.3/usr/share/julia/stdlib/v1.5/REPL/src/REPL.jl:288

I don’t see it as that weird. It’s using the fact that let creates a local scope to hide a bunch of variables that are closed over. It is not using the ability of let to shadow an existing local binding with a new local binding. In this case there’s little difference since the outer scope is global, there are no outer bindings with those names, and the let block isn’t in a loop or any other structure where creating a new binding on each evaluation would be observable.

3 Likes

I really do not remember this example from @Mason ever being valid. I think that maybe there was an a variable in the scope outside the outer let that was forgotten.

3 Likes

In any case I owe @Mason and @shashi an apology. I do think there’s some weirdness here, but a lot of this seems to be my misunderstanding, and I should have sorted that out before dragging them into it. Sorry. Also I’m very much enjoying SymbolicUtils.jl, and I’m excited to see where it goes from here :slight_smile:

1 Like

Effectively.

julia> a = 2
2

julia> let
         let
           a = 1
         end
         a
       end
2

Yet one could perhaps expect that let block to result in undefined a anyway, since its scope is local (but since we are not modifying a it is considered as the global a). One has to get used to this.

1 Like

Right, I think some of my confusion is coming from the quirks of local scope in Julia. Is it correct to say that the first line of a let has hard local scope, and the rest has soft local scope?

1 Like

Ok, so the cleanest way I can see to get the behavior I was expecting is

julia> function f4()
           a = 1
           b = 2
           c = let
               local a = 3
               local b = 4
               a+b
           end
           (a,b,c)
       end
f4 (generic function with 1 method)

julia> f4()
(1, 2, 7)
3 Likes