Document number: P0825R1
Date: 2018-2-10
Project: Programming Language C++, Library Evolution Working Group
Reply-to: Agustín Bergé agustinberge@gmail.com

A friendlier tuple get

0. History

Changes from P0825R0:

  • Add proposed wording.
  • Move alternative designs discussion to appendix.

1. Introduction

This paper proposes changing std::get overloads to behave gracefully in the presence of user defined get overloads.

2. Motivation

Consider the following example, presented on Cpplang at Slack:

template <typename... Ts>
struct WeirdTuple : private std::tuple<Ts...> {
  using std::tuple<Ts...>::tuple;
};
 
template <std::size_t I, typename... Ts>
auto get(WeirdTuple<Ts...>& t) { return I + 10; }
 
int main() {
  WeirdTuple<int> wt(1);
 
  get<0>(wt); // changing this 0 to anything else will break
              // theoretically, that should still work -
              // we're obviously using the WeirdTuple overload of get<>(),
              // but the compiler fails when trying to compile every possible overload!
}

After the alluded change, the result is:

get<1>(wt); // error: static assertion failed: tuple index is in range
            // in instantiation of 'std::tuple_element<1, std::tuple<int>>'
            // required by substitution of
            //   constexpr std::tuple_element_t<I, std::tuple<Ts...>>&
            //   std::get(std::tuple<Ts...>&)
            //   [with I = 1; Ts = {int}]

The confusion arises from a disagreement between the programmer and the implementation on the "obviousness" of the intented target. A sufficiently advanced implementation might realize that no std::get overload would possibly be a better match than the WeirdTuple overload and thus skip substitution altogether, but it is not required to do so. During that substitution process the std::get overloads render the program ill-formed, effectively poisoning the overload set.

3. Discussion

LEWG discussion in Albuquerque favored a solution based on conditionally deleting std::get overloads for out-of-bounds calls (see Alternative Designs):

APPROVAL VOTE:

  • Option #1: SFINAE-friendly 2
  • Option #2: Conditionally Deleted 8
  • Option #3: Deduced Return Type 4

Conditionally deleted get overloads solve the issue by deferring the effect of making the program ill-formed to the point in which the overload is actually used, rather than when forming the candidate overload set:

WeirdTuple<int> wt(1);
get<0>(wt); // ok, returns 10
get<1>(wt); // ok too, returns 11

Another consequence of this deferred effect is that it makes the calls SFINAE-friendly, since the invalid expression happens in an immediate context, and as such they can be used in expression constraints:

template <std::size_t I, class Tuple>
concept has_get = requires(Tuple& t) { get<I>(t); };
 
static_assert(has_get<0, std::tuple<int>> == true, "elem 0: int");
static_assert(has_get<1, std::tuple<int>> == false, "out-of-bounds");

Given that deleted overloads do participate in overload resolution, even out-of-bounds calls to get will prefer a (deleted) std::get overload to any other user defined viable overload —Murphy, not Machiavelli— when the argument is one of the standard library types:

namespace ud {
  struct foo { /*...*/ };
 
  template <std::size_t I>
  void get(std::any thing) { /*gotten*/ }
}
 
std::tuple<ud::foo> t;
get<0>(t); // ok
get<1>(t); // error: call to deleted function 'get'
           // note: declared here
           //   std::get(std::tuple<Ts...>&) = delete;
           //   [with I = 1; Ts = {ud::foo}]

As an additional side effect, since the error happens only after overload resolution has finished its job, the resulting diagnostics for an out-of-bounds call will only mention the selected (deleted) overload.

4. Proposed Wording

This wording is relative to [N4713].

Change 23.2.1 [utility.syn], Header <utility> synopsis, as indicated:

