EXPLORATION 69

image

Overloaded Functions and Operators

Exploration 24 introduced the notion of overloaded functions. Exploration 30 continued the journey with overloaded operators. Since then, we’ve managed to get by with a commonsense understanding of overloading. I would be remiss if I did not delve deeper into this subject, so let’s finish the story of overloading, by examining the rules of overloaded functions and operators in greater depth. (Operators and functions follow the same rules, so in this Exploration, understand that functions applies equally to functions and user-defined operators.)

Type Conversion

Before jumping into the deep end of the overloading pool, I need to fill in some missing pieces with respect to type conversion. Recall from Exploration 25 that the compiler promotes certain types to other types, such as short to int. It can also convert a type, such as int, to another type, such as long.

Another way to convert one type to another is with a one-argument constructor. You can think of rational{1} as a way to convert the int literal 1 to the rational type. When you declare a one-argument constructor, you can tell the compiler whether you want it to perform such type conversion implicitly or require an explicit type conversion. That is, if the constructor is implicit (the default), a function that declares its parameter type to be rational can take an integer argument, and the compiler automatically constructs a rational object from the int, as in the following:

rational reciprocal(rational const& r)
{
  return rational{r.denominator(), r.numerator()};
}
rational half{ reciprocal(2) };

To prohibit such implicit construction, use the explicit specifier on the constructor. That forces the user to explicitly name the type in order to invoke the constructor. For example, std::vector has a constructor that takes an integer as its sole argument, which initializes the vector with that many default-initialized elements. The constructor is explicit to avoid statements such as the following:

std::vector<int> v;
v = 42;

If the constructor were not explicit, the compiler would automatically construct a vector from the integer 42 and assign that vector to v. Because the constructor is explicit, the compiler balks and reports an error.

Another way to convert one type to another is with a type-conversion operator. Write such an operator with the operator keyword, followed by the destination type. Like a one-argument constructor, you can declare the type-conversion operator to be explicit. Instead of the convert or as_float functions in rational, you could also write type conversion operators, as follows:

explicit operator float() const{
    return float(numerator()) / float(denominator());}

One context in which the compiler automatically invokes a type-conversion operator is a loop, or if-statement, condition. Because you are using the expression in a condition, and the condition must be Boolean, the compiler considers such a use to be an explicit conversion to type bool. If you implement a type conversion operator for type bool, always use the explicit specifier. You will be able to test objects of your type in a condition, and you will avoid a nasty problem in which the compiler converts your type to bool and then promotes the bool to int. You don’t really want to be able to write, for example:

int i;
i = std::cin;

In the following discussion of overload resolution, type conversion plays a major role. The compiler doesn’t care how it converts one type to another, only whether it must perform a conversion, and whether the conversion is built into the language or user-defined. Constructors are equivalent to type-conversion operators.

Review of Overloaded Functions

Let’s refresh the memory a bit. A function name is overloaded when two or more function declarations declare the same name in the same scope. C++ imposes some restrictions on when you are allowed to overload a function name.

The primary restriction is that overloaded functions must have different argument lists. This means the number of arguments must be different, or the type of at least one argument must be different.

void print(int value);
void print(double value);         // valid overload: different argument type
void print(int value, int width); // valid overload: different number of arguments

You are not allowed to define two functions in the same scope when the functions differ only in the return type.

void print(int);
int print(int);  // illegal

Member functions can also differ by the presence or absence of the const qualifier.

class demo {
   void print();
   void print() const; // valid: const qualifier is different
};

A member function cannot be overloaded with a static member function in the same class.

class demo {
   void print();
   static void print(); // illegal
};

A key point is that overloading occurs within a single scope. Names in one scope have no influence or impact on names in another scope. Remember that a code block is a scope (Exploration 13), a class is a scope (Exploration 40), and a namespace is a scope (Exploration 52).

Thus, member functions in a base class are in that class’s scope and do not impact overloading of names in a derived class, which has its own scope, separate and distinct from the base class’s scope.

