To make a class with private members (or non-public access) support structured binding, implement the tuple-like protocol:

  1. Specialize std::tuple_size — number of elements.
  2. Specialize std::tuple_element for each index — element types.
  3. Provide get<N>() — access to values (member or free function via ADL).

Example: User → (id, name)

#include <cstddef>
#include <string>
#include <utility>

class User {
private:
    int id_;
    std::string name_;

public:
    User(int id, std::string name)
        : id_(id), name_(std::move(name)) {}

    template <std::size_t Index>
    decltype(auto) get() & {
        if constexpr (Index == 0) return (id_);
        else if constexpr (Index == 1) return (name_);
    }

    template <std::size_t Index>
    decltype(auto) get() const & {
        if constexpr (Index == 0) return (id_);
        else if constexpr (Index == 1) return (name_);
    }

    template <std::size_t Index>
    decltype(auto) get() && {
        if constexpr (Index == 0) return std::move(id_);
        else if constexpr (Index == 1) return std::move(name_);
    }

    template <std::size_t Index>
    decltype(auto) get() const && {
        if constexpr (Index == 0) return std::move(id_);
        else if constexpr (Index == 1) return std::move(name_);
    }
};

namespace std {

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

template <>
struct tuple_element<0, User> {
    using type = int;
};

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

} // namespace std

Usage:

User user{101, "Oleksii"};

auto& [id, name] = user;
name = "Dmytro";

const auto& [cid, cname] = user;

auto [id_copy, name_copy] = user;

auto [moved_id, moved_name] = User{202, "Temporary"};

Notes

Finding get. First a member e.get<N>(). If missing — a free function via ADL: get<N>(e). A free function is useful when you cannot change the class:

template <std::size_t Index>
decltype(auto) get(const User& u) {
    if constexpr (Index == 0) return u.get_id();
    else if constexpr (Index == 1) return u.get_name();
}

Parentheses in return (id_). With decltype(auto) this returns int& / const int& (an lvalue expression), not a copy. For && overloads, members are moved with std::move.

Four call categories. Without && / const &&, unpacking a temporary may copy instead of move, or fail to compile the way you expect.


In C++23 the four get overloads can collapse into one method via deducing this and std::forward_like — covered in a separate note.