Naming positional arguments at call site

I’m much the same… as a recent example within a little Python script I wrote yesterday:

def main( filename ):
    image = get_image( filename)
    generate_base_mesh( width=image.size[0], height=image.size[1] )
    map_image_to_sizing_mesh( image)
    generate_final_mesh( width=image.size[0], height=image.size[1], min_ratio=0.001, max_ratio=0.004, elem_type="tri" )

With all the methods using positional arguments. That code style is definitely my personal preference; I probably won’t look at this code again for a year or more, and I like how it’s largely self-documenting as opposed to:

def main( filename ):
    image = get_image( filename )
    generate_base_mesh( image.size[0], image.size[1] )
    map_image_to_sizing_mesh( image )
    generate_final_mesh( image.size[0], image.size[1], 0.001, 0.004, "tri" )

which requires me to read the rest of my code to find out what 0.004 sets.

But there are some very good reasons mentioned above for why named positionals aren’t going to happen in Julia, and some good-enough workarounds if you wish you had them. And, admittedly, the workarounds are pretty obvious.

5 Likes

This seems overstated a bit as it should be enough to see the call signature or docstring of generate_final_mesh. Both would be readily accessible in modern IDEs, but I agree that the latter is more readable especially if no IDE is available for whatever reason.
On the other hand, imho, reliance on gobal state (or where else is the mesh defined?) is a larger problem in that code snippet than the keyword syntax.

This code snippet demonstrates what I don’t really enjoy about ‘Python style’. Really long names with functions that seem too specific, a ‘wall of text’ effect, referencing the arguments in the function name, underscores, etc.

Imo it would read much more clearly with something like map(image, sizing_mesh), where the function is a verb and the arguments are nouns, and don’t bleed into the function name. (Yeah, I know map is taken.)

Clean code should have concise, clear names, in my opinion.

(Sorry to pick on you, btw, but this seems so much like ‘classic’ Python style, which is why I find it hard to understand why it (Python style) is held up as an example of clarity, while I find it kind of ‘messy’.)

4 Likes

On the other hand, imho, reliance on gobal state (or where else is the mesh defined?) is a larger problem in that code snippet than the keyword syntax.

The mesh gets written to a file and I just hardcoded the names. It was a little script I wrote around 1AM to create the “trimesh photos” that are currently circulating on LinkedIn (see imgur link below). The Python is part of our softwares API and supports solution-based refinement - so the process involves writing a mesh file, adding “results” via the mesh formats own Python API, reading back in.

No worries, I wouldn’t have aired my dirty laundry if I didn’t intend for people to comment on it!

