Document number: P2141R2
Project: Programming Language C++
Audience: LEWG
 
Antony Polukhin <antoshkka@gmail.com>, <antoshkka@yandex-team.ru>
 
Date: 2024-03-07

Aggregates are named tuples

“Call him Voldemort, Harry. Always use the proper name for things.”

― J.K. Rowling, Harry Potter and the Sorcerer's Stone

O. Changelog

R2:

R1:

R0:

I. Quick Introduction

In C++ we have:

This paper was inspired by multiple years of experience with PFR/magic_get library. The core idea of this paper is to add functionality to some aggregates so that they could behave as tuples.

II. Motivation and Examples

std::tuple and std::pair are great for generic programming, however they have disadvantages. First of all, code that uses them becomes barely readable. Consider two definitions:

struct auth_info_aggregate {
    std::int64_t id;
    std::int64_t session_id;
    std::int64_t source_id;
    std::time_t valid_till;
};

using auth_info_tuple = std::tuple<
    std::int64_t,
    std::int64_t,
    std::int64_t,
    std::time_t
>;

Definition via structure is much more clear. Same story with usages: return std::get<1>(value); vs. return value.session_id;

Another advantage of aggregates is a more efficient copy, move construction and assignments:

template <class T>
constexpr bool validate() {
    static_assert(std::is_trivially_move_constructible_v<T>);
    static_assert(std::is_trivially_copy_constructible_v<T>);
    static_assert(std::is_trivially_move_assignable_v<T>);
    static_assert(std::is_trivially_copy_assignable_v<T>);
    return true;
}

constexpr bool tuples_fail = validate<auth_info_tuple>(); // Fails majority of checks
constexpr bool aggregates_are_ok = validate<auth_info_aggregate>();

Because of the above issues many coding guidelines recommend to use aggregates instead of tuples.

However at the moment aggregates fail when it comes to the functional like programming:

namespace impl {
    template <class Stream, class Result, std::size_t... I>
    void fill_fields(Stream& s, Result& res, std::index_sequence<I...>) {
        (s >> ... >> std::get<I>(res));
    }
}

template <class T>
T ExecuteSQL(std::string_view statement) {
    std::stringstream stream;
    // some logic that feeds data into stream

    T result;
    impl::fill_fields(stream, result, std::make_index_sequence<std::tuple_size_v<T>>());
    return result;
}

constexpr std::string_view query = "SELECT id, session_id, source_id, valid_till FROM auth";
const auto tuple_result = ExecuteSQL<auth_info_tuple>(query);
const auto aggregate_result = ExecuteSQL<auth_info_aggregate>(query); // does not compile

// Playground https://godbolt.org/z/y49lya

By bringing the functionality of tuples into aggregates we get all the advantages of tuples without loosing advantages of aggregates. We get named tuples.

III. The Idea

Make std::get, std::tuple_element and std::tuple_size/std::element_count work with aggregates. This also makes std::tuple_element_t, std::apply, std::tuple_cat and std::make_from_tuple usable with aggregates.

Or provide close to the above functions not related to existing tuple-protocol.

IV. Interaction with other papers

P1061 "Structured Bindings can introduce a Pack" makes it really simple to implement the ideas proposed in this paper. For example elements count detection could be implemented as:

template <class T>
constexpr std::size_t fields_count() {
    auto [...x] = T();
    return sizeof...(x);
}

P1061 is not a requirement for this paper acceptance. Same logic could be implemented as a compiler built-in or even via some metaprogramming tricks, as in PFR/magic_get library.

There may be concerns, that proposed functionality may hurt N4818 "C++ Extensions for Reflection" adoption, as some of functionality becomes available without reflection. Experience with PFR/magic_get library shows that std::get and std::tuple_size functions cover only very basic cases of reflection. we still need reflection for trivial things, like serialization to JSON, because only reflection gives us field names of the structure.

Parts of P1858R1 "Generalized pack declaration and usage" address some of the ideas of this paper on a language level and give simple to use tools to implement ideas of this paper. However this paper brings capabilities symmetry to the standard library, shows another approach to deal with field access by index and allows existing user code to work out-of-the-box with aggregates:

