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:
- The prefix form returns
cursor &. Notcursor, notcursor const &. It returns*this, so that++++i,(i = ++j)andf(++i)all behave the way they behave forint. - The postfix form takes an
int. The parameter is a dummy: it is never passed, never named, never read. It exists only becauseoperator++()andoperator++(int)are different signatures, and that is the only way the language can tell the two spellings apart. - The postfix form is written in terms of the prefix form. Copy,
++*this, return the copy. Never write the advancing logic twice. [[nodiscard]]on the postfix form only. Discarding the result ofi++means you should have written++i; discarding the result of++iis normal, because advancing for effect is what it is for.
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
- Write
++i. Write--i. Use the postfix form only when you actually need the value from before the step, which is almost never. - Prefix returns
T &. Postfix returnsT. Get those two wrong and every generic algorithm that assumes the usual contract stops working. - The postfix form is three statements and no logic: copy,
++*this, return the copy. [[nodiscard]]on the postfix form, never on the prefix form.noexcepton both, and mean it: an increment that can fail is a named function wearing a glyph.+=for stepping by more than one. Do not overload++to mean "advance a lot".- If your type has no postfix form, that is fine — nothing in this chapter's rules requires one. If your type has no prefix form, your type is the reason the rule exists.