Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
Problems with default function arguments (2020) (quuxplusone.github.io)
31 points by flipchart on Oct 11, 2023 | hide | past | favorite | 27 comments


As someone who does not do modern C++ development, the problems they bring up seem to largely be of the flavor that positional parameters work poorly with default values in C++. The reason being that all default values do is give you the ability to elide the last N arguments. This is much worse than automatic selection of the correct overload based on the supplied arguments.

However, it seems to me that you could entirely resolve this problem by having true keyword arguments and default arguments must be keyword arguments; they can not be supplied positionally. If you then want some syntactic sugar in your API, you can then do what the author suggests as a solution to default values and supply variants that explicitly pass "defaults" to the master function.

Keyword arguments allows defaults while still allowing exact control over the arguments, exact control over the "overload selected" without explicit variants, and the ability to supply explicit variants if you want API ergonomics. The only downside I can see (relative to the solutions presented by the author) is that you have to write some extra argument names when passing non-default values to any variants that do not trivially pass them through, but that is a pretty minor cost especially with a IDE that can autocomplete the keywords, and it provides extra useful documentation to the maintainer and client, so it is not even all bad.


Python has supported keyword-only arguments since 3.0, and added positional-only arguments in 3.8+ <https://docs.python.org/3/whatsnew/3.8.html#positional-only-...>:

  def name(positional_only_parameters, /, positional_or_keyword_parameters, *, keyword_only_parameters):
      pass
I don’t use Python much these days, and Rust and JavaScript don’t have equivalents, so I haven’t thought much about this, but my gut feeling is that there’s probably never a good reason to support taking an argument by both position and keyword, that it should instead be one or the other. But as I say, I haven’t meditated on this. Curious if any popular linting tool has rules to detect any of these three classes of argument (… and also args and *kwargs).


In the Julia language overloading only considers positional arguments (which must be given positionally) with keyword arguments entirely separate (and they must be given as keyword arguments).

I quite like that approach.


Keyword/labeled arguments are also much more robust against API changes breaking silently.


This is what C# does, for example.


Not really. You can have default values for positional arguments just fine in C#.


One might be able to force names with code style.


Sure, the pitfalls are real. But they have benefits too. A fair assessment should examine both sides before coming to a decision, not just one. Off the top of my head, these come to mind:

- Defaults avoid having to duplicate the rest of the function (everything outside the body braces) just for the sake of an additional parameter. It might not be a big deal for reset() which is simple and only takes one argument anyway, but for other functions it can be a lot of boilerplate to keep in sync with the main overload: everything from the documentation, templates, parameter names, parameter types, etc. needs to be duplicated and kept in sync.

- Without default arguments, you lose the ability to capture parameters by-value with guaranteed move/copy elision. You have to capture by reference and then construct at least one instance that you otherwise be able to elide. Sure, you don't need that performance all the time, but that's not the point. The point is there are times when you do.

- "Go to definition" in your IDE goes directly to the place you care about; "find all references" finds all references directly.

- Optionals are just way easier to read. Otherwise every reader or maintainer must read every other parameter and ensure they're all forwarded 1:1 without side effects to understand if the semantics of the call are identical with the optional parameter supplied explicitly.


It's a funny coincidence that your username is "dataflow" when that is exactly what's broken with default arguments: you can't pass the default values around, they can't flow in the code.

If you want to create a proxy function to a function that has default arguments, and want to transparently allow the "default" features to be used from the wrapper function as well, then you have to duplicate the default value in the signature of the wrapper function.

There are other problems, for example due to the nature of function call syntax with positional arguments.

The solution is: Use a struct to hold default values.

    struct FooDefaults
    {
        int arg1 = 3;
        int arg2 = 7;
    }

    void FooFunction(int x, int y, FooDefaults defaults)
    {
        ...
    }

    void usage_code(...)
    {
        int x = 1;
        int y = 2;
        FooDefaults defaults;
        defaults.arg2 = 9;
        FooFunction(defaults);
    }


> you can't pass the default values around, they can't flow in the code.

I see you worked very hard on those contortions just to find some way to call them dataflow ;)

The values can obviously be passed around just fine. The issue is duplication of their source of truth, not their inability to be passed around. And the duplication of the source is easy enough to fix - if you don't want to hard-code them then you can just make a static function (or constant) that returns them so callers can refer to that same value without duplicating the source of truth. No need to throw entire the baby out with the bathwater.

(And the struct solution is an alternative to unnamed arguments, not to default arguments per se. It has its own advantages and disadvantages.)


To be more precise you can't read default values from the source of truth, which is the parameter list. There is syntactically no way, and they aren't materialised in any accessible way at compile time.

> you can just make a static function (or constant) that returns them so callers can refer to that same value without duplicating the source of truth

Oh, but now you have duplicated it. Maybe not a value literal, but you still have to synchronize the default value expression (constant reference or whatever) between the parameter list and every other place that is interested.

And at any place that is not interested in such data you still have to meticulously forward the precise list of default values in the right order to a final consumer of the data.

You cannot

    void func(int x = 1, int y = 2, int z = 3);

    void usage()
    {
        int x = gimme_default(func, x);
        int y = gimme_default(func, y);
        int z = gimme_default(func, z);
        ...
    }
You also cannot

    void func(42, PASSDEFAULT, -1);
As you explained you can

    constexpr int FUNC_DEFAULT_X = 1;
    constexpr int FUNC_DEFAULT_Y = 2;
    constexpr int FUNC_DEFAULT_Z = 3;

    void func(int x = FUNC_DEFAULT_X, int y = FUNC_DEFAULT_Y, int z = FUNC_DEFAULT_Z);

    {
        int x = FUNC_DEFAULT_X;
        int y = FUNC_DEFAULT_Y;
        int z = FUNC_DEFAULT_Z;
        ...
    }
