A case against default values for function arguments

There is a good rule of designing functions (and class methods, which are a variant of functions): a function should do one thing only, and do it well.

A corollary of it is another tactical design rule: a function should have as few arguments as possible. Three is the absolute maximum to allow oneself. Zero arguments is rarely possible, so one or two arguments is the usual norm of good design.

The discussion then continues to explain why boolean arguments are bad (they indicate that the function that accepts them does two things, one for true case and another for false case) and why arguments that can be null are just as bad (again, one behavior is needed for null and another for non-null inputs).

While talking with my students, I heard a couple of interesting attempts to stretch the rules, or to justify situations when having more arguments would be fine. I wanted to dispel these attempts.

«Most important arguments should go first, and less important ones should be at the end»

All arguments to a function should be equally important. Firstly, it ensures strong cohesion between them and the function. A “less important” argument would be more amenable to change than others. Secondly, if you have something resembling a less important argument, it means that you are allowed to pass all kinds of irrelevant values in it. That would mean that the function taking it is not working particularly well, it does not really know what to do with it, or that is not fully understood by the reader.

«Least significant parameters should have default values»

Certain programming languages allow to provide specific values for all or some of function’s arguments. The caller can then omit specifying those arguments when making a call. Python and C++ are just a couple of such languages.

frobulate(arg1, arg2 = default_second_arg) -> return_value_t

Relying on this syntactic sugar is very similar to having aforementioned boolean or possibly-null arguments. Just as before, a default argument means that the function does two separate things.

  1. The first thing is when the default-valued-argument is indeed omitted by the caller. The function has an additional responsibility to decide what its value should be. If the type of the argument is not trivial, the function has to construct it, i.e., it starts to depend on a specific class to invoke its constructor.

  2. The second thing is when the parameter is specified in the call. It means the value is already built somewhere else. The called function could have to do less stuff with it than is done in the previous case, or it could be forced to do more (e.g. ensure validity of the incoming value etc.)

If default arguments are so bad, why do they exist at all?

One reasonable exception to tolerate them is to be able to add new arguments to existing public APIs (but ABI is not always preserved, e.g. in the case of C++, all arguments affect mangled name of the function visible to linker). It allows to preserve existing client code of that API; it continues functioning unmodified.

New clients, created after the argument expansion, can use the new richer argument list, not relying on default values.

This is, of course, a design compromise aiming to preserve legacy public API and its clients. In a freshly created public function, none of its arguments should have default values. In private functions, it should be possible to find and refactor all existing callers by adding the new argument, i.e., there is no need for default arguments either.

A better solution

There is an even better alternative to using default arguments that works even in languages that do not support this syntactic sugar. If you want to add new behavior to existing function, why not just put it into a new function? The price is having to invent a new name for that fresh function. Refactor the old one to become an adapter that adds the missing arguments:

/* frobulate()''s new responsibility is to pass the 2nd argument */

   return_value_t frobulate(arg_t arg1) {
    arg_t second_arg = default_second_arg;
    return frobulate_v2(arg1, second_arg); 
}

return_value_t frobulate_v2(arg_t arg1, arg_t arg2) {
    /* Original logic of frobulate() is extended to react on arg2 */
    ...
}

Existing old clients still see the original frobulate(arg_t). Future new clients are instructed to use frobulate_v2(arg_t, arg_t).

New code can be discouraged to link against or import the old name by decorating it (e.g., [[deprecated]] in C++ and @deprecated in Python). With more attributes visible to the linker (a feature supported by a few runtimes and compilers), we can even hide the original frobulate() and alias the somewhat ugly-named second version frobulate_v2() to the original name, depending on what API version the client code reports to support.

In the case of languages with ad-hoc polymorphism (i.e., syntactic sugar of function overloading ), even these linker shenanigans are not needed. Simply define two same-named functions, but with different lists of argument types. In C++, this works out of the box; in Python, there is @overload decorator to achieve the same thing.


Written by Grigory Rechistov in Uncategorized on 17.12.2025. Tags: api, clean code, design,


Copyright © 2025 Grigory Rechistov