`let` syntax weirdness

Yeah that was caused by me not being careful when I made that example, sorry. I had defined a above. I was instead thinking of an example like this:

julia> let
           b = 1
           let
               b = 2
           end
           b
       end
2

This is just how local scoping works in julia, itā€™s the same as functions

julia> function f()
           b = 1
           function g()
               b = 2
           end
           g()
           b
       end
f (generic function with 1 method)

julia> f()
2
3 Likes

Yeah, I mean thereā€™s nothing wrong with it, itā€™s just not a common pattern people do in package code from my experience, so I see why it can trip people up or confuse them.

1 Like

Hmm, no this still isnā€™t right. From the manual:

When x = occurs in a local scope, Julia applies the following rules to decide what the expression means based on where the assignment expression occurs and what x already refers to at that location:

  1. Existing local: If x is already a local variable, then the existing local x is assigned;
  2. Hard scope: If x is not already a local variable and assignment occurs inside of any hard scope construct (i.e. within a let block, function or macro body, comprehension, or generator), a new local named x is created in the scope of the assignment;

So the first line of a let is ā€œharder than hardā€. And the only ways to get this nameless ā€œsuperhard scopeā€ seem to be

  1. Between let and the next newline
  2. By using local in the declaration
  3. Just gensym all the things

Again, if we are talking about code outside the REPL, then let and for have no differences in this aspect, and having

a = 10
for i in 1:10
     b = a + i
     println(b)
end

to not work because a is not defined in the for would be unnerving. for loops almost always access bindings of the outer scope.

Yes, of course, it was only that in those artificial examples one might be tricked.

The let construct is traditional, but it does two technically unrelated things which would perhaps be better disentangled:

  1. Create a local scope
  2. Create new bindings for a set of names in that scope

We can imagine a scope ... end construct that just does the first. Then you could translate

let a = 1, b = 2
    c = 3
    # stuff
end

to this:

scope
    local a = 1
    local b = 2
    c = 3
end

So a and b are new locals in the let block no matter what, whereas c follows the normal scope rule: if there is already a local named c it is assigned, if there isnā€™t then it is created in this scope. Arguably it would be cleaner to keep these orthogonal, but let is traditional and allows you to do both together.

16 Likes

You may already be aware of this, but the easier way to write

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

is like this:

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

I donā€™t know if itā€™s been made clear yet or not, but the purpose of the let block that you linked to in SymbolicUtils.jl is to create closures. The other option is to use global constants:

const d = Dict(1 => 11, 2 => 12)

function foo(x)
    if x > 0
        x + d[1]
    else
        x + d[2]
    end
end

In fact, Iā€™ve been wondering for a while now if thereā€™s any advantage to

let
    d = Dict(1 => 11, 2 => 12)

    global function foo(x)
        if x > 0
            x + d[1]
        else
            x + d[2]
        end
    end
end

over the version with a global constant, aside from reducing namespace clutter. But I guess thatā€™s a little off topic.

1 Like

Thanks, yes Iā€™m aware of this. The point was that if we have many bindings or much longer ones, this gets very awkward. The natural solution I could see is to allow let begin. Maybe this could be allowed at some point.

Comma-separated assignments also strike me as strange. But it reminds me a little of named tuples. So I could also imagine being able to do

julia> nt = (a=1,b=2)
(a = 1, b = 2)

julia> let nt...
           a+b
       end

But that doesnā€™t work today.

1 Like

Just stick to one letter variable names. :wink:

1 Like

My understanding is that there arenā€™t many times when you need a let block in Julia. As far as I know, there are two main use cases for let blocks:

  • Creating closures at the top-level, like in the SymbolicUtils example.
  • Shenanigans where you are creating closures in a while block.

Of course, the other use is just to create a local scope with some local bindings, but given that the Julia style is to write lots of small functions, I havenā€™t really found myself needing let blocks for that.

2 Likes

Oh, I just realized thereā€™s a compromise syntax you can use when there are a lot of names you want to bind in a let block:

function foo()
	a = 1
	b = 2
	c = 3
	let a, b, c
		a = 4
		b = 5
		c = 6
		a + b + c
	end
end
2 Likes

I had another use case in mind. For metaprogramming, itā€™s nice to be able to generate code thatā€™s a little more readable. If weā€™re doing this in a purely functional way, itā€™s nice to have a guarantee that a generated Expr can be inserted anyway without affecting values outside its context.

Oh, thatā€™s very nice. Thanks for pointing that out!

1 Like

Whatā€™s wrong with:

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

if your lines get too long?

10 Likes

Nice! Didnā€™t realize you could do that.

Another case when let blocks are helpful is notebooks, both jupyter and pluto. let is a convenient way to have variables local to a cell that donā€™t pollute ā€œglobalā€ namespace.

6 Likes

I think this would be an excellent syntax addition. Itā€™s also not something you can implement with macros without introducing type instability, see this discussion thread.

1 Like

Yes, I can confirm this. Basically every cell that I have either:

  1. Is not let-wrapped because it only prints or introduce bindings I want to be global.
  2. Is let-wrapped to avoid leaking local variables.
  3. Is let-wrapped so I can do the following:
new_global_dataframe = let df = deepcopy(raw_global_dataframe)
    # lots of mutating changes over df
    df
end

Itā€™s probably unlikely to become syntax, since itā€™s fundamentally incompatible with how bindings currently work in Julia.

Simon, do you mind explaining how itā€™s fundamentally incompatible? I keep learning more about how Julia operates though explanations by you, Steven and so many others.