Search code examples
design-patternsjuliafunction-pointersfunctor

A function as an argument in Julia (best practice / code design)


What is the best/most elegant code design in the Julia programming language for the following problem:

The modules mod1, mod2, mod3, ... implement a complicated function fun(a,b,c), which calculates and returns a matrix M.

A function foo of my program needs to calculate the matrix M repeatably via the function fun with varying input parameters a,b,c. However, the user of my program can specify which module is used for fun. Note, that the user is no programmer and only wants to type something like julia program.jl and specifies the used module with a string like "MODULE = mod2" in some input file.

The most naive and inelegant solution would probably be via if...elseif...

function foo(...
[...]
    if SpecifiedModule == "mod1"
        M = mod1.fun(a,b,c)
    elseif SpecifiedModule == "mod2"
        M = mod2.fun(a,b,c)
    SpecifiedModule == "mod3"
        M = mod3.fun(a,b,c)
    end
    # process M

An alternative way would be to use function-pointers. Or even functors as known from C++?

What is best practice in Julia for this case? Is there a way to get rid of this ugly if..elseif... code?

PS: there are similarities to the case of finding an optimized value of a function, like in the module Optim.jl (https://github.com/JuliaNLSolvers/Optim.jl/). If I am not wrong, a user specified function f is passed as an argument to the optimize(f, ...) function.


Solution

  • Identical return types

    If the return types are guaranteed to be identical, you could use Match.jl and pattern match over Symbols or enums.

    import Pkg
    Pkg.add("Match")
    using Match
    
    function fun(args, SpecifiedModule::Symbol)
        M = @match SpecifiedModule begin
            :mod1 => Mod1.fun(args...)
            :mod2 => Mod2.fun(args...)
            :mod3 => Mod3.fun(args...)
            _ => Mod1.fun(args)  # default behaviour, error handling etc
        end
    end
    
    M1 = fun(args, :mod1)  # call with Mod1
    M2 = fun(args, :mod2)  # call with Mod2
    # ... and so on
    

    This should work fine if the return types of fun are guaranteed to be the same, else there will be type instabilities.

    General return types, but module known at compile time

    If the module name is guaranteed to be known at compile, you can use Val with symbols to use Julia's multiple dispatch/function overloading as a pattern matching mechanism.

    get_module(::Val{:mod1}) = Mod1
    get_module(::Val{:mod2}) = Mod2
    get_module(::Val{:mod3}) = Mod3
    # ... and so on
    
    
    function fun(args, SpecifiedModule)
        get_module(SpecifiedModule).fun(args)
    end
    
    fun(args, Val(:mod1))  # calls Mod1.fun
    fun(args, Val(:mod2))  # calls Mod2.fun
    

    Arguments to Val can only be compile-time known isbitstype values, so you cannot assign e.g. :mod1 to a variable and pass it. Some exposition on Val (full disclosure, I am the author).

    foo as a higher order function

    Just as in the example of Optim.jl, you could let users pass an arbitrary function f like foo(a, b, c, Mod1.fun). This is a natural choice when the input function needs to be somewhat general, such as some function that you want to optimize, differentiate etc. I am not sure about your particular use case, but if you want to restrict the users' choices to a specific set of functions, then it would be better to avoid this.