Typed globals: macro to define multiple, without manual type annotations

I vaguely remember seeing a macro somewhere to define typed globals without having to give the type explicitly, something like

@typed begin
  a = 2
  b = 2.8
  ⋮
end

which would expand to

a::Int = 2
b::Float64 = 2.8
⋮

Does anyone remember where that was?

Not sure if something like this is already in a package somewhere, but here’s one way to write such a macro:

function rewrite_assignment(@nospecialize(ex))
    if Meta.isexpr(ex, :(=))
        l, r = ex.args
        l isa Symbol || error("Destructuring not supported")
        tmp = gensym(l)
        return esc(:(let $tmp = $r; global $l::typeof($tmp) = $tmp end))
    elseif Meta.isexpr(ex, :block)
        return Expr(:block, Base.mapany(rewrite_assignment, ex.args)...)
    else
        return ex
    end
end

macro typed(ex)
    return rewrite_assignment(ex)
end
julia> @typed begin
         a = 2
         b = 2.8
       end
2.8

julia> code_typed() do
           a, b
       end
1-element Vector{Any}:
 CodeInfo(
1 ─ %1 = Main.a::Int64
│   %2 = Main.b::Float64
│   %3 = Core.tuple(%1, %2)::Tuple{Int64, Float64}
└──      return %3
) => Tuple{Int64, Float64}
5 Likes

Amazing, thanks!

Could we write $l::typeof($r) = $r instead? (i.e. w/o let and tmp)

That’s fine if the right-hand side is a literal or symbol, but you might also have an expression with side effects, for example something like @typed a = pop!(v). Or even one that returns different results each time like @typed a = rand((1, 2.0)). In those cases you want to avoid evaluating r twice.

5 Likes

Just adding this for reference: “typeconst” is a relevant keyword for this problem on the discourse: Search results for 'typeconst' - Julia Programming Language (thanks Stefan Karpinski)

Ah, found what I was originally thinking of: it’s the “@stable” macro by @giordano in the Seven Lines of Julia thread:

Simeon’s is a bit robuster indeed, with the $tmp.

@simeonschaub I was also wondering, why the Base.mapany (and not just map)?

EDIT: I found why here (in the SnoopCompile docs) (maybe):

mapany avoids trying to narrow the type of f(v[i]) and just assumes it will be Any, thereby avoiding invalidations of many convert methods.

So, it’s used to spare the compiler some type inference work and/or to avoid convert invalidations, I think

The final thing I wonder about is the @nospecialize:
all Exprs are the same type, so there is no “too many specializations” problem right?

Like, why not say rewrite_assignment(ex::Expr).
And why the Meta.isexpr(ex, …) instead of just ex.head == …?

Ah, these are to handle LineNumberNodes, I assume.

Still, how is the @nospecialize useful?

1 Like

I think I’m missing why that’s more robust (than what?)

1 Like

Sorry, I hadn’t looked at your ‘seven lines’ well.
I thought it said something like

:(
    $lhs::typeof($rhs) = $rhs 
)

(With left- and right-hand sides lhs, rhs = ex.args).

But instead it uses a local temporary variable.
I.e. (rewriting a bit for ease of comparison):

:(
    begin
        local $tmp = $rhs
        $(lhs)::typeof($tmp) = $tmp
    end
)

(with tmp = gensym(lhs), for when applying the macro to more than one assignment at once)

…which I suppose is equivalent to Simeon’s let & global approach:

:(
    let $tmp = $rhs
        global $(lhs)::typeof($tmp) = $tmp
    end
)

As an addition, in my code where I define a macro like this, I added branch on whether typeof(rhs) ∈ [Expr, Symbol] or not.

If it is, I do as above (i.e. the $tmp approach, to handle non-determinism and side effects).

But if not (i.e. the right-hand side is a literal (hopefully)), I eval the typeof already in the macro:

T = Symbol(@eval typeof($rhs))
:(
    $lhs::$T = $rhs
)

The goal is to show something less scary to users when they e.g. @macroexpand, in the simple (and most common) cases.

For the record, I had written a blog post about a slightly more elaborate version of my macro:

1 Like