Here comes another post on named arguments ("keyword" arguments).
I am sure there is no need for an introduction to the topic, so I will go straight.
Part 1. Inferior workarounds
1. "Just use types instead"
This does not work in cases where we have more then one argument of the same type.
Even a trivial example like setSize(int width, int height) works only because we use an implicit convention - width comes first.
Note, width and height are cross-assignable, which makes introducing a new type 100% superfluous.
Also note the high cost of maintenance of additional classes.
If we change the example to child(name mother, name father), we completely lose expressiveness:
child("Tony Robinson", "Dustin Hana");
This solution also does not scale well with libraries which use predominately basic types like integers and floats - math and physics libraries are a great example of this.
Even the standard library is not an exception:
// note, angle phi and coordinate x are both the same type
sph_legendre( unsigned l, unsigned m, double θ );
assoc_legendre( unsigned int n, unsigned int m, double x );
// the call to such functions is, understandably, quite unclear
sph_legendre(3, 0, 1.2345);
assoc_legendre(2, 0, .5);
2. "Just use named functions"
Named functions are poor-man's alternative to named arguments - they can't name individual arguments and do not scale.
sph_legendre(3, 0, 1.2345); //< named, yet still unclear
child::withMotherAndFatherAgedWithSiblings(. . .); //< does not scale well to multiple arguments
Other than that, if the function is a static function, used as "named constructor", this means we lose all benefits of a constructor - library and metaprogramming construction support, deduction guides, explicitness of what creates instances, and more.
3. Use "multi-stage construction" [if you need multiple arguments in a constructor]
Read more about this advice here
This is an ok advice as long as the design of the class is not changed to enable this kind of initialization, otherwise it is an antipattern - one both introduces new public interfaces and additional mutability into the class.
Even when it is ok, it is far from great - the constructor, along its benefits, is used only partially; it is not clear if the order function calls is important; you can't easily create a const variable; the solution ends up actually extremely verbose.
This solution does not scale and it has massive code duplications. It is also by no means obvious what is expected from the user of the interface and if order of calls is important.
5. Use an arguments aggregate class, together with designated initializers.
Multitude of issues - maintenance cost, code duplication, does not scale, problems with overloading, the interface actually hides the arguments into a new object, implementation is severely affected by the interface.
6. Lastly, almost all of the alternatives have an additional drawback - the naming is mandatory. In practice, we often have variables, which can stand in for the arguments names.
const auto mother = ". . .";
const auto father = ". . .";
child::withMotherAndFather(mother, father); //< needlessly verbose
Part 2. Problematic solutions
1. "Just works"
This solution envisions C/C++ arguments be used as-they-are and the client opts-in to get a warning on mismatch.
First, there is the fundamental problem with the fact that something, which has been private for 40+ years, is now essentially made public. This is wrong on its own.
Even if we ignore that, the solution is not practical:
- Different implementations have different naming (and we can't force standardization of all arguments, forever);
- Any change in the implementation naming will trigger false positives (and the majority of the library code has no separate implementation)
- This solution is too taxing for library creators - suddenly we demand, stable arguments for all your interfaces, forever. And you have nothing to do about it.
- No interface ever needs all arguments named.
- Warnings are actually not good enough - the type of problems, rising from wrong arguments are just too severe, as they are, in a way, a breach of contract.
2. "It must [just] work with the standard library"
This requirement is unfounded.
Most if not all of the arguments made in "Just works" apply here as well, but there is one additional observation - the standard library does not really need named arguments.
The reason for this is simply, because it was designed that way. There are multiple ways, used to get around current limitations, first and foremost - named functions, but also use of types (chrono) and small number of similar arguments (algorithms)
This is not to say, the standard library can't take advantage of named arguments, but this can happen only in specific places and after careful consideration - the standard library will never need all arguments to be named!
3. The developer [just] marks arguments to become names
This has the reverse problem such as, the public interfaces dictates the implementation - the library author is forced to use names, not helpful to him, but helpful to the client, calling the function.
This is not good. In a way, for the client to win, the author has to lose, yet it is the library author who actually uses these arguments!
4. "Names must be mandatory/part of the signature"
This is a non-starter for C++ because of generic code and variadic forwarding.
Part 3. Suggested approach
1. Names are separate from the arguments. This leaves the implementation free to change the names it uses and also lets user-facing names be specifically designed to be a public interface.
child(name mother: mom, name father: dad);
copy(const Path& src, const Path& to: dst); //< copy("/path/to/image", to: "/some/other/path")
replace(const QByteArray& ol, const QByteArray& with: nw) //< var.replace("yes", with: "no")
replace(int at: pos, int count: len, const QByteArray& with: after) //< var.replace(at: 5, count: 4, with: "Bye")
template<class D, class K>
setData(D&& v, const K& forKey: key); //< setData("data"s, forKey: "one"s);
If the argument is omitted, the name can be reused as an argument, but only if there is no default value.
slider(int value:, pair<int, int> range: minmax = {0, 99});
2. Names are not mandatory and don't change the signature
As mentioned, often times we have suitably named arguments, no need for extra verbosity, be it as a function or argument name:
copy(path, destpath); //< clear enough
Pointers and function objects are not names-aware:
using p = void(const Path&, const Path&);
p = ©
p("/path/to/image", "/some/other/path");
//p("/path/to/image", to: "/some/other/path"); //< error
p = &rename; //< allowed
3. Names are declared using the same rules as default arguments - only one visible declaration must have names.
namespace {
auto child(name mother: mom, name dad);
// auto child(name mother: mom, name dad); //< error
// auto child(name mom: mom, name dad); //< error
auto child(name mutter, vater);
auto child(name mother, name father)
{
. . .
}
}//< ns
4. The argument name is a compiler-brokered "handshake" b/w the library author and the user and both must agree on it.
It is an error to pass a name to an argument that it is not declared to have name.
void func(int arg);
// user
func(arg: 5); //< error
There is one exception. If, and only if, the argument is forwarding reference and it is forwarded,
then the forwarding function is allowed to be called with an argument name, even if one is not declared for that argument.
5. Because names are purely compile time, and perfect forwarding is always inline, names are able to change call sites.
This is done by the compiler alone, by prepending calls to std::forward with the name, used on the forwarded argument, for the call of the enclosing function.
void func(int& foo: arg);
template<class T>
void fwdfunc(T&& arg) //< no name for the argument
{
func(std::forward<T>(arg));
}
// user
int val = 5;
fwdfunc(foo: val) //< allowed
// generates
void fwdfunc(int& && arg)
{
func(std::forward<int&>(arg));
// => func(foo: std::forward<int&>(arg))
// => func(foo: int&)
}
If the forwarding function declares a name for that argument, the user specified name is checked against that name instead and std::forward is called normally.
6. Calling a function with a name for an argument, but with no value for that argument, implies default value.
This rule has two important applications.
First it allows us to explicitly pass defaulted arguments.
func(int a, int b: b = 10. int c: c = -1, string text = "hello");
func(10, b:, c:, "bye");
Second, it enables using default arguments in environments where default arguments are currently considered bad practice.
func(Somethings some, M magic: m = "goodMagic");
func(something, magic:); //< clear, magic is involved
7. Names do not contribute to overload resolution.
Argument names are not part of the type system and not part of the function name, they are last minute confirmation, a handshake, after all the details are resolved.
This way we don't complicate overload resolution further and prevent wrongly remembered, and/or wrongly typed, names to pick the incorrect overload or to fail finding an overload at all.
Letting names "guide" or "early out" overloading means making them stronger or equal to types which is unsound, considering they are optional and not part of the name/signature or the function.
In other words, the times names will help will eventually be countered by the times names will get in the way, yet in the former case, changes to the overloading are needed. That's a bad trade off.
Part 4. What about the standard library?
As said, the standard library as a whole does not really need named arguments, yet some improvements could be made.
Some interfaces could be streamlined and some - made more clear.
For example the _n versions for most algorithms introduce this new function name not because the function does something different, but only to make the calls more clear.
This task could ideally be accomplished by a single named argument:
void fill( OutputIt first, Size count: count, const T& value );
With this interface the user is free to name the, probably confusing, argument
fill(begin, 5, -1); //< confusing (but correct - all fill()-s are under the same name)
fill(begin, count: 5, -1); //< clear
fill(begin, count, -1); //< also clear, without unneeded over-specifications
Even more interesting is the case with search_n. Few things make this interesting.
First is the fact, that although it ends with _n, yet this indicates something completely different then with all other algorithms.
The suffix does not indicate the count of items to operate on, but the count of consecutive values, equal to the 'value' argument.
Second, the suffix does not help making the call more clear, not to the slightest, but what is more interesting - making the arguments names public, does not make things any more clear either!
What we need instead is, actually to do a design work on a argument name, or couple of argument names, in order to make the interface clear and understandable.
Here are few suggestions to highlight the possibilities
FrwIt search( FrwIt first, FrwIt last, Size encounters: count, const T& ofValue: value );
FrwIt search( FrwIt first, FrwIt last, Size consecutiveItems: count, const T& equalTo: value );
FrwIt search( FrwIt first, FrwIt last, const T& forValue: value, Size repeated: count );
// use
search(begin, end, encounters: 5, ofValue: 0);
search(begin, end, consecutiveItems: 5, equalTo: 0);
search(begin, end, forValue: 0, repeated: 5);
This shows clearly, named arguments have no alternative when it comes to expressiveness. It also shows the importance of them being optional, as well as being separate from the argument.
Let's see some more examples where using named arguments make the code notably more clear
create_directory("path/to/directory", attributesFrom: "path/to/file");
nth_element(begin, partitionPoint: (end - begin)*.33, end);
replace(begin, end, 5, with: 1);
sample(begin, end, begin1, samples: 1000, gen);
copysign(2, from: -1 ); assoc_laguerre(n: 1, m: 10, x: 0.5);
sph_legendre(n: 3, m: 0, theta: 1.2345);ellint_1(k: 0, phi: acos(-1)/2);
It bears repeating, names are optional - the user is free to use them only where he considers, there is a need for them.
Part 5. What makes this different the other named arguments proposals?
1. Argue, names need to different from the argument they represent, most of the time, not by exception.
2. Argue, no API needs all arguments named. Names must have targeted, carefully designed use. This includes the standard library.
For example, there is an overarching convention in the standard library, that algorithms take an input range as the first two arguments. These don't need to be named. Some algorithms, which write the result to a new location, take an output iterator as a third argument. This argument should be named, but it is not essential. And then, there is the regex library, which takes output as the first argument. This argument really should be named as it breaks any previous conventions.
regex_replace(out: begin1, begin, end, vowel, "*");
This way the code is both self-documenting and more resilient to calling the incorrect overload.
3. Argue, "forwarding" of names is doable without involving the type system.
4. Argue, not contributing to the overload resolution is a good thing.
Conclusion
Contrary to the common perception, named arguments are actually an enabling technology - they enable interfaces, impractical or even dangerous otherwise.
When used for constructors they promote immutability and minimalistic public interface. For regular functions, they promote natural function naming - what the function does - and natural number of arguments, not limited by readability. They lower maintenance cost and code size by eliminating superficial constructs, used to work around lack of named arguments.
Thank You for Your time!