第17.2章 优先用命名函数:concat,而不是 +
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. 经验法则
- 优先用命名函数。运算符必须自己挣得那个标点。
- 不要写会失败、会分配、会阻塞的运算符。这三件事是读者必须看得见的。
- 如果你提供了运算符,就让它拿命名函数定义,而不是反过来。
- 一个运算符就是一个操作、一种代价。如果
a + b有时拷贝有时不拷贝, 那是两个操作共用一个标点。 - 永远不要让运算符的含义依赖于模板实参的取值,从而悄悄改变它是否分配或是否抛。 候选函数匹配不是文档通道。