When you define a function in a derived class, it hides all functions with the same name in a base class or in an outer scope, even if those functions take different arguments. This rule is a specific example of the general rule that a name in an inner scope hides names in outer scopes. Thus, any name in a derived class hides names in base classes and at namespace scope. Any name in a block hides names in outer blocks, and so on. The only way to call a hidden function from a derived class is to qualify the function name, as shown in Listing 69-1.

Listing 69-1.  Qualifying a Member Function with the Base Class Name

#include <iostream>
 
class base {
public:
   void print(int x) { std::cout << "int: " << x << ' '; }
};
class derived : public base {
public:
   void print(double x) { std::cout << "double: " << x << ' '; }
};
int main()
{
   derived d{};
   d.print(3);           // prints double: 3
   d.print(3.0);         // prints double: 3
   d.base::print(3);     // prints int: 3
   d.base::print(3.0);   // prints int: 3
}

Sometimes, however, you want overloading to take into account functions in the derived class and the functions from the base class too. The solution is to inject the base class name into the derived class scope. You do this with a using declaration (Exploration 52). Modify Listing 69-1 so derived sees both print functions. Change main so it calls d.print with an int argument and with a double argument, with no qualifying names. What output do you expect?

_____________________________________________________________

_____________________________________________________________

Try it and compare your result with that in Listing 69-2.

Listing 69-2.  Overloading Named with a using Declaration

#include <iostream>
 
class base{
public:
   void print(int x) { std::cout << "int: " << x << ' '; }
};
class derived : public base {
public:
   void print(double x) { std::cout << "double: " << x << ' '; }
   using base::print;
};
int main()
{
   derived d{};
   d.print(3);            // prints int: 3
   d.print(3.0);          // prints double: 3
}

A using declaration imports all the overloaded functions with that name. To see this, add print(long) to the base class and a corresponding function call to main. Now your example should look something like Listing 69-3.

Listing 69-3.  Adding a Base Class Overload

#include <iostream>
 
class base {
public:
   void print(int x) { std::cout << "int: " << x << ' '; }
   void print(long x) { std::cout << "long: " << x << ' '; }
};
class derived : public base {
public:
   void print(double x) { std::cout << "double: " << x << ' '; }
   using base::print;
};
int main()
{
   derived d{};
   d.print(3);           // prints int: 3
   d.print(3.0);         // prints double: 3
   d.print(3L);          // prints long: 3
}

The overload rules usually work well. You can clearly see which print function the compiler selects for each function call in main. Sometimes, however, the rules get murkier.

For example, suppose you were to add the line d.print(3.0f); to main. What do you expect the program to print?

_____________________________________________________________

The compiler promotes the float 3.0f to type double and calls print(double), so the output is as follows:

double: 3

That was too easy. What about a short? Try d.print(short(3)). What happens?

_____________________________________________________________

The compiler promotes the short to type int and produces the following output:

int: 3

That was still too easy. Now try unsigned. Addd.print(3u). What happens?

_____________________________________________________________

That doesn’t work at all, does it? The error message probably says something about an ambiguous overload or function call. To understand what went wrong, you need a better understanding of how overloading works in C++, and that’s what the rest of this Exploration is all about.

Overload Resolution

The compiler applies its normal lookup rules (Exploration 68) to find the declaration for a function name. The compiler stops searching when it finds the first occurrence of the desired name, but that scope may have multiple declarations with the same name. For a type or variable, that would be an error, but functions may have multiple, or overloaded, declarations with the same name.

After the compiler finds a declaration for the function name it is looking up, it finds all the function declarations of that name in the same scope and applies its overloading rules to choose the one declaration that it deems to match the function arguments the best. This process is called resolving the overloaded name.

To resolve an overload, the compiler considers the arguments and their types, the types of the function parameters in the function declarations, and type conversions and promotions that are necessary to convert the argument types to match the parameter types. Like name lookup, the detailed rules are complicated, subtle, and sometimes surprising. But if you avoid writing pathological overloads, you can usually get by with some commonsense guidelines.