// 23.4.4, tuple-like access to pair
  template<class T> class tuple_size;
  template<size_t I, class T> class tuple_element;
   
  template<class T1, class T2> struct tuple_size<pair<T1, T2>>;
  template<size_t I, class T1, class T2> struct tuple_element<I, pair<T1, T2>>;
   
  template<size_t I, class T1, class T2>
    constexpr tuple_element_t<I, pair<T1, T2>see below& get(pair<T1, T2>&) noexcept;
  template<size_t I, class T1, class T2>
    constexpr tuple_element_t<I, pair<T1, T2>see below&& get(pair<T1, T2>&&) noexcept;
  template<size_t I, class T1, class T2>
    constexpr const tuple_element_t<I, pair<T1, T2>see below& get(const pair<T1, T2>&) noexcept;
  template<size_t I, class T1, class T2>
    constexpr const tuple_element_t<I, pair<T1, T2>see below&& get(const pair<T1, T2>&&) noexcept;
  template<class T1, class T2>
    constexpr T1& get(pair<T1, T2>& p) noexcept;
  template<class T1, class T2>
    constexpr const T1& get(const pair<T1, T2>& p) noexcept;
  template<class T1, class T2>
    constexpr T1&& get(pair<T1, T2>&& p) noexcept;
  template<class T1, class T2>
    constexpr const T1&& get(const pair<T1, T2>&& p) noexcept;
  template<class T2, class T1>
    constexpr T2& get(pair<T1, T2>& p) noexcept;
  template<class T2, class T1>
    constexpr const T2& get(const pair<T1, T2>& p) noexcept;
  template<class T2, class T1>
    constexpr T2&& get(pair<T1, T2>&& p) noexcept;
  template<class T2, class T1>
    constexpr const T2&& get(const pair<T1, T2>&& p) noexcept
  template<class T, class T1, class T2>
    constexpr T& get(pair<T1, T2>& p) noexcept;
  template<class T, class T1, class T2>
    constexpr const T& get(const pair<T1, T2>& p) noexcept;
  template<class T, class T1, class T2>
    constexpr T&& get(pair<T1, T2>&& p) noexcept;
  template<class T, class T1, class T2>
    constexpr const T&& get(const pair<T1, T2>&& p) noexcept

Change 23.4.4 [pair.astuple], Tuple-like access to pair, as indicated:

template<class T1, class T2>
  struct tuple_size<pair<T1, T2>> : integral_constant<size_t, 2> { };
 
tuple_element<I, pair<T1, T2>>::type

-1- Requires: I < 2. The program is ill-formed if I is out of bounds.

-2- Value: The type T1 if I == 0, otherwise the type T2.

template<size_t I, class T1, class T2>
  constexpr tuple_element_t<I, pair<T1, T2>>V& get(pair<T1, T2>& p) noexcept;
template<size_t I, class T1, class T2>
  constexpr const tuple_element_t<I, pair<T1, T2>>V& get(const pair<T1, T2>& p) noexcept;
template<size_t I, class T1, class T2>
  constexpr tuple_element_t<I, pair<T1, T2>>V&& get(pair<T1, T2>&& p) noexcept;
template<size_t I, class T1, class T2>
  constexpr const tuple_element_t<I, pair<T1, T2>>V&& get(const pair<T1, T2>&& p) noexcept;

-3- Returns: If I == 0 returns a reference to p.first; if I == 1 returns a reference to p.second; otherwise the program is ill-formed.

-?- Remarks: If I < 2 the type V is tuple_element_t<I, pair<T1, T2>>. Otherwise this function is defined as deleted, and V is an unspecified referenceable type.

template<class T1, class T2>
  constexpr T1& get(pair<T1, T2>& p) noexcept;
template<class T1, class T2>
  constexpr const T1& get(const pair<T1, T2>& p) noexcept;
template<class T1, class T2>
  constexpr T1&& get(pair<T1, T2>&& p) noexcept;
template<class T1, class T2>
  constexpr const T1&& get(const pair<T1, T2>&& p) noexcept;

-4- Requires: T1 and T2 are distinct types. Otherwise, the program is ill-formed.

-5- Returns: A reference to p.first.

template<class T2, class T1>
  constexpr T2& get(pair<T1, T2>& p) noexcept;
template<class T2, class T1>
  constexpr const T2& get(const pair<T1, T2>& p) noexcept;
template<class T2, class T1>
  constexpr T2&& get(pair<T1, T2>&& p) noexcept;
template<class T2, class T1>
  constexpr const T2&& get(const pair<T1, T2>&& p) noexcept;

-6- Requires: T1 and T2 are distinct types. Otherwise, the program is ill-formed.

-7- Returns: A reference to p.second.

template<class T, class T1, class T2>
    constexpr T& get(pair<T1, T2>& p) noexcept;
  template<class T, class T1, class T2>
    constexpr const T& get(const pair<T1, T2>& p) noexcept;
  template<class T, class T1, class T2>
    constexpr T&& get(pair<T1, T2>&& p) noexcept;
  template<class T, class T1, class T2>
    constexpr const T&& get(const pair<T1, T2>&& p) noexcept

-?- Returns: If is_same_v<T, T1> is true returns a reference to p.first; if is_same_v<T, T2> is true returns a reference to p.second.

-?- Remarks: This function is defined as deleted unless T1 and T2 are distinct types, and T is either T1 or T2.

