第17.7章 下标、调用与解引用
1. 三个有真实职责的运算符
和 + 不同,这三个不表示某种数学操作。它们表示三种具体的能力,
而每一种都有诚实的用法:
operator[]—— “把这个位置上元素给我,就现在,别的什么都别做”。operator*/operator->—— “像指向这个对象的指针那样行事”。operator()—— “这个对象存在的目的就是被调用”。
三者的失败方式完全相同:运算符开始做得比它宣称的能力更多。
当 a[i] 能插入、能分配、能失败时,
读者心里“这是一次数组访问”的模型就静默地错了。
2. 完整例子
#include <fast_io.h>
#include <fast_io_dsal/string_view.h>
#include <cstddef>
#include <cstdint>
struct matrix
{
static constexpr ::std::size_t width{4};
::std::int_least32_t cells[8]{};
/* cost: one multiply and one add. No check, no allocation, no failure. */
[[nodiscard]] constexpr ::std::int_least32_t *operator[](::std::size_t row) noexcept
{
return cells + row * width;
}
[[nodiscard]] constexpr ::std::int_least32_t const *operator[](::std::size_t row) const noexcept
{
return cells + row * width;
}
};
struct word_count
{
::fast_io::string_view *keys;
::std::int_least32_t *counts;
::std::size_t used;
::std::size_t capacity;
/* lookup: returns null when the key is absent, and never changes the table */
[[nodiscard]] constexpr ::std::int_least32_t *find(::fast_io::string_view key) noexcept
{
for (::std::size_t i{}; i != used; ++i)
{
if (keys[i] == key)
{
return counts + i;
}
}
return nullptr;
}
/* the creating operation has a name, and it says it creates */
constexpr ::std::int_least32_t &insert_or_assign(::fast_io::string_view key, ::std::int_least32_t v) noexcept
{
if (::std::int_least32_t *p{find(key)}; p != nullptr)
{
*p = v;
return *p;
}
keys[used] = key;
counts[used] = v;
return counts[used++];
}
};
struct point
{
::std::int_least32_t x{};
::std::int_least32_t y{};
};
/* a non-owning handle: * and -> are the whole interface, and neither can fail */
struct point_ref
{
point *ptr;
[[nodiscard]] constexpr point &operator*() const noexcept
{
return *ptr;
}
[[nodiscard]] constexpr point *operator->() const noexcept
{
return ptr;
}
};
/* () is for types whose purpose is to be called */
struct scale_by
{
::std::int_least32_t factor{};
[[nodiscard]] constexpr ::std::int_least32_t operator()(::std::int_least32_t v) const noexcept
{
return v * factor;
}
};
int main()
{
try
{
matrix m;
m[1][2] = 7;
::fast_io::io::println("m[1][2] = ", m[1][2], " m[0] row = ", m[0][0], " ", m[0][1], " ", m[0][2], " ", m[0][3]);
::fast_io::string_view keys[4]{"apple", "pear", "", ""};
::std::int_least32_t counts[4]{3, 5, 0, 0};
word_count table{keys, counts, 2, 4};
if (::std::int_least32_t *p{table.find("apple")}; p != nullptr)
{
::fast_io::io::println("apple: ", *p);
}
::fast_io::io::println("find(\"fig\") is null: ", table.find("fig") == nullptr);
::fast_io::io::println("used after find = ", table.used);
table.insert_or_assign("fig", 1);
::fast_io::io::println("after insert_or_assign: used = ", table.used, " fig = ", *table.find("fig"));
point pt{1, 2};
point_ref h{__builtin_addressof(pt)};
::fast_io::io::println("h->x = ", h->x, " (*h).y = ", (*h).y);
::fast_io::io::println("scale_by{3}(7) = ", scale_by{3}(7));
}
catch throws (::std::error e)
{
::fast_io::io::print("error: ");
::fast_io::io::println(e);
}
}
m[1][2] = 7 m[0] row = 0 0 0 0
apple: 3
find("fig") is null: 1
used after find = 2
after insert_or_assign: used = 3 fig = 1
h->x = 1 (*h).y = 2
scale_by{3}(7) = 21
3. operator[]:便宜、不会失败,否则就不是运算符
matrix::operator[] 返回一行第一个元素的指针,
而 m[r][c] 就是一次指针加法再接一次指针加法。这就是契约:
[] 是下标的写法,而下标就是算术。它不做边界检查 ——
和 C 数组一样,和 ::fast_io::vector::operator[] 一样 ——
所以越界的下标是你程序里的 bug,不是要报告的错误。
如果你的容器需要检查,那个操作有另一个名字和另一份契约,你就把它写出来:
[[nodiscard]] ::std::int_least32_t at(::std::size_t i) const
throws(!noexcept(::fast_io::herbceptions::throws_errc(::std::errc::result_out_of_range)))
{
if (i >= size_)
{
::fast_io::herbceptions::throws_errc(::std::errc::result_out_of_range);
}
return cells_[i];
}
现在调用者能在名字里看见检查的代价,编译器能在 throws 里看见它,
而 operator[] 保持它本来的含义。
两个著名库例子说明了另一种做法有多糟。::std::map 的
operator[] 在键缺失时会插入,所以 m[k]
能分配、能失败,而且总是写入容器 ——
循环里一次不小心的查找就把一趟只读遍历变成了不断增长的 map。
::std::vector<bool> 返回的是一个代理对象而不是 bool&,
所以 auto& b = v[0]; 不能编译,而 v[0] 也不是引用;
下标已经不再是下标了。
4. 查找不能插入:命名替代方案
上面那个 word_count 就是“如果 ::std::map 按本章规则设计”
的样子。它有两个操作、两个名字:
find(key)返回指向计数的指针,或者nullptr。 它是noexcept、不分配、也不改变used—— 输出记录证明了这一点:used after find = 2,和查找之前一样。insert_or_assign(key, v)是那个能创建条目的操作,它的名字就说明了这一点。 输出记录显示used只在这次调用之后才从 2 变成 3。
整个类型里没有出现任何下标运算符。t["fig"] 必须返回点什么,
而不管它返回什么,都会让一次查找看起来像一次读,实际上却是写。
5. *、-> 和 ()
point_ref的存在是为了传递一个不能为空的指针, 它的整个接口就是*p和p->member。两个都不会失败、 都不分配,对象只有一个指针宽。这和智能句柄是同一个形状 ——::std::unique_ptr就是这个再加所有权 (第18.1章)。scale_by是一个“存在就是为了被调用”的类, 所以operator()是它天然的接口:它能和算法、可调用对象组合, 它能携带状态(factor),而且scale_by{3}(7)不会和别的东西混淆 (第6节)。- 反模式是把
operator()当成“服务的入口”:app()、db()、ctx(...), 对象根本不是函数,用这个运算符只是因为比写run短。 调用应该写成server.run(),因为server()说的是“这个对象就是那个操作”。
6. operator():函数调用运算符
() 是唯一能让一个对象可调用的运算符,
而在这个语言里"可调用"不是口味问题 —— 它是贯穿整个库的契约。
每个接受谓词、比较器、投影或哈希的算法说的都是同一句话:"我会调用
f(x)"。operator() 就是一个类型同意这句话的方式,
这也是本章里唯一一个"泛型库有真实理由要求它"的运算符。
这个形状有几条容易搞错的规则:
- 它必须是成员函数。没有非成员形式,也没有任何办法让
x(y)对一个没有声明它的类型有任何含义。 - 元数任意,可以是零个、可以是一个参数包、可以是模板。 "调用运算符"不是"零参数运算符" —— 库里最常见的实例恰恰是带两个参数的比较器。
- 不修改对象时写
const,而这几乎总是。 会修改自己对象的operator()无法被"把可调用对象按 const 持有"的算法使用, 而大多数算法都是这么持有的。 - 契约规则与其它任何函数相同:
noexcept或throws, 没有函数能逃过这一条 (第6.16章)。
它正确的场合是"这个类型存在的理由就是被调用" —— 一个缩放因子、一个阈值判断、
一个哈希器、一个比较器。它错误的场合是"这个对象碰巧有个操作":
用 server() 表示"运行服务器",等于说这个对象就是那个操作,
而读者从声明里看不出这一点。那个应该写成 server.run()。
无状态的可调用对象:写成 static
如果这个操作不需要状态 —— 除了类型自己的模板参数之外什么都没有 ——
就把 operator() 声明成 static inline constexpr。
这样调用它既不构造对象,也不传递 this:
at_least<10>{}(11) 就是一个只有一个参数的函数调用,
那个空的临时对象从来不会被真正生成。
#include <fast_io.h>
#include <concepts>
#include <cstdint>
template <::std::int_least32_t Threshold>
struct at_least /* stateless: the threshold lives in the type, not in an object */
{
static inline constexpr bool operator()(::std::int_least32_t v) noexcept
{
return v >= Threshold;
}
};
struct scale_by /* stateful: the factor lives in the object */
{
::std::int_least32_t factor{};
[[nodiscard]] constexpr ::std::int_least32_t operator()(::std::int_least32_t v) const noexcept
{
return v * factor;
}
};
int main()
{
try
{
static_assert(::std::predicate<at_least<10>, ::std::int_least32_t>);
::fast_io::io::println("at_least<10>{}(11) ", ::fast_io::mnp::boolalpha(at_least<10>{}(11)));
::fast_io::io::println("at_least<10>{}(9) ", ::fast_io::mnp::boolalpha(at_least<10>{}(9)));
::fast_io::io::println("scale_by{3}(7) ", scale_by{3}(7));
::fast_io::io::println("sizeof at_least<10> ", sizeof(at_least<10>));
::fast_io::io::println("sizeof scale_by ", sizeof(scale_by));
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
at_least<10>{}(11) true
at_least<10>{}(9) false
scale_by{3}(7) 21
sizeof at_least<10> 1
sizeof scale_by 4
把最后两行放在一起读。at_least<10> 只占一个字节,因为它是空的,
而且调用点根本没有它的对象;scale_by 占四个字节,因为它的因子必须跟着调用走。
两者都可调用,两者都满足算法所要求的东西 ——
那个 static_assert 就是为了说明无状态的那一个也是
<concepts> 的正式公民 ——
而它们的差别恰好就是"这个操作有状态吗?",
这正是声明里写不写 static 所回答的问题。
你不能写的是带 cv 限定符的 static 版本:没有对象可以被限定。
编译器用一行话告诉你:
struct bad
{
static inline constexpr bool operator()(int v) const noexcept { return v > 0; }
};
st2.cc:3:62: error: static member function ‘static constexpr bool bad::operator()(int)’ cannot have cv-qualifier
static 对 operator[] 也是同样的条件、同样的理由:
一个无状态的下标不需要对象来被下标访问。而让这一切保持诚实的规则,
就是 第17.2章 里那条:
如果永远没有任何东西会调用你的对象 —— 没有算法、没有概念、
没有接受可调用对象的接口 —— 那这个运算符什么也没给你买到,命名函数更清楚。
7. 经验法则
operator[]必须是你最便宜的访问方式:不分配、不插入、不检查、不失败。 否则它就不是下标,它要有一个名字。- 如果操作可能失败,就把检查放进命名函数(
at、find、try_get),并写throws契约。 - 除非这个代理就是一个普通引用式的代理,在你关心的每个上下文里都表现得像
T&,否则不要从operator[]返回代理 (::std::vector<bool>是反面教材,不是范本)。 operator*和operator->是给“行为像指针”的东西用的。 如果它不是一个指针,就用.get()、.value()或者命名访问器。- 只有当类型存在的理由就是被调用时才写
operator()—— 无状态时写成static inline constexpr(第6节)。 - 有状态的可调用对象用
const的operator(), 除非它本来就是那个会修改的操作;无状态的一律加static。 - 永远不要当服务的入口:写
server.run(),而不是server()。