What is the best way to require exactly 1 of 2 keyword arguments when writing a function?

I have a function “counts” that divides a sorted array of floats into subarrays based off a specified maximum value and an integer number of divisions, and then counts the length of each subarray, returning an array of lengths - the “counts” of each subsection. Currently it is something like

function counts(arr, max, divisions) ...

called with
counts(arr, 5.0, 5)
This function works fine for this, but I would like to overload the function to be able to express the required subsections as either an integer number of divisions, or a float interval size. This could be easily achieved by

function counts(arr, max, divisions :: Int) ...
function counts(arr, max, interval :: Float64) ...

However this could easily lead to misuse, where a user would get a very different result from using a float instead of an int. To avoid this, it would make sense to use keyword arguments, such that the function might be called with
counts(arr, 5.0, divisions = 5)
or
counts(arr, 5.0, interval = 1.0)
Thereby making it clearer to the user what they are doing. Exactly one of these should be required.
Here lies the problem. I can easily make keywords required by not assigning a default value

function counts(arr, max; interval :: Float64, divisions :: Int) ...

but I don’t want both to be required, since either one is sufficient, and preferably I don’t want the user to try and specify both since it can only lead to mistakes. I don’t think I can do this in separate definitions as when they were not keywords, since the second just overwrites the first definition. One workaround I can imagine is to assign both keyword arguments a default value (such as 0) and then have the function check that exactly one is assigned to a non-zero value, and throw an error if not. That makes some sense since the given argument should not be 0 anyway, but manually checking that the function has been given the correct number of arguments feels wrong. Alternatively I could have an overload with one mode as a required keyword and the other not a keyword:

function counts(arr, max, divisions :: Int) ...
function counts(arr, max; interval :: Float64) ...

which I have a feeling some native functions may do. Is this the best approach?

Here are a couple of options:

Option 1: Multiple keywords and a run-time check:

Just set each keyword’s default to nothing and verify that one of them is not nothing inside the body of the function. This requires slightly more typing, but it should have little or no runtime cost.

julia> function counts(arr, max; interval::Union{Float64, Nothing} = nothing, divisions::Union{Float64, Nothing} = nothing)
         if interval === nothing && divisions === nothing
           error("Please provide `interval=` or `divisions=` as keyword arguments")
         end
         do_stuff()
       end
counts (generic function with 1 method)

Option 2: Wrapper types

Create a wrapper type to indicate what kind of computation you are requesting, and then implement separate methods for those computations:

julia> struct Divisions
         divisions::Int
       end

julia> struct Interval
         interval::Float64
       end

julia> function counts(arr, max, divisions::Divisions)
         do_stuff()
       end
counts (generic function with 4 methods)

julia> function counts(arr, max, interval::Interval)
         do_stuff()
       end
counts (generic function with 4 methods)

julia> counts([], 1, Divisions(2))

julia> counts([], 1, Interval(2))

This method will be easier to work with if you want to add some third method later on, since you just add more struct definitions and methods for counts, rather than having to edit an increasingly long list of keywords. This method should also impose little or no run-time cost.

9 Likes

I think @rdeits gave good solutions. Although instead of option 2 it might just be simpler to just use two different function names something like countsByDivisions and countsByInterval. Unless there is another reason to keep the function names the same. :slight_smile:

1 Like

Option 3: Multiple keywords forwarded to multiple dispatch

count(arr, max; divisions::Union{Int,Nothing} = nothing, interval::Union{Float64,Nothing} = nothing) = 
      count_impl(arr, max, divisions, interval)
count_impl(arr, max, divisions::Int, interval::Nothing) = do_stuff()
count_impl(arr, max, division::Nothing, interval::Float64) = do_stuff()

This is very similar to Option 1 but has zero runtime cost.

One drawback of Option 2 is that names like Divisions and Interval are likely to clash with other packages.

3 Likes

From the Book of Ill-advised Dispatch Patterns in Julia (Miskatonic University Press, 2027, forthcoming):

