C++ Logo

std-proposals

Advanced search

Re: Yet another member function for std::map

From: Barry Revzin <barry.revzin_at_[hidden]>
Date: Thu, 29 Jul 2021 08:16:38 -0500
On Wed, Jul 28, 2021 at 8:39 PM Arthur O'Dwyer <arthur.j.odwyer_at_[hidden]>
wrote:

> On Wed, Jul 28, 2021 at 8:29 PM Barry Revzin <barry.revzin_at_[hidden]>
> wrote:
>
>> On Wed, Jul 28, 2021 at 2:40 PM Arthur O'Dwyer via Std-Proposals <
>> std-proposals_at_[hidden]> wrote:
>>
>>>
>>>
>> 2, "return bare pointer," is correct.
>>>
>>
>> optional<T&> is a much better choice. Semantically, it matches the
>> functionality better (you want either a T& or nothing, which is exactly the
>> semantics of optional<T&>, while T* is more ambiguous) and the API of
>> optional<T&> is just *much* better for this problem than the API of T*,
>> in terms of ergonomics for the user. As is very clear from your example:
>>
>
> Meh, I would argue that what you want is either the address of a T or
> something-which-is-falsey-and-also-not-a-T, which is exactly what `T*`
> gives you (because null pointers).
> Historically this user interface has been so abundantly successful that
> when C++17 adopted `std::optional`, they pasted the whole "pointer"
> interface onto it, so that e.g. you can write `o->foo()` instead of
> `o.value().foo()`.
>

> The STL already uses this idiom in `std::get_if` and `std::any_cast`, as
>>> well as `std::dynamic_cast<T*>`.
>>> Actually, many industry codebases already have a convenience function
>>> along these lines. Serendipitously, I just added one to HyperRogue the
>>> other day:
>>>
>>> https://github.com/zenorogue/hyperrogue/pull/246/files#diff-f42f1abd2e52db6ab3f7da5cc6c2e290adc46e1d7cd47c673ea49f5ead738c96R851
>>>
>>> However, notice that there are many operations that you'd still want to
>>> do via third-party convenience functions. For example, in the exact same
>>> HyperRogue patch, I also introduced a helper function `span_at`, with these
>>> semantics:
>>>
>>> template<class Map, class Key, class T = /*metaprogramming*/>
>>> span<const T> span_at(const Map& map, const Key& key) {
>>> auto it = map.find(key);
>>> return (it == map.end()) ? span<const T>() : span<const
>>> T>(it->second.data(), it->second.size());
>>> }
>>>
>>> This is useful for things like
>>> const std::map<Parent, std::vector<Child>> m = ...;
>>> Parent p = ...;
>>> for (const Child& c : span_at(m, p)) { ... }
>>> where a naïve
>>> for (const Child& c : m[p])
>>> would try to insert into `m`.
>>> (And yes there's prior art for *this* API in optional::value_or. But
>>> `value_or(m, p, span<T>())` would have been much too verbose, and would
>>> have had trouble with type-checking, so for this codebase we want the
>>> shorter simpler version.
>>>
>>
>> If you had a member function value_at that returned an optional, this
>> would be:
>>
>> for (child Child& c : m.value_at(p).value_or(span<T>())) { ... }
>>
>
> Sure, that's the option I presented in the previous sentence and said
> - it would have been much too verbose: compare
> for (const Child& c : hr::span_at(m, p)) { ... }
> for (const Child& c : m.value_at(p).value_or(std::span<Child>())) { ... }
> - it would have had trouble with type-checking: `vector` is convertible to
> `span`, but is not literally the same as `span`, so .value_or won't compile
> it
> https://godbolt.org/z/fMvb1Gs3a
>
> What would you do if value_at returned a T*? You can't do value_or() on a
>> T*, because pointers have very few operations you can do on them (and
>> nearly all of those would be straight up invalid in this use-case - which
>> does not make for a great API!) you'd either push for some language feature
>> that does that or you'd write some non-member function that handles this
>> case. Which would be fine for value_or(), but not for any number of other
>> operations that work for optional but not for pointers. What if you wanted
>> the size of the span there, or 0?
>>
>
> Right — it sounds like you're agreeing with my point.
>
There are just too many things you could imagine doing with a helper
> function here.
>
 for (const Child& c : hr::size_at(m, p)) { ... }
> or
> for (const Child& c :
> m.value_at(p).transform(std::ranges::size).value_or(0)) { ... }
> (FWIW, this requires grafting monadic `transform` onto `std::optional` —
> but that's https://github.com/cplusplus/papers/issues/112 , likely to
> happen in C++23 anyway)
> And then what if you wanted "the integer at that key position, if it
> exists, but only if it's even"? You could graft monadic `filter` onto
> `optional`... or, again, you could write a helper function. There will
> always be functions like this. You can write them yourself as free
> functions, or you can graft them onto `map`; or you can graft them onto
> `optional`; or you could even invent a new sort of `monadic_return<T>` that
> you can graft arbitrary functions onto, without choosing `optional`
> specifically to be that type. But (1) the stream of functions and knobs is
> never-ending, and (2) I'd rather write `size_at` than
> `value_at().transform(ranges::size).value_or(0)` any day.
>
> –Arthur
>

It's not really relevant that you prefer size_at to
value_at().transform(ranges::size).value_or(0).

For those people that prefer writing small functions and then writing
programs that are built on composing those functions, returning
optional<T&> from value_at() actually allows that style to be even
possible, because optional<T&> composes while T* does not. Similarly to how
ranges compose, but iterators do not. The advantage to such a compositional
style is that there really is not a never-ending stream of utility
functions, and once you learn the ones that do exist, you can build a
combinatorial explosion of functionality. It's easy to read the latter
expression at a glance and know exactly what it means (whereas size_at,
while clearly suggesting that it returns the size of something, does not
suggest a zero as a default). And any slight variation on any of these
operations, I can also do, pretty easily. The amount of functionality that
I get without writing new functions is extremely large. Moreover, the
relative benefit from adding new functions to Optional ends up being quite
large - both because of the number of different ways you can use them, and
also because basically every member function you could add to Optional is
pretty trivial, there's basically no implementation complexity to speak of.

For those people that prefer writing bespoke functions to solve problems,
as you apparently do, then it doesn't even matter what value_at() returns.
Or even that value_at(), or any other member function that users might find
value from, exists. This certainly isn't any kind of argument for T* as a
return type.

Also, I don't know if the filter example was supposed to be a gotcha!!, but
you can absolutely do a filter using and_then() straightforwardly enough
(and you of course cannot do this with T*, just like you cannot do any of
these things with T*). I don't have a filter on my optional since I guess
this particular operation hasn't come up much, but it's certainly not
unreasonable to add (and a very easy function to implement) - and certainly
not at the scale of gotcha. Besides, what is even the argument here?
Optional doesn't have filter, therefore optional is useless? Optional does
not solve every possible problem, therefore we should return T*, which
solves no problem? Adding filter to optional is a really easy addition that
extends our toolkit. Meanwhile, are you going to write size_at_if() and
span_at_if() if you end up needing that functionality on top of all of your
other non-composable operations? This is a pretty clear win on optional.

Utility types having a bunch of member functions is a good thing - they're
utility types, we should care about people's user experience. Rust has 27
member functions on its Option (including filter). That's not a bad thing.
And hardly never-ending.

Barry

Received on 2021-07-29 08:16:54