第17.7章 下标、调用与解引用

English

1. 三个有真实职责的运算符

和 + 不同,这三个不表示某种数学操作。它们表示三种具体的能力, 而每一种都有诚实的用法:

三者的失败方式完全相同:运算符开始做得比它宣称的能力更多。 当 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 按本章规则设计” 的样子。它有两个操作、两个名字:

整个类型里没有出现任何下标运算符。t["fig"] 必须返回点什么, 而不管它返回什么,都会让一次查找看起来像一次读,实际上却是写。

5. *、-> 和 ()

6. operator():函数调用运算符

() 是唯一能让一个对象可调用的运算符, 而在这个语言里"可调用"不是口味问题 —— 它是贯穿整个库的契约。 每个接受谓词、比较器、投影或哈希的算法说的都是同一句话:"我会调用 f(x)"。operator() 就是一个类型同意这句话的方式, 这也是本章里唯一一个"泛型库有真实理由要求它"的运算符。

这个形状有几条容易搞错的规则:

它正确的场合是"这个类型存在的理由就是被调用" —— 一个缩放因子、一个阈值判断、 一个哈希器、一个比较器。它错误的场合是"这个对象碰巧有个操作": 用 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. 经验法则