The C++ logo, by Jeremy Kratz, licensed under CC0 1.0 Universal Public Domain Dedication

Virtual functions, overloads, and default parameters - pick one

Notes published the
7 - 9 minutes to read, 1774 words

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.

Example with pre and postconditions
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);
}

Alternate approach

Another workaround is to group parameters in a struct, and use (again), only function overloads.

struct param1{int i; char c = 'a';};
void foo(param1 p);
struct param2{int i; int j = 10;};
void foo(param2 p);

int main(){
  foo(param1{.i=42});
  foo(param2{.i=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.