第17.2章 优先用命名函数:concat,而不是 +

English

1. 主张

当运算符和一个函数能表达同一件事时,写那个函数。 不是因为运算符慢 —— 它们内联得很好 —— 而是因为标点符号是一个承诺: 这个操作就是所有人都已经知道的那个操作。对两个数,+ 就是那个操作。 对两个字符串、两个向量、两个文件或两棵树,它不是: 没人能读着 a + b 就知道它是连接、是合并、是求并, 分配了内存、拷贝了十兆字节,还是失败了。

最典型的场合是序列连接,C++ 从 1998 年的标准库继承下来的 operator+ 就是它。这是个好例子,因为它太熟悉了,以至于看起来无害, 而且它可以被测量。

2. 测量

我们用三种办法构造同样的 4000 字节结果 —— 用 operator+、 用一个返回新值的 concat 函数、用一个就地把内容接上去的 append 成员。这里的 text 统计它拷贝构造函数拷贝了多少字节; 那个计数器是测量仪器,不是你会发布出去的东西。


#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
#include <cstddef>

struct text
{
	::fast_io::string chars{};

	static inline ::std::size_t copied{}; /* a test instrument, not a member you ship */

	text() = default;
	explicit text(::fast_io::string_view vw) : chars{vw} {}
	text(text const &other) : chars{other.chars}
	{
		copied += chars.size();
	}
	text(text &&other) noexcept : chars{::std::move(other.chars)} {}
	text &operator=(text &&other) noexcept
	{
		chars = ::std::move(other.chars);
		return *this;
	}

	void append(text const &other)
	{
		chars.append(other.chars);
	}
};

inline text operator+(text const &a, text const &b)
{
	text r{a}; /* copies every byte of a */
	r.append(b);
	return r;
}

inline text concat(text const &a, text const &b)
{
	return a + b; /* a named function that still copies - the name is not magic */
}

