Functions as a struct Field

I am aware of some related discussion (Have a function field in a structure, using another field in the structure and Alternative to function as field in struct), but the discussion there is insufficient for my case. Therefore, it is probably useful to start a separate thread here.

If, for example, I need to define a type AtomicOrbital as

struct AtomicOrbital
    n::Int64
    l::Int64
    m::Int64
    radial_function::Function
end

where the radial_function should be specified by the user to describe the radial part of the atomic orbital. The definition above is not completely satisfactory, since Function is an abstract type and such definition will hurt performance. However, storing the radial_function inside the struct seems to be the most reasonable way to organize data.

To eliminate the abstract type, the above type can be defined as

struct AtomicOrbital{T<:Function}
    n::Int64
    l::Int64
    m::Int64
    radial_function::T
end

However, I am not completely comfortable with the above definition. One reason is that this definition does not seem to be logical to me. r -> exp(-r) and r -> exp(-2r) lead to two different concrete types, which is logically weird. Another reason is that I am not sure whether this approach will put too much pressure on the Julia compiler.

Another way to improve the performance is to define an abstract type

abstract type AbstractAtomicOrbital end

function get_radial end

and ask the user to defined custom types as

struct MyAtomicOrbital <: AbstractAtomicOrbital
    n::Int64
    l::Int64
    m::Int64
end

function get_radial(orbital, r)
    return exp(-r)
end

However, with this approach, I partially lose control of how AtomicOrbital is defined. If, for some reason, I want to define

function transform_orbital(orbital::AbstractAtomicOrbital)
    # do something to define new orbital
    return new_orbital
end

to transform one atomic orbital into another (e.g., make the radial function decay quicker). I have no idea how transform_orbital should be defined, since I don’t have information how MyAtomicOrbital is defined by the user.

So how do people generally handle this kind of situation, which I believe is not that rare in the real world?

Another related question: why Julia does not have something like Function{Float64,Float64} to specify the input and output of a function? It seems to solve the above issue.

If you have a class of functions with some parameter, like e^{-\alpha r} for different decay rates \alpha, you can make them the same type by defining a callable struct for that set of functions, storing the parameter as an explicit field. For example:

struct ExpDecay <: Function
    α::Float64
end
(f::ExpDecay)(r) = exp(-f.α * r) 
2 Likes

How about a functor?

struct ExponentialRadialDecay
    k::Int
end
(erd::ExponentialRadialDecay)(r) = exp(-erd.k*r)

Then k can be a fixed parameter of the type.

julia> erd1 = ExponentialRadialDecay(1)
ExponentialRadialDecay(1)

julia> erd2 = ExponentialRadialDecay(2)
ExponentialRadialDecay(2)

julia> erd1(1)
0.36787944117144233

julia> erd2(1)
0.1353352832366127
1

julia> erd2(0.5)
0.36787944117144233
1

julia> erd1(log(1))
1.0

julia> erd2(0.5log(1))
1.0

You can add it to AtomicOrbital

julia> struct AtomicOrbital
           n::Int64
           l::Int64
           m::Int64
           radial_function::ExponentialRadialDecay
       end

julia> ao = AtomicOrbital(0 ,0, 0, erd1)
AtomicOrbital(0, 0, 0, ExponentialRadialDecay(1))

julia> ao.radial_function(0)
1.0

julia> ao.radial_function(log(2))
0.5
2 Likes

Also FunctionWrappers.jl might do what you want.

3 Likes

@stevengj @mkitti Thank you, but I really want radial_function to be completely general. \exp (-\alpha r) was only an example.

@bertschi FunctionWrappers.jl seesm to be what I needed, although I don’t really understand how it is implemented within 149 lines of code.

Currently I am using the following approach:

I still define AtomicOrbital as

struct AtomicOrbital
    n::Int64
    l::Int64
    m::Int64
    radial_function::Function
end

but I use a function barrier whenever I need to use radial_function. What do you think of this approach?

Edit: more explicitly, I define

function get_radial(orbital::AtomicOrbital, r::Float64)::Float64
    return orbital.radial_function(r)
end

and call get_radial instead of orbital.radial_function anywhere else in the code.

You probably will be safer using the concrete type for the atomic orbital field, and the functor if you need many different functions that are variations of some set of types (which is probably the case).

Otherwise you’ll carry these type instabilities in your code, and that may make your debugging hard later on.

In all cases, you will have to take care in not iterating over arrays of atomic orbitals that contain different types of functions in hot loops, because you will unavoidably (with any of the approaches) hit some dynamic dispatch, independently of the return types being the same, or the function types being concretely typed in the struct.

Even with FunctionWrappers.jl? For example, FunctionWrapper{Float64, Tuple{Float64, Float64}} seems to be a concrete type.

The srtuct will be concrete, but I don’t think it will handle splitting the iteration on different calls. That would need a macro to be applied at the loop level.

JET.jl detects runtime dispatches at a FunctionWrapper call because of a possible reinit_wrapper call on the contained function and its type in ::Any fields to make a valid function pointer after instantiation during precompilation, but otherwise that pointer is already made at instantiation. I’m pretty sure in that case there’s no runtime dispatch, at least it doesn’t contribute allocations like runtime dispatches typically do.

Incidentally, use reinit_wrapper on existing FunctionWrapper instances after you edit methods, the function pointer is not updated automatically.

2 Likes

I believe that FunctionWrappers.jl should do what you want and be fine to store many different functions with identical signatures without causing type instabilities due to abstract Function supertypes or requiring dynamic dispatch.

My understanding is that a FunctionWrapper is concretely typed and requires no dynamic dispatch at a call site. FunctionWrappers takes a Julia function (with arbitrarily many methods) and uses a concrete call signature to select a single specific method instance of that function. That method instance is stored as a function pointer that is directly callable (and is called via some version of @ccall, probably). Thus, no dynamic dispatch is required (or even possible) at the call site.

Somebody with more knowledge or experience with FunctionWrappers may need to correct me on a few points.

2 Likes

I would do something like this:

abstract type RadialFunctionType end

struct ExponentialDecay{T} <: RadialFunctionType
    factor::T
end

struct AnotherTypeOfFunction <: RadialFunctionType
    ...
end

radialfunction(x::ExponentialDecay) = (r -> exp(-x.factor * r))
radialfunction(x::AnotherTypeOfFunction) = ... # whatever

struct AtomicOrbital{R<:RadialFunctionType}
    n::Int64
    l::Int64
    m::Int64
    radial_function::R
end

Then, you can do:

function transform_orbital(orbital::AbstractAtomicOrbital{R}) where R
    # do something to define new orbital
    # (the parameter R tells what radial function is originally involved)
    return new_orbital
end

Does this solve your needs?

Thank you. I guess this is as good as it can be.