C++20This paperP1858
// Works only with tuples
// 
int foo(auto value) {
    if (!std::get<10>(value)) {
        return 0;
    }

    return std::apply(function, value);
}
// Works with tuples and aggregates
// No code change required
int foo(auto value) {
    if (!std::get<10>(value)) {
        return 0;
    }

    return std::apply(function, value);
}
// Works with tuples and aggregates
// Users are forced to rewrite code
int foo(auto value) {
    if (!value::[10]) {
        return 0;
    }

    return std::invoke(function, value::[:]);
}
template <class T>
auto portable_function(const T& value) {
    // Works with tuples since C++11
    return std::get<2>(value);
}
template <class T>
auto portable_function(const T& value) {
    // Works with tuples since C++11 and with aggregates
    return std::get<2>(value);
}
template <class T>
auto portable_function(const T& value) {
  #ifdef __cpp_generalized_packs
    // Works with tuples and aggregates
    return value::[2];
  #else
    // Works with tuples since C++11
    return std::get<2>(value);
  #endif
}

V. Design decisions and LEWG questions.

A. LEWG: Does it affect the user-customized structured bindings?

Good news: no, it does not affect the user-customized structured bindings. The user already specialized the std::tuple_size for its type, std::tuple_size specialization from this proposal is less specialized. Online playground: https://godbolt.org/z/Pxnvbcv6v.

Bad news: R0 of the proposal does affect all the non-customized structured bindings https://godbolt.org/z/dro9nGEd7. It is because R0 of the proposal specializes the std::tuple_size and the [dcl.struct.bind] p4 uses std::tuple_size to distinguish between "customized" and "compiler implemented" [dcl.struct.bind] p5 structured bindings.

The solution is to either adjust the [dcl.struct.bind] p4 to not take into account the added specialization of std::tuple_size or to not specialize the std::tuple_size and use a separate function for getting the elements count from an aggregate.

See the "X. To specialize or not to specialize std::tuple_size" below for more discussion.

B. LEWG: does it change the meaning of code with usages of tuple_cat on string literals?

std::tuple_cat could be used with parameters that satisfy the tuple-like concept. According to [tuple.like] the concept is not satisfied for string literals in C++23. So the meaning does not change because string literals can not be used right now with std::tuple_cat. Godbolt playground: https://godbolt.org/z/cxc8s5qeG.

With this proposal code like std::tuple_cat(aggreaget1{"foo"}, aggreaget2{"bar", "baz"}) would produce std::tuple<const char*, const char*, const char*>{"foo", "bar", "baz"} if aggregate1 and aggregate2 have const char* elements, or would fail to compile because std::tuple<char[4], char[4], char[4]> could not be constructed. Godbolt playground: https://godbolt.org/z/fxM8za3WG.

C. LEWG: Null termination of string literals can lead to surprising tuple_cat behavior

std::tuple_cat("a", "b") would produce a std::tuple<char, char, char, char>{'a', '\0', 'b', '\0'}. That might be surprising for first time, but that's what the code asked for: treat two parameters as tuples and concatenate those. If that behavior is undesired, then it could be disabled by prohibiting arrays in tuple-like.

D. LEWG: Does it change the behavior of user tuple_flatten-like functions?

Consider the following snippet:

int main() {
    std::tuple<
        std::tuple<std::tuple<Noisy, Noisy>>,
        std::tuple<std::tuple<Noisy>>
    > t;        

    auto x = tuple_flatten(t);
    static_assert(std::is_same_v<decltype(x), std::tuple<Noisy, Noisy, Noisy>>);
    static_assert(!std::is_same_v<decltype(x), std::tuple<int, short, int, short, int, short>>);
}
    

Implementation of the tuple_flatten could use one of the following mechanics to decide when to stop flattening:

  1. Internals of the tuple_flatten accept only std::tuple, so the tuple_flatten works only with std::tuple.
  2. Internals of the tuple_flatten use tuple-like concept.
  3. Internals of the tuple_flatten SFINAE on std::tuple_size<T>::value

tuple-like concept is exposition only, users should not use it.