int main()
{
	try
	{
		text const word{::fast_io::string_view{"abcdefgh"}};

		text with_plus{};
		text::copied = 0;
		for (int i{}; i != 500; ++i)
		{
			with_plus = with_plus + word;
		}
		::fast_io::io::println("a + b   size=", with_plus.chars.size(), " copied=", text::copied);

		text with_concat{};
		text::copied = 0;
		for (int i{}; i != 500; ++i)
		{
			with_concat = concat(with_concat, word);
		}
		::fast_io::io::println("concat  size=", with_concat.chars.size(), " copied=", text::copied);

		text with_append{};
		text::copied = 0;
		for (int i{}; i != 500; ++i)
		{
			with_append.append(word);
		}
		::fast_io::io::println("append  size=", with_append.chars.size(), " copied=", text::copied);

		auto const joined{::fast_io::concat_fast_io("concatenated: ", 1, " + ", 2, " = ", 3)};
		::fast_io::io::println(joined);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

a + b   size=4000 copied=998000
concat  size=4000 copied=998000
append  size=4000 copied=0
concatenated: 1 + 2 = 3

仔细读这三行,因为它们说出了“运算符没问题”和“运算符是邪恶的”这两种说法都错过的内容。

a + b 和 concat 花的代价完全相同。 为了产出 4000 字节输出,拷贝了 998000 字节。把运算符改名成函数, 在性能上什么都没换来 —— 操作数仍然在说“返回一个新值”, 而返回新值就意味着在 500 次迭代的每一次里都把累积起来的字符串拷贝一遍。 这就是 O(n²) 的形状,也是每种把 + 这么定义的语言里 a = a + b 写进循环之后的形状。

append 的代价是零。不是“更少”:是零。 它在已经存在的缓冲区上扩展,所以已经拷好过的字节一个都没再拷。 想从 + 拿到这个行为,你得写 a += b, 那是另一个标点、另一个含义,而对一个建模“一个值”而不是“一个容器”的类型来说, 这个运算符常常根本不存在。

所以这次测量真正的教训不是“把它叫 concat”。而是: 运算符的写法强行附加了一套你没有选择的拷贝语义, 而名字是让这个选择变得可见的东西。concat(a, b) 告诉读者出现了一个新值; a.append(b) 告诉读者 a 长大了、别的什么都没发生。 a + b 什么都没告诉读者,而其中一种代价是 998000 字节。

3. fast_io 自己的 concat 说明了什么

程序最后两行用了 ::fast_io::concat_fast_io,它值得停一下看, 因为它展示了命名函数允许、而运算符拒绝的形状:


auto const joined{::fast_io::concat_fast_io("concatenated: ", 1, " + ", 2, " = ", 3)};

三个不同类型的六个实参,由 fast_io 自己的 print 机制格式化 (第15.11章), 写进一个缓冲区,一次分配;并且当且仅当任何一个实参的格式化可能抛时它才 throws。试着用运算符表达这件事:+ 是二元的, 所以你需要五个,每一个都在分配,或者至少每一个都是独立的函数调用节点, 每一个都是读者怀疑“这结果是不是数值”的机会。

这就是为什么 ::fast_io::print 和 ::fast_io::scan 是有名字的函数,而不是 operator<< 和 operator>>(第17.8章): 一个以“把值变成文本”为业的库,承受不起隐藏方向、编码或失败的标点符号。

4. ::fast_io::string 故意不提供 operator+

::std::string 有。这正是那个习惯的源头: 二十年的 a + b 教会了一代人"连接就是标点"。 同一个库里也有 append,而它才是绝大多数调用点真正想要的操作, 而且它不做拷贝。

::fast_io::string 既没有 operator+,也没有 operator+=。这不是疏忽,也不是缺少特性 —— 这是一个决定, 而本页就是为它辩护的论证。把过去写成 a + b 的那个表达式写出来, 编译器会告诉你:


#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>

int main()
{
	::fast_io::string a{::fast_io::string_view{"hello "}};
	::fast_io::string b{::fast_io::string_view{"world"}};
	auto c{a + b};
	::fast_io::io::println(c);
}

plus.cc: In function ‘int main()’:
plus.cc:9:18: error: no match for ‘operator+’ (operand types are ‘fast_io::string’ {aka ‘fast_io::containers::basic_string<char, fast_io::generic_allocator_adapter<fast_io::c_malloc_allocator> >’} and ‘fast_io::string’ {aka ‘fast_io::containers::basic_string<char, fast_io::generic_allocator_adapter<fast_io::c_malloc_allocator> >’})
    9 |         auto c{a + b};
      |                ~ ^ ~
      |                |   |
      |                |   basic_string<[...],[...]>
      |                basic_string<[...],[...]>
  • there is 1 candidate
    • candidate 1: ‘constexpr fast_io::posix_timespec64 fast_io::operator+(posix_timespec64, posix_timespec64)’
      • no known conversion for argument 1 from ‘fast_io::string’ {aka ‘fast_io::containers::basic_string<char, fast_io::generic_allocator_adapter<fast_io::c_malloc_allocator> >’} to ‘fast_io::posix_timespec64’

编译器在整个头文件集合里能找到的唯一候选,是给时间戳用的 operator+(include/fast_io_core_impl/iso/iso8601.h,第 108 行), 那是一个配得上这副标点的运算符:一个时间点加一段时长,不分配、不失败、答案唯一。 字符串没有候选,因为字符串没有 operator+。 候选下面重复的那段源码回显在上面被省略了。

库提供的,是调用点真正必须在其中做选择的那些操作:


#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>

int main()
{
	try
	{
		::fast_io::string a{::fast_io::string_view{"hello "}};
		::fast_io::string b{::fast_io::string_view{"world"}};

		::fast_io::string c{a}; /* the copy that "+" would have made, made on purpose */
		c.append(b);            /* in place: nothing that was already there is copied again */
		::fast_io::io::println("c        ", c);

		a.append(::fast_io::string_view{"there"});
		::fast_io::io::println("a        ", a);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

c        hello world
a        hello there

append 在 include/fast_io_dsal/impl/string.h 里有四个重载 —— 一个 string_view、另一个 basic_string、 一对 [begin, end) 指针、以及指针加长度 —— 而且它们都返回 void。追加不可能产出一个值,因为并没有新值: 有的是你本来就持有的那个字符串,它变长了。 当你确实想要一个新值时,那是第3节里的 ::fast_io::concat_fast_io, 它也是函数,也把这件事写在名字里。

重点不是"::std::string 错了、fast_io 对了"。 重点是这种拼写是一个带可测量代价的设计决定, 而把一个昂贵的拼写从接口里拿掉,正是库让你"有意识地"而不是"凭习惯"去做这个决定的方式。 这里的代价是 998000 个被拷贝的字节对 0。

5. 写标点符号之前要问的四个问题

问题 如果答案是“是”
它会失败吗? 不要用运算符。一个标点会同时隐藏错误返回和赫布异常, 而 a + b 的读者两个都不会去找。用名字,并写 throws 契约。
它会分配内存或大量拷贝吗? 不要用运算符。看不见的代价就是没被计入的代价。 concat 至少会提醒你出现了一个新值;+ 不会。
对一个从没见过你这个类型的人来说,含义显而易见吗? 那运算符大概没问题 —— 这就是算术的场合 (第17.3章)。
用名字会比用一段注释解释这个运算符更长吗? 那运算符对所有人都更便宜,包括下一个读者。

把这个测试用到熟悉的运算符上:::std::map 的 operator[] 在键缺失时会插入一个默认值,所以 m[k] 会失败(分配内存)并且会修改容器 —— 这就是 第17.7章 更偏爱 find 的原因。operator<< 往流里写, 并通过设置一个没人检查的流状态来报告失败 —— 这就是 第17.8章 把它删掉的原因。

6. 该用的名字

不要写 写 因为名字说明了
两个字符串上的 a + b concat(a, b) 一个由两者组成的新序列,分配了内存
a = a + b a.append(b) 就地,摊还,不拷贝 a
a + b + c + d concat(a, b, c, d) 一次操作、四个输入、一次分配
集合或标志位上的 a | b set_union(a, b)、with_flag(f) 重复元素是保留还是去掉,以及哪一边优先
路径上的 a / b join(a, b) 是连接而不是除法;而且分隔符是一个策略
矩阵上的 a * b a.multiply(b) 或 matmul(a, b) 是哪种乘法、什么顺序,以及它会分配

标准库最终也同意这一页。C++26 里恰好做这个操作的 range 适配器叫 ::std::views::concat —— 一个有名字的函数 —— 而不是两个 range 之间的 operator+。当两个 range 被惰性连接时, 既没有新容器也没有分配,而把这个叫 + 会同时暗示这两件事。

7. 当你两者都需要

有时候为了数学代码的可读性,在命名函数之上再提供一个运算符是值得的。 那就把运算符写成一行别名,把真正的工作放在函数里:


template <typename... Args>
[[nodiscard]] inline ::fast_io::string concat(Args const &...args)
	throws(!noexcept(::fast_io::concat_fast_io(args...)))
{
	return ::fast_io::concat_fast_io(args...);
}

这个反转才是重点。如果运算符是原语,调用者就会用它,命名函数变成没人读的包装。 如果函数是原语,运算符就是有文档的便利 —— 而且你可以 grep 出到底有多少地方 选择了那种昂贵的写法。

8. 经验法则