f(; kwargs...) = _f((; kwargs...))
_f(kwargs::NamedTuple{(:interval,)}) = "$(kwargs.interval) interval"
_f(kwargs::NamedTuple{(:divisions,)}) = "$(kwargs.divisions) divisions"

In practice, I would go with Option 1 by @rdeits.

5 Likes

Thanks for the suggestions. I had thought of option 1 but I’m trying to avoid it on the principle that runtime checks for types should be a last resort. Option 2 is an interesting thought that I hadn’t considered. It certainly does what I want, although it feels slightly more convoluted than I like. I might avoid it on this occasion, but I will keep it as a tool for potential future use.

That… would make sense. In this circumstance it is as good a solution as any. It doesn’t exactly answer my question about keyword arguments but I will very likely end up doing this. I have a habit of trying to make use of overloading more than is perhaps wise…

FWIW, the code I posted doesn’t actually cause any cost at run-time because the compiler is smart enough to figure out what will happen just from the types of the arguments. Check out this example:

julia> function foo(x)
         if x === nothing
           return "hello world"
         else
           return 1.0
         end
       end
foo (generic function with 1 method)

This looks like it’s type-unstable because it could either return a String or a Float64 depending on the value of x. But the compiler is smart enough to know that the only case for which x === nothing holds is if x is of type Nothing. So it can actually figure out which branch of the if statement will be taken just from the type of the argument, and no checks have to happen at run-time. For example:

julia> @code_warntype foo(nothing)
Variables
  #self#::Core.Compiler.Const(foo, false)
  x::Core.Compiler.Const(nothing, false)

Body::String
1 ─ %1 = (x === Main.nothing)::Core.Compiler.Const(true, false)
│        %1
└──      return "hello world"
2 ─      Core.Compiler.Const(:(return 1.0), false)
julia> @code_warntype foo(2)
Variables
  #self#::Core.Compiler.Const(foo, false)
  x::Int64

Body::Float64
1 ─ %1 = (x === Main.nothing)::Core.Compiler.Const(false, false)
└──      goto #3 if not %1
2 ─      Core.Compiler.Const(:(return "hello world"), false)
3 ┄      return 1.0

The if statement has been removed at compile time, and there’s no actual type instability.

This is a pretty convenient pattern in Julia: just write out the code in a straightforward obvious way, including using run-time checks if that’s convenient. You can use @code_warntype to check for type instabilities and @btime (from BenchmarkTools) to check for performance problems and only go to more complicated type manipulation if it’s actually necessary.

3 Likes

That’s an interesting workaround. I think it’s probably the closest answer to what I wanted. Am I correct in thinking that count(arr, max) will still throw an error at runtime, but here explicitly using the internal type checking rather than a manual error branch?

Wow that’s impressive work from the compiler! I haven’t delved into those meta tools yet, I will have to give them a try.

Am I correct in thinking that count(arr, max) will still throw an error at runtime, but here explicitly using the internal type checking rather than a manual error branch?

Yes. Except that one could argue that the error occurs at compile time rather than runtime, but the difference isn’t all that relevant in Julia.

Here’s a concrete example:

julia> foo(;a::Union{Int,Nothing} = nothing, b::Union{Int,Nothing} = nothing) = foo_impl(a,b)
       foo_impl(a::Int, b::Nothing) = println("foo(a = $a)")
       foo_impl(a::Nothing, b::Int) = println("foo(b = $b)")

julia> foo()
ERROR: MethodError: no method matching foo_impl(::Nothing, ::Nothing)

julia> foo(a = 42)
foo(a = 42)

julia> foo(b = 42)
foo(b = 42)

julia> foo(a = 42, b = 42)
ERROR: MethodError: no method matching foo_impl(::Int64, ::Int64)

As you can see, you indeed get an error for argument combinations that you did not define, but the error message might be a bit confusing for a user. But you can easily fix the error message by adding more methods to foo_impl().