So the behavior changes if we specialize the std::tuple_size and users SFINAE on std::tuple_size or std::tuple_size_v. In many cases such behavior change would be detected at compile time, however may be some cases when the compilation would succeed and the code silently changes behavior.

See the "X. To specialize or not to specialize std::tuple_size" below for more discussion.

E. What about customization?

The intent of this proposal it to follow the structured bindings behavior and customizations. So if the user customized that the aggregate with three elements of type int has 2 elements of type short - the std::get should follow.

The alternative is to skip all the customizations and just do the aggregate reflection as is. This contradicts the Reflections, as the main steam is the "value based reflection" rather than template based.

F. Why not just use structured bindings?

  1. There is no way to SFINAE on structured binding which is important for implementing generic functions. Some kind of std::element_count, or std::tuple_size specializations or public concept is required.
  2. There is no simple and fast way to get elements count. Some kind of std::element_count function is required or P1061 "Structured Bindings can introduce a Pack".
  3. No ready to use functions to work with aggregates as with tuples, like std::tuple_cat. tuple-like should be adjusted or new functions should be provided.
  4. Existing user and standard library implementations of algorithms rely on std::get. To ease the migration from std::tuple to aggregates those functions should be also provided.

In other words: for smooth use of structured binding in a generic programming we need something close to the changes proposed in this paper.

G. Impact on the Standard Library

For wording that relies on tuple protocol

  1. std::tuple becomes constructible and assignable from aggregates of the matching elements count.
  2. std::pair becomes constructible and assignable from aggregates of 2 elements.
  3. std::tuple_cat can concatenate elements of aggregates into std::tuple.
  4. std::apply can apply elements of an aggregate to a function.
  5. std::make_from_tuple<T> can construct type T from aggregate.
  6. std::tuple becomes comparable with aggregates of matching elements count.

Functions that explicitly require instance of std::tuple or std::pair are not affected (basic_common_reference, common_type, format_kind, 'm' range-type specifier).

H. To specialize or not to specialize std::tuple_size

Specialize std::tuple_sizeProvide a separate std::element_count
Does not affect the user-customized structured bindings
Does not change the meaning of code with std::tuple_cat
Does not change the behavior of user tuple_flatten-like functions
User tuple-code works with aggregates out of the box✓ if SFINAEs on std::tuple_size❌ (requires explicit std::element_count usage)
Gives a way to explicitly allow reflection of aggregates✓ (SFINAE on std::is_aggregate_v)✓ (SFINAE on std::tuple_size)
Is it customizeable?

Assuming that the tuple_flatten-like functions are common and could lead to silent behavior change this revision concentrates on std::element_count approach. If LEWG decides that the paper should proceed with std::tuple_size specialization, the structured binding wording [dcl.struct.bind] p4 should be changed to something like the following:

Otherwise, if the qualified-id std​::​tuple_­size<E> names a complete class type
not inherited from std::element_count<E> and with a member named value, the
expression std​::​tuple_­size<E>​::​value shall be a well-formed integral constant expression and the number of ele-
ments in the identifier-list shall be equal to the value of that expression.

J. To use tuple protocol or not

In the nearest future along with the existing tuple protocol we'll have the Reflection. The first works well with std::tuple and aggregates with customized structure binding, but lacks the compatibility with non-customized aggregates.

-Existing tuple protocolReflection
Aggregates
// not supported std::get<1>(aggregate) = 42;
// not supported static_assert(std::tuple_size_v<Aggr> == 3);
// not supported std::apply(func, aggregate);
aggregate.*(pointer_to_member(nonstatic_data_members_of(^Aggr)[1])) = 42;
static_assert(nonstatic_data_members_of(^Aggr).size() == 3);
std::apply(func, /*a lot of complicated code */);
Tuples
std::get<1>(tuple) = 42;
static_assert(std::tuple_size_v<Tuple> == 3);
std::apply(func, tuple);
// does not work out of the box

To work with tuples and with aggregates the users have to do manual dispatch:

