Puzzling construction with executable statements in struct definition

I thought I had seen most things in julia, but I’ve come across the following construction which I don’t think I’ve seen in the docs:

using MacroTools
const gotit = Set()

macro genconstructor(spec)
    if !@capture(spec, function name_{T__}(args__) where TV__ body_ end)
        error("error")
    end

    ename = esc(name)
    newspec = quote
        if !$(name in gotit)
            $(push!(gotit, name))
            function $ename(args...; kw...)
                call_some_constructor(args...)
            end
        end
    end

    quote
        $(esc(spec))
        $newspec
    end
    
end

struct Foo{T}
    a::T

    @genconstructor function Foo{T}(x) where T
        Foo(x)
    end

    @genconstructor function Foo{T}(x::T) where T
        Foo(x+1)
    end
end

julia> methods(Foo)
# 1 method for type constructor:
 [1] Foo(args...; kw...)

julia> methods(Foo{Int})
# 2 methods for type constructor:
 [1] Foo{T}(x::T) where T
 [2] Foo{T}(x) where T

I.e. the macro @genconstructor takes a function, a constructor, looking like

function Foo{T}(args) where T 
... 
end

It outputs the same function definition, and conditionally, a slightly different definition, so the full output of the macro is two constructors, the original and a simplified one:

function Foo{T}(args) where T 
... 
end
if !(:Foo in gotit)
    push!(gotit, :Foo)
    function Foo(args...; kw...)
        dosomething()
    end
end

The point with the test and the gotit Set, is to avoid defining the extra constructor repeatedly if the macro is used more than once in the same struct (which would lead to a warning, and prevent precompilation).

Now, my question is, what are the rules for executing statements inside a struct definition? Can local variables be made there? Is it just like any other local scope, except for definition of functions named like the struct, which becomes inner constructors?

Are things like this officially supported?:

struct Bar
    a::Int

    local b = Ref(0)
    Bar(n) = (e = new(n+b[]); b[] += n; e)
end
julia> Bar(1)
Bar(1)

julia> Bar(1)
Bar(2)

julia> Bar(1)
Bar(3)

julia> Bar(1)
Bar(4)

As far as I understand, all the explanation on @genconstructor only serves as an example to show that you can have e.g. branches in a struct definition? If so, I think you could make the question clearer by moving this into a collapsible

Motivation

or something similar

and just using a simpler MWE.

I don’t have deep insights into how everything works under the hood, but my impression is that you can do anything you would inside a let block (i.e. with hard scoping rules), with the notable exception that new variables have to be declared local to distinguish them from fields (which of course can be declared in contrast to in let blocks).

E.g.

julia> bool = nothing
       value = 3.16
       struct S
           s  # or s::..., becomes a field

           local square = value * value
           global bool = square > 10  # updates the outside variable bool
           if bool
               S() = new(1)
           else
               S() = new(0)
           end
       end

julia> bool
false

julia> S()  # (Note: if you now increase value without redeclaring S, S() will remain S(0).)
S(0)

julia> struct S2
           s

           square = value * value
           S2() = new(square)
       end
ERROR: syntax: "square = (value * value)" inside type definition is reserved around REPL[5]:1
Stacktrace:
 [1] top-level scope
   @ REPL[4]:1

EDIT:

Seems you have updated the question while I was typing, but yeah, that sounds about right :slight_smile: , except for the requirement of having to use local. Whether this is officially supported I can’t say.


Interesting. It means that it’s possible to create structs with fully hidden “fields”, but with accessor functions using local variables. Not that I would recommend it, but it might have its uses as struct level variables. Like class variables, not object variables. I wonder if these local variables are at all visible?

struct A
    local a::Float64 = 0.0
    A() = new()
    A(::Val{:seta}, x) = a = convert(Float64, x)::Float64
    A(::Val{:geta}) = a
end
Base.getproperty(a::A, ::Symbol) = A(Val(:geta))   # some checks can be done here
Base.setproperty!(a::A, ::Symbol, val) = A(Val(:seta), val)

julia> a = A()
A()
julia> a.a = 2.0
2.0
julia> a.a
2.0
1 Like