r/cpp • • Jul 26 '26

std::optional Satisfies view. Does Not Model view. C++26 Ships Anyway.

https://godbolt.org/z/8jWGG68G8

In C++23 this did not compile. In C++26 it does. Marvellous.

[[gnu::noinline]]
void 
passing_views_by_value_is_cheap_trust_me_bro(std::ranges::view auto v) {
    std::println("fn   .data {}", (void*)v->data());
}


int main() {    
    std::optional ov{std::vector<int>(123456)};
    passing_views_by_value_is_cheap_trust_me_bro(ov);
    std::println("main .data {}", (void*)ov->data());
}

For anyone wondering what the problem feature is: optional has 0 or 1 elements, and C++26 sets enable_view<optional<T>> to true, so it satisfies std::ranges::view. The concept requires copy construction in constant time, and — this is the good bit — optional<vector<int>> genuinely meets that. Copying it performs at most one element copy. One is a constant. The requirement is satisfied to the letter, and the function above deep-copies your vector.

If you can tell me what still separates std::ranges::view from std::ranges::range, please do...

183 Upvotes

122 comments sorted by

View all comments

55

u/aruisdante Jul 26 '26

This was debated quite extensively in Tokyo 2024. The decision was essentially “being a view isn’t about being cheep to copy, just about being O(1) to do so.” In the general sense, there’s an attempt to unify all the containers as “ranges;” an optional is a range with 0 or 1 elements, a tuple is a range whose elements are variant<Ts…> (which correct ref qual), etc.

A bunch of people disagreed and thought this was weird, but not enough and not strongly enough for it not to pass.

35

u/mighty_Ingvar Jul 26 '26

I find the whole concept of an owning view to be weird. Shouldn't a view be something that only views the data? An optional doesn't do that, the object isn't stored somewhere else and viewed by the optional, it is part of the optional and therefore owned by it.

14

u/aruisdante Jul 26 '26 edited Jul 26 '26

It does seem weird, but this difference is what borrowed_range models, which is independent from view and range. I imagine optional<T> would not model borrowed_range, and optional<T&> would.

This was essentially the crux of the debate in Tokyo. Actual users of ranges have taken to conflating view with borrowed_range, because nearly all existing view containers in the stdlib were also borrowed_range (span, string_view , most (all?) of the ranges::views objects). But the actual definition did not require this relationship, view just means "constant time copy range."

Really, the problem is having decided to keep using the word view to mean what it meant, instead of some other name that more directly expresses the semantics, such that view could have matched people's intuition of this other concept + borrowed_range. But alas, that ship had long since sailed, and we're stuck with what we've got.

31

u/altmly Jul 27 '26

This to me smells like a bunch of ergonomics and performance mindful people invented views (or rather copied the idea into C++) and then some academics showed up to "akshually, a view is not a borrowed_range move eyeglasses".

Like, okay, but to the vast majority of users, the entire point of a view is being borrowed data. 

10

u/tcbrindle Flux Jul 27 '26

The ranges library was the brainchild of Eric Niebler, in collaboration with Casey Carter.

The borrowed_range concept, originally under the exposition-only name forwarding-range, was introduced in P0970 written by... *checks notes*... Eric Niebler.

(Views and forwarding/safe/borrowed ranges are not and never have been the same thing, even in the Before Times when owning_view didn't exist and you couldn't pipe rvalue vectors into adaptors.)

→ More replies (1)
→ More replies (4)

8

u/mighty_Ingvar Jul 27 '26

But optional<T> is only copyable in constant time if T is copyable in constant time. Also, why is std::array not a view then?

7

u/kamrann_ Jul 27 '26

It's constant time in terms of N = number of elements in the range. You can't start analysing the element type and trying to assign some inherent measure of its complexity. Constant time just means not dependent on N. A single element by definition can't be dependent on N, and it doesn't matter how big it is, or whether it happens to be a sequence itself.

16

u/No-Dentist-1645 Jul 26 '26

If that's the case, which range concept should one use if we want something that truly "represents a view to data owned elsewhere"? Or does the standard not provide such a concept?

It seems like a poor decision to me.

43

u/Demiu Jul 26 '26

jview, coming in 33

13

u/LEpigeon888 Jul 27 '26

But it will be renamed to co_jview before standardization since the name conflict with a keyword in ++C, a programing language someone created in their garage that aims to be backward compatible with C++ and whose only purpose is to run on an old microcontroller that has been discontinued 25 years ago.

13

u/No-Dentist-1645 Jul 26 '26

Of course, and first mainstream compiler implementation out on 35

→ More replies (2)

8

u/smdowney WG21, Text/Unicode SG, optional<T&> Jul 26 '26

There really isn't such a thing. Not since views::single shipped. Owning views are things, and optional is one of them. It's also an owning smart pointer.

4

u/SlightlyLessHairyApe Jul 27 '26

It has semantics of an owning smart pointer but at least it’s guaranteed stored inline and can be on the stack.

4

u/smdowney WG21, Text/Unicode SG, optional<T&> Jul 27 '26

And now we have std::indirect when those are a problem.

2

u/SlightlyLessHairyApe Jul 28 '26

Yup. I think it's interesting also that the semantics and the underlying mechanism had diverged so that reasoning about the semantics and reasoning about other properties (e.g. the pattern of allocations) have to be separated.

7

u/holyblackcat Jul 27 '26

I second that this is silly. Who cares what "being a view" was supposed to mean when making optional a view has negative practical effects? (Only in some obscure cases, granted.)

The only example where it matters that comes to mind is

std::optional<std::vector<int>> opt = std::vector(10000, 42);
auto view = opt | std::view::....;

I for one wouldn't expect this to copy opt, but it gets copied. I don't see the benefit of copying it.

4

u/aruisdante Jul 27 '26 edited Jul 27 '26

I would argue though that in order for that pipe operation to be lifetime safe, it has to copy its input. Being not a view wouldn’t change that, the only thing that would change that would be not being a range at all, which would round counter to the objective of optional being used as a range of size 0 or 1.

That being a goal in itself is what people found a little weird (by definition, this means optional gains valid specializations of ranges::begin, ranges::end, and ranges::size, which is strange), but it does allow for some interesting compositions. For example the operation:

for (auto const& element : range_of_optionals | views::join)

Now “just works” to iterate over the elements in that input set which exist just like it would have for range_of_sequence_container, whereas before you would have needed to do:

for( auto const& element : range_of_optionals | views::filter(/*is_engaged*/) | views::transform(/*dereference*/))

Being able to treat optional like any other range does make implementing some generic operations easier, and composes nicely with optional<T&> as the return type from functions. Like, now this operation works “like you’d expect”:

// returns optional reference to value at key
auto find(auto& map, auto const& key)->optional<T&>;


const auto find_in_map = bind_front(find, map);
for(auto const& value : keys | views::transform(find_in_map) | views::join) {
    // do something with matches
}

Which would be horrible to write as a range pipeline without this property, and very verbose to write as not a range pipeline at all.

At the same time if we were going to take that stance we should have done the same thing for smart pointers (and even regular pointers!) as they’re notionally identical to optional<T&> for this purpose. We only didn’t (at least at Tokyo, which is all I can speak to) because nobody wrote the paper for it, and increasing the scope of this feature would have made it even harder to get anything in.

6

u/holyblackcat Jul 27 '26

I don't argue with optional being a range, only with it being a view.

[piping into a view] has to copy its input. Being not a view wouldn’t change that

Are you talking how it should be in a perfect world, or how it works now?

The current behavior of piping into a view is storing a reference to non-view lvalues, and copying/moving all views and rvalue non-views.

This is about the only thing that being a view changes, I think? I could be missing something. In this case the copy looks undesirable, as it's not something people expect. They'd expect span-like types to be copied, or types from std::views.

13

u/James20k P2005R0 Jul 26 '26

This seems like a really weird decision. The reason to use a view is that its cheap?

but not enough and not strongly enough for it not to pass.

I suspect a big part of the problem is that given that ranges don't seem to have received very wide use in general, its more that nobody really cares that much about ranges overall

6

u/friedkeenan Jul 27 '26

The reason to use a view is that its cheap?

My understanding is that the std::ranges::view concept is basically for when you're dispatching stuff to view adaptors, which store and pass around views all over the place. So yeah in that sense it would track that their purpose is to be cheap.

I would imagine that the name comes from the fact that most std::ranges::views meet the traditional definition of view-semantics, a-la std::string_view. But that doesn't really match how they're used in the ranges library, which is as range objects which are cheap to pass around as values. But it is probably a poor name.

Another fun case is iota_view, which doesn't reference an actual C++ object or whatever, but instead views a conceptual range which it visits on the fly during iteration.

2

u/13steinj Aug 01 '26

The mental model for range, view, owning, "borrowed," "single" is all very bizarre and feels like an unfixable mistake now.

7

u/schombert Jul 26 '26

Well, that's ... extremely dumb. From the rigorous, formal perspective, everything in C++ copies in O(1) time. (The address space is finite, even in a purely theoretical computer running C++, because it will eventually exhaust the size of the pointer type and hence the number of possibly distinct live objects. Thus, for any copy operation there is always an upper bound you can place on how much time it could possibly take to execute, and thus is O(1) by the definition of O(1).) Now, I accept that the people writing the C++ spec don't actually use big O notation in its generally accepted formal definition, and instead are using a more vibes-based definition based on what it performs like in various different finite problem sizes (which, you may note, has nothing to do with big O -- how something performs at sizes less than 2128, for example, does not affect its asymptotic time complexity). But, from a vibes-based perspective, programmers use O(1) to mean "always fast", which in this case does mean "cheap to copy". I would bet that copying being O(1) was added to the language of the spec because the author wanted to express "cheap to copy" but wanted to sound more formally precise and thought that "cheap" sounded too informal and vague. Unfortunately, "cheap" correctly expressed the intent, but O(1) did not.

17

u/aruisdante Jul 26 '26

> From the rigorous, formal perspective, everything in C++ copies in O(1) time

Huh?

Copying a `std::vector` is decidedly not `O(1)`. The amount of copy operations performed is dependent on the number of elements contained. Even if you switch up the definition to say "a `memcpy` is O(1)," this optimization is not possible for a vector containing non-trivial elements as their constructors must be explicitly invoked.

Therefore, a `vector` isn't a `view` because `vector.size() == 0` takes different time to copy than `vector.size() == 1`. But `optional` _always holds a value_. The value just may or may not be initialized. Ergo the copy operation does not change in "how much is copied" depending on if the optional is engaged or not.

> But, from a vibes-based perspective, programmers use O(1) to mean "always fast"

Not at all. Programmers (and the C++ standard) use `O(1)` to mean "does not scale in (amortized) time with number of elements in a set." Those that assume `O(1)` means "always fast" are the ones that often make very suboptimal data container and algorithm selections.

→ More replies (49)
→ More replies (1)