Ch17.8: Operator Overloadings That Should Not Exist
1. Exactly Two Have No Defensible Use
Of the whole operator set, exactly two overloadings are wrong in every program, for reasons that do not depend on style. Each one removes a guarantee the language makes about that expression, and each one removes it silently.
| Overloading | What it removes | Is there a case where it is right? |
|---|---|---|
, |
Sequencing. The built-in comma evaluates the left operand, then the right one after it, and discards the left result. An overloaded comma is a function call, so its operands are function arguments and their order is unspecified (§2). | no |
& (unary, address-of) |
Address-of. The one operation by which C has named an object for
fifty years, and the reason the standard library needed a compiler intrinsic called
std::addressof
(§3). |
no |
Everything else is a judgement call, and this chapter has been the record of that
judgement: + and * on value types
(Ch17.3),
== and <=>
(Ch17.4),
++ and --
(Ch17.5),
[], *, -> and ()
(Ch17.7).
&& and || are ordinary overloads too, with no special
hazard at all.
The standard library's own false overloadings
The committee did not merely fail to prevent this; it shipped the falsest examples itself,
in <iostream> and <string>. Neither can be repaired
now, and both are a change of meaning rather than a convenience.
<< and >> for I/O. These are the
shift operators. Shifting has nothing to do with input or output, and the operators were
taken anyway, so a << 2 and os << 2 are the same
tokens with nothing whatsoever in common — the textbook case of an overload that
changed what the operator means. Every reader of a shift expression and every reader of a
print expression now has to work out which one is in front of them, and the associativity
that means "shift by a then by b" means "print a
then print b" one token later. Do not imitate it and do not use it: iostreams
are bad and harmful, and fast_io exists so that printing is print and reading
is scan (Ch11 §0).
+ on basic_string. An operator that reads as
arithmetic and allocates. Every + produces a string that has to hold the whole
prefix, so a chain copies the prefix again at every step: four 4,000-character pieces copy
8,000, then 12,000, then 16,000 characters — 36,000 copies to produce a
16,000-character result — and the total grows with the square of the number of terms
while the expression stays indistinguishable from adding four integers. The rvalue overloads
that steal the temporary do not rescue it: the buffer still has to grow, and everything
after the growth point is copied again — which is why there is a whole family of those
overloads, and the cost is still there. ::fast_io::concat_fast_io does the thing once — it computes
the total size of every piece, reserves once, and writes each piece once, 16,000 characters
for the same result (Ch17.2).
And it formats: concat_fast_io(name, ::fast_io::mnp::os_c_str(path), count)
writes the integer count straight into the destination buffer, while
+ can only join strings, so every non-string argument needs a temporary string
first — another allocation and another copy, for each one. Allocation and copying are
not arithmetic, an operator that reads as free must not be quadratic, and a concatenation
that cannot format a number is missing the job it exists for.
2. ,: the Operator That Cannot Be Overloaded Correctly
The built-in comma operator does two things at once, and the second one is the important
one. It evaluates the left operand, then sequenced after it evaluates the right
operand, then discards the left result. That is the only reason the comma appears in real
code: for (i = 0, j = n; ...) is two statements jammed into one clause, with a
guarantee about their order.
An overloaded comma is a function call, and a function call gives you neither half of that. The operands become function arguments, so they are unsequenced with respect to each other, and "discard the left value" is gone as well: the operator returns whatever it likes.
#include <fast_io.h>
/* the comma operator, overloaded: `a , b` becomes operator,(a, b) */
struct operand
{
int tag{};
};
[[nodiscard]] inline operand operator,(operand a, operand b) throws
{
::fast_io::io::print(" operator, ran\n");
return b;
}
[[nodiscard]] operand make(int t) throws
{
::fast_io::io::print(" make(", t, ")\n");
return operand{t};
}
int main()
{
try
{
::fast_io::io::println("built-in comma: two int values, sequenced left to right");
(::fast_io::io::print(" left\n"), ::fast_io::io::print(" right\n"));
::fast_io::io::println("overloaded comma: the operands are function arguments");
auto const r = (make(0), make(1));
::fast_io::io::println(" result tag = ", r.tag);
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
built-in comma: two int values, sequenced left to right
left
right
overloaded comma: the operands are function arguments
make(0)
make(1)
operator, ran
result tag = 1
The built-in part of the transcript is guaranteed: left completes before
right begins. The overloaded part is not. Both make() calls happen
before operator, runs, and the compiler is free to make them in either order
— this one chose make(0) first, and nothing in the program says it has
to. A reader who sees a, b and knows the built-in rule will read a sequencing
guarantee that the type has just deleted.
There is no repair and no idiom that needs it. If you want two things to happen in order,
write two statements. If you want a builder, give it a name that shows the accumulation
(add(a).add(b)). The comma operator was inherited from C and overloaded for a
few list-literal libraries; the cost is paid by every reader of every comma expression, in
the form of a question they should never have to ask.
3. operator&: the One That Broke the Language
Allowing operator& was a mistake, and not a mild one. Every other entry in
this chapter destroys a convenience: short-circuiting, sequencing, a stream's error
channel. operator& destroys the only operation by which a C program has
ever referred to an object.
In C, &obj means "the address of obj". Full stop. There is no
exception, no type can redefine it, and every interface in the language is built on that
sentence: malloc's result is stored through &p,
scanf writes through &n, memcpy takes
&x. C++ kept the syntax and let any class take the sentence away:
#include <fast_io.h>
#include <memory>
struct sneaky
{
int v{};
[[nodiscard]] sneaky *operator&() noexcept /* C++ lets you write this. Never write it. */
{
return nullptr;
}
};
template <typename T>
[[nodiscard]] bool points_at_its_argument(T &ref) noexcept
{
return &ref != nullptr; /* true for every type in the language, except the ones that lie */
}
int main()
{
try
{
int plain{7};
sneaky s{7};
::fast_io::io::println("plain: &x != nullptr ", ::fast_io::mnp::boolalpha(points_at_its_argument(plain)));
::fast_io::io::println("sneaky: &x != nullptr ", ::fast_io::mnp::boolalpha(points_at_its_argument(s)));
::fast_io::io::println("&s == nullptr ", ::fast_io::mnp::boolalpha(&s == nullptr));
::fast_io::io::println("::std::addressof(s) == null ", ::fast_io::mnp::boolalpha(::std::addressof(s) == nullptr));
::fast_io::io::println("s.v ", s.v);
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
plain: &x != nullptr true
sneaky: &x != nullptr false
&s == nullptr true
::std::addressof(s) == null false
s.v 7
Read the third and fourth lines together. &s is nullptr, and
the object is perfectly alive at a real address. There is no diagnostic, no warning, and no
way for a caller to notice: a template that writes &ref — which is
what every container, every wrapper, every sorting algorithm and every debugger does
— silently receives a lie. That is the false on line two, where line one
is true for every other type in the language.
The scar tissue: std::addressof
The standard library had to be given a way out, and it is one function with one job:
template<typename _Tp>
[[__nodiscard__,__gnu__::__always_inline__]]
inline _GLIBCXX17_CONSTEXPR _Tp*
addressof(_Tp& __r) noexcept
{ return __builtin_addressof(__r); }
// _GLIBCXX_RESOLVE_LIB_DEFECTS
// 2598. addressof works on temporaries
template<typename _Tp>
const _Tp* addressof(const _Tp&&) = delete;
That is libstdc++'s <bits/move.h>, and it is worth staring
at: std::addressof is not an algorithm and not a library feature. It is
compiler magic with a standard name — __builtin_addressof — and
the whole reason it exists is that & stopped meaning what
& means. The deleted rvalue overload (LWG 2598) exists for the same
reason: if & had kept its meaning, none of this wording would be needed.
So the cost of the feature is not paid by the type that overloads it. It is paid by every
template in the world, which must now write ::std::addressof(x) where the
language should have let it write &x; and by every reader who has to know
that &x is not the address of x unless x's type
was written by somebody who declined a feature.
And the repair does not exist where it is needed most. A standard feature that is not
freestanding does not exist: freestanding is not a corner case, it is the compiler runtime,
the bootloader, the embedded target and half of what ships.
std::addressof was not required in freestanding before C++23, so plenty of
toolchains do not provide it there at all — and you cannot write it yourself, because
it needs compiler magic (__builtin_addressof) and it is
constexpr. So in freestanding C++ there was no way to take the address of an
object: &x could be a lie and there was nothing else to write. C has had
&x mean the address of x since 1972. C++ let one feature
break that guarantee and then spent fifty years not providing the escape hatch everywhere.
Chapter 20 is written to that standard of proof,
because it is the one this book uses.
Why was it allowed? The historical answer is COM. In the Microsoft object model a smart
pointer conventionally answered &p with a T**, so that a
function could install a fresh interface pointer into it, and call sites read
CoCreateInstance(..., &p, ...). That convention is now unrecoverable:
&p means "the address of p" to every template and "the
address of the T* inside p" to every COM call site, and nothing
in the type system tells them apart. If you want that out-parameter idiom, give it a name
— p.address_for_init() — and let &p keep its
meaning.
The rule for your own code has no exceptions: never overload unary
&. Do not overload binary & either, for the
arithmetic reason in Ch17.3
— if your type has a bitwise-and, the reader has to know which meaning was intended.
And do not write an interface that requires one: a type that needs
&x to mean something else has an interface problem that a named function
solves.
4. Rules of Thumb
- Never overload
,. A function call cannot sequence its arguments, and sequencing is the only thing the comma operator is for. - Never overload unary
&. It is the operation C has used to name an object for fifty years, and the repair —std::addressof— is compiler magic that every template now has to call (§3). - Never overload
<</>>for I/O. Shifting is not printing; that the standard library took them for iostreams does not make it right. Use fast_io'sprintandscan. +on a long string allocates and copies a whole new buffer per operator, soa + b + c + dcopies the prefix once for every+. Use::fast_io::concat_fast_io, which sizes once and allocates once (Ch17.2).- No class-level
operator new/operator delete(Ch18.7). - Everything else is judgement, case by case, with the four questions from Ch17.2.