Overload resolution starts after the compiler finds a declaration of the function name. The compiler collects all declarations of the same name in the same scope. This means that the compiler does not include functions of the same name from any base or ancestor classes. A using declaration can bring such names into the derived class scope and, thus, have them participate in overload resolution. If the function name is unqualified, the compiler looks for member and nonmember functions. A using directive, on the other hand, has no effect on overload resolution, because it does not alter any names in a namespace.

If the function is a constructor, and there is one argument, the compiler also considers type-conversion operators that return the desired class or a derived class.

The compiler then discards any functions with the wrong number of parameters or those for which the function arguments cannot be converted to the corresponding parameter type. When matching member functions, the compiler adds an implicit parameter, which is a pointer to the object, as though this were a function parameter.

Finally, the compiler ranks all the remaining functions by measuring what it needs to do to convert each argument to the corresponding parameter type, as explained in the next section. If there is a unique winner with the best rank, that compiler has successfully resolved the overload. If not, the compiler applies a few tie-breaker rules to try to select the best-ranked function. If the compiler cannot pick a single winner, it reports an ambiguity error. If it has a winner, it continues with the next compilation step, which is to check access levels for member functions. The best-ranked overload might not be accessible, but that doesn’t impact how the compiler resolves the overload.

Ranking Functions

In order to rank functions, the compiler determines how it would convert each argument to the corresponding parameter type. The executive summary is that the best-ranked function is the one that requires the least work to convert all the arguments to the desired parameter types.

The compiler has several tools at its disposal to convert one type to another. Many of these you’ve seen earlier in the book, such as promoting arithmetic types (Exploration 25), converting a derived-class reference to a base-class reference (Exploration 39), or calling a type-conversion operator. The compiler assembles a series of conversions into an implicit conversion sequence (ICS). An ICS is a sequence of small conversion steps that the compiler can apply to a function-call argument with the end result of converting the argument to the type of the corresponding function parameter.

The compiler has ranking rules to determine whether one ICS is better than another. The compiler tries to find one function for which the ICS of every argument is the best (or tied for best) ICS among all overloaded names, and at least one ICS is unambiguously the best. If so, it picks that function as the best-ranked. Otherwise, if it has a set of functions that are all tied for best set of ICSes, it goes to a tie-breaker, as described in the next section. The remainder of this section discusses how the compiler ranks ICSes.

First, some terminology. An ICS may involve standard conversions or user-defined conversions. A standard conversion is inherent in the C++ language, such as arithmetic conversions. A user-defined conversion involves constructors and type conversion operators on class and enumerated types. A standard ICS is an ICS that contains only standard conversions. A user-defined ICS consists of a series of standard conversions with one user-defined conversion anywhere in the sequence. (Thus, any overload that requires two user-derived conversions in order to convert the argument to the parameter type never even gets this far, because the compiler cannot convert the argument to the parameter type and so drops that function signature from consideration.)

For example, converting short to const int is a standard ICS with two steps: promoting short to int and adding the const qualifier. Converting a character string literal to std::string is a user-defined ICS that contains a standard conversion (array of const char converted to pointer to const char), followed by a user-defined conversion (the std::string constructor).

One exception is that invoking a copy constructor to copy identical source and destination type or derived-class source to a base-class type is a standard conversion, not a user-defined conversion, even though the conversions invoke user-defined copy constructors.

The compiler has to pick the best ICS of the functions that remain under consideration. As part of this determination, it must be able to compare standard conversions within an ICS. A standard conversion falls into one of three categories. In order from best to worst, the categories are exact match, promotion, and other conversion.

An exact match is when the argument type is the same as the parameter type. Examples of exact match conversions are

  • Changing only the qualification, e.g., the argument is type int and the parameter is const int (but not pointer to const or reference to const)
  • Converting an array to a pointer (Exploration 59), e.g., char[10] to char*
  • Converting an lvalue to an rvalue, e.g., int& to int

