Ch17.5: Increment and Decrement

中文(简体)

1. One Operation, Two Spellings, Two Contracts

++ is the only operator in this chapter that is really two operators wearing one glyph. ++i has one contract and i++ has a different one:

Spelling Signature What it returns
++i T &operator++() the object itself, advanced — a reference, no copy
i++ T operator++(int) a value: what the object was before it advanced
--i T &operator--() the object itself, stepped back
i-- T operator--(int) a value: where the object was before it stepped back

For an int, the two spellings produce the same code and the choice is taste. For a class type the choice is a copy: the postfix form has to keep the old state somewhere while it advances, and the only place to keep it is a temporary object. So the language gives you one primitive and one convenience, and the primitive is the prefix form.

This is why the second of this chapter's rules exists: write ++i, and give your own types a prefix ++ that is the real work, with the postfix form defined as one line on top of it.

2. How to Write It


struct cursor
{
	::std::uint64_t lane[8]{};

	cursor &operator++() noexcept /* the primitive: advance, hand back *this */
	{
		++lane[0];
		return *this;
	}

	[[nodiscard]] cursor operator++(int) noexcept /* the convenience: hand back where we were */
	{
		cursor old{*this};
		++*this;
		return old;
	}
};

Six lines of logic, and four decisions inside them:

noexcept is not decoration here. Advancing a cursor over a container cannot fail: there is no allocation, no bounds to check (that is operator*'s job), and no error to report. An increment that can fail is not an increment — if you find yourself wanting one, you want a named function such as advance_to(next) with a throws contract (Ch6.16).

This is not a house style; it is the shape fast_io's own containers use. In include/fast_io_dsal/impl/list.h:


	inline constexpr list_iterator &operator++() noexcept
	{
		iter = static_cast<list_node_common *>(iter)->next;
		return *this;
	}

	inline constexpr list_iterator operator++(int) noexcept
	{
		auto temp(*this);
		++*this;
		return temp;
	}

and identically in deque.h, forward_list.h and every other iterator in the library, for -- as well as ++ (Chapter 9).

3. What the Postfix Form Costs

We count copies instead of measuring time, because the copy is the thing that differs and a counter is exact. The type carries 64 bytes of state — the sort of thing a real iterator drags along — and its copy constructor does one visible thing: it counts itself.


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

struct cursor
{
	::std::uint64_t lane[8]{}; /* stands in for the state a real iterator drags along */
	static inline ::std::uint64_t copies{};

	cursor() = default;
	cursor(cursor const &other) noexcept : lane{}
	{
		for (::std::size_t i{}; i != 8; ++i)
		{
			lane[i] = other.lane[i];
		}
		++copies;
	}

	cursor &operator++() noexcept /* the primitive: advance, hand back *this */
	{
		++lane[0];
		return *this;
	}

	[[nodiscard]] cursor operator++(int) noexcept /* the convenience: hand back where we were */
	{
		cursor old{*this};
		++*this;
		return old;
	}
};

int main()
{
	try
	{
		constexpr ::std::uint64_t rounds{4000000uz};

		cursor prefix{};
		cursor::copies = 0;
		for (::std::uint64_t i{}; i != rounds; ++i)
		{
			++prefix;
		}
		auto const prefix_copies{cursor::copies};

		cursor postfix{};
		cursor::copies = 0;
		for (::std::uint64_t i{}; i != rounds; ++i)
		{
			(void)(postfix++); /* the old value is dropped on the floor */
		}
		auto const postfix_copies{cursor::copies};

		::fast_io::io::println("++i  advanced=", prefix.lane[0], " copies=", prefix_copies);
		::fast_io::io::println("i++  advanced=", postfix.lane[0], " copies=", postfix_copies);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

++i  advanced=4000000 copies=0
i++  advanced=4000000 copies=4000000

Both loops advanced four million times. Only one of them created four million objects to do it. That is the whole difference, and it is a difference in the type's contract, not in the loop.

Two honest qualifications, because measurements in a tutorial should not be oversold.

First: when the discarded temporary is provably unused, the optimiser deletes the copy. The counter above still fires only because that copy constructor has a side effect it must preserve. So on this machine, at -O2, the two loops take the same time — and the counter is the only instrument that still shows the copy happening. The moment you write sum += *it++, the old value is used, the temporary is real, and the copying is real with it.

Second: for a scalar like the loop counter itself, there is no difference at all — the compiler emits identical code for i++ and ++i. The reason to write ++i anyway is not that the counter is expensive. It is that for (auto it{first}; it != last; ++it) is the same statement for every type in the language, and the version with it++ is only the same statement for types that provide a postfix form — and, when they do, it asks them for a copy they do not need.

4. Why the Fixed Shape Matters More Than the Copy

The copy is the cheap half of the argument. The expensive half is that a postfix form written as a second implementation drifts away from the prefix form, and nothing tells you. Here is a counter that stops at 100, implemented both ways:


#include <fast_io.h>

struct broken
{
	int v{};
	static constexpr int max{100};

	broken &operator++() noexcept /* clamps at max */
	{
		if (v < max)
		{
			++v;
		}
		return *this;
	}
	[[nodiscard]] broken operator++(int) noexcept /* a second copy of the logic, and it forgot the clamp */
	{
		broken old{*this};
		v = v + 1;
		return old;
	}
};

struct fixed
{
	int v{};
	static constexpr int max{100};

	fixed &operator++() noexcept
	{
		if (v < max)
		{
			++v;
		}
		return *this;
	}
	[[nodiscard]] fixed operator++(int) noexcept /* one line, so there is no logic to forget */
	{
		fixed old{*this};
		++*this;
		return old;
	}
};

int main()
{
	try
	{
		broken b{};
		for (int i{}; i != 200; ++i)
		{
			++b;
		}
		broken p{};
		for (int i{}; i != 200; ++i)
		{
			(void)(p++);
		}
		fixed fb{};
		for (int i{}; i != 200; ++i)
		{
			++fb;
		}
		fixed fp{};
		for (int i{}; i != 200; ++i)
		{
			(void)(fp++);
		}
		::fast_io::io::println("broken  ++i v=", b.v, "  broken  i++ v=", p.v);
		::fast_io::io::println("fixed   ++i v=", fb.v, "  fixed   i++ v=", fp.v);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

broken  ++i v=100  broken  i++ v=200
fixed   ++i v=100  fixed   i++ v=100

broken saturates at 100 when you use ++b and runs to 200 when you use b++. The two spellings of one operation disagree. Nobody would write the second version on purpose; it appears because the logic was copied, and because whoever copied it was thinking about "the value before" rather than about the invariant.

The one-line postfix makes that class of bug impossible, not unlikely: there is exactly one place where the state changes, so a change to the rule cannot miss one of the two forms.

5. The Warning That Enforces the Rule

Because the postfix form is marked [[nodiscard]], the case where you did not need the old value becomes a diagnostic instead of a silent copy:


struct cursor
{
	::std::size_t pos{};
	cursor &operator++() noexcept { ++pos; return *this; }
	[[nodiscard]] cursor operator++(int) noexcept
	{
		cursor old{*this};
		++*this;
		return old;
	}
};

void step(cursor &c) noexcept
{
	c++;   /* the old value is thrown away: this is the statement the warning is about */
}

warn.cc: In function ‘void step(cursor&)’:
warn.cc:17:10: warning: ignoring return value of ‘cursor cursor::operator++(int)’, declared with attribute ‘nodiscard’ [-Wunused-result]
   17 |         c++;   /* the old value is thrown away: this is the statement the warning is about */
      |          ^~
warn.cc:7:30: note: declared here
    7 |         [[nodiscard]] cursor operator++(int) noexcept
      |                              ^~~~~~~~

The fix the compiler is pointing at is one character: ++c;. And you get that warning in the library you are writing, for free, for every caller — which is a much better enforcement mechanism than a sentence in a style document.

6. What ++ Is Not For

Temptation Verdict Instead
++ that advances by ten no += 10, which exists for exactly this and is what random-access iterators provide (deque.h has operator+= and operator+ in both operand orders)
++ on something with no notion of position no a named function; x++ on a configuration object is a puzzle, not a shorthand
++ that can fail or allocate no advance_to(next) with a throws contract; failure is invisible inside a glyph (Ch17.2)
a type with only a postfix ++ no define the prefix form and derive the postfix form from it; the reverse is impossible
++ on a temporary, to force a side effect no write the statement; ++make_cursor() increments an object that is destroyed immediately

Binary + on a random-access iterator is a legitimate operator, and fast_io writes both orders for it:


inline constexpr ::fast_io::containers::details::deque_iterator<T, isconst> operator+(::fast_io::containers::details::deque_iterator<T, isconst> a, postype pos) noexcept

It earns the glyph because the meaning is the meaning a pointer already has: no allocation, no failure, one obvious answer, and symmetry — a + 3 and 3 + a are the same iterator (Ch17.3 makes the same argument for numbers).

7. Rules of Thumb