template <std::size_t I, class T>
decltype(auto) structure_get(T&& arg) {
  if constexpr (requires {std::tuple_size<T>{};}) {
    // std::tuple or customized user type
    return std::get<I>(std::forward<T>(arg));
  } else {
    // aggreagte without customization
    static_assert(std::is_aggregate_v<T>);
    return std::forward<T>(arg).*(pointer_to_member(nonstatic_data_members_of(^T)[I]));
  }
}

The above sample is verbose and not user friendly. Implementing std::apply or std::make_from_tuple like functionality would require a lot of knowledge from the developer.

Early revisions of this proposal were extending the functions from <tuple> to work with aggregates. This implied that std::tuple_size specialized for an aggregate was affecting the result of all the functions.

LEWG recommended to move away from that tuple-protocol approach towards a separate set of functions that work only with aggregates. The main difference of such switch is that users have to explicitly state that they are planning to use tuples or aggregates, dispatching the functions in complicated cases:

-With tuple protocolWithout tuple protocol
Aggregates
std::get<1>(aggregate) = 42;
static_assert(std::element_count_v<Aggr> == 3);
std::apply(func, aggregate);
std::get_element<1>(aggregate) = 42;
static_assert(std::element_count_v<Aggr> == 3);
std::apply_elements(func, aggregate);
Tuples
std::get<1>(tuple) = 42;
static_assert(std::tuple_size_v<Tuple> == 3);
std::apply(func, tuple);
std::get<1>(tuple) = 42;
static_assert(std::tuple_size_v<Tuple> == 3); 
std::apply(func, tuple);
Code that works with tuples and aggregates
std::get<1>(custom) = 42;
static_assert(std::element_count_v<Custom> == 3);
std::apply(func, custom);
  if constexpr (requires {std::tuple_size<T>{};}) {
    std::get<1>(custom) = 42;
    static_assert(std::tuple_size_v<Custom> == 3);
    std::apply(func, custom);
  } else {
    std::get_element<1>(custom) = 42;
    static_assert(std::element_count_v<Custom> == 3);
    std::apply_elements(func, custom);
  }

However, we could avoid making the tuple protocol a foundation for the proposal and still provide some minimal migration path for users from tuples to aggregates. Consider the approach, where most of the functionality for aggregates resides in a separate namespace aggr and does not rely on tuple protocol:

-Approach with namespace aggr
Aggregates
std::aggr::get<1>(aggregate) = 42;
static_assert(std::aggr::tuple_size_v<Aggr> == 3);
std::aggr::apply(func, aggregate);
Tuples
std::get<1>(tuple) = 42;
static_assert(std::tuple_size_v<Tuple> == 3);
std::apply(func, aggregate);
Code that works with tuples and aggregates
using std::get;
using std::aggr::get;
get<1>(custom) = 42;

using std::apply;
using std::aggr::apply;
apply(func, aggregate);

// Oops, we could not do the following:
// using std::tuple_size_v;
// using std::aggr::tuple_size_v;
// static_assert(tuple_size_v<T> == 3);

That's the approach that Boost.PFR uses. Advantages of the approach:

VI. Wording (no tuple protocol)

After adjusting yyyymm (below) so as to denote this proposal’s month of adoption, insert the following line among the similar directives following [version.syn]/2:

#define __cpp_lib_aggregate_as_tuple yyyymmL // also in <utility>

Add to the bottom of [utility.syn], right before the last closing bracket:

namespace aggr {
  // [utility.aggregate], tuple-like access to aggregate
  template<class T>
    concept tuple-like-aggregate = see below;         // exposition only

  template<tuple-like-aggregate T>
    using tuple_size = see below;

  template<tuple-like-aggregate T>
    constexpr size_t tuple_size_v = tuple_size<T>::value;

  template<size_t I, tuple-like-aggregate T>
    using tuple_element = see below;

  template<size_t I, tuple-like-aggregate T>
    using tuple_element_t = typename tuple_element<I, T>::type;

  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, T>& get(T&) noexcept;
  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, T>&& get(T&&) noexcept;
  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, const T>& get(const T&) noexcept;
  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, const T>&& get(const T&&) noexcept;

