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