第17.5章 自增与自减

English

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;
	}
};

六行逻辑,里面有四个决定:

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. 经验法则