C++ Structured Binding for Custom Classes

Structured binding in C++17 allows developers to decompose objects into individual variables with a clean, expressive syntax. While simple aggregate types with public member variables work out of the box, types with private data members or non-trivial interfaces require implementing the "tuple-like protocol." This mechanism enables custom classes to participate in structured bindings by specializing std::tuple_size and std::tuple_element, alongside implementing an accessible get function template.

The Tuple-Like Protocol Overview

When the compiler encounters a structured binding declaration such as auto [a, b] = my_object;, it inspects the type to determine how to perform the decomposition. For non-aggregate types or types explicitly opting into customization, the compiler checks for the existence of std::tuple_size<T>. If present, it treats the type as tuple-like and strictly requires three components:

  1. std::tuple_size<T> specialization: Specifies the exact number of elements returned by the decomposition.
  2. std::tuple_element<I, T> specialization: Defines the type of each element corresponding to index I.
  3. get<I>() overload: Accesses the underlying element at index I, implemented either as a member function template or as a free function template found via Argument-Dependent Lookup (ADL).

Step 1: Specialize std::tuple_size

The std::tuple_size template must be specialized in namespace std. It must inherit from std::integral_constant<std::size_t, N>, where N is the fixed number of elements exposed for structured binding.

#include <cstddef>
#include <type_traits>

class Coordinate {
private:
    double x_;
    double y_;

public:
    Coordinate(double x, double y) : x_(x), y_(y) {}

    // Friends or member methods provide access to data
    template <std::size_t Index>
    friend auto& get(Coordinate& c);

    template <std::size_t Index>
    friend const auto& get(const Coordinate& c);
};

template <>
struct std::tuple_size<Coordinate> : std::integral_constant<std::size_t, 2> {};

Step 2: Specialize std::tuple_element

The compiler needs to know the type produced at each position I during decomposition. Specialize std::tuple_element for each index from 0 to N - 1:

template <std::size_t Index>
struct std::tuple_element<Index, Coordinate> {
    using type = double;
};

If the types differ across elements, you can use conditional type definitions or provide explicit specializations for each index:

template <>
struct std::tuple_element<0, CustomType> {
    using type = int;
};

template <>
struct std::tuple_element<1, CustomType> {
    using type = std::string;
};

Step 3: Implement the get Template

The get function can be defined either as a member template (e.g., c.get<0>()) or as a free function template in the same namespace as the custom type so that ADL can locate it. Free functions are generally preferred as they keep the class interface clean and mirror the standard library conventions.

Using C++17 if constexpr, the implementation can handle multiple indices compactly:

template <std::size_t Index>
auto& get(Coordinate& c) {
    if constexpr (Index == 0) return c.x_;
    else if constexpr (Index == 1) return c.y_;
}

template <std::size_t Index>
const auto& get(const Coordinate& c) {
    if constexpr (Index == 0) return c.x_;
    else if constexpr (Index == 1) return c.y_;
}

template <std::size_t Index>
auto&& get(Coordinate&& c) {
    if constexpr (Index == 0) return std::move(c.x_);
    else if constexpr (Index == 1) return std::move(c.y_);
}

Providing overloads for non-const references, const references, and rvalue references ensures that structured bindings behave correctly whether bound via auto [x, y], const auto& [x, y], or auto&& [x, y].

Value and Reference Binding Semantics

When structured binding syntax is executed:

Coordinate point{10.5, 20.0};
auto& [x_ref, y_ref] = point;
x_ref = 15.0; // Modifies point.x_ directly

The compiler creates an invisible, hidden variable that holds the bound expression (either as a copy or reference depending on auto, auto&, or auto&&). The named identifiers (x_ref, y_ref) are aliases referencing the results of the corresponding get<0>() and get<1>() invocations on that hidden variable.

By fulfilling the tuple-like protocol through tuple_size, tuple_element, and get, custom classes achieve full integration with C++ structured bindings while strictly preserving encapsulation.