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:
operator[]— "give me the element at this position, now, without doing anything else".operator*/operator->— "behave like a pointer to this object".operator()— "this object's purpose is to be called".
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:
find(key)returns a pointer to the count, ornullptr. It isnoexcept, it does not allocate, and it does not changeused— which the transcript proves:used after find = 2, the same value as before the lookup.insert_or_assign(key, v)is the one that can create an entry, and its name says so. The transcript showsusedgoing from 2 to 3 only after this call.
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 ()
point_refexists to pass a pointer that must not be null, and its whole interface is*pandp->member. Neither can fail, neither allocates, and the object is one pointer wide. This is the same shape as a smart handle —::std::unique_ptris just this plus ownership (Ch18.1).scale_byis a class whose purpose is to be called, sooperator()is its natural interface: it composes with algorithms and callables, it can carry state (factor), andscale_by{3}(7)cannot be confused with anything else (§6).- The anti-pattern is
operator()used as "the entry point of a service":app(),db(),ctx(...), where the object is not a function at all and the operator is being used because it is shorter thanrun. A call should readserver.run(), becauseserver()says the object is the operation.
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 must be a member function. There is no non-member form, and no way to make
x(y)mean anything for a type that does not declare one. - Any arity, including zero, including a parameter pack, including a template. The "call operator" is not "the zero-argument operator" — a comparator with two arguments is the most common instance in the library.
constwhen it does not modify the object, which is almost always. Anoperator()that mutates its object cannot be used by an algorithm that holds the callable const, and most do.- The same contract rules as any other function:
noexceptorthrows, and no function escapes that (Ch6.16).
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
operator[]must be the cheapest access you have: no allocation, no insertion, no check, no failure. Otherwise it is not a subscript, and it gets a name.- If the operation can fail, put the check in a named function
(
at,find,try_get) with athrowscontract. - Never return a proxy from
operator[]unless the proxy is an ordinary proxy reference that behaves likeT&in every context you care about (::std::vector<bool>is the cautionary tale, not the model). operator*andoperator->are for things that behave like pointers. If the thing is not a pointer, use.get(),.value(), or a named accessor.operator()only when the type's reason to exist is being called — andstaticwhen it needs no state (§6).- A callable with state gets a
constoperator()unless it is genuinely the operation that mutates; a callable without state getsstatic. - Never the entry point of a service:
server.run(), notserver().