Change 23.5.2 [tuple.syn], Header <tuple> synopsis, as indicated:

// 23.5.3.7, element access
template<size_t I, class... Types>
  constexpr tuple_element_t<I, tuple<Types...>>see below& get(tuple<Types...>&) noexcept;
template<size_t I, class... Types>
  constexpr tuple_element_t<I, tuple<Types...>>see below&& get(tuple<Types...>&&) noexcept;
template<size_t I, class... Types>
  constexpr const tuple_element_t<I, tuple<Types...>>see below& get(const tuple<Types...>&) noexcept;
template<size_t I, class... Types>
  constexpr const tuple_element_t<I, tuple<Types...>>see below&& get(const tuple<Types...>&&) noexcept;
template<class T, class... Types>
  constexpr T& get(tuple<Types...>& t) noexcept;
template<class T, class... Types>
  constexpr T&& get(tuple<Types...>&& t) noexcept;
template<class T, class... Types>
  constexpr const T& get(const tuple<Types...>& t) noexcept;
template<class T, class... Types>
  constexpr const T&& get(const tuple<Types...>&& t) noexcept;

Change 23.5.3.7 [tuple.elem], Element access, as indicated:

template<size_t I, class... Types>
  constexpr tuple_element_t<I, tuple<Types...>>V&
    get(tuple<Types...>& t) noexcept;
template<size_t I, class... Types>
  constexpr tuple_element_t<I, tuple<Types...>>V&&
    get(tuple<Types...>&& t) noexcept; // Note A
template<size_t I, class... Types>
  constexpr const tuple_element_t<I, tuple<Types...>>V&
    get(const tuple<Types...>& t) noexcept; // Note B
template<size_t I, class... Types>
  constexpr const tuple_element_t<I, tuple<Types...>>V&& get(const tuple<Types...>&& t) noexcept;

-1- Requires: I < sizeof...(Types). The program is ill-formed if I is out of bounds.

-2- Returns: A reference to the Ith element of t, where indexing is zero-based.

-?- Remarks: If I < sizeof...(Types) the type V is tuple_element_t<I, tuple<Types...>>. Otherwise this function is defined as deleted, and V is an unspecified referenceable type.

-3- [Note A: If a T in Types is some reference type X&, the return type is X&, not X&&. However, if the element type is a non-reference type T, the return type is T&&. -end note]

-4- [Note B: Constness is shallow. If a T in Types is some reference type X&, the return type is X&, not const X&. However, if the element type is a non-reference type T, the return type is const T&. This is consistent with how constness is defined to work for member variables of reference type. -end note]

template<class T, class... Types>
  constexpr T& get(tuple<Types...>& t) noexcept;
template<class T, class... Types>
  constexpr T&& get(tuple<Types...>&& t) noexcept;
template<class T, class... Types>
  constexpr const T& get(const tuple<Types...>& t) noexcept;
template<class T, class... Types>
  constexpr const T&& get(const tuple<Types...>&& t) noexcept;

-5- Requires: The type T occurs exactly once in Types.... Otherwise, the program is ill-formed.

-6- Returns: A reference to the element of t corresponding to the type T in Types....

-?- Remarks: This function is defined as deleted unless the type T occurs exactly once in Types....

-7- [Example:

const tuple<int, const int, double, double> t(1, 2, 3.4, 5.6);
  const int& i1 = get<int>(t); // OK. Not ambiguous. i1 == 1
  const int& i2 = get<const int>(t); // OK. Not ambiguous. i2 == 2
  const double& d = get<double>(t); // ERROR. ill-formeddeleted

-end example]

-8- [Note: The reason get is a non-member function is that if this functionality had been provided as a member function, code where the type depended on a template parameter would have required using the template keyword. -end note]

Change 26.3.7.6 [utility.syn], Tuple interface to class template array, as indicated:

template<class T, size_t N>
  struct tuple_size<array<T, N>> : integral_constant<size_t, N> { };
 
tuple_element<I, array<T, N>>::type

-1- Requires: I < N. The program is ill-formed if I is out of bounds.

-2- Value: The type T.

template<size_t I, class T, size_t N>
  constexpr T& get(array<T, N>& a) noexcept;
template<size_t I, class T, size_t N>
  constexpr T&& get(array<T, N>&& a) noexcept;
template<size_t I, class T, size_t N>
  constexpr const T& get(const array<T, N>& a) noexcept;
template<size_t I, class T, size_t N>
  constexpr const T&& get(const array<T, N>&& a) noexcept;

-3- Requires: I < N. The program is ill-formed if I is out of bounds.

