Convenient sentinels for keyword arguments in Julia

When writing a high-level interface function that takes many keyword options, the desired default values may sometimes be best set conditionally based on the values of other keywords. In such a case, handling the interdependency among keywords can be easiest if the function declaration uses a sentinel value like nothing, but then the type restrictions must be rewritten to Union{T, Nothing} in every case, which can get tedious. With a relatively simple macro, though, we can automate this transformation.


Introduction

I’ve recently been playing around with and making use of Julia’s macros more extensively. It mostly started with PR JuliaIO/HDF5.jl#664 where I wrote a code generator for writing the very repetitive boiler plate functions that wrap HDF5’s C library functions into error-checked Julia functions. That spurred some more macro development, in both HDF5.jl with an attempt at hiding some implementation details and in my own new package UnixMmap.jl to define OS-specific @enum and @bitflag data types.

With that context in my mind, I realized there’s a simple macro which can help simplify some function definitions similar to those being used in UnixMmap.jl. For example, the following function prototype from src/UnixMmap.jl has several keyword arguments that contain nothing default values:

203
204
205
206
207
208
function mmap(io::IO, ::Type{Array{T}}, dims::NTuple{N,Integer};
              offset::Union{Integer,Nothing} = nothing,
              prot::Union{MmapProtection,Nothing} = nothing,
              flags::Union{MmapFlags,Nothing} = nothing,
              grow::Bool = true
             ) where {T, N}

Using nothing sentinel defaults instead of some static value was motivated by the fact that there are several overloads with different positional arguments, and (a) keeping all signatures in sync is easier when they can all share the same few lines in the function declaration and (b) the default values can have non-trivial interactions with each that are easier to write out in the function body than to try to compress into the declaration itself.

The @? macro

The suggested syntax in some of the previously linked Julia issues is to use the question mark ? appended to various contexts (function calls, variable names, etc). Without modifying the parsing rules for Julia, though, the tool we have available is instead a macro, and thankfully the macro call @? itself is valid syntax in all Julia v1.0+ versions (though defining the macro itself across all versions does require a bit of extra help).

The macro defined below supports translating the following declarations:

f(@?(a::String = nothing))
g(@?(b::Int) = 1)
h(@?(c::Float64))

into

f(a::Union{Nothing,String} = nothing)
g(b::Union{Nothing,Int} = 1)
h(c::Union{Nothing,Float64})

Namely, the macro supports recognizing both just the variable-type part of the expression (i.e. @?(b::Int) and @?(c::Float64)) as well as the full assignment assignment expression (i.e. @?(a::String = nothing).1

import Base.Meta: isexpr

# var"x" syntax was added in Julia v1.3; full support for the feature cannot be emulated,
# but this simple non-standard string literal is sufficient for our needs here.
@static if VERSION < v"1.3"
    macro var_str(x)
        esc(Symbol(x))
    end
end

macro var"?"(expr::Expr)
    hasassign = false
    if isexpr(expr, :(=))
        hasassign = true
        assign = expr.args[2]
        expr = expr.args[1]
    end

    if !isexpr(expr, :(::))
        error("Expected a typed variable name `var::type`, got `", expr, "`")
    end

    var = expr.args[1]::Symbol
    typ = expr.args[2]::Union{Symbol, Expr}
    varexpr = Expr(:(::), var, Expr(:curly, :Union, :Nothing, typ))
    if hasassign
        varexpr = Expr(:kw, varexpr, assign)
    end
    return esc(varexpr)
end

With this macro, the mmap declaration could be rewritten instead as:

function mmap(io::IO, ::Type{Array{T}}, dims::NTuple{N,Integer};
              @?(offset::Integer = nothing),
              @?(prot::MmapProtection = nothing),
              @?(flags::MmapFlags = nothing),
              grow::Bool = true
             ) where {T, N}

which save a bit on typing and also arguably makes the intended types of the keyword arguments more obvious.

Conclusions

Note that the goal here is a much smaller syntax transformation than issues like Julia’s #34821/#36628 or the ecosystem’s Maybe.jl package are trying to solve — those issues are trying to add syntax to the language that “lifts” particular expressions to containing either missing or nothing values in a wide variety of circumstances. Here, I’m interested only in a very limited transformation, applies very specifically in the context of function parameter declarations.2


  1. With the assignment form, one can easily modify the macro to allow for slightly more complex rewriting, such as if the assigned value is missing, the generated expression is a Union{Missing,T} instead of Union{Nothing,T}↩︎

  2. This scope restriction is actually partly motivated by how Julia lowers function arguments and keywords with default values to the AST expressions. Rather than using Expr nodes with :(=) heads as you might guess from the code syntax of an assignment expression, the lowered form in function declarations actually constructs Exprs with the head :kw instead. Since our @? macro here doesn’t have any context to work with, it has to hard-code the use of Expr(:kw, ...) rather than Expr(:(=), ...), and that necessarily limits the scope of this transformation. ↩︎