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:
|
|
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
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 aUnion{Missing,T}
instead ofUnion{Nothing,T}
. ↩︎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 constructsExpr
s with the head:kw
instead. Since our@?
macro here doesn’t have any context to work with, it has to hard-code the use ofExpr(:kw, ...)
rather thanExpr(:(=), ...)
, and that necessarily limits the scope of this transformation. ↩︎