[ANN] ArgumentModes.jl - an implementation of set-of-symbols like type for function arguments

I’d like to announce the package ArgumentModes.jl (also available in the registry) which provides type Mode for usage as a function argument type and in function calls. Conceptually it is similar to Val type, but working only with symbols. The main advantage of Mode is that it could contain a set of symbols, and the dispatch process can refer to exact combination of the symbols when the method is being chosen. Moreover, Mode type of a function argument could be defined to be able to accept only prescribed list of symbols – then the method would be chosen only if a Mode instance has all or any subset of the prescribed symbols. As an optional feature – each of symbols might also have additional parameters with prescribed types, and the dispatch process considers the types of the parameters’ types.

As I see, the main advantages of using Mode could be:

  • Specialization and dispatch based on the value of a Mode instance - in many case no additional code or runtime overhead is needed.
  • Since the list of allowed symbols for a method is defined directly in a function signature, in most cases the dispatch process would control for typos in symbols names. Moreover, the “Closest candidates are:” section of MethodError would contain meaningful description of possible symbols for corresponding methods.

Here an example:

julia> f(x::Real) = println("Number: $x")
       f(x::Mode[:iterator => Any]) = println("Values from iterator: $(x[]...)")
       f(x::Mode[:fromargs], y...) = println("Fromargs: $(y...)")
       function f(x::Union{Int, Mode[:a, :b, :c => Int]})
           checkmode(x, :a) do _;  println(":a")  end
           checkmode(x, :c) do c;  println(":c = $c")  end
           checkmode(x, :b) && println(":b")
           if x isa Int;  println("x = $x") end
       end
f (generic function with 4 methods)

julia> methods(f)
# 4 methods for generic function "f":
[1] f(x::Mode[:iterator => Any]) in Main at REPL[34]:1
[2] f(x::Mode[:fromargs], y...) in Main at REPL[36]:1
[3] f(x::Union{Int64, Mode[:c => Int64, :a, :b]}) in Main at REPL[38]:1
[4] f(x::Real) in Main at REPL[33]:1

julia> f(25.0)
Number: 25.0

julia> f(Mode(:iterator)=> 1:5)
Values from iterator: 12345

julia> f(Mode(:fromargs), 1, 2, 3, 4, 5)
Fromargs: 12345

julia> f(1)
x = 1

julia> f(Mode(:a, :c => 125))
:a
:c = 125

julia> f(Mode(:fromargs, :iterator => 1:5), 2)
ERROR: MethodError: no method matching f(::Mode[==, :iterator => UnitRange, :fromargs], ::Int64)
Closest candidates are:
  f(::Mode[:fromargs], ::Any...) at REPL[36]:1
Stacktrace:
 [1] top-level scope
   @ REPL[59]:1

The specialization of Mode with the list of accepted symbols for function arguments is generated using the call of form Mode[:symbol1, :symbol2, symbol3 => Tuple{Int, AbstractString}, ... ]. An instance of Mode for a function call is created with constructor Mode(:symbol1, symbol2, :symbol3 => (1, "Hello"), ... ). Another form for the instance construction (which is a product of my dislike of the nested parentheses) is Mode(:symbol1) ~ Mode(:symbol2) ~ Mode(:symbol3)=>(1, "Hello") ~ ....

The value of an additional parameter for a symbol could be extracted from a Mode instance as m[:symbol1]. Or for several symbols (as a tuple): m[:symbol1, :symbol2, ...]. A call m[] would test whether the instance m has exactly one symbol and return the value associated with it.

A function checkmode is also defined to test a Mode instance for the symbols. For example:

julia> m = Mode(:a => 1, :b => 2.5, :c)
Mode[==, :b => Float64, :a => Int64, :c]((a = 1, b = 2.5, c = nothing))

julia> checkmode(m, :a)
true

julia> checkmode(m, :d)
false

julia> checkmode(m, |, :a, :d)
true

julia> checkmode(m, &, :a, :d)
false

julia> checkmode(m, ==, :a, :b)
false

julia> checkmode(m, ==, :a, :b, :c)
true

julia> checkmode(m, :a) do a;  @show a   end
a = 1
1

julia> checkmode(m, :a, :b) do a, b;  @show a, b   end
(a, b) = (1, 2.5)
(1, 2.5)

julia> checkmode(m, :a, :e) do x, y;  @show x, y   end

julia> checkmode(12, :a)
false

Please, refer to the in-julia documentation of the type and the function for more detailed information.

Series of tests showed that the current implementation of Mode fully compiles out when it used for function arguments and method dispatch, so it seems that there is no runtime overhead for using it (at least for the use cases considered in the tests).

I’m not sure if the concept is actually any good, I guess I would know myself only from attempts of using it. As of now I see 2 uses of Mode:

  1. Replacement of using ordinary Symbol as a function argument as a flag
    parameter. Here Mode allows to explicitly declare a function argument as a
    set of symbols (flags) with a distinct list of accepted symbols for each of
    a function’s methods. For example, open(f, Mode(:read)), open(f, Mode(:write)),
    open(f, Mode(:write, :sync)) might correspond (depending on the design)
    to 3 different methods.

  2. The way to explicitly show in which meaning a value to a function argument
    is provided. This might be useful when it is not possible to distinct the
    meaning of an argument only by its type.

    For example, suppose a function f processes array-typed objects with
    arbitrary dimensions number. We might want to declare methods for both
    processing a single object and an iteratable collection of objects. It
    would be difficult to distinct the methods using only the type of the
    argument since f([x,y]) could both mean to process a single object [x,y]
    or to process two objects x and y. However, using Mode in the
    declaration of method for a collection, the user would be allowed to
    explicitly indicate what is passed in the call: f([x,y]) for a single
    object and f(Mode(:collection)=> [x, y]) for a collection of objects.

5 Likes