foonathan::blog()

Thoughts from a C++ library developer.

Controlling overload resolution #1: Preventing implicit conversions

Overload resolution is one of C++ most complicated things and yet it works most of the time without the need to think about it. In this mini-series, I will show you how to control this complex machinery so it is even more powerful and completely under your control.

The first post shows you how to delete candidates and how you can use that to prevent implicit conversions.


Advertisement

C++11’s =delete

Most of you know that since C++11 you can specify = delete to inhibit the generation of the special member functions like copy or move constructors. But less people know that you can use it on any function and delete it.

The standard simply specifies in the beginning of §8.4.3[dcl.fct.def.delete]:

1 A function definition of the form: attribute-specifier-seqopt decl-specifier-seqopt declarator virt-specifier-seqopt = delete ; is called a deleted definition. A function with a deleted definition is also called a deleted function.

2 A program that refers to a deleted function implicitly or explicitly, other than to declare it, is ill-formed.

This means you can write the following program:

void func() = delete;
	
int main()
{
	func();
}

And if you try to compile it, you get a similar error message:

prog.cpp: In function ‘int main()’: prog.cpp:5:2: error: use of deleted function ‘void func()’ func(); ^ prog.cpp:1:6: note: declared here void func() = delete;

Now that feature is not very useful. If you don’t want to have a function, simply don’t declare it at all!

Unless you are dealing with automatically generated functions of course.

But consider what happens if the function is overloaded:

#include <iostream>

void func(int)
{
	std::cout << "int\n";
}

void func(double) = delete;

int main()
{
	func(5);
}

Now we have two versions of func, one taking an int and a deleted one taking a double. On the first look it does not seem any more useful than before. If you don’t want to have an overload, simply don’t declare it!

But take a second look and consider the consequences of §8.4.3: A function with = delete at the end, isn’t only a declaration, it is also a definition! And since name lookup only looks for matching declarations, a deleted function is a normal candidate that can participate in overload resolution.

But the compiler can see that the function has a deleted definition, so why is it considered?

Because it is completely legal to declare a function in a header file and delete it in the source file, since a deleted function provides a normal definition.

(It is not, my mistake). And also because the standard says so.™

If you write func(5.0), you now call it with a double. The compiler chooses the overload for double, because a deleted function participates in overload resolution, and complains that the function is deleted.

This prohibits passing double to func, even though it could be implictly converted.

Prohibiting implicit conversions

As shown above, you can delete candidates to avoid certain implicit conversions in overload resolution.

If you have one or more overloads of a function accepting a certain set of types, you can also call it with types that are implictly convertible to the accepted types. Often this is great and terse and avoids verbose boilerplate.

But sometimes these implicit conversion are not without loss or expensive. User-defined conversions can be controlled by using explicit, but the implicit conversions built-in in the language like double to int? You can’t write explicit there.

Allowing lossy conversions was one of the many mistakes C made and C++ inherited.

But you can write another overload that takes the types you want to prohibit and delete it.

Let’s extend the example above by prohibiting all floating points, not only double:

void func(int)
{
	std::cout << "int\n";
}

void func(float) = delete;
void func(double) = delete;
void func(long double) = delete;

Now you cannot call it with any floating point.

You should write an overload for each floating point type, otherwise the call is ambigous. E.g. when having only the deleted double overload, calling it with a long double is ambigous between int and double, since both conversions lead to a loss. It works here but could have side-effects in more complicated examples. So to be safe, just write it explicitly.

You could also use templates to generate the three overloads, use SFINAE to enable it only for floating points:

template <typename T,
		  typename = std::enable_if_t<std::is_floating_point<T>::value>>
void func(T) = delete;

Yes, that’s ugly. I want concepts!

Probihiting implicit conversions: Temporaries

Some kind of implicit conversions can be especially bad: Those user-defined conversions that create temporaries.

For example, passing a string literal to a function taking a std::string creates a temporary std::string to initialize the argument. This can be especially surprising in the following case:

void func(const std::string &str);
...
func("Hello, this creates a temporary!");

Here the writer of func took a std::string by (const) reference because he or she doesn’t want to copy the string, because that can involve costly heap allocations. But passing a string literal does involve heap allocations due to the temporary. And since temporary (rvalues, that is) bind to const (lvalue) references, this works.

This is often behavior that is tolerated, but sometimes the cost can be too expensive to allow the (accidental) creation of the temporary. In this case, a new overload can be introduced that takes a const char*, which is deleted:

void func(const std::string &str);
void func(const char*) = delete;
...
func("this won't compile");
func(std::string("you have to be explicit"));

On a related note, sometimes you have a function taking a const reference to something and the function stores a pointer to it somewhere. Calling it with a temporary would not only be expensive, but fatal, since the temporary is - well - temporary and the pointer will soon point to a destroyed object:

void func(const T &obj)
{
	// store address somewhere outside the function
}
...
func(T()); // dangerous!

Here in this case we need the more general form of disallowing any temporary objects. So we need an overload taking any rvalue, that is an overload taking an rvalue reference:

void func(const T &obj) {...}
void func(T &&) = delete;
...
func(T()); // does not compile

Note that T is a concrete type here, the deleted overload is no template and thus T&& is not a forwarding reference, but a normal rvalue reference.

This works, but it isn’t perfect. Let’s say you have a function foo that returns a const T (for some reason):

const T foo();
void func(const T &obj) {...}
void func(T &&) = delete;
...
func(foo()); // does compile!

This compiles because a const rvalue does not bind to a non-const rvalue reference, as such the lvalue overload is selected, which is - again - dangerous.

The solution? Simple, just use a const rvalue reference:

const T foo();
void func(const T &obj) {...}
void func(const T &&) = delete;
...
func(foo()); // does not compile

The deleted overload accepts any rvalue, const or non-const. This is one of the few good use cases for const rvalue references.

This trick is also used in the standard library. func is std::ref/std::cref, the temporary versions are deleted, so that no std::reference_wrapper to a temporary is created.

Conclusion

Sometimes it can be useful to forbid certain kinds of implicit conversions in function overloading, since they can be expensive or lead to loss.

This is especially true for temporaries that bind to const lvalue referenceres. They can also be dangerous, if you take and store an address of the referenced object, then you don’t want to allow temporaries as arguments.

To prevent such things, simply define new overloads that take the type which would be implictly converted and mark it as deleted. In the case of preventing temporaries, the new overload should take a const rvalue reference to the appropriate type.

Overload resolution will prefer an exact match and choose the deleted overload which result in a compile-time error.

In the next post of this mini series, I will use this technique even further to improve error messages on failed overload resolution and show you a way to completely customize the error message when a deleted function is chosen.


Advertisement