第17.5章 自增与自减
1. 一个操作,两种拼写,两份契约
++ 是本章里唯一一个"两个运算符共用一副标点"的东西。
++i 有一份契约,i++ 有的是另一份:
| 拼写 | 签名 | 返回什么 |
|---|---|---|
++i |
T &operator++() |
前进之后的对象本身 —— 引用,没有拷贝 |
i++ |
T operator++(int) |
一个值:对象前进之前的样子 |
--i |
T &operator--() |
后退之后的对象本身 |
i-- |
T operator--(int) |
一个值:对象后退之前所在的位置 |
对 int 来说,两种拼写生成同样的代码,选哪个是口味问题。
对类类型来说,这个选择是一次拷贝:
后缀形式必须在前进的同时把旧状态留在某处,而唯一能留它的地方就是一个临时对象。
所以语言给你一个原语和一个便利形式,而原语是前缀形式。
这就是本章第二条规则存在的原因:
写 ++i;给你自己的类型写一个真正干活的前缀
++,后缀形式则建立在它之上,只有一行。
2. 怎么写
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;
}
};
六行逻辑,里面有四个决定:
- 前缀形式返回
cursor &。 不是cursor,也不是cursor const &。 它返回*this,这样++++i、(i = ++j)、f(++i)的表现才和int上的表现一致。 - 后缀形式带一个
int参数。 这个参数是占位的:它从不被传入、从不被命名、从不被读取。 它存在只是因为operator++()和operator++(int)是两个不同的签名,而这是语言区分两种拼写的唯一办法。 - 后缀形式是用前缀形式写出来的。
拷贝、
++*this、返回那份拷贝。绝不把前进逻辑写两遍。 [[nodiscard]]只加在后缀形式上。 丢掉i++的结果,意味着你本该写++i; 丢掉++i的结果则是常态,因为"为了副作用而前进"正是它的用途。
noexcept 在这里不是装饰。让一个游标在容器上前进不可能失败:
不分配、没有边界要检查(那是 operator* 的活)、也没有错误可报。
一个会失败的自增不是自增 —— 如果你发现自己想要一个,
你想要的是带 throws 契约的命名函数,比如
advance_to(next)(第6.16章)。
这不是本文的风格偏好,而是 fast_io 自己的容器用的形状。在
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;
}
在 deque.h、forward_list.h 以及库里其它每一个迭代器里,
对 -- 和 ++ 也都是一模一样的写法
(第9章)。
3. 后缀形式要付什么
我们数拷贝次数,而不是掐表,因为拷贝正是不同的那部分,而计数器是精确的。 这个类型携带 64 字节状态 —— 真实迭代器拖着的差不多就是这种东西 —— 它的拷贝构造函数只做一件看得见的事:给自己计数。
#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
两个循环都前进了四百万次。只有一个为了做到这件事创建了四百万个对象。 这就是全部差别,而且这个差别属于类型的契约,不属于循环。
两点诚实的限定,因为教程里的测量不该被夸大。
第一:当被丢弃的临时对象可证明没有被使用时,优化器会删掉那次拷贝。
上面那个计数器之所以还会响,只是因为那个拷贝构造函数有一个它必须保留的副作用。
所以在这台机器上、在 -O2 下,两个循环耗时相同 ——
而此时唯一还能显示"拷贝确实发生过"的仪器就是计数器。
一旦你写 sum += *it++,旧值就被用上了,
临时对象是真实的,拷贝也就跟着真实了。
第二:对循环计数器本身这样的标量,完全没有差别 ——
编译器为 i++ 和 ++i 生成同样的代码。
之所以仍然要写 ++i,不是因为计数器贵,
而是因为 for (auto it{first}; it != last; ++it)
对语言里每一个类型都是同一条语句,
而写成 it++ 的版本只对提供了后缀形式的类型才是同一条语句 ——
而且当它们真的提供时,它向它们要了一份并不需要的拷贝。
4. 为什么"固定下来的写法"比那次拷贝更重要
拷贝只是这个论证里便宜的一半。贵的那一半是: 把后缀形式当成第二份实现来写,它会慢慢偏离前缀形式,而没有任何东西会告诉你。 下面这个计数器在 100 处停住,两种写法都实现了:
#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 在你用 ++b 时饱和在 100,在你用 b++
时就一路跑到 200。同一个操作的两种拼写说法不一致。
没有人会故意写第二个版本;它出现是因为逻辑被复制了,
而复制的人当时想的是"之前的值",而不是那个不变式。
一行的后缀形式让这一类 bug 变成不可能,而不是"不太可能": 状态改变的地方只有一处,所以对规则的任何修改都不可能漏掉两种形式中的一种。
5. 强制这条规则的警告
因为后缀形式被标记了 [[nodiscard]],
"你其实不需要旧值"这种情况就不再是静默的拷贝,而是一条诊断:
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
| ^~~~~~~~
编译器指的修法是一个字符:++c;。
而且这个警告是免费的、对你写的库里每个调用者都生效的 ——
这比风格文档里的一句话有效得多。
6. ++ 不是用来干什么的
| 诱惑 | 结论 | 改用 |
|---|---|---|
前进 10 的 ++ |
不行 | += 10,它存在就是为了这个,随机访问迭代器提供的也正是它
(deque.h 里有 operator+=,以及两种操作数顺序的
operator+) |
往没有"位置"概念的东西上写 ++ |
不行 | 用命名函数;对配置对象做 x++ 是个谜题,不是简写 |
会失败或会分配的 ++ |
不行 | 带 throws 契约的 advance_to(next);
失败藏在标点里是看不见的
(第17.2章) |
只有后缀 ++ 的类型 |
不行 | 定义前缀形式,再由它派生后缀形式;反过来是做不到的 |
对临时对象写 ++ 来制造副作用 |
不行 | 把语句写出来;++make_cursor() 递增的是一个立刻就被销毁的对象 |
随机访问迭代器上的二元 + 是正当的运算符,fast_io 为它写了两种操作数顺序:
inline constexpr ::fast_io::containers::details::deque_iterator<T, isconst> operator+(::fast_io::containers::details::deque_iterator<T, isconst> a, postype pos) noexcept
它配得上这副标点,因为它的含义就是指针本来具有的含义:
不分配、不失败、答案唯一,而且对称 —— a + 3 和 3 + a
是同一个迭代器(第17.3章
对数字给出了同样的论证)。
7. 经验法则
- 写
++i。写--i。只有在你确实需要步进之前那个值的时候才用后缀形式, 而这种时候几乎没有。 - 前缀返回
T &,后缀返回T。这两条弄反, 每一个假设了通常契约的泛型算法就会失效。 - 后缀形式是三条语句、零逻辑:拷贝、
++*this、返回那份拷贝。 [[nodiscard]]加在后缀形式上,永远不加在前缀形式上。- 两个都标
noexcept,并且是真的:会失败的自增,是套着标点的命名函数。 - 步进多个单位用
+=。不要让++表示"前进一大截"。 - 如果你的类型没有后缀形式,那没问题 —— 本章的规则不要求它。 如果你的类型没有前缀形式,那你的类型就是这条规则存在的理由。