I agree, in general with clean code needing to have clear names - in my own experience I find that most code I read “in the wild” looks something like (# Comments mine):

// from our C++ codebase, written five years ago
// What's pp? Dim? What's `r`?  What's `V`? 
pp = basis::FnBernsteinBasis::simplexMultiply( mDim, mDeg, mV, r.mDeg, r.mV );
// PETSC
KSP_PCApplyBAorAB(ksp, VEC_VV(it), VEC_VV(1 + it), VEC_TEMP_MATOP)
# From DifferentialEquations.jl, 
function default_algorithm(prob::DiffEqBase.AbstractBVProblem; kwargs...)
    o = Dict{Symbol, Any}(kwargs)
    extra_kwargs = Any[]
    alg = Shooting(Tsit5())  # What is Tsit5? Does Shooting() apply a shooting method on this invocation, or returning a name? What is alg?
    uEltype = eltype(prob.u0) # What is eltype? Is it an "Element Type"?  What's u0?

    alg_hints = get_alg_hints(o)  # Wait, what's `o`?

    alg, extra_kwargs
end

Some languages even tout the fact that you can write complex programs within (original sized) tweets, and issuing challenges to their readers to interpret “mystery programs” like that’s a good thing.

With exception of the “Tweet a Program” examples, these are all examples that I’m sure feel clean to the original developer, but to me… well, I’ve spent hours in rabbit-holes with each of them.

If I were to write the first and last examples above in my own preferred style, sans language limitations, I’d write them as:

degree_coeffs_pair =  basis::FnBernsteinBasis::simplexMultiply( along_dimension = mDim,
                                                                left_simplex_degree = mDeg, 
                                                                left_simplex_coefficients = mV, 
                                                                right_simplex_degree = r.mDeg, 
                                                                right_simplex_coefficients = r.mV );
function default_algorithm( problem::DiffEqBase.AbstractBVProblem; kwargs... )
    optional_keyword_arguments = Dict{Symbol, Any}(kwargs)
    extra_kwargs = Any[]
    algorithm = OrdinaryDiffEq.SingleShooting( integrator = SimpleDiffEq.Tsitouras54RungeKutta( ) )
    
    initial_values_eltypes = Base.eltypes( prob.initial_values ) 
    algorithm_hints = get_algorithm_hints( optional_keyword_arguments )

    return algorithm, extra_kwargs
end

But that’s just my preference, and I certainly don’t mean to impose it on others.

4 Likes

At the risk of bikeshedding here, I’m struggling to understand this example, because even in Python, your collaborator could write the function call

sample(Xoshiro(0), NUTS(), my_loglikelihood)

and send you running to the documentation (in fact, this happens to me all the time, and in many cases the problematic “collaborator” is my very self). OTOH, if you are the one writing the code, there is nothing stopping you from writing (in Python or Julia)

sample(
    Xoshiro(0),         # rng
    NUTS(),             # sampler
    my_loglikelihood    # model
)

to provide inline documentation.

So, it looks like what the situation really calls for is a way to enforce self-documenting code by making these keyword-only arguments, and Julia and Python already support this feature via

sample(; rng, sampler, model) = ...

and

def sample(*, rng, sampler, model):
    ...

respectively.

Now, I am the weird case of a developer who first learned to write “real” code[1] in Julia and then migrated to Python because it’s what my clients use, so I can accept the possibility that my intuitions are the weird ones here, but if you find ambiguous function calls like sample(Xoshiro(0), NUTS(), my_loglikelihood) to be frustrating, isn’t Python the more irritating language in this regard?

You are probably right, but I (personally) am interested in the discussion about the motivation because I would like to learn new design patterns and their pitfalls so I can get better at the craft.


  1. Defined as writing my own libraries and APIs for them as opposed to just stringing existing modules together in a script or ipynb—but I realize this is a bit gatekeepey, so please understand that this is just a term I’m using locally and I don’t have any prejudice against those who find existing libraries adequate for their needs. ↩︎

3 Likes

Possibly there’s a miscommunication here, because already the second example in the docs you linked here shows the pattern that I mean (passing posargs as kwargs for clarity), and that you assert “is not why people pass arguments by keyword”:

def parrot(voltage, state='a stiff', action='voom', type='Norwegian Blue'):
    #...

parrot(voltage=1000)

I also do C++, and find it irritating that I cannot use argument names in a call, especially when calling decades-old legacy code or solvers/ctors with sometimes a dozen parameters. I know it’s “bad” if code is structured like that, but typically there’s not much I can do about it, I only call it.
If you have a bunch of func(double, double, bool, double, double, double, double,...) you have to rely on your IDE to untangle that, or end up counting args manually, and that is (IMO unnecessarily) error-prone.

I feel that all the other approaches (adding additional comments, defining extra variables to pass in,…) mostly just add noise to the code and require additional effort. :person_shrugging:

Putting aside the fact that parrot’s arguments are all positional-or-keyword and that the tutorial soon moves on to explain you can’t pass position-only arguments with keywords, there are also 2 calls with positional arguments, 2 calls where keyword arguments defy positional order, and 1 call with both. Feel free to interpret the 1 other call as a demonstration of keyword clarity, but I don’t come to the same conclusion when taking all the examples into account. If I had to infer anything, it seems like parrot(voltage=1000) is intended to correspond to the 1st example parrot(1000), and the tutorial does not compare their clarity at all.

In fact, the parrot function seems like a good demonstration of how keywords can be too short to explain anything. What does voltage have to do with a parrot? A state of what? An action by or toward the parrot? Why is the builtin keyword type being shadowed here and used for a color? Or is that the parrot’s species? It’s the print statements in the function body that actually explain the meanings of the keywords. It may be easy to make examples where keywords solve clarity problems, but it’s just as easy to make examples where they don’t.

Even assuming when keywords are enough for clarity, it only saves typing when the value is given by a literal. I don’t run into that often, so I end up with variables and cheap calls, alongside comments if needed. In that case, keywords become redundant in calls like someweirdplot(times, altitudes, figure = figure2, width = width(figure), height = figure.height, linestyle = linestyles[i]). Such redundancies are commonplace in documentation and tutorials, and it is only worth it when providing the values out of order and skipping over many default arguments.

The discussion is really interesting in showing how many faces there are to readable/good/clean/ code. Imho, in most examples discussed keyword arguments are arguably the least important facet:

  • basis::FnBernsteinBasis::simplexMultiply already has too many arguments and would benefit from a proper simplex data type, i.e., giving a type signature simplexMultiply(::Int, ::Simplex, ::Simplex). Then, the names almost don’t matter and a call like simplexMultiply(d, l, r) reads just fine with little clutter.

  • The default_algorithm method from DifferentialEquations seems fine with me, i.e., short, simple and clear, except that it contains some unused code and should just be:

    function default_algorithm(prob::DiffEqBase.AbstractBVProblem; kwargs...)
        extra_kwargs = Any[]
        # alg, algo or algorithm would all be clear enough here
        alg = Shooting(Tsit5())  # Should have a rough idea that these name algorithms when working in ODEs
    
        alg, extra_kwargs
    end
    

    Similarly, when working in the corresponding domain and reading sample(Xoshiro(0), NUTS(), my_loglikelihood), I should probably know that Xoshiro and NUTS are RNGs and MCMC methods respectively, i.e., naming them would be purely redundant.

  • parrot(voltage=1000) just as @Benny and even with all those keyword names, I could not guess what this is supposed to do anyways.

Probably some of the best tips on clean code I have come across are in Peter Norvig’s Tutorial on Good Lisp Programming Style (Especially the refactor on pages 50-52 is great). In the end, better understanding of a problem/domain/program is what leads to better code …

3 Likes

generate_final_mesh seems like a prototypical example of a function that should have keyword args not positional ones. The fact that Python lets you pass the positionally seems bad, imo.

2 Likes

I’m guessing you’re not a monty python fan.

No way, “how could I miss [that]. Next time definite!”