but that's already too painful for me to write, when the more realistic setting is that there is also

    void func_variant1(int foo, int x = FUNC_DEFAULT_X, int y = FUNC_DEFAULT_Y)
    {
         do_foo(foo, x, FUNC_DEFAULT_Y);
         func(x, y, FUNC_DEFAULT_Z);
    }

    void func_variant2(int foo, int bar, int x = FUNC_DEFAULT_X, int y = FUNC_DEFAULT_Y, int z = FUNC_DEFAULT_Z); // etc

There is already significant boilerplate / repetition and I have a sense that there will be a new FUNC_DEFAULT_W coming soon that has to be inserted in 53 places. This is the textbook application for structs, which can abstract over sets of primitive data items.

Code that makes significant use of default arguments always has that unstable, arbitrary sense to it, with function parameter lists that grow too long, and it always seems to be badly structured and fragile. I cannot prove it formally but I have the hair to prove that I've spent months of my life refactoring such code.


   void FooFunction(int x, int y, optional<int> optarg1 = {}, optional<int> optarg2 = {}) {
     int arg1 = optarg1.value_or(3);
     int arg2 = optarg1.value_or(7);
   }

   void usage_code() {
     FooFunction(x, y, {}, 9);
   }
I.e. for complex interfaces defaulted arguments should default to an out-of-band placeholder, not to the actual value.

I do like the struct as well, but it is still not ideal if you want to use initializers. I.e this doesn't work in C++:

   FooFunction(x, y, {.arg2 = 9});
You have to specify all preceding values FooFunction(x, y, {.arg1 = 2, .arg2 = 9});

Works better with optional (and converting everything to a struct):

   FooFunction({.x = x, .y=x, .arg1 = nullopt, .arg2 = 9});


All C++ specific though right? Trying to imagine the insanity that would be Numpy (or xyz other Scipy lib) without default function args ...


Yea. And there's even pitfalls the author didn't list. My biggest pet peeve about C++ default arguments it that they are compile time inserted based on the header file. So imagine:

void foo(int a = 1) {}

Now you ship this in a DLL, but realize the default was bad, so you change it to 2 and ship the new DLL. Well, the 1/2 isn't in the DLL at all. It's only in the header file. So everyone must recompile. Fun times.


Technically that's a subtly incompatible ABI change. ABI changes can be notoriously tricky to spot. That's handled properly on an ELF-based system by bumping the SONAME major (if the library developer knows what they're doing) but that won't help non-ELF platforms or vendored uses.

Of course, the same problem applies in other languages if a constant value changes. A preprocessor macros in C, for example. It's all about non-manifest interfaces and "making things easier" so that any idiot can write software (so they do). Public APIs can be hard when the public is imperfect.


> Technically that's a subtly incompatible ABI change

Yea, that's what I said. And it's subtle because of the language making it a foot gun.

> Of course, the same problem applies in other languages if a constant value changes.

That's just C++ the apologist talking.


This may just be my inexperience with the intricacies of C++, but it seems like a decent number of the problems raised here could be fixed by allowing one to name default args like proper keyword arguments. Consider the first issue discussed and how naming the argument just fixes it:

  print_square('*');
  print_square(fill='*'); // Hypothetical fix
It also seems to be an issue exacerbated by C++ implicitly converting a char into an int.

> The client programmer doesn’t want a “puzzling” print_square(x) that treats x sometimes as a side length and sometimes as a fill character!

This is also fixed if C++ allowed you to name keyword arguments explicitly (and didn't sometimes implicitly convert types, but that's baked in pretty hard by now).

> The “boolean parameter tarpit”

Again, imagine how much more understandable it would be if you could write:

  doTask(task1, 100s, catchStderr=true);
I wonder whether the author would change their stance on default args if they were made to be more usable.


Default arguments are used for sake of the DRY principle. Whenever it is highly likely that the majority of cases of application of a specific function are going to use the same parameter value it can be considered reasonable to imply this parameter value as a default while allowing it to be specified explicitly whenever it make sense to tweak it. Using overloading to implement this pattern would be way more verbose, introduce unnecessary complexity and actually repeat some code.


Let's be real, default arguments are most commonly used because the programmer is lazy and doesn't want to update the function call in 200 places.


Why would overloading repeat the code? It's typical to do it like that (in Java):

public int doStuff(int a, int b) { //some code }

public int doStuff(int a) { return doStuff(a, 7);} // so b=7 by default.

No need to repeat actual business logic code.

Of course that gets complicated if there are many parameters with default values.


I mean yeah, in a language without default arguments, it certainly is typical to use the only other way (overloading) to easily emulate that behaviour.


Are there any languages that do not have positional arguments at all and they’re always keyword arguments?

My pet peeve is reading code and trying to reason about

    foo(true, 1,  1, null, “cupcakes”)


I believe in Smalltalk all method arguments are by keyword. Objective-C probably has a similar restriction. I've never written any serious code in either, so I could be wrong.


Not a language, but I have seen some recent editor integration with LSP stuff that will visually display that as

        foo(bake: true, batches: 1,  boxes:1, frosting: null, type: “cupcakes”)


Oh that’s helpful. I should look into setting that for TS.

Also frosting can’t be null when type is cupcakes. We found a bug.


Swift, more or less.


void log(const std::string_view message, const std::source_location location = std::source_location::current());




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: