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:
const. "Is there anything in there?" must not modify the handle, or you cannot ask it of ahandle const— which is the only kind you can hand to a reader.noexcept. This operator gets called from conditions and from destructor-time clean-up paths. A truth test that can throw is not a truth test.- No side effects, no I/O, no syscall. If the answer requires asking
the operating system, it is not a conversion; give it a name and a contract, for example
[[nodiscard]] bool is_valid() const throws(Ch6.16).
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
explicit operator bool: yes, and make itconst noexceptand free of side effects.- Every other conversion operator: no. Write the named accessor
(
count(),raw(),view(),has_value()) and let callers convert deliberately. - Never a conversion to a pointer, a C string, or an arithmetic type. Those are the three conversions that silently change the meaning of an expression, and all three are measured bugs above.
- One conversion per type at most. Two means the compiler picks, and a script cannot tell which one the author meant.
- If a conversion would need to allocate, throw, or touch the operating system, it is a named function with a contract, not an operator.
- Constructors are the mirror image:
explicitby default, and a named factory when the conversion carries a rule. - Only the postfix form of these rules is worth restating: if the operator would be the only way to reach a decision the reader cannot see, it should not be an operator.