A promotion(Exploration 25) is an implicit conversion from a smaller arithmetic type (such as short) to a larger type (such as int). The compiler considers promotion better than conversion, because a promotion does not lose any information, but a conversion might.

All other implicit type conversions—for example, arithmetic conversions that discard information (such as long to int) and derived-class pointers to base-class pointers—fall into the final category of miscellaneous conversions.

The category of a sequence is the category of the worst conversion step in the sequence. For example, converting short to const int involves an exact match (const) and a promotion (short to int), so the category for the ICS as a whole is promotion.

If one argument is an implicit object argument (for member function calls), the compiler compares any conversions needed for it too.

Now that you know how the compiler orders standard conversions by category, you can see how it uses this information to compare ICSes. The compiler applies the following rules to determine which of two ICSes is better:

  • A standard ICS is better than a user-defined ICS.
  • An ICS with a better category is better than an ICS with a worse category.
  • An ICS that is a proper subset of another ICS is better.
  • A user-defined ICS, ICS1, is better than another user-defined ICS, ICS2, if they have the same user conversion and the second standard conversion in ICS1 is better than the second standard conversion of ICS2.
  • Less restrictive types are better than more restrictive ones. This means an ICS with target type T1 is better than an ICS with target type T2, if T1 and T2 have the same base type, but T2 is const and T1 is not.
  • A standard conversion sequence ICS1 is better than ICS2, if they have the same rank, but
  • ICS1 converts a pointer to bool, or
  • ICS1 and ICS2 convert pointers to classes related by inheritance, and ICS1 is a “smaller” conversion. A smaller conversion is one that hops over fewer intermediate base classes. For example, if A derives from B and B from C, then converting B* to C* is better than converting A* to C*, and converting C* to void* is better than A* to void*.

List Initialization

One complication is the possibility of a function argument that has no type because it is not an expression. Instead, the argument is a curly-brace-enclosed list of values, such as the brace-enclosed list that is used for universal initialization. The compiler has some special rules for determining the conversion sequence of a list.

If the parameter type is a class with a constructor that takes a single argument of type std::initializer_list<T>, and every member of the brace-enclosed list can be converted to T, the compiler treats the argument as a user-defined conversion to std::initializer_list<T>. All the container classes, for example, have such a constructor.

Otherwise, the compiler tries to find a constructor for the parameter type such that each element of the brace-enclosed list is an argument to the constructor. If it succeeds, the compiler considers the list a user-defined conversion to the parameter type. Note that another user-defined conversion sequence is allowed for each constructor argument.

The compiler considers std::initializer_list initialization better than the other constructor list initialization. That’s why std::string{42, 'x'} does not invoke the std::string(42, 'x') constructor: the compiler prefers treating {42, 'x'} as std::initializer_list, which results in a string with two characters, one with code point 42 and the letter x, and not the constructor that creates a string with 42 repetitions of the letter x.

If the parameter type is not a class, and the brace-enclosed list contains a single element, the compiler unwraps the value from the curly braces and applies the normal ICS that results from the enclosed value.

Tie-Breakers

If the compiler cannot find one function that unambiguously ranks higher than the others, it applies a few final rules to try to pick a winner. The compiler checks the following rules in order. If one rule yields a winner, the compiler stops at that point and uses the winning function. Otherwise, it continues with the next tie-breaker

  • Although return type is not considered part of overload resolution, if the overloaded function call is used in a user-defined initialization, the function’s return type that invokes a better standard conversion sequence wins.
  • A non-template function beats a function template.
  • A more-specialized function template beats a less-specialized function template. (A reference or pointer template parameter is more specialized than a non-reference or non-pointer parameter. A const parameter is more specialized than non-const.)
  • Otherwise, the compiler reports an ambiguity error.

Listing 69-4 shows some examples of overloading and how C++ ranks functions.