-4- Returns: A reference to the Ith element of a, where indexing is zero-based.

-?- Remarks: This function is defined as deleted unless I < N.

Change 23.7.2 [variant.syn], Header <variant> synopsis, as indicated:

// 23.7.5, value access
template<class T, class... Types>
  constexpr bool holds_alternative(const variant<Types...>&) noexcept;
 
template<size_t I, class... Types>
  constexpr variant_alternative_t<I, variant<Types...>>see below& get(variant<Types...>&);
template<size_t I, class... Types>
  constexpr variant_alternative_t<I, variant<Types...>>see below&& get(variant<Types...>&&);
template<size_t I, class... Types>
  constexpr const variant_alternative_t<I, variant<Types...>>see below& get(const variant<Types...>&);
template<size_t I, class... Types>
  constexpr const variant_alternative_t<I, variant<Types...>>see below&& get(const variant<Types...>&&);
 
template<class T, class... Types>
  constexpr T& get(variant<Types...>&);
template<class T, class... Types>
  constexpr T&& get(variant<Types...>&&);
template<class T, class... Types>
  constexpr const T& get(const variant<Types...>&);
template<class T, class... Types>
  constexpr const T&& get(const variant<Types...>&&);
 
template<size_t I, class... Types>
  constexpr add_pointer_t<variant_alternative_t<I, variant<Types...>>see below>
    get_if(variant<Types...>*) noexcept;
template<size_t I, class... Types>
  constexpr add_pointer_t<const variant_alternative_t<I, variant<Types...>>see below>
    get_if(const variant<Types...>*) noexcept;
 
template<class T, class... Types>
  constexpr add_pointer_t<T>
    get_if(variant<Types...>*) noexcept;
template<class T, class... Types>
  constexpr add_pointer_t<const T>
    get_if(const variant<Types...>*) noexcept;

Change 23.7.5 [variant.get], Value access, as indicated:

template<class T, class... Types>
  constexpr bool holds_alternative(const variant<Types...>& v) noexcept;

-1- Requires: The type T occurs exactly once in Types.... Otherwise, the program is ill-formed.

-2- Returns: true if index() is equal to the zero-based index of T in Types....

template<size_t I, class... Types>
  constexpr variant_alternative_t<I, variant<Types...>>V& get(variant<Types...>& v);
template<size_t I, class... Types>
  constexpr variant_alternative_t<I, variant<Types...>>V&& get(variant<Types...>&& v);
template<size_t I, class... Types>
  constexpr const variant_alternative_t<I, variant<Types...>>V& get(const variant<Types...>& v);
template<size_t I, class... Types>
  constexpr const variant_alternative_t<I, variant<Types...>>V&& get(const variant<Types...>&& v);

-3- Requires: I < sizeof...(Types). Otherwise the program is ill-formed.

-4- Effects: If v.index() is I, returns a reference to the object stored in the variant. Otherwise, throws an exception of type bad_variant_access.

-?- Remarks: If I < sizeof...(Types) the type V is variant_alternative_t<I, variant<Types...>>. Otherwise this function is defined as deleted, and V is an unspecified referenceable type.

template<class T, class... Types> constexpr T& get(variant<Types...>& v);
template<class T, class... Types> constexpr T&& get(variant<Types...>&& v);
template<class T, class... Types> constexpr const T& get(const variant<Types...>& v);
template<class T, class... Types> constexpr const T&& get(const variant<Types...>&& v);

-5- Requires: The type T occurs exactly once in Types.... Otherwise, the program is ill-formed.

-6- Effects: If v holds a value of type T, returns a reference to that value. Otherwise, throws an exception of type bad_variant_access.

-?- Remarks: This function is defined as deleted unless the type T occurs exactly once in Types....

template<size_t I, class... Types>
  constexpr add_pointer_t<variant_alternative_t<I, variant<Types...>>V>
    get_if(variant<Types...>* v) noexcept;
template<size_t I, class... Types>
  constexpr add_pointer_t<const variant_alternative_t<I, variant<Types...>>V>
    get_if(const variant<Types...>* v) noexcept;

-7- Requires: I < sizeof...(Types). Otherwise the program is ill-formed.

-8- Returns: A pointer to the value stored in the variant, if v != nullptr and v->index() == I. Otherwise, returns nullptr.

-?- Remarks: If I < sizeof...(Types) the type V is variant_alternative_t<I, variant<Types...>>. Otherwise this function is defined as deleted, and V is an unspecified referenceable type.

template<class T, class... Types>
  constexpr add_pointer_t<T>
    get_if(variant<Types...>* v) noexcept;