  template<class T, tuple-like-aggregate TupleLike>
    constexpr T& get(TupleLike&) noexcept;
  template<class T, tuple-like-aggregate TupleLike>
    constexpr T&& get(TupleLike&&) noexcept;
  template<class T, tuple-like-aggregate TupleLike>
    constexpr const T& get(const TupleLike&) noexcept;
  template<class T, tuple-like-aggregate TupleLike>
    constexpr const T&& get(const TupleLike&&) noexcept;

  template<class F, tuple-like-aggregate TupleLike>
    constexpr decltype(auto) apply(F&& f, TupleLike&& t) noexcept(see below);

  template<template<class...> class T, tuple-like-aggregate TupleLike>
    constexpr auto make_from_tuple(TupleLike&& t);
  template<class T, tuple-like-aggregate TupleLike>
    constexpr T make_from_tuple(TupleLike&& t);
}  // namespace aggr
}

Add after [pair.piecewise]:

22.3.6 Tuple-like access to aggregate [utility.aggregate]

  template<class T>
    concept tuple-like-aggregate = see below;           // exposition only
      A type T models and satisfies the exposition-only concept tuple-like-aggregate
      if T is an aggregate and none of its
      non static data members is a bitfield.
      [Note: Completeness of std::tuple_size<T> is not checked to make
      following code ambiguous for types with customized structured binding:
        using std::apply;
        using std::aggr::apply;
        apply(function, customized_aggregate);
      -end note.]

  template<tuple-like-aggregate T>
    using tuple_size = see below;
      The tuple_size meets the Cpp17UnaryTypeTrait requirements ([meta.rqmts]) with a base
      characteristic of integral_constant<size_t, N> for N being the number of
      non static data members in remove_cv_ref_t<T>.

  template<size_t I, tuple-like-aggregate T>
    using tuple_element = see below;
      Let TE denote the type of the Ith aggregate element of remove_const_t<T>, where indexing is zero-based.
      Specialization meets the Cpp17TransformationTrait
      requirements ([meta.rqmts]) with a member typedef type that names the type TE
      if T is not const qualified, const TE otherwise.

  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, T>& get(T& t) noexcept;
  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, T>&& get(T&& t) noexcept;
  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, const T>& get(const T& t) noexcept;
  template<size_t I, tuple-like-aggregate T>
    constexpr tuple_element_t<I, const T>&& get(const T&& t) noexcept;
      Mandates: I < aggr::tuple_size<T>::value.
      Returns: A reference to the Ith aggregate element of T, where indexing is zero-based.

  template<class T, tuple-like-aggregate TupleLike>
    constexpr T& get(TupleLike& t) noexcept;
  template<class T, tuple-like-aggregate TupleLike>
    constexpr T&& get(TupleLike&& t) noexcept;
  template<class T, tuple-like-aggregate TupleLike>
    constexpr const T& get(const TupleLike& t) noexcept;
  template<class T, tuple-like-aggregate TupleLike>
    constexpr const T&& get(const TupleLike&& t) noexcept;
      Let v0, ..., vn-1 be the non static data members of aggregate TupleLike.
      Mandates: Exactly one of the v0, ..., vn-1 has type T.
      Returns: A reference to the element corresponding to the type T.

  template<class F, tuple-like-aggregate TupleLike>
  constexpr decltype(auto) apply(F&& f, Tuple&& t) noexcept(see below);
    Effects: Given the exposition-only function:
      namespace std {
        template<class F, tuple-like-aggregate TupleLike, size_t... I>
        constexpr decltype(auto) apply-impl(F&& f, TupleLike&& t, index_sequence<I...>) {
                                                                          // exposition only
            return INVOKE(std::forward<F>(f), aggr::get<I>(std::forward<TupleLike>(t))...);     // see [func.require]
          }
      }

    Equivalent to:
      return apply-impl(std::forward<F>(f), std::forward<TupleLike>(t),
                        make_index_sequence<aggr::tuple_size_v<remove_reference_t<TupleLike>>>{});

    Remarks: Let I be the pack 0, 1, ..., (aggr::tuple_size_v<remove_­reference_­t<TupleLike>> - 1).
    The exception specification is equivalent to:
      noexcept(invoke(std::forward<F>(f), aggr::get<I>(std::forward<TupleLike>(t))...))

  template<template<class...> class T, tuple-like-aggregate TupleLike>
    constexpr auto make_from_tuple(TupleLike&& t);
      Effects: Given the exposition-only function:
        namespace std::aggr {
          template<template<class...> class T, tuple-like-aggregate TupleLike, size_t... I>
          constexpr auto make-from-tuple-impl(TupleLike&& t, index_sequence<I...>)   // exposition only
            requires requires { T(aggr::get<I>(std::forward<TupleLike>(t))...); }
          {
            return T(aggr::get<I>(std::forward<TupleLike>(t))...);
          }
        }

      Equivalent to:
        return make-from-tuple-impl<T>(
                 std::forward<TupleLike>(t),
                 make_index_sequence<aggr::tuple_size_v<TupleLike>>{});
      [Note: one of the intents of this function is to provide a conversion from
       aggregate to tuple via make_from_tuple<std::tuple>(aggregate). - end note]

  template<class T, tuple-like-aggregate TupleLike>
    constexpr T make_from_tuple(TupleLike&& t);
      Mandates: If aggr::tuple_size_v<TupleLike> is 1, then
      reference_­constructs_­from_­temporary_­v<T, decltype(aggr::get<0>(declval<TupleLike>()))> is false.

      Effects: Given the exposition-only function:
        namespace std::aggr {
          template<class T, tuple-like-aggregate TupleLike, size_t... I>
            requires is_constructible_v<T, decltype(aggr::get<I>(declval<TupleLike>()))...>
          constexpr T make-from-tuple-impl(TupleLike&& t, index_sequence<I...>) {   // exposition only
            return T(aggr::get<I>(std::forward<TupleLike>(t))...);
          }
        }

      Equivalent to:
        return make-from-tuple-impl<T>(
                 std::forward<TupleLike>(t),
                 make_index_sequence<aggr::tuple_size_v<TupleLike>>{});