Listing 69-4.  Ranking Functions for Overload Resolution

 1 #include <iostream>
 2 #include <string>
 3
 4 void print(std::string const& str) { std::cout << str; }
 5 void print(int x)                  { std::cout << "int: " << x; }
 6 void print(double x)               { std::cout << "double: " << x; }
 7
 8 class base {
 9 public:
10   void print(std::string const& str) const { ::print(str); ::print(" "); }
11   void print(std::string const& s1, std::string const& s2)
12   {
13     print(s1); print(s2);
14   }
15 };
16
17 class convert : public base {
18 public:
19   convert()              { print("convert()"); }
20   convert(double)        { print("convert(double)"); }
21   operator int() const   { print("convert::operator int()"); return 42; }
22   operator float() const { print("convert::operator float()"); return 3.14159f; }
23 };
24
25 class demo : public base {
26 public:
27   demo(int)      { print("demo(int)"); }
28   demo(long)     { print("demo(long)"); }
29   demo(convert)  { print("demo(convert)"); }
30   demo(int, int) { print("demo(int, int)"); }
31 };
32
33 class other {
34 public:
35   other()        { std::cout << "other::other() "; }
36   other(int,int) { std::cout << "other::other(int, int) "; }
37   operator convert() const
38   {
39     std::cout << "other::operator convert() "; return convert();
40   }
41 };
42
43 int operator+(demo const&, demo const&)
44 {
45   print("operator+(demo,demo) "); return 42;
46 }
47
48 int operator+(int, demo const&) { print("operator+(int,demo) "); return 42; }
49
50 int main()
51 {
52   other x{};
53   demo d{x};
54   3L + d;
55   short s{2};
56   d + s;
57 }

What output do you expect from the program in Listing 69-4?

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

Most of the time, commonsense rules help you understand how C++ resolves overloading. Sometimes, however, you find the compiler reporting an ambiguity when you did not expect any. Other times, the compiler cannot resolve an overload when you expected it to succeed. The really bad cases are when you make a mistake and the compiler is able to find a unique function, but one that is different from the one you expect. Your tests fail, but when reading the code, you look in the wrong place, because you expect the compiler to complain about bad code.

Sometimes, your compiler helps you by identifying the functions that are tied for best rank. Sometimes, however, you might have to sit down with the rules and go over them carefully to figure out why the compiler isn’t happy. To help you prepare for that day, Listing 69-5 presents some overloading errors. See if you can find and fix the problems.

Listing 69-5.  Fix the Overloading Errors

#include <iostream>
#include <string>
 
void easy(long) {}
void easy(double) {}
void call_easy() {
   easy(42);
}
 
void pointer(double*) {}
void pointer(void*) {}
const int zero = 0;
void call_pointer() {
   pointer(&zero);
}
 
int add(int a) { return a; }
int add(int a, int b) { return a + b; }
int add(int a, int b, int c) { return a + b + c; }
int add(int a, int b, int c, int d) { return a + b + c + d; }
int add(int a, int b, int c, int d, int e) { return a + b + c + d + e; }
void call_add() {
   add(1, 2, 3L, 4.0);
}
 
void ref(int const&) {}
void ref(int) {}
void call_ref() {
   int x;
   ref(x);
}
 
class base {};
class derived : public base {};
class sibling : public base {};
class most_derived : public derived {};
 
void tree(derived&, sibling&) {}
void tree(most_derived&, base&) {}
void call_tree() {
   sibling s;
   most_derived md;
   tree(md, s);
}

The argument to easy() is an int, but the overloads are for long and double. Both conversions have conversion rank, and neither one is better than the other, so the compiler issues an ambiguity error.

The problem with pointer() is that neither overload is viable. If zero were not const, the conversion to void* would be the sole viable candidate.

The add() function has all int parameters, but one argument is long and another is double. No problem, the compiler can convert long to int and double to int. You may not like the results, but it is able to do it, so it does. In other words, the problem here is that the compiler does not have a problem with this function. This isn’t really an overloading problem, but you may not see it that way if you run into this problem at work.

