Toward a real and final solution to Julia’s variable scope issue

I have been learning Julia lately and found it amazing. I was planning to make it one of my main tools, along Haskell and Rust. But the scope issues killed my enthusiasm. I read the documentation about it, but I am still not satisfied. It is not intuitive (despite all claims) and not consistent. I even found a bug. That’s the first time in history, the explanation of variable scope took that much amount of words. That’s a clear statement of how inconsistent and confusing Julia’s variable scoping is.

Julia made the same mistake JavaScript made at its early beginning (‘==’, ‘var’, etc): make some choices just for sake of convenience - in Julia’s case, trying not to be verbose. At the end, that choice will make Julia even more verbose, trying to get around the problem. And this will slow down and even kill the adoption of Julia. No serious programming could take place in Julia with such scoping confusion. It’s sad because Julia got everything else right.

Julia’s scopes should be redesigned and as soon as possible, before the language becomes popular for some reasons. It took JavaScript more than two decades and the creation of a new parallel language (JavaScript ES6), trying without success to recover from its early-age mistakes which cost time and money to an entire industry; and despite.

I have a proposal that would be transitional and could fix the problem. Variable scoping is a foundational feature in a language. Failing the implementation of it, makes the language useless. Except for a couple of data science scripts, Julia can’t be used for anything serious.

The issue with Julia’s variable scopes becomes evident when you start nesting scopes, two or more levels down. The only thing that remains consistent is the explicit local declaration ‘local x’.

All scopes should have been made ‘hard’ local - using the tag ‘local’ should have been unnecessary. Accessing a variable of the same name in the lexical scope (any ancestor scope) should have been done using a tag such as ‘ancest x’ - where ‘ancest’ stands for any ancestor scope in any chain of scopes, the scope is involved in, including the ‘global’ scope. The ‘global’ (even though unnecessary) could be kept for those who like the way it works now.

PS: Julia got everything right, except this local scope issue. It’s really sad. The fix is easy. Just get it right and let Julia succeed.

Given the lack of humility in your post, I do not expect people to be very receptive of your suggestions, even if they have technical merit. It also does not help to say things like

Except for a couple of data science scripts, Julia can’t be used for anything serious.

when Julia is quite certainly used for a lot of serious things. This is incredibly inflammatory, not constructive.

At the end, not everyone needs to like all the choices the language made. There is always a tradeoff in these things. The choices made in julia mostly work well for me and others. If they do not work for you, no one is insisting that you use the language.

If you have technical contributions to suggest or implement, they would be welcomed, but some level of understanding of history will be expected of you as well. For starters, julia would not be making any breaking changes anytime soon.

19 Likes

Welcome!

Would you be willing to share this bug you have found, so that we can fix it?

It’s unfortunate that you found the documentation confusing - do you have suggestions for how we can improve it?


Also, please note that PSA: Julia is not at that stage of development anymore, so it’s quite unlikely that the scoping is going to change at this point.

9 Likes

Let’s back up a bit: first, welcome @anon98050359! I’m sorry your excitement was so rapidly dashed, but like @Krastanov I suspect there may be some kind of misunderstanding. There is one element of complexity in Julia’s scopes, but AFAIK it’s limited to the difference between “code scope” and “interactivity scope,” i.e., (slightly) different rules apply to the REPL than in real code, because many people seem to want that. It might be worth noting that when Julia 1.0 came out we only had code-scope, but this caused regular complaints that were largely quelled by the introduction of soft scope.

It would be great if you could expand on your concerns, and point out the bug that you’ve seen! That way we might have a more productive discussion.

17 Likes

Considering your hypersensitivity, you could ignore my post. If your ego is more important than the merit of any of my suggestions, then let Julia die in its current anonymity.
I am quite happy with Haskell and Rust. And I would pick a slow Python over a fast Julia with this scope issue. :slightly_smiling_face:

The confusion comes from trying to justify or make sense of a flawed design. The bug shows up when you nest a hard local scope in another hard local scope. I will share with you a sample code soon. Stay tuned.

Thanks for your feedback. I will expand on it soon. The discrepancy between the REPL and the real is an issue. That caused some confusion when I was going through learning Julia. Then I started compiling (no longer using the REPL). That’s when I realized that a ‘hard’ local code, nested in another ‘hard’ local scope, still leaks into any parent local scope at any level of the nesting. Not really consistent and can lead to many serious bugs.

The issue with Julia’s variable scopes becomes evident when you start nesting ‘had’ local scopes, two or more levels down. The only thing that remains consistent is the explicit local declaration ‘local x’.

All scopes should have been made ‘hard’ local - using the tag ‘local’ should have been unnecessary. Accessing a variable of the same name in the lexical scope (any ancestor scope) should have been done using a tag such as ‘ancest x’ - where ‘ancest’ stands for any ancestor scope in any chain of scopes, the scope is involved in, including the ‘global’ scope. The ‘global’ (even though unnecessary) could be kept for those who like the way it works now.

Can you give a concrete example? I tried what I think you were saying and didn’t see the issue you describe:

julia> function outer()
           function inner(y)
               x = y
           end
           inner(3)
           return x
       end
