I thought I understood "pass-by-sharing" in Julia until I found this

Yes, both maybe would not match what OP expected, even elucidating this misconception. There are others things at play, but mostly, they are related to how closures work, and after understanding the scope problem, I think they become minor details.

This gives

2
3

and I suppose you mean the expected would be

1
2

What happens here is that the first closure captures the binding i in the first scope, and the second closure captures the binding i in the second scope. Moreover, both closures are callled after the loop has ended. The i of the first scope, at the ends of its loop/scope, has value 2, and the i of the second scope, at the ends of its loop/scope, has value 3. These both distinct is keep existing because they are referred by the closure, and they print the last value they had. If you did:

anon = Array{Any}(undef, 2)
for i = 1:2
    # the nothing below is just to avoid extra printing in the REPL
    anon[i] = () -> (println(i); i += 1; nothing)
    i += 1
end
anon[1]()
anon[1]()
anon[1]()
anon[1]()
anon[1]()

anon[2]()
anon[2]()

You would get

2
3
4
5
6
3
4

If you want something that really is surprising, you should check that closures are able to capture not-yet-defined variables of the scope in which they are defined.

anon = Array{Any}(undef, 2)
for i = 1:2
    anon[i] = () -> println(x)
    x = i * 2
end
anon[1]()
anon[2]()

gives

2
4

But at least:

anon = Array{Any}(undef, 2)
for i = 1:2
    anon[i] = () -> println(x)
    anon[i]()
    x = i * 2
end

Gives

ERROR: UndefVarError: x not defined

In the second example you give, I do not really agree the behaviour is surprising:

anon = Array{Any}(undef, 2)
let
    local i
    for outer i = 1:2
        anon[i] = ()-> println(i)
        i += 1
    end
end
anon[1]()
anon[2]()

Giving

3
3

Makes complete sense to me. i is not a binding of the inner scope, it is a binding of the outer scope (as the keyword indicates) so there is only one of it, and it is shared among all iterations. The for loop do not declare a new binding i each iteration but just attributes the correct value to the i already available. Note that the for do not just increase i, otherwise the second iteration could end up being skipped, it does attribute to i the right value each iteration. In the example below, anon[2] would not be defined if it did not work this way:

anon = Array{Any}(undef, 2)
let
    local i
    for outer i = 1:2
        anon[i] = ()-> println(i)
        i = 20
    end
end

anon

Gives

2-element Array{Any,1}:
 var"#3#4"(Core.Box(20))
 var"#3#4"(Core.Box(20))

This kinda kills my argument there are no boxes in Julia :sweat_smile: :laughing:.

Finally, as I said before, all closures are capturing and sharing the same binding, what can be checked by:

anon = Array{Any}(undef, 2)
let
    local i
    for outer i = 1:2
        # the nothing below is just to avoid extra printing in the REPL
        anon[i] = ()-> (println(i); i *= 2; nothing)
    end
end
anon[1]()
anon[2]()
anon[1]()
anon[2]()
anon[1]()
anon[2]()

Which gives:

2
4
8
16
32
64
6 Likes