Discussion: String Theory
Execute the code to understand the output and gain insights into overload resolution.
We'll cover the following
Run the code
Now, it’s time to execute the code and observe the output.
#include <iostream>#include <string>void serialize(const void*) { std::cout << "const void*"; }void serialize(const std::string&) { std::cout << "const string&"; }int main(){serialize("hello world");}
Why does passing a string to serialize
cause the overload taking a void pointer to be called rather than the overload taking a string?
Overload resolution
When we’re calling a function with multiple overloads, the compiler uses a process called overload resolution to figure out which one is the best fit. The compiler does this by attempting to convert each function argument to the corresponding parameter type for each overload. Some conversions are better than others, and the best conversion is if the argument is already of the correct type.
All the overloads where all arguments can be successfully converted are added to a set of viable functions. Then, the compiler needs to figure out which overload to select from this set. If an overload has a better conversion for at least one argument and not a worse one for any of the other arguments, this overload is deemed to be the best viable function and is selected by overload resolution. If no overload is better than all the others, the call is ill-formed and fails to compile.
Have a look at this example:
serialize(int, int); // 1serialize(float, int); // 2
Given these two overloads, suppose we call serialize
like this:
serialize(1, 2);
Both overloads of serialize
are viable. But the first overload has a better conversion for the first argument (int
→ int
is better than int
→ float
) and not a worse conversion for the second argument (int
→ int
for both overloads), so it is selected by overload resolution as the best viable function.
#include <iostream>void serialize(int a, int b) { std::cout << "serialize(int, int) called" << std::endl; }void serialize(float a, int b) { std::cout << "serialize(float, int) called" << std::endl; }int main(){serialize(1, 2); // Should call serialize(int, int)serialize(1.0f, 2); // Should call serialize(float, int)}
Understanding the output
Looking at our puzzle, which is a bit simpler than the example given above, both overloads of serialize
only have one parameter. The first takes a const void*
and the second takes a const std::string&
. What does the conversion look like for each of the overloads?
void serialize(const void*) { std::cout << "const void*"; }void serialize(const std::string&) { std::cout << "const string&"; }
The std::string
is a class in the standard library. It’ll typically allocate memory on the heap (unless the string is very small) and allow the string to grow or be otherwise modified at runtime.
serialize("hello world");
However, the "hello world"
string is not a std::string
but a simple string literal. String literals are plain C-style arrays of char
, which get baked into the binary by the linker and cannot be modified at runtime. A string literal has the type “array of const char
.” The string "hello world"
has 11 characters plus a terminating \0
, so its type is “array of 12 const char
.”
Since the argument "hello world"
is neither a const void*
nor a std::string
but an “array of 12 const char
,” a conversion is needed for both overloads. If an implicit conversion exists from the argument to the parameter type, that overload is added to the set of viable functions. Otherwise, the overload is ignored.
First overload
Let’s examine the first overload and see if the “array of 12 const char
” can be implicitly converted to const void*
. The first thing that happens is that the array gets converted to a pointer. Any “array of T
” can be converted to a “pointer to T
” pointing to the first element. So now our “array of 12 const char
” has turned into a “pointer to const char
.’’
Next, any “pointer to T
” (where cv means const
, volatile
, const volatile
, or none of these) can be converted to “pointer to void
.” So now our “pointer to const char
” has turned into a “pointer to const void
,” which is exactly what the first overload expects.
Notice that no constructors or conversion functions were involved in this conversion sequence. This means it’s a standard conversion sequence and not a user-defined conversion sequence. That gets important later.
Second overload
Let’s now examine the second overload and see if our “array of 12 const char
” can be converted to a “reference to const std::string
.” The std::string
has a constructor std::string(const char* s)
, which we can use. First, we convert the “array of 12 const char
” to a “pointer to const char
” as we did so above. Then, we pass this to the std::string
constructor and get a std::string
back containing a copy of the string literal. The const std::string&
parameter can bind directly to our std::string
argument.
Notice that we had to use a constructor for this. This means it’s a user-defined conversion sequence and not a standard conversion sequence. It doesn’t matter that std::string
is a standard library type; it still counts as user-defined. The rules are the same for us and the standard library.
Now the compiler has found a valid conversion sequence from our “array of 12 const char
” to the parameter type of each overload and has to figure out which sequence is best.
We only require a standard conversion sequence (top) to call the const void*
overload. To call the std::string
overload, we need a user-defined conversion sequence (bottom), which involves creating a new temporary std::string
object. A standard conversion sequence is always better than a user-defined conversion sequence, so the first overload gets called and const void*
is printed.
Level up your interview prep. Join Educative to access 70+ hands-on prep courses.