Julia's Design Choices for Keyword Arguments in Function Calls and Broadcasting

Hello Julia community, hello internals folks!

I’ve been increasingly appreciating Julia’s thoughtful design decisions. Recently, I noticed something interesting about keyword arguments and why positional arguments must be explicitly named in function calls.

For comparison, in Python:

def myfun(a, *, b):
    print(a, b)
myfun(1, b=3)  # works as expected
# myfun(1, 3)  # fails as expected
# but
myfun(a=1, b=3)  # works, though conceptually inconsistent with Julia's approach

Julia takes a clearer stance on this, which I initially only partially understood. Today, I was thinking about broadcasting over keyword arguments and found this in the docs/en/v1/manual/functions/#man-vectorized:

“Keyword arguments are not broadcasted over, but are simply passed through to each call of the function. For example, round.(x, digits=3) is equivalent to broadcast(x → round(x, digits=3), x).”

While I don’t have a specific use case for broadcasting over keyword arguments, I’m curious about:

  1. The decision to make keyword arguments unmentionable in function calls
  2. The decision to not broadcast over keyword arguments

Could someone explain the reasoning behind these choices and whether there’s a connection between them? I’m interested in understanding the language design philosophy here.

I think part of the answer here is the design decision that keyword arguments do not participate in dispatch. This causes a clear semantic difference between keyword and positional args.

Of course, this just raises the question of why keyword args don’t play a role for dispatch. I think the reason is that keyword arguments don’t have a fixed order and are usually optional. The former point of course could be resolved by additional logic. The latter point however could lead to clashes very easily if different methods try to use different default values I think. Decoupling dispatch from keyword arguments makes them much more free.

2 Likes

Could you explain:

"keyword args … are usually optional … (this) could lead to clashes very easily if different methods try to use different default values I think. "

with an example?

Also could you explain if this does not happen for positional argument which may be optional too?

Am I assuming correctly you’re mistakenly saying “positional” instead of “keyword” sometimes? Keyword arguments are what needs to be named in calls, positional arguments cannot be named in calls.

If you’re trying to say that Python has positional-or-keyword arguments instead of strictly positional (unnamed) arguments, then Python 3.8+ actually has those as well:

def myfun2(p1, p2, /, pk1, pk2, *, k1, k2):
    print(p1, p2, pk1, pk2, k1, k2)

PEP 570 describes the rationale, but the bulk of it concerns API development. For example, if you allow specifying a name as a keyword (a for myfun), then you’re stuck with it for backwards compatibility.

As for why Julia doesn’t have positional-or-keyword arguments at all, it just makes multimethod dispatch inherently ambiguous, even if we’re still not dispatching with respect to keyword arguments. Languages can only pull off positional-or-keyword arguments with unimethods.

This is a very separate topic from the above. I don’t know exactly why and I haven’t been able to find any statements explaining this. However, we can make a very reasonable guess. Even in languages that don’t have a discrepancy in the capabilities of positional versus keyword arguments, keyword arguments aren’t used the same way as positional arguments. The primary utility of keyword arguments is to specify a few values by name out of many default values. Broadcasting doesn’t play nicely with default values, and we can demonstrate it with default positional values:

julia> foo(a = [1 0;0 1], b = [2, 3]) = a*b;

julia> foo() # expected matrix multiplication
2-element Vector{Int64}:
 2
 3

julia> foo([1 0;0 1], [2, 3]) # expected same result
2-element Vector{Int64}:
 2
 3

julia> foo.() # expected result of broadcasting over nothing
2-element Vector{Int64}:
 2
 3

julia> foo.([1 0;0 1], [2, 3]) # uh oh, now it's elementwise multiplication
2×2 Matrix{Int64}:
 2  0
 0  3

See, we don’t expect to broadcast over default values we may not even be aware of, and the language also can’t parse dotted calls to broadcast over absent arguments. Consequently, we don’t typically have collections as default positional values in practice.

Reasonably, the response would be that we would only broadcast over arguments we specify. That could actually be allowed for keyword arguments, though we would now need new syntax to manually opt in to broadcasting in order to remain backwards-compatible with the current state where no keyword arguments are broadcasted over:
Proposal: keyword argument broadcasting · Issue #34737 · JuliaLang/julia
However, there wasn’t the demand to put it in v1 and it still doesn’t have any motion today. Elementwise operations across several input dimensions typically involve a small fixed set of inputs, and those don’t tend to end up as keyword arguments in practice. Infix operators can only have 2 positional arguments, after all. For now, in the rare case someone does want to do this, it can still be done with a full broadcast call with the kernel function passing positional arguments to keyword arguments e.g. broadcast((p1, k2p) -> foo(p1; k1=k2p), P, K).

2 Likes

This old comment succinctly describes my thoughts:

I [typically] think of keyword arguments as the “how to process” and the positional args as my “what to process.” I find I’m much more likely to broadcast over the whats than the hows, but there are definitely cases where I’ve wanted the latter.

Broadcasting does a lot of implicit magic. And implicit magic is one of those things that feels powerful but ironically the more implicit magic you have the less powerful it ends up being in practice because it becomes harder to read and understand. Where you draw the line at “too much magic” is gonna be a very opinionated design choice. Personally, I think we ended up a little too far over that line into the too-much-magic-land.

11 Likes