World age delay doesn't occur with `@eval` in `begin` or `let` blocks, but does with `eval`

World age is expected to produce a delay between a method or Task’s compiled code and their changes to the global state, but it wasn’t clear to me what happens in top-level begin or let blocks. Weirdly, there’s this discrepancy between eval and @eval, and I still don’t know whether these blocks are supposed to commit to a specific world age or not. I haven’t exhaustively wrote versions for all blocks in Julia, but it also applies to for and while.

julia> const pi = 3 # v1.12.2
3

julia> begin
       println(pi)
       const pi = 3.1 # possible here without manual eval
       println(pi) # updated
       end;
3
3.1

julia> begin
       println(pi)
       @eval const pi = 3.14
       println(pi) # updated
       end;
3.1
3.14

julia> begin
       println(pi)
       eval(:(const pi = 3.141))
       println(pi) # obsolete
       end;
3.14
3.14

julia> let
       println(pi)
       @eval const pi = 3.1415
       println(pi) # updated
       end;
3.141
3.1415

julia> let
       println(pi)
       eval(:(const pi = 3.14159))
       println(pi) # obsolete
       end;
3.1415
3.1415

The first tls_world_age example in the Manual page also changes to show the local world age incrementing if Core.eval is changed to @eval.

julia> function f end
f (generic function with 0 methods)

julia> begin
       @show (Int(Base.get_world_counter()), Int(Base.tls_world_age()))
       @eval @__MODULE__() f() = 1
       @show (Int(Base.get_world_counter()), Int(Base.tls_world_age()))
       f()
       end
(Int(Base.get_world_counter()), Int(Base.tls_world_age())) = (38693, 38693)
(Int(Base.get_world_counter()), Int(Base.tls_world_age())) = (38694, 38694)
1

@eval inserts an explicit world age increment:

julia> @macroexpand @eval x = 1
:(let var"#1#eval_local_result" = Core.eval(Main, $(Expr(:copyast, :($(QuoteNode(:(x = 1)))))))
      $(Expr(Symbol("latestworld-if-toplevel")))
      var"#1#eval_local_result"
  end)

The function version can’t because it’s not syntax.

4 Likes

The following statements raise the current world age:

9. Certain other macros like @eval (depends on the macro implementation)

So this was referring very directly to $(Expr(Symbol("latestworld-if-toplevel"))) as a version of Core.@latestworld that doesn’t error or do anything in function scopes? I guess the question then is why @eval intentionally deviates from eval at top level in 1.12 when it has been described as a simple macro abbreviation by the Metaprogramming page for a while.

julia> @macroexpand @eval x=1 # 1.11 and earlier
:(Core.eval(Main, $(Expr(:copyast, :($(QuoteNode(:(x = 1))))))))

Incidentally a harsh reminder that top level isn’t just global scope, but frankly the documentation and implementation are still not clear about what top level is:

julia> function()
         struct X end # definitely not top level
       end
ERROR: syntax: "struct" expression not at top level
Stacktrace:
 [1] top-level scope
   @ REPL[22]:1

julia> begin
         struct X end # so this is top level?
       end

julia> begin
         module X end # no?
       end
ERROR: syntax: "module" expression not at top level
Stacktrace:
 [1] top-level scope
   @ REPL[24]:1

julia> function()
         Core.@latestworld
       end
ERROR: syntax: World age increment not at top level
Stacktrace:
 [1] top-level scope
   @ REPL[26]:1

julia> begin
         Core.@latestworld # same deal as struct
       end

I was just trying to be nice to the ecosystem. It was less breaking this way.

1.11 did not have world-age partitioning for bindings, the code in your original post is UB in these versions. However, it’s not entirely related since 1.11 raised world ages after most statements anyway (but was inconsistent about when). The fix for that was separate from binding partitions and was related to the correctness of inference results in global scope.

1 Like

True - one of the things to clean up after JuliaLowering lands.

Also, hey - I’m proud of myself for actually having documented this :).

3 Likes

True, I suppose I’d have to do the zero-argument function call as a pseudo-constant:

julia> pip() = 3 # 1.11
pip (generic function with 1 method)

julia> begin
       println(pip())
       @eval pip() = 3.1
       println(pip()) # updated
       end;
3
3.1

julia> begin
       println(pip())
       eval(:(pip() = 3.14))
       println(pip()) # updated
       end;
3.1
3.14

which I really hope is just long-unclarified undefined behavior if that can’t happen in 1.12.

I’m pretty grateful that it’s finally moving because the Manual has been ambiguous about MANY fundamentals like this to make room for future core features (at least, I think most people have been surprised that constants weren’t part of world age until now, especially given the zero-argument function workaround). While these don’t seem to cause widespread issues, people have noticed odd implementation inconsistencies, like the difference between importing a function versus a struct, that end up being specified. I think Julia is just going to have to be a little weird until the core features and specification are solid enough to even begin considering a more straightforward v2. I personally would love if explicit-only variable declarations (and thus much simpler conditions for assignment) in v2 could finally let us freely paste code between the global and local scopes and do away with soft scope contexts, and world age bindings would probably be important there.