Virtual functions, overloads, and default parameters - pick one
In C++ function can have different properties.
In its simplest form, a function has a return type (or void
) and accepts one or more parameters.
// a function taking no parameters and returning an int
int foo();
// usage
int a = foo();
// a function that takes two parameters of type int that does not return anything (returns type is void)
void bar(int, int);
//usage
bar(1,2);
Contrary to C, a function can have one or more default parameters. A default parameter is useful if a parameter has a sensible default value, and you do not want the caller to write it every time.
// a function that takes two parameters of type int, the second one with a default argument
void foo(int, int = 12);
// usage
foo(1,2);
foo(1); // equivalent to foo(1,12)
A function can have overloads. An overload is a separate function with the same name, but a different signature (different number of parameters and/or different types of parameters)
void foo(int);
void foo(char);
// usage
foo(1); // calls foo(int)
foo('a'); // calls foo(char)
In C++ there are also member functions. Member functions that are marked virtual
can be overridden by a derived class.
struct base{
virtual int foo(int);
int bar(int); // no virtual
};
struct derived : base {
int foo(int) override;
int bar(int); // no override
};
void baz1(base* ptr){
// usage
base* ptr = ...
ptr->foo(1); // calls dynamically base::foo or derived::foo, depending on the object pointed by base*
ptr->bar(1); // always calls base::foo
}
void baz2(derived* ptr){
// usage
base* ptr = ...
ptr->foo(1); // calls dynamically base::foo or derived::foo, depending on the object pointed by base*
ptr->bar(1); // always calls derived::foo
}
Just like normal functions, virtual functions can have overloads and default parameters, and overloaded functions can have default parameters too.
Why should you pick only one?
To avoid surprises!
Virtual functions and overloads
struct printer{
virtual void print(const char*);
virtual void print(int);
};
struct advanced_printer : printer {
void print(const char*) override;
};
void baz1(printer* ptr){
ptr->print("hello"); // calls dynamically printer::print or advanced_printer::print
ptr->print(1); // calls dynamically printer::print or advanced_printer::print
}
void baz1(advanced_printer* ptr){
ptr->print("hello"); // calls dynamically printer::print or advanced_printer::print
ptr->print(1); // does not compile
}
Note that all overloads do not need to be virtual to reproduce the error:
struct printer{
virtual void print(const char*);
void print(int);
};
struct advanced_printer : base {
void print(const char*) override;
};
void baz1(printer* ptr){
ptr->print("hello"); // calls dynamically printer::print or advanced_printer::print
ptr->print(1); // calls printer::print
}
void baz1(advanced_printer* ptr){
ptr->print("hello"); // calls dynamically printer::print or advanced_printer::print
ptr->print(1); // does not compile
}
will have the same issue
Workarounds
If you can change advanced_printer
, then add the missing overloads:
struct advanced_printer : printer {
void print(int) override;
using printer::print; // make overloads visible
};
void baz1(advanced_printer* ptr){
ptr->print("hello"); // calls dynamically printer::print or advanced_printer::print
ptr->print(1); // calls dynamically printer::print or advanced_printer::print
}
If you cannot change advanced_printer
, then the simplest workaround is to convert advanced_printer*
to printer*
:
struct advanced_printer : printer {
void print(int) override;
using printer::print; // make overloads visible
};
void baz1(advanced_printer* ptr_){
printer* ptr = ptr_;
ptr->print("hello"); // calls dynamically printer::print or advanced_printer::print
ptr->print(1); // calls dynamically printer::print or advanced_printer::print
}
Fix
Do not mix overloads and virtual functions.
If the print
functions have a common implementation, or even call each other, move the implementation to one protected virtual function:
struct printer{
void print(const char*); // calls print_impl internally
void print(int); // calls print_impl internally
protected:
virtual void print_impl(const char*);
};
struct advanced_printer : printer {
void print_impl(const char*) override;
};
If there is no common implementation, or if it makes sense to provide the ability to specialize the functions independently, add one protected virtual function per overload, ideally named differently.
struct printer{
void print(const char* str){
return print_impl1(str);
}
void print(int i){
return print_impl2(i);
}
protected:
virtual void print_impl1(const char*);
virtual void print_impl2(int);
};
struct advanced_printer : printer {
void print_impl1(int) override;
};
Further advantages
Having a non-public virtual function means that the base class controls the entry and exit points of the customization points of the whole class hierarchy.
This makes it possible, for example, to enable or disable logging uniformly for all derived classes, add pre and post-conditions, or even define other types of hooks, without changing anything in the derived classes.
struct base{
int foo(int i){
assert(i>0);
int res = foo_impl(i, 0);
assert(res<0);
return res;
}
int foo(int i, int j){
assert(i>0);
int res = foo_impl(int, int);
assert(res<0);
return res;
}
protected:
virtual int foo_impl(int, int );
};
struct derived : base {
int foo_impl(int, int) override;
};
Virtual functions and default parameters
struct printer{
virtual void print(const char*, int = 80);
};
struct advanced_printer : base {
void print(const char*, int ) override;
};
struct very_advanced_printer : derived {
void print(const char*, int = 120 ) override;
};
void bar1(base* printer){
ptr->print("", 0); // calls virtual print with "" and 0 as arguments
ptr->print(""); // calls virtual print with "" and 80 as arguments
}
void bar2(derived* ptr){
ptr->print("", 0); // calls virtual print with "" and 0 as arguments
ptr->print(""); // fails to compile, user must provide second parameter
}
void bar3(derived2* ptr){
ptr->print("", 0); // calls virtual print with "" and 0 as arguments
ptr->print(""); // calls virtual print with "" and 120 as arguments
}
Ideally bar1
, bar2
, and bar3
should behave the same, especially since they only call virtual methods. But default parameters are not inherited, which makes using the class more error-prone than it needs to be.
Workaround
The main workaround is to ensure that all overridden functions specify the same default parameter. This is unfortunately error-prone, and in the language itself, there is no tool for verifying if all defaulted parameters are the same.
The second workaround would be not to default the parameters in the derived classes. Instead of having inconsistencies at runtime, a compile error is easier to deal with.
Fix
Remove defaulted parameters from virtual functions.
struct printer{
void print(const char* str, int width = 80){
return print_impl(str, width);
}
protected:
virtual void print_impl(const char*, int);
};
struct advanced_printer : base {
virtual void print_impl(const char*, int);
};
struct very_advanced_printer : derived {
virtual void print_impl(const char*, int);
};
void bar1(printer* ptr){
ptr->print("", 0); // calls virtual print_impl with "" and 0 as parameters
ptr->print(""); // calls virtual print_impl with "" and 80 as parameters
}
void bar2(derived* ptr){
ptr->print("", 0); // calls virtual print_impl with "" and 0 as parameters
ptr->print(""); // calls virtual print_impl with "" and 80 as parameters
}
void bar3(derived2* ptr){
ptr->print("", 0); // calls virtual print_impl with "" and 0 as parameters
ptr->print(""); // calls virtual print_impl with "" and 80 as parameters
}
The function call is consistent in all three cases, and it is not possible to add an inconsistency by accident.
Function overloads and default parameters
When a function has both overloads and default parameters, there can be some combinations that are more or less useless:
void foo(int);
void foo(int, int = 10);
void bar(){
foo(42);
}
or
void foo(int, char = 'a');
void foo(int, int = 10);
void bar(){
foo(42);
}
In both cases, the compiler will not know which overloaded function to call.
Since overloads can provide a superset of the functionality of default parameters, the simplest "fix" is to prefer overloads to default parameters.
void foo(int);
void foo(int, int);
void bar(){
foo(42);
}
and
void foo(int);
void foo(int, char);
void foo(int, int);
void bar(){
foo(42);
}
Further considerations
While for free functions, overloads and default parameters are not as problematic when interacting with virtual functions, they can still cause issues when someone wants to use functions as parameters (for example for an algorithm), as they need to work with function pointers.
Function pointers are confusing in the presence of default arguments because the function signature doesn’t match the call signature. Adding a default argument to an existing function changes its type, which can cause problems with code taking its address.
void foo(int, int = 0);
void bar(){
foo(42);
auto f = &foo;
f(42); // does not compile
f(42, 0); // need to spell default parameters explicitely
}
Using function overloads avoids this particular issue, but makes taking the address of a function more difficult.
void foo(int);
void foo(int, int);
void bar(){
foo(42);
auto f = &foo; // does not compile with auto
using F = void(int);
F* f1 = &foo; // need to write explicit type out
auto f2 = static_cast<F*>(&foo); // or to make a cast
f1(42);
f2(42);
}
Another solution for both issues is to use a lambda:
void foo(int, int = 0);
void bar(){
foo(42);
auto f = [](int i){foo(i);};
f(42);
}
and
void foo(int);
void foo(int, int);
void bar(){
foo(42);
auto f = [](int i){foo(i);};
f(42);
}
Since the lambda is stateless, it can even be converted to a function pointer.
Conclusion
When using virtual functions, avoid default parameters and overloads. Default parameters are not inherited, and function overload will hide non-overridden functions.
When using non-virtual functions, having default parameters and overloads is not as problematic, but can cause some surprises at compile-time.
The main advantage of default parameters is having only one function. An often overlooked detail is that the default parameter is created before the function is called, which makes classes like std::source_location
extremely useful.
But if there are already function overloads, then mixing overloads and default parameters might not bring any benefit, and you can still use std::source_location
by packing multiple parameters together in a structure, as shown here.
Do you want to share your opinion? Or is there an error, some parts that are not clear enough?
You can contact me anytime.