VII. Wording (for tuple protocol. Informative only, to be removed)

After adjusting yyyymm (below) so as to denote this proposal’s month of adoption, insert the following line among the similar directives following [version.syn]/2:

#define __cpp_lib_aggregate_as_tuple yyyymmL // also in <utility>, <tuple>

Add to the bottom of [utility.syn], right before the last closing bracket:

  // [utility.aggregate], tuple-like access to aggregate
  template<tuple-like T>
    using element_count = see below;

  template<tuple-like T>
    constexpr size_t element_count_v = element_count<T>::value;

  template<size_t I, tuple-like T> struct tuple_element;

  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, T>& get(T&) noexcept;
  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, T>&& get(T&&) noexcept;
  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, const T>& get(const T&) noexcept;
  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, const T>&& get(const T&&) noexcept;

  template<class T, tuple-like TupleLike>
    constexpr T& get(TupleLike&) noexcept;
  template<class T, tuple-like TupleLike>
    constexpr T&& get(TupleLike&&) noexcept;
  template<class T, tuple-like TupleLike>
    constexpr const T& get(const TupleLike&) noexcept;
  template<class T, tuple-like TupleLike>
    constexpr const T&& get(const TupleLike&&) noexcept;
}

Add after [pair.piecewise]:

22.3.6 Tuple-like access to aggregate [utility.aggregate]

  template<tuple-like T>
    using element_count = see below;
      The element_count meets the Cpp17UnaryTypeTrait requirements ([meta.rqmts]) with a base
      characteristic of integral_constant<size_t, N> for N being tuple_size<T>::value
      if tuple_size<remove_cv_ref_t<T>> names a complete class type with a member named value; otherwise N is the number of
      non static data members in remove_cv_ref_t<T>.

  template<size_t I, tuple-like T> struct tuple_element;
      Let TE denote the type of the Ith aggregate element of T, where indexing is zero-based.
      Specialization meets the Cpp17TransformationTrait
      requirements ([meta.rqmts]) with a member typedef type that names the type TE.

  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, T>& get(T& t) noexcept;
  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, T>&& get(T&& t) noexcept;
  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, const T>& get(const T& t) noexcept;
  template<size_t I, tuple-like T>
    constexpr tuple_element_t<I, const T>&& get(const T&& t) noexcept;
      Let v0, ..., vn-1 be the identifiers introduced by structured binding declaration
      auto [v0, ..., vn-1] = std::forward<decltype(t)>(t);,
      where n is equal to element_count<T>::value.
      Mandates: I < element_count<T>::value.
      Returns: A reference to the vI.

  template<class T, tuple-like TupleLike>
    constexpr T& get(TupleLike& t) noexcept;
  template<class T, tuple-like TupleLike>
    constexpr T&& get(TupleLike&& t) noexcept;
  template<class T, tuple-like TupleLike>
    constexpr const T& get(const TupleLike& t) noexcept;
  template<class T, tuple-like TupleLike>
    constexpr const T&& get(const TupleLike&& t) noexcept;
      Let v0, ..., vn-1 be the identifiers introduced by structured binding declaration
      auto [v0, ..., vn-1] = std::forward<decltype(t)>(t);,
      where n is equal to element_count<T>::value.
      Mandates: Exactly one of the v0, ..., vn-1 identifiers has type T.
      Returns: A reference to the identifier corresponding to the type T.

Adjust [tuple.like]:

  template<class T>
    concept tuple-like = see below;           // exposition only
      A type T models and satisfies the exposition-only concept tuple-like
      if remove_­cvref_­t<T> is
      a specialization of array, pair, tuple, or ranges​::​subrange
      the structured binding declaration auto [v0, ..., vN-1] = declval<T>(); would be well formed for some N
      and none of the v0, ..., vN-1 refers to a bitfield.

Change the tuple_size_v usages to element_count_v:

[tuple.syn]:

  template<class T>
    concept pair-like =                     // exposition only
      tuple-like<T> && element_count_v<T>tuple_size_v<remove_cvref_t<T>> == 2;

[tuple.cnstr]:

template<tuple-like UTuple>
  constexpr explicit(see below) tuple(UTuple&& u);

    Let I be the pack 0, 1, …, (sizeof...(Types) - 1).
    Constraints:
       - different-from<UTuple, tuple> ([range.utility.helpers]) is true,
       - remove_­cvref_­t<UTuple> is not a specialization of ranges​::​subrange,
       - sizeof...(Types) equals element_count_v<UTuple>tuple_size_v<remove_cvref_t<UTuple>>,

[tuple.assign]:

template<tuple-like UTuple>
  constexpr tuple& operator=(UTuple&& u);

    Constraints:
      - different-from<UTuple, tuple> ([range.utility.helpers]) is true,
      - remove_­cvref_­t<UTuple> is not a specialization of ranges​::​subrange,
      - sizeof...(Types) equals element_count_v<UTuple>tuple_size_v<remove_cvref_t<UTuple>>, and,
      - is_­assignable_­v<Ti&, decltype(get<i>(std​::​forward<UTuple>(u)))> is true for all i.

    Effects: For all i, assigns get<i>(std​::​forward<UTuple>(u)) to get<i>(*this).
    Returns: *this.

template<tuple-like UTuple>
  constexpr const tuple& operator=(UTuple&& u) const;

    Constraints:
      - different-from<UTuple, tuple> ([range.utility.helpers]) is true,
      - remove_­cvref_­t<UTuple> is not a specialization of ranges​::​subrange,
      - sizeof...(Types) equals element_count_v<UTuple>tuple_size_v<remove_cvref_t<UTuple>>, and,

[tuple.creation]:

template<tuple-like... Tuples>
  constexpr tuple<CTypes...> tuple_cat(Tuples&&... tpls);

    Let n be sizeof...(Tuples).
    For every integer 0≤i<n:
      - Let Ti be the ith type in Tuples.
      - Let Ui be remove_­cvref_­t<Ti>.
      - Let tpi be the ith element in the function parameter pack tpls.
      - Let Si be tuple_size_velement_count_v<Ui>.

[tuple.apply]:

template<class F, tuple-like Tuple>
  constexpr decltype(auto) apply(F&& f, Tuple&& t) noexcept(see below);
    Effects: Given the exposition-only function:
      namespace std {
        template<class F, tuple-like Tuple, size_t... I>
        constexpr decltype(auto) apply-impl(F&& f, Tuple&& t, index_sequence<I...>) {
                                                                          // exposition only
            return INVOKE(std::forward<F>(f), get<I>(std::forward<Tuple>(t))...);     // see [func.require]
          }
      }

    Equivalent to:
      return apply-impl(std::forward<F>(f), std::forward<Tuple>(t),
                        make_index_sequence<element_count_v<Tuple>tuple_size_v<remove_reference_t<Tuple>>>{});

    Remarks: Let I be the pack 0, 1, ..., (element_count_v<Tuple>tuple_size_v<remove_­reference_­t<Tuple>> - 1).
    The exception specification is equivalent to:
      noexcept(invoke(std::forward<F>(f), get<I>(std::forward<Tuple>(t))...))


template<class T, tuple-like Tuple>
  constexpr T make_from_tuple(Tuple&& t);
    Mandates: If element_count_v<Tuple>tuple_size_v<remove_­reference_­t<Tuple>> is 1, then
    reference_­constructs_­from_­temporary_­v<T, decltype(get<0>(declval<Tuple>()))> is false.

    Effects: Given the exposition-only function:
      namespace std {
        template<class T, tuple-like Tuple, size_t... I>
          requires is_constructible_v<T, decltype(get<I>(declval<Tuple>()))...>
        constexpr T make-from-tuple-impl(Tuple&& t, index_sequence<I...>) {   // exposition only
          return T(get<I>(std::forward<Tuple>(t))...);
        }
      }

    Equivalent to:
      return make-from-tuple-impl<T>(
               std::forward<Tuple>(t),
               make_index_sequence<element_count_v<Tuple>tuple_size_v<remove_reference_t<Tuple>>>{});

[tuple.rel]:

template<class... TTypes, class... UTypes>
  constexpr bool operator==(const tuple<TTypes...>& t, const tuple<UTypes...>& u);
template<class... TTypes, tuple-like UTuple>
  constexpr bool operator==(const tuple<TTypes...>& t, const UTuple& u);

    For the first overload let UTuple be tuple<UTypes...>.
    Mandates: For all i, where 0≤i<sizeof...(TTypes), get<i>(t) == get<i>(u) is a valid expression.
    sizeof...(TTypes) equals tuple_size_velement_count_v<UTuple>.

    Preconditions: For all i, decltype(get<i>(t) == get<i>(u)) models boolean-testable.

    Returns: true if get<i>(t) == get<i>(u) for all i, otherwise false.
    [Note 1: If sizeof...(TTypes) equals zero, returns true.
    — end note]

    Remarks:
      - The elementary comparisons are performed in order from the zeroth index upwards.
        No comparisons or element accesses are performed after the first equality comparison that evaluates to false.
      - The second overload is to be found via argument-dependent lookup ([basic.lookup.argdep]) only.

template<class... TTypes, class... UTypes>
  constexpr common_comparison_category_t<synth-three-way-result<TTypes, UTypes>...>
    operator<=>(const tuple<TTypes...>& t, const tuple<UTypes...>& u);
template<class... TTypes, tuple-like UTuple>
  constexpr common_comparison_category_t<synth-three-way-result<TTypes, Elems>...>
    operator<=>(const tuple<TTypes...>& t, const UTuple& u);

      For the second overload, Elems denotes the pack of types tuple_­element_­t<0, UTuple>, tuple_­element_­t<1, UTuple>,
      …, tuple_­element_­t<tuple_size_velement_count_v<UTuple> - 1, UTuple>.

      Effects: Performs a lexicographical comparison between t and u.
      If sizeof...(TTypes) equals zero, returns strong_­ordering​::​equal.
      Otherwise, equivalent to:
        if (auto c = synth-three-way(get<0>(t), get<0>(u)); c != 0) return c;
        return ttail <=> utail;
      where rtail for some r is a tuple containing all but the first element of r.

      Remarks: The second overload is to be found via argument-dependent lookup ([basic.lookup.argdep]) only.

VIII. Acknowledgments

Many thanks to Barry Revzin for writing P1858 and providing early notes on this paper.