A macro for parametrized functions

I am contemplating writing a macro @parameterized that turns

@parameterized function ControlField(
    t; # variable
    ΔT₁, ΔT₂, ΔT₃, ϕ₁=0.0, ϕ₂=0.0, ϕ₃=0.0, E₀₁, E₀₂, E₀₃, # parameters
    a::Float64=100.0  # constants (deteced by having type annotation)
)
    _tanhfield(t; E₀=E₀₃, t₁=(ΔT₁+ΔT₂), t₂=(ΔT₁+ΔT₂+ΔT₃), a=E.a) * cos(ϕ₃)
end

into

struct ControlField <: ParameterizedFunction
    parameters::ComponentVector{Float64,Vector{Float64},Tuple{Axis{(ΔT₁=1, ΔT₂=2, ΔT₃=3, ϕ₁=4, ϕ₂=5, ϕ₃=6, E₀₁=7, E₀₂=8, E₀₃=9)}}}
    a::Float64
end

# Maybe also a nice keyword-argument constructor here.

function (E::ControlField)(t)
    @unpack E₀₃, ΔT₁, ΔT₂, ΔT₃, ϕ₃ = E.parameters
    _tanhfield(t; E₀=E₀₃, t₁=(ΔT₁+ΔT₂), t₂=(ΔT₁+ΔT₂+ΔT₃), a=E.a) * sin(ϕ₃)
end

Question 1: That seems like it should be doable, right? (Anybody getting hooked to write part of that macro for me? :wink: ) The @unpack in the output would have to be handled via macroexpand, right? Any other useful tips for going about this?

Question 2: Is my understanding correct that any code using that macro would need to import the names used in the expanded version:

using ComponentArrays: ComponentVector, Axis
using UnPack: @unpack # not needed if I'm using `macroexpand`, I guess
using QuantumPropagators.Controls: ParameterizedFunction

Or is there some way where these names only need to be available in the place where the macro is being defined?

I think it should not.
I view macros as a shortcut to typing more. As such, they should just return what they saved the user from typing, which is your second code block and that contains a “raw” @unpack.

Any other useful tips for going about this?

@macroexpand and @macroexpand1 are your friends for developing :slight_smile:

Yes, same idea as above. If the user would have typed it out, they would have also needed the usings to make it run. I guess you could also include the using statements in the expanded macro expression, but I never thought about its pros and cons and I always require the usings to be added by the user (which is always myself :slight_smile: ).

With regards to skipping using UnPack. I guess that could work, because IIRC @unpack falls back to getproperty. However, I personally would regard that as an implementation detail and not rely on macroexpand to remove all ties to UnPack. In theory it could be that in the future @unpack expands to something like Unpack.mygetproperty and then it breaks. I was wrong, @unpack expands to Unpack.unpack, so macroexpand won’t work, see post below.

1 Like

Are you saying it is possible to have a macro call inside another macro?

(Any example for that in the wild?)

using UnPack

macro unpack_with_message(msg, ex)
  return esc(quote
    println($msg)
    @unpack $ex
  end)
end

a = (; x1=1, x2=2, x3=3)

@unpack_with_message "unpacking something" x1 = a

display(@macroexpand1 @unpack_with_message "unpacking something" x1 = a)

display(@macroexpand @unpack_with_message "unpacking something" x1 = a)

yields

julia> include("mwe_goerz.jl")
unpacking something
quote
    #= /home/flo/wd/scratch/julia/mwe_goerz.jl:5 =#
    println("unpacking something")
    #= /home/flo/wd/scratch/julia/mwe_goerz.jl:6 =#
    #= /home/flo/wd/scratch/julia/mwe_goerz.jl:6 =# @unpack x1 = a
end
quote
    #= /home/flo/wd/scratch/julia/mwe_goerz.jl:5 =#
    println("unpacking something")
    #= /home/flo/wd/scratch/julia/mwe_goerz.jl:6 =#
    begin
        #= /home/flo/.julia/packages/UnPack/EkESO/src/UnPack.jl:100 =#
        local var"##240" = a
        #= /home/flo/.julia/packages/UnPack/EkESO/src/UnPack.jl:101 =#
        begin
            x1 = (UnPack).unpack(var"##240", Val{:x1}())
        end
        #= /home/flo/.julia/packages/UnPack/EkESO/src/UnPack.jl:102 =#
        var"##240"
    end