Do you see the missing & in the second ref() function? The compiler considers both ref() functions to be equally good. If you declare the second to be ref(int&), it becomes the best viable candidate. The exact reason is that the type of x is int&, not int, that is, x is an int lvalue, an object that the program can modify. The subtle distinction has not been important before now, but with respect to overloading, the difference is crucial. The conversion from an lvalue to an rvalue has rank exact match, but it is a conversion step. The conversion from int& to int const& also has exact match. Faced with two candidates with one exact match conversion each, the compiler cannot decide which one is better. Changing int to int& removes the conversion step, and that function becomes the unambiguous best.

Both tree() functions require one conversion from derived-class reference to base-class reference, so the compiler cannot decide which one is better. The first call to tree requires a conversion of the first argument from most_derived& to derived&. The second call requires a conversion of the second argument from sibling& to base&.

Remember that the purpose of overloading is to allow a single logical operation across a variety of types, or to allow a single logical operation (such as constructing a string) to be invoked in a variety of ways. These rules will help guide you to make good choices when you decide to overload a function.

image Tip  When you write overloaded functions, you should make sure that every implementation of a particular function name has the same logical behavior. For example, when you use an output operator, cout << x, you just let the compiler pick the correct overload for operator<<, and you don’t have to concern yourself with the detailed rules as laid out in this Exploration. All the rules apply, but the standard declares a reasonable set of overloads that work with the built-in types and key library types, such as std::string.

Default Arguments

Now that you think overloading is so frightfully complicated that you never want to overload a function, I will add yet another complexity. C++ lets you define a default argument for a parameter, which lets a function call omit the corresponding argument. You can define default arguments for any number of parameters, provided you omit the right-most arguments and don’t skip any. You can provide default arguments for every parameter if you wish. Default arguments are often easy to understand. Read Listing 69-6 for an example.

Listing 69-6.  Default Arguments

#include <iostream>
 
int add(int x = 0, int y = 0)
{
  return x + y;
}
 
int main()
{
  std::cout << add() << ' ';
  std::cout << add(5) << ' ';
  std::cout << add(32, add(4, add(6))) << ' ';
}

What does the program in Listing 69-6 print?

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

It’s not hard to predict the results, which are shown in the following output:

0
5
42

Default arguments offer a shortcut, in lieu of overloading. For example, instead of writing several constructors for the rational type, you can get by with one constructor and default arguments, as follows:

template<class T> class rational {
public:
  rational(T const& num = T{0}, T const& den = T{1})
  : numerator_{num}, denominator_{den}
  {
    reduce();
  }
  ...omitted for brevity...
};

Our definition of a default constructor must change somewhat. Instead of being a constructor that declares no parameters, a default constructor is one that you can call with no arguments. This rational constructor meets that requirement.

As you may have guessed, default arguments complicate overload resolution. When the compiler searches for overloaded functions, it checks every argument that explicitly appears in the function call but does not check default argument types against their corresponding parameter types. As a result, you can run into ambiguous situations more easily with default arguments. For example, suppose you added the example rational constructor to the existing class template without deleting the old constructors. The following definitions would both result in ambiguity errors:

rational<int> zero{};
rational<int> one{1};

Default arguments have their uses, but overloading usually gives you more control. For example, by overloading the rational constructors, we avoid calling reduce() when we know the denominator is 1. Using inline functions, one overloaded function can call another, which often eliminates the need for default arguments completely. If you are unsure whether to use default arguments or overloading, I recommend overloading.

Although you may not believe me, my intention was not to scare you away from overloading functions. Rarely will you have to delve into the subtleties of overloading. Most of the time, you can rely on common sense. But sometimes, the compiler disagrees with your common sense. Knowing the compiler’s rules can help you escape from a jam when the compiler complains about an ambiguous overload or other problems.

The next Exploration visits another aspect of C++ programming for which the rules can be complicated and scary: metaprogramming, or writing programs that run at compile time.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset