Ch17.6: Conversion Operators

中文(简体)

1. What a Conversion Operator Is

operator T() is a member function with no return type and no parameters. It says: "wherever a T is wanted, I can produce one". It is the most invasive operator in the language, because it does not participate in one expression — it participates in every expression that involves an object of the type: initialisation, assignment, argument passing, return, the operands of every built-in operator, the conditions of if and while, both arms of ?:, and the ranking of every overload set the object appears in.


struct handle
{
	unsigned p{};
	operator bool() const noexcept { return p != 0; }  /* implicit: every context */
};

With that one line, h + 1 compiles, h * h compiles, h == "text" compiles, and f(h) becomes viable for every f taking anything a bool converts to. Nothing in the calling code mentions a conversion, which is the definition of a conversion at a distance.

So this chapter's rule for conversions is narrower than its rule for arithmetic operators: there is exactly one conversion operator worth having — explicit operator bool — and everything else should have been a named function.

2. The One Worth Having

A handle — a pointer wrapper, a file, a socket, an optional-like value — has a natural question attached to it: "is there anything in there?". That question has a conventional spelling in C++ (if (h)), and it is worth supporting. The conversion that supports it is explicit, which restricts it to the contexts where the language requires a truth value ("contextual conversion to bool"): the condition of if, while, for and switch, the operands of !, && and ||, and the condition of ?:.


#include <fast_io.h>
#include <cstdint>

struct [[nodiscard]] handle
{
	::std::uintptr_t p{};

	explicit operator bool() const noexcept /* the one conversion worth having */
	{
		return p != 0;
	}

	[[nodiscard]] ::std::uintptr_t raw() const noexcept /* everything else gets a name */
	{
		return p;
	}
};

int main()
{
	try
	{
		handle empty{};
		handle full{0x1234uz};

		::fast_io::io::println("empty          ", ::fast_io::mnp::boolalpha(static_cast<bool>(empty)));
		::fast_io::io::println("full           ", ::fast_io::mnp::boolalpha(static_cast<bool>(full)));
		::fast_io::io::println("!empty         ", ::fast_io::mnp::boolalpha(!empty));
		::fast_io::io::println("full || empty  ", ::fast_io::mnp::boolalpha(full || empty));
		::fast_io::io::println("full.raw()     ", full.raw());
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

empty          false
full           true
!empty         true
full || empty  true
full.raw()     4660

Note what the program needs in order to print: static_cast<bool>(empty). That is not ceremony — an explicit conversion is allowed exactly where the language asks for truth, and println asks for a value to format, not for truth. The restriction is doing its job: the comfortable spellings work, and the accidental ones do not.

Three requirements on this operator, all of which are easy to get wrong:

3. What explicit Refuses to Do

The same handle, one line that should not compile — and does not. The output below is the compiler's, with the repeated source echo elided:


struct handle
{
	unsigned p{};
	explicit operator bool() const noexcept { return p != 0; }
};

int f(handle h) noexcept
{
	return h + 1;   /* explicit conversions do not apply here */
}

e1.cc: In function ‘int f(handle)’:
e1.cc:9:18: error: no match for ‘operator+’ (operand types are ‘handle’ and ‘int’)
    9 |         return h + 1;   /* explicit conversions do not apply here */
      |                ~ ^ ~
      |                |   |
      |                |   int
      |                handle
  • there is 1 candidate
    • candidate 1: ‘operator+(int, int)’ (built-in)
      • no known conversion for argument 1 from ‘handle’ to ‘int’

And the comparison that looks harmless:


bool f(handle h) noexcept
{
	return h == 1;   /* explicit conversions do not apply here either */
}

e4.cc: In function ‘bool f(handle)’:
e4.cc:9:18: error: no match for ‘operator==’ (operand types are ‘handle’ and ‘int’)
    9 |         return h == 1;   /* explicit conversions do not apply here either */
      |                ~ ^~ ~
      |                |    |
      |                |    int
      |                handle
  • there is 1 candidate
    • candidate 1: ‘operator==(int, int)’ (built-in)
      • no known conversion for argument 1 from ‘handle’ to ‘int’

This is the point of explicit: the interface is usable in the one context it is for and invisible everywhere else. The diagnostics are the feature.

4. Two Bugs You Can Measure

Now remove explicit, and remove it in the other direction: let the type convert to a pointer, and let two numeric types convert to int. Both compilations succeed, and both programs are wrong.


#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
#include <cstddef>

struct cpath /* implicit conversion to the bytes of the path */
{
	char const *s{};
	operator char const *() const noexcept { return s; }
};

struct bytes /* implicit conversion to an int */
{
	int v{};
	operator int() const noexcept { return v; }
};

struct items /* a different quantity, the same implicit conversion */
{
	int v{};
	operator int() const noexcept { return v; }
};

int main()
{
	try
	{
		char buffer[16]{}; /* the same ten bytes as the literal, in a buffer we own */
		for (::std::size_t i{}; i != 10; ++i)
		{
			buffer[i] = "/etc/hosts"[i];
		}
		cpath p{buffer};

		::fast_io::io::println("p == \"/etc/hosts\"  ", ::fast_io::mnp::boolalpha(p == "/etc/hosts"));

		bytes b{100};
		items it{3};
		::fast_io::io::println("b + it            ", b + it);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

p == "/etc/hosts"  false
b + it            103

The first line. p and the literal hold the same ten bytes, and == answers false, because with the conversion in place p == "/etc/hosts" is not a comparison of paths at all: it is the built-in comparison of two char const *, i.e. of two addresses. No warning, no diagnostic, no test failure unless the test compares paths rather than a path to a literal that happens to be the same object.

The second line. 100 bytes plus 3 items is 103, and the type system had nothing to say, because both types volunteered to be int. This is the failure that the strong-typing of enum classes exists to prevent, reintroduced through the back door of a conversion operator.

Neither bug is exotic. Both are one removed word away from code that a reviewer would approve, which is exactly why this chapter asks for the word.

5. Two Conversions Are One Too Many

A type with operator bool and operator int does not produce an ambiguity error at the call site; the compiler simply picks. This program prints the choice:


#include <fast_io.h>

struct flag
{
	bool b{};
	operator bool() const noexcept { return b; }
	operator int() const noexcept { return static_cast<int>(b); }
};

int main()
{
	try
	{
		flag x{true};
		::fast_io::io::println("x == 1  ", ::fast_io::mnp::boolalpha(x == 1));
		::fast_io::io::println("x == 2  ", ::fast_io::mnp::boolalpha(x == 2));
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

x == 1  true
x == 2  false

x == 2 answering false is only possible if the compiler reached the comparison through operator int (giving 1 == 2); through operator bool it would have been true == true and the answer would have been true. So the second comparison means something the author never wrote, and nothing in the language reports the substitution. When a type has more than one way to be something else, every expression that mentions it has to be re-read by hand.

6. The Named Way Out

The repair is not "make the conversion explicit"; it is "do not have the conversion". Give the type named accessors, and let the comparison happen on a type that knows how to compare contents. The same program with the conversions deleted:


#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
#include <cstddef>

struct path /* no conversion operator: one named way out */
{
	char const *s{};
	::std::size_t n{};

	[[nodiscard]] ::fast_io::string_view view() const noexcept
	{
		return ::fast_io::string_view{s, n};
	}
};

struct byte_count /* a count of bytes, not a number */
{
	::std::uint_least64_t n{};

	[[nodiscard]] ::std::uint_least64_t count() const noexcept
	{
		return n;
	}
};

int main()
{
	try
	{
		char buffer[16]{};
		for (::std::size_t i{}; i != 10; ++i)
		{
			buffer[i] = "/etc/hosts"[i];
		}

		path p{buffer, 10};
		::fast_io::io::println("p.view() == \"/etc/hosts\"  ",
							   ::fast_io::mnp::boolalpha(p.view() == ::fast_io::string_view{"/etc/hosts"}));

		byte_count b{100};
		::fast_io::io::println("b.count()                ", b.count());

		::fast_io::string a{::fast_io::string_view{"abc"}};
		::fast_io::string c{::fast_io::string_view{"abc"}};
		::fast_io::io::println("a == c                   ", ::fast_io::mnp::boolalpha(a == c));
		::fast_io::io::println("a == \"abc\"               ", ::fast_io::mnp::boolalpha(a == "abc"));
		::fast_io::io::println("a.size()                 ", a.size());
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

p.view() == "/etc/hosts"  true
b.count()                100
a == c                   true
a == "abc"               true
a.size()                 3

Every line is now true, and every line says what it does. p.view() returns a string_view, a type whose == compares bytes, so the comparison is about paths. b.count() returns a number, and b + 1 would now be a compile error instead of 101:


struct byte_count
{
	unsigned n{};
	unsigned count() const noexcept { return n; }
};

int f(byte_count b) noexcept
{
	return b + 1;
}

e2.cc: In function ‘int f(byte_count)’:
e2.cc:9:18: error: no match for ‘operator+’ (operand types are ‘byte_count’ and ‘int’)
    9 |         return b + 1;
      |                ~ ^ ~
      |                |   |
      |                |   int
      |                byte_count

That error is the fix. The mistake moved from run time, where it printed 103, to compile time, where it prints a file name and a line number.

7. What fast_io Does

::fast_io::string has no conversion operator at all: no operator char const *, no operator ::fast_io::string_view. If you want the bytes you call c_str() or data(); if you want the length you call size() (include/fast_io_dsal/impl/string.h, lines 246 and 267). It also has no operator+, for the reason Ch17.2 measures.

Comparison, meanwhile, works without any conversion, because the overloads are written against types that already know how to compare bytes:


inline constexpr bool operator==(::fast_io::containers::basic_string<chtype, allocator1> const &lhs, ::fast_io::containers::basic_string<chtype, allocator2> const &rhs) noexcept
inline constexpr bool operator==(::fast_io::containers::basic_string<chtype, allocator1> const &lhs, ::fast_io::containers::basic_string_view<chtype> rhs) noexcept
inline constexpr bool operator==(::fast_io::containers::basic_string<char_type, allocator1> a, char_type const (&buffer)[n]) noexcept

Three overloads, three decisions made once, in one place: two strings compare by content, a string and a view compare by content, and a string and a literal compare by content — the last one through a reference-to-array, so the literal never decays to a pointer and the pointer-comparison bug above cannot be reached from a ::fast_io::string. That is what a comparison operator looks like when the type refuses to be converted: the library has to write the three cases down instead of letting the compiler pick a built-in operator and hope.

Ordering follows the same shape: operator<=> is provided for string/string, string/view and string/literal, and returns the category those comparisons actually have (Ch17.4).

8. Converting Constructors: The Same Decision, Reversed

A one-argument constructor is a conversion operator facing the other way: it decides what can become this type. The default here is the opposite of the built-in one, and for the same reason:


struct metres
{
	double m{};

	explicit metres(double value) noexcept : m{value} {} /* you must say what unit you mean */
};

struct milliseconds
{
	unsigned long long ms{};

	explicit milliseconds(unsigned long long value) noexcept : ms{value} {}
};

[[nodiscard]] milliseconds from_seconds(unsigned long long s) noexcept /* a name for a conversion that has arithmetic in it */
{
	return milliseconds{s * 1000uz};
}

explicit metres(3.0) says what it converts; metres m = 3.0; does not, and is rejected. When the conversion involves a rule — a unit, a rounding, a failure mode — give it a function name, because the name is where the rule goes. from_seconds cannot be mistaken for "assign a number to a duration", which is the mistake operator int on a duration invites (Ch13.6).

The same applies to the template form. A conversion operator that is a template


template <typename T>
operator T() const noexcept;   /* every overload set in the program now considers this */

is the most expensive kind of conversion to reason about, because it makes the object a candidate for every type in every call. If you have one, mark it explicit. If you can avoid having one, avoid it — this chapter's chapter-mate, Ch17.1, has the same advice for template operators in general.

9. Rules of Thumb