Ch17.7: Subscript, Call, and Dereference

中文(简体)

1. Three Operators With Real Jobs

Unlike +, these three do not stand for a mathematical operation. They stand for three concrete capabilities, and each one has an honest use:

The failure mode of all three is the same: the operator starts doing more than the capability it advertises. When a[i] can insert, allocate, or fail, the reader's mental model of an array access becomes wrong, silently.

2. A Complete Example


#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[]: Cheap, Infallible, or Not an Operator

matrix::operator[] returns a pointer to the first element of a row, and m[r][c] is a pointer addition followed by another pointer addition. That is the contract: [] is a notation for indexing, and indexing is arithmetic. It performs no bounds check — the same as a C array, and the same as ::fast_io::vector::operator[] — so an out-of-range index is a bug in your program, not an error to report.

If your container needs a check, that operation has a different name and a different contract, and you write it as one:


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

Now the caller can see the cost of the check in the name, the compiler can see it in the throws, and operator[] keeps meaning what it means.

Two well-known library examples show how bad the alternative is. ::std::map's operator[] inserts when the key is missing, so m[k] can allocate, can fail, and always writes to the container — and one accidental lookup in a loop turns a read-only pass into a growing map. ::std::vector<bool> returns a proxy object rather than bool&, so auto& b = v[0]; does not compile and v[0] is not a reference; the subscript stopped being a subscript.

4. Lookup Must Not Insert: the Named Alternative

The word_count type above is what ::std::map would look like if it had been designed around this chapter's rule. It has two operations with two names:

No subscript operator appears anywhere in the type. t["fig"] would have to return something, and whatever it returned would make a lookup look like a read when it is a write.

5. *, ->, and ()

6. operator(): the Function Call Operator

() is the only operator that makes an object callable, and "callable" is not a matter of taste in this language — it is a library-wide contract. Every algorithm that takes a predicate, a comparator, a projection or a hash says the same sentence: "I will call f(x)". operator() is how a type agrees to that sentence, which is why it is the one operator in this chapter that a generic library has a real reason to require.

The shape has a few rules that are easy to get wrong:

It is right when the type's reason to exist is being called — a scalar multiplier, a threshold test, a hasher, a comparator. It is wrong when the object is a thing that happens to have an operation: server() as "run the server" says the object is the operation, and the reader has no way to know that from the declaration. That one is server.run().

Stateless callables: make it static

If the operation needs no state — nothing but the type's own template arguments — declare operator() static inline constexpr. Calling it then constructs no object and passes no this: at_least<10>{}(11) is a call to a function with one argument, and the empty temporary is never materialised.


#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

Read the last two lines together. at_least<10> is one byte because it is empty and no object of it exists at the call site; scale_by is four bytes because its factor has to travel with the call. Both are callable, both satisfy whatever the algorithm asks for — the static_assert is there to say the stateless one is a full citizen of <concepts> — and the difference between them is exactly "does this operation have state?", which is the question the declaration answers by writing static or not writing it.

What you cannot write is a static one with a cv-qualifier: there is no object to qualify. The compiler says so in one line:


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 works on operator[] on the same terms and for the same reason: a stateless index needs no object to be indexed. And the rule that keeps all of this honest is the one from Ch17.2: if nothing will ever call your object — no algorithm, no concept, no interface that takes a callable — then the operator buys you nothing and a named function is clearer.

7. Rules of Thumb