end

So I was actually wrong, and UnPack does not use getproperty, but instead UnPack.unpack. (its actually documented GitHub - mauro3/UnPack.jl: `@pack!` and `@unpack` macros). So I would not really work with just macroexpand, I think.

Sorry, can’t think of one right now, but I use it in my own projects quite frequently.

1 Like

Another gem is MacroTools.jl, in particular prewalk(rmlines, ex) to remove those line blocks when developing.

2 Likes

This can be done more straightforwardly with MacroTools.striplines(ex).

1 Like

To question 1: Inserting another macro call into the expansion does not pose any problems and does not require any special handling. When the newly passed expression gets smaller, you can even use the same macro in its expansion recursively:

macro or(exprs...)
    if isempty(exprs)
       :(true)
    else
       :(let v = $(exprs[1])
             if v
                v
             else
                @or $(exprs[2:end]...)
             end
         end)
    end
end

It’s very rare that you need to call macroexpand inside macro code and in any case, probably on the expression that what passed as an argument, i.e., to look inside a macro it was handed. I would not bother about that at the moment (and it won’t be necessary for your macro anyways).

2 Likes

Here is a quick and dirty attempt:

module CoolMacro

using MacroTools
using Parameters
using ComponentArrays

abstract type ParameterizedFunction end  # Should import this ...

# Some helper functions creating the expansion
function split_args(kwexprs)
    params = []
    consts = []
    for expr in kwexprs
        # Note: You might not want to ignore val here!
        if @capture(expr, name_::T_ = val_) || @capture(expr, name_::T_ = val_)
            push!(consts, name => T)
        elseif @capture(expr, name_ = val_) || @capture(expr, name_)
            push!(params, name)
        end
    end
    (params, consts)
end

function expand_struct(name, params, consts)
    # Note: You might want to parameterize the eltype!
    axistype = Expr(:tuple, (:($x = $i) for (i, x) in enumerate(params))...)
    paramtype = :(ComponentVector{Float64,Vector{Float64},Tuple{Axis{$axistype}}})
    :(struct $name <: ParameterizedFunction
          parameters::$paramtype
          $((:($x::$(esc(T))) for (x, T) in consts)...)
      end)
end

function expand_call(name, arg, params, body)
    # Note: I don't bother to unpack only the required parameters!
    :(function (E::$(esc(name)))($arg)
          @unpack $(Expr(:tuple, params...)) = getfield(E, :parameters)
          $(esc(body...))
      end)
end

# The final macro
macro parameterized(fundef)
    @capture(fundef, function name_(arg_; kwargs__) body__ end) || error("Function definition with specific format expected!")
    params, consts = split_args(kwargs)
    struct_def = expand_struct(name, params, consts)
    call_def = expand_call(name, arg, params, body)
    :(begin
          $struct_def
          $call_def
      end)
end

end # module

You can check the expansion via:

julia> @macroexpand1 CoolMacro.@parameterized function ControlField(
           t; # variable
           ΔT₁, ΔT₂, ΔT₃, ϕ₁=0.0, ϕ₂=0.0, ϕ₃=0.0, E₀₁, E₀₂, E₀₃, # parameters
           a::Float64=100.0  # constants (deteced by having type annotation)
       )
           _tanhfield(t; E₀=E₀₃, t₁=(ΔT₁+ΔT₂), t₂=(ΔT₁+ΔT₂+ΔT₃), a=E.a) * cos(ϕ₃)
       end

To question 2: If you are careful in using esc the definitions in the expansion should refer to the place where the macro has been defined, i.e., module CoolMacro, and not where it’s used. Thus, the user should not need to include Parameters for the macro to be able to use @unpack … not quite sure, if including it is necessary to run the function created by the macro though.

In any case, your desired macro is quite large and requires some care. You certainly want to read and understand the section on macro hygiene.

2 Likes

Wow, thanks everyone! These are some great tips and starting points!