outer (generic function with 1 method)

julia> outer()
ERROR: UndefVarError: `x` not defined
Stacktrace:
 [1] outer()
   @ Main ./REPL[1]:6
 [2] top-level scope
   @ REPL[2]:1
3 Likes
a = 1     
b = a

lc1 = let
    a = 10         
    c = a

    lc2 = let 
        a = 100
        d = a

        lc3 = let 
            # This 'local a' definition affects all the declaration 
            # of 'a' in any parent scope, except the global scope 
            # (i.e. all the local scopes above).
            # Not only affecting the local scopes above is an 
            # issues, affecting the scopes above, without  
            # affecting the last one (global), is inconsistent
            a = 1000           

            println("a = $a in 3-let")
        end

        println("a = $d in lc2, before local scope 3")
        println("a = $a in lc2, after local scope 3")
    end

    println("a = $c in lc1, before local scope 2")
    println("a = $a in lc1, after local scope 2")
end

println("a = $b in global, before local scope 1")
println("a = $a in global, after local scope 1")

This is entirely consistent with the scoping rules though, isn’t it?

  1. 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;

If you do want to affect the global variable, you need the global keyword. If you want the a = to be a new local variable, you need to have it on the same line as let (or explicitly signify it to be local to the current function with local); the newline enters the body of the block, if I understand your MWE correctly.

3 Likes

Here is the same issue using functions

a = 1
b = a

function lc1()
    a = 10 
    c = a

    function lc2() 
        a = 100
        d = a
        
        function lc3() 
            # This local variable leaks into all the local scopes above.
            # Which means that this local scope leaks
            a = 1000

            println("a = $a in lc3")
        end
        lc3()

        println("a = $d in lc2, before local scope 3")
        println("a = $a in lc2, after local scope 3")
    end
    lc2()

    println("a = $c in lc1, before local scope 2")
    println("a = $a in lc1, after local scope 2")
end
lc1()

println("a = $b in global, before local scope 1")
println("a = $a in global, after local scope 1")
1 Like

I repeated the same issue with functions instead of ‘let’, in the same thread. That should help you see the problem

… that’s why the nested ‘hard’ local scope should NOT LEAK that new local variable into the local scopes above. That’s a bug!

But x is already a local variable, in the parent hard local scope :thinking: What it sounds like you’re proposing is that local scopes shouldn’t capture the variables from their parent scopes at all…?

2 Likes

Isn’t this a major reason for using local functions? Capturing of variables of the outer scope?

5 Likes

A local variable could read a variable of scalar type (such as Integer, Boolean, etc) from a parent scope, but should NOT modify it. All the changes done to it in a local scope should remain in that local scope. That’s the expected behavior.

That’s a different issue when it comes to variables referencing structures such as arrays of structs. There are various approaches. Some languages don’t even treat arrays as references unless you specify it explicitly.

That’s why I propose the following:

“All scopes should have been made ‘hard’ local - using the tag ‘local’ would then be unnecessary. Accessing a variable of the same name in the lexical scope (any ancestor scope) should be done using a tag such as ‘ancest x’ - where ‘ancest’ stands for any ancestor scope in any chain of scopes, the scope is involved in, including the ‘global’ scope. The ‘global’ (even though unnecessary) could be kept for those who like the way it works now.”

‘ancest’ would allow a clean capture of the scope above.

PS: I would also be perfectly fine with Julia adopting the scoping approach in Python. But what Julia currently has is completely inconsistent and would lead to serious bugs and doesn’t help with developing certain code patterns. We need something consistent and easily predictable, or which at least adopts some widely accepted patterns and approaches. Reinventing the wheel should give some better wheels - not some horses’ legs! :slightly_smiling_face:

Welcome @anon98050359. You’re coming in quite hot and fast here — and you’ll likely start running into the Discourse board’s limits on new users. We like to encourage new folks here to spend some time to get to know the discussion norms throughout the site at large as described in Discourse’s post on user trust levels. One of those norms (and this goes for everyone) is that we try to be agreeable — even if we disagree: FAQ - Julia Programming Language.

Different languages do things differently — and trust me when I say that we’ve all heard many reasons why Julia is doomed (e.g., 1-based indexing, brackets, OOP syntax, you name it). I encourage you to spend more time with the language and community; it does take time to get used to new systems/idioms/etc, but it’s well worth it in my obviously biased opinion.

19 Likes

At the syntactic level, these all look the same though. Scoping is decided before types of objects are a thing - it’s only variable bindings/expressions that exist at that point. There can’t be a solution that binds differently based on the type - that would mean that things like this:

function foo(x::T) where T
   function bar()
       x = copy(x)
   end
   bar
end

Would mean something different depending on what T is - it’s not like the semantics of whether T is stored in a register, as a reference, a pointer or something else entirely are exposed at the variable level in julia, and since julia uses “pass by sharing”, having a difference there would conflict with how variables are passed in functions.

1 Like

The paradigmatic use:

function makecountdown(n)
   function counter()
       n = n-1
       return n
   end
end

cnt = makecountdown(10)
cnt()
9
cnt()
8

is out of the question then?

5 Likes