P1456R1: Move-only views

P1456R1 is a proposal for C++20 that relaxes the move concept to support move-only types, which will enable a few useful range types to model view, including std::generator.

Introduction

P0896R4 introduces the view concept. It states that a type T models view if it's default constructible, constant-time copyable, and constant-time movable. These requirements allow view to be copied through the composition of range adaptors.

The copyable requirement implies that all data members of a view should be copyable. However, some range adaptors only require these data members to be copy-constructible on their interface, such as the element of single_view and the function object of transform_view. To turn a copy-constructible type into a copyable type, the implementation wraps the type in a copyable_box.

copyable_box

The copyable_box converts a type that is copy constructible (but might not copy assignable) into a type that is both copy constructible and copy assignable, thus copyable.

  • If the type is not copyable, the copyable_box is trivial.
  • If the type is not copyable and the copy-constructor is noexcept, the copyable_box provides a copy-assignment operator that destroys its wrapped data using std::destroy_at and copy-constructs it from the other copyable_box using std::construct_at.
  • If the type is not copyable and the copy-constructor is not noexcept, the copyable_box holds the wrapped data in a std::optional and provides a copy-assignment operator that copy-constructs the wrapped data from the other copyable_box. If the copy-construction throws, the wrapped data is in an empty state.

The following code snippet is an implementation of copyable_box where the type is not copyable and the copy-constructor is not noexcept:

class copy_constructible_t {
public:
  copy_constructible_t() = default;
  copy_constructible_t(const copy_constructible_t &) = default;
  copy_constructible_t &operator=(const copy_constructible_t &) = delete;
};

static_assert(std::copy_constructible<copy_constructible_t>);
static_assert(!std::copyable<copy_constructible_t>);

template <typename T>
  requires std::copy_constructible<T>
class copyable_box {
public:
  constexpr copyable_box() noexcept(std::is_nothrow_default_constructible_v<T>)
    requires std::default_initializable<T>
      : val_(std::in_place) {}

  template <class... Args>
    requires std::is_constructible_v<T, Args...>
  constexpr explicit copyable_box(
      std::in_place_t, Args &&...args
  ) noexcept(std::is_nothrow_constructible_v<T, Args...>)
      : val_(std::in_place, std::forward<Args>(args)...) {}

  copyable_box(const copyable_box &) = default;

  constexpr copyable_box &operator=(copyable_box const &other
  ) noexcept(std::is_nothrow_copy_constructible_v<T>) {
    if (this != std::addressof(other)) {
      if (other.val_.has_value()) {
        val_.emplace(*other.val_);
      } else {
        val_.reset();
      }
    }
    return *this;
  }

private:
  std::optional<T> val_;
};

static_assert(std::copyable<copyable_box<copy_constructible_t>>);

Motivation

Range Adaptor

Since C++11, the standard library has been adapted to support move-only types. For example, they can be stored in containers. A notable exception is the standard library algorithms, such as std::find_if, as they don't support move-only function objects. A simple workaround is to define such a function object as a variable and wrap it in a std::reference_wrapper.

std::vector<std::uint8_t> buf;
auto move_only_predicate = [capture = std::make_unique<std::uint64_t>(0)](
                               const std::uint8_t
                           ) noexcept -> bool { return true; };
std::find_if(buf.cbegin(), buf.cend(), std::cref(move_only_predicate));

However, for range adaptors that take a function object such as transform_view, this workaround is a burden. Because these range adaptors support composition and are lazily-evaluated, ensuring that the composed adaptors don't outlive the function object is a non-trivial task.

Moreover, the range adaptors only copy views when the user calls the base() member function to retrieve a copy of the underlying view being adapted. None of the adaptors copy views in the process of their normal operation.

Generator

Generators are the intersection of coroutines and ranges. A generator is a coroutine that models the range concept. Coroutine frames are not copyable resources, so it's not possible to implement a generator that can produce independent copies. Therefore, generators can't model the view concept unless the copyable constraint is dropped.

std::generator<std::uint64_t> fib() {
  std::uint64_t a = 0;
  std::uint64_t b = 1;
  while (true) {
    co_yield std::exchange(a, std::exchange(b, a + b));
  }
}

Proposal

The author suggests that the view concept should be relaxed to support move-only types without excluding any types that model the prior formulation of the concept. Copyable types that intend to model view must still be constant-time copyable.

template <typename T>
concept View = std::ranges::range<T> && std::movable<T> &&
               std::default_initializable<T> && std::ranges::enable_view<T>;

To redress the problem that the base() members of the range adaptors return copies of the underlying view, each base() member is replaced with a const&-qualified overload that copies the view (if it's copy-constructible) and a &&-qualified overload that extracts the view from the adaptor.