template<class T, class... Types>
  constexpr add_pointer_t<const T>
    get_if(const variant<Types...>* v) noexcept;

-9- Requires: The type T occurs exactly once in Types.... Otherwise, the program is ill-formed.

-10- Effects: Equivalent to: return get_if<i>(v); with i being the zero-based index of T in Types....

-?- Remarks: This function is defined as deleted unless the type T occurs exactly once in Types....

5. References

A. Alternative Designs

A.1 SFINAE-friendly

A traditional SFINAE-friendly implementation will get out of the user's way when used with an out-of-bounds index. The main disadvantage of this approach is that by not participating in overload resolution, it opens the door for user defined overloads even when called on a standard library tuple-like type; that is, given t of type std::tuple<UDT>, get<1>(t) might silently fall back to a get overload in an associated namespace of UDT. This regresses key functionality in the current std::get design, which mandates a diagnostic for out-of-bounds calls.

template <std::size_t I, typename ...Ts,
  typename Enable = std::enable_if_t<I < sizeof...(Ts)>>
std::tuple_element_t<I, std::tuple<Ts...>>&
get(std::tuple<Ts...>& t) {
  return /*...*/;
}
 
// Out-of-bounds calls:
std::tuple<int> t;
std::get<1>(t); // error: no matching function for call to 'get<1>(std::tuple<int>&)'
                // note: candidate template ignored:
                //   std::get(array<Ts...>&)
                //   could not match 'array' against 'tuple'
                // note: candidate template ignored:
                //   std::get(pair<Ts...>&)
                //   could not match 'pair' against 'tuple'
                // note: candidate template ignored:
                //   std::get(variant<Ts...>&)
                //   could not match 'variant' against 'tuple'
                // note: candidate template ignored:
                //   std::get(std::tuple<Ts...>&)
                //   [with I = 1; Ts = {int}]
                //   requirement 'I < sizeof...(Ts)' was not satisfied

Making std::tuple_element SFINAE-friendly would have the same effect, while leaving existing std::get by-index overloads unchanged.

A note on Concepts

A traditional Concept-based implementation is essentially equivalent to a traditional SFINAE-friendly implementation, and so it shares the same disadvantages. That includes the diagnostics generated for an out-of-bounds calls (for currently available implementations).

template <std::size_t I, typename ...Ts>
  requires I < sizeof...(Ts)
std::tuple_element_t<I, std::tuple<Ts...>>&
get(std::tuple<Ts...>& t) {
  return /*...*/;
}

[Note: The current specification unintentionally requires std::tuple_element_t<I, std::tuple<Ts...>> be instantiated before checking that the associated constraints are satisfied, resulting in an ill-formed program for out-of-bounds calls. The results presented here circumvent this issue, under the assumption that it will be rectified. CWG-issue-pending-publication. ]

A.2 Conditionally Deleted

A conditionally deleted implementation prevents the unintended fall back behavior of the traditional SFINAE-friendly approach, while still remaining SFINAE-friendly. As a bonus, diagnostics on out-of-bounds calls tend to be concise.

template <std::size_t I, typename ...Ts,
  typename Enable = std::enable_if_t<I < sizeof...(Ts)>>
std::tuple_element_t<I, std::tuple<Ts...>>&
get(std::tuple<Ts...>& t) {
  return /*...*/;
}
 
template <std::size_t I, typename ...Ts>
std::enable_if_t<sizeof...(Ts) <= I>
get(std::tuple<Ts...>& t) = delete;
 
// Out-of-bounds calls:
std::tuple<int> t;
std::get<1>(t); // error: call to deleted function 'get'
                // note: declared here
                //   std::get(std::tuple<Ts...>&) = delete;
                //   [with I = 1; Ts = {int}]

A.3 Deduced Return Type

An implementation that uses deduced return types can defer the required diagnostic until the definition is instantiated. The main disadvantage is that such definition may need to be instantiated earlier/more often than an explicitly typed alternative, and that the result is SFINAE-unfriendly in those contexts. On the other side, diagnostics on out-of-bounds calls tend to be concise and could include a custom tailored message.

template <std::size_t I, typename ...Ts>
decltype(auto) get(std::tuple<Ts...>& t) {
  static_assert(I < sizeof...(Ts), "tuple index is in range");
  return /*...*/;
}
 
// Out-of-bounds calls:
std::tuple<int> t;
std::get<1>(t); // error: static assertion failed: tuple index is in range
                // in instantiation of
                //   std::get(std::tuple<Ts...>&)
                //   [with I = 1; Ts = {int}]