📦 csl::ag
Tuple-like interface for aggregate types.
📦 csl::ag Documentation

Fork me on GitHub Check documentation on GitHub-pages

Overall presentation

The goal of csl::ag is to offer convenient ways to manipulate aggregate types.

Overview demo

The following example demonstrates some of the features which are available in csl::ag.

C++ code ( Try me on compiler-explorer ) Console output

struct S { char c; int i; };
static_assert(
csl::ag::concepts::aggregate<S> and
csl::ag::size_v<S> == 2
);
static_assert(std::same_as<char, csl::ag::element_t<0, S>>);
static_assert(std::same_as<int, csl::ag::element_t<1, S>>);
S value{ 'A', 41 }; ++std::get<1>(value);
using namespace csl::ag::io;
std::cout << "value: " << value << '\n';
// (wip) compatibility with `fmt` and `std::print` will be available soon

value: S& : {
[0] char : A
[1] int : 42
}

Introduction

By default, the C++ standard allow structured-binding for aggregate types.

C++ code ( Try me on compiler-explorer )

struct type{ int i; char c; };
auto value = type{ 42, 'A' }; // NOLINT
[[maybe_unused]] auto && [ v0, v1 ] = value;
assert(v0 == 42); // pass
assert(v1 == 'A'); // pass

However, there is no - simple - way to access the following informations for a given aggregate type or value :

  • The count of fields
  • Access a field's value by index
  • Iterate over fields

This library provides a way to obtain such information, and internally use it to provide convenient high-level conversions and printing functions.


This library is divided in five distinct parts :

  • #1 Aggregates-related concepts
  • #2 Aggregates-related type-traits
  • #3 Conversion to tuples for aggregate types (owning or not)
  • #4 A tuplelike interface for aggregates types
  • #5 (WIP) Pretty-printing (using std::ostream & operator<< overloads or fmt)

Philosophy & design choices

The key idea of this library is to ease iterations over aggregates's member-variables,
which is especially convenient when dealing with reflection and serialization.


Getting starting

This library is single-header, header-only. Users may use it in various ways, however CMake is the promoted one for both download and configuration.

Integration

Plain download

⚠️ Proceeding the ways enumerated above is fast & simple. However this prevent users from using certain configuration mechanismes. See the configuration section for more information.

CMake

Then use the csl::ag target.

Note : to disable tests, set the cmake cache variable CSL_BUILD_ALL_TESTS to false.

Configuration

This project can be configured using the following cmake cache entries, grouped by categories:

Bitfields support

⚠️ By default, bitfields support is disabled.
Using features for this library with any aggregate type using custom layout will results in ☣️ undefined behavior.
Most likely, a compile-time error will be emitted. However, such behavior is not guaranteed.

struct S {
int b0 : 1, b1 : 1, b2 : 1, b3 : 1;
char : 0;
char && c;
};
static_assert(csl::ag::size_v<S> == 5); // ☣️ UB by default

If you plan to use features of this library with aggregate types containing bitfields, you must first enable such support either using one of the two following ways :

  • Using CMake, edit the cache to set the CSL_AG__ENABLE_BITFIELDS_SUPPORT option to on.
    or
  • Using plain C++, define the preprocessor variable CSL_AG__ENABLE_BITFIELDS_SUPPORT.
    #define CSL_AG__ENABLE_BITFIELDS_SUPPORT true

Question : Why such option exists ?

The (compile-time) algorithm internally used by the library to count fields for aggregate types possibly containing bitfields is much slower than the default one.
One might want to challenge his/her project's design in order to avoid such high performance cost.

Highier limit for aggregate field count

This library relies on a CMake cache variable CSL_AG__MAX_FIELDS_SUPPORTED_COUNT to generate code in order to properly handle aggregate types with fields up to this value.

By default, CSL_AG__MAX_FIELDS_SUPPORTED_COUNT is set to 128, meaning the library supports aggregate types with up to 128 fields.

To extend such support, edit your CMake cache to set CSL_AG__MAX_FIELDS_SUPPORTED_COUNT to a greater integral value.

Question : What if I don't use CMake ?

Then the library will always use the default value.

Question : Why such configuration/limitation ?

Despite interesting proposals that aim to enhance & offer new code generation mecanisms as part of the C++ language, such features are not available yet.

The choice here to use CMake in order to generate C++ code upstream is a reasonable trade-off to guarantee easier debugging and avoid dark-magic tricks (such as relying on PP macros, etc.).

👉 If you are willing to propose a better design, you can submit a PR here.

Formatting and printing (experimentale, WIP)

⚠️ This section is experimentale, and SHOULD NOT be used in production.
Breaking changes are very likely, as the API is instable for now.

All options in this section are opt-ins *(OFF by default)*

  • CSL_AG__ENABLE_FORMAT_SUPPORT: add std::formatter<csl::ag::aggregate T>
const auto formatted = std::format("my aggregate = {}", my_aggregate{});
std::print("{}", my_aggregate_value); // default presentation (compact)
std::print("{:c}", my_aggregate_value); // compact presentation
std::print("{:p}", my_aggregate_value); // pretty presentation
  • CSL_AG__ENABLE_FMTLIB_SUPPORT: makes csl::ag depends on the fmt library, and add fmt::formatter<csl::ag::aggregate T>.

If fmtlib's cmake target fmt::fmt-header-only is not available when building csl::ag with CSL_AG__ENABLE_FMTLIB_SUPPORT set to ON, then such a dependency will be injected using cmake FetchContent.

const auto formatted = fmt::format("my aggregate = {}", my_aggregate{});
fmt::print("{}", my_aggregate_value); // default presentation (compact)
fmt::print("{:c}", my_aggregate_value); // compact presentation
fmt::print("{:p}", my_aggregate_value); // pretty presentation
  • CSL_AG__ENABLE_IOSTREAM_SUPPORT: add csl::ag::io::operator<<(const csl::io::indented_ostream os, csl::ag::concepts::structured_bindable auto && value)
using namespace csl::ag::io;
std::cout << my_aggregate{}; // equivalent to: `indented_ostream{std::cout} << my_aggregate{};`
indented_ostream{std::cout, 2} << my_aggregate{}; // explicit indentation

About compact vs. pretty presentations:

struct A{ int i{}; };
struct my_aggregate{ char c = 'a'; A a = A{ .i = 13} };
  • Compact presentation:
my_aggregate: { char: 'c', A: { int: 13 } }
  • Pretty presentation:
my_aggregate: {
char: 'c',
A: {
int: 13
}
}

Content

All components that are part of the public interface are defined in the namespace csl::ag,
except for nested-namespaces named details.

In other words, the library provides no guarantee to any direct use of namespaces named with a pattern like csl::ag::*::details::*.

Aggregate-related concepts

All concepts that are part of the public interface are defined in the namespace csl::ag::concepts.

unqualified_aggregate<T>

Requirements that given T type must meet to be considered as an unqualified (e.g, not cvref-qualified) aggregate type by this library components.

  • std::is_aggregate_v<T>
  • not std::is_empty_v<T>
  • not std::is_union_v<T>
  • not std::is_polymorphic_v<T>
  • not std::is_reference_v<T>

aggregate<T>

T must be a possibly cvref-qualified aggregate, meeting the unqualified_aggregate<std::remove_cvref_t<T>> requirement.

Note that such requirement is widely used in this library.

aggregate_constructible_from<T, args_ts...>

T must be a valid aggregate type, constructible using brace-initialization using values of types args_ts....

aggregate_constructible_from_n_values<T, std::size_t N>

T must be a valid aggregate type, constructible using N values (which types does not matter here).
This does not mean that T has N fields : it can be more.

tuplelike<T>

T must meet the tuplelike interface, with valid implementation of :

csl::ag::concepts::structured_bindable<T>

T must either match tuplelike<T> or aggregate<T> requirements.

See the structured_binding documentation for more details.

Aggregate-related type-traits

csl::ag::size<T>

Integral constant type which value represents the count of fields for a given aggregate type.

struct A{ int i; float f; };
static_assert(csl::ag::size<A>::value == 2);
static_assert(csl::ag::size_v<A> == 2);
Definition: ag.hpp:645

Try me on compiler-explorer.

Just like std::tuple_size/std::tuple_size_v, the value can be accessed using a convenience alias :

template <csl::ag::concepts::aggregate T>
constexpr inline static auto size_v = size<T>::value;

csl::ag::element<std::size_t, concepts::aggregate>

Type-identity of a field's type of a given aggregate type.

struct A{ int i; float f; };
static_assert(std::same_as<int, csl::ag::element_t<0, A>>);
static_assert(std::same_as<float, csl::ag::element_t<1, A>>);

Try me on compiler-explorer.

Just like std::tuple_element/std::tuple_element_t, the type can be accessed using a convenience alias :

template <std::size_t N, concepts::aggregate T>
using element_t = typename element<N, T>::type;

csl::ag::view_element<std::size_t, concepts::aggregate>

In a similar way to csl::ag::element<std::size_t, T>, csl::ag::view_element<std::size_t,T> is a type-identity for a field's type of a given aggregate view type.
For more details about aggregate's view, see the to-tuple non-owning conversion (view) section.

struct A{ int i; float & f; const char && c; };
static_assert(std::same_as<int&&, csl::ag::view_element_t<0, A&&>>);
static_assert(std::same_as<float&, csl::ag::view_element_t<1, A&&>>);
static_assert(std::same_as<const char&&, csl::ag::view_element_t<2, A&&>>);
static_assert(std::same_as<const int&, csl::ag::view_element_t<0, const A&>>);
static_assert(std::same_as<float&, csl::ag::view_element_t<1, const A&>>);
static_assert(std::same_as<const char&&, csl::ag::view_element_t<2, const A&>>);

Try me on compiler-explorer.

The type nested-type can be accessed using a convenience alias :

template <std::size_t N, concepts::aggregate T>
using view_element_t = typename view_element<N, T>::type;

to-tuple conversion for aggregate types

This library provides two ways to convert an aggregate's value to std::tuple, distinguishing between proprietary and non-proprietary tuples of values.

  • Owning is a plain translation of an aggregate type as a tuple.

    Each std::tuple_element_t of the resulting type will be strictly equivalent to csl::ag::element_t of the source one.
    The value of each field is pass by-value (understand: copy).

    See the Owning conversion section hereunder.

  • Non-owning (undestand: view, or lightweight accessor) conversion offers a cheap way to convert an aggregate into a tuple of references;
    offering a convenient way to then use already-existing features - or even libraries - that operates on std::tuple values.

    • Field types that already are references will remain unchanged : csl::ag::element_t is strictly equivalent to std::tuple_element_t.
    • Field types that are not references will acquire the cvref-qualifier of the source aggregate value.

    See the Non-owning conversion (view) section hereunder.

Owning conversion

The following factory (as a function) creates a std::tuple of csl::ag::elements, never altered.

The value of each member-variable of the aggregate's value are forwarded, in order to preserve cvref-semantic.
Meaning that using a const-lvalue-reference of a given type S 's value will result in a copy of each of its field that are not ref-qualified,
while using a rvalue-reference will results in a perfect-forwarding that member-variable.

C++ code ( Try me on compiler-explorer ) Console output

struct A{ int i; float f; };
constexpr auto value = A{ .i = 42, .f = 0.13f };
constexpr auto value_as_tuple = csl::ag::to_tuple(std::move(value));
[&]<std::size_t ... indexes>(std::index_sequence<indexes...>){
static_assert((std::same_as<
csl::ag::element_t<indexes, A>, // { 0: int, 1:float }
std::tuple_element_t<indexes, std::remove_cvref_t<decltype(value_as_tuple)>>
> and ...));
((std::cout << std::get<indexes>(value_as_tuple) << ' '), ...);
}(std::make_index_sequence<csl::ag::size_v<A>>{});

42 0.13

The main advantage here is to use such function in a constexpr contexts.
A precondition while doing so is that each aggregates field's value must be usable in a constexpr context though (e.g not ref-qualified).

struct A{ int i; float f; };
static_assert(std::same_as<
std::tuple<int, float>,
csl::ag::to_tuple_t<A>
>);
static_assert(std::same_as<int, csl::ag::element_t<0, A>>);
static_assert(std::same_as<int, std::tuple_element_t<0, csl::ag::to_tuple_t<A>>>);
static_assert(std::same_as<int, std::tuple_element_t<0,decltype(csl::ag::to_tuple(A{}))>>);
constexpr auto value = A{ .i = 42, .f = 0.13f };
constexpr auto value_as_tuple = csl::ag::to_tuple(std::move(value));
static_assert(42 == std::get<0>(value_as_tuple));
static_assert(0.13f == std::get<1>(value_as_tuple));

Try me on compiler-explorer.

Additionaly, std::tuple_element_t can be use to obtains the conversion result's element types.

  • Example 1 : aggregate type with not-cvref-qualified fields

    C++ code ( Try me on compiler-explorer ) Console output

    struct A{ int i; float f; };
    constexpr auto value = csl::ag::to_tuple(A{ .i = 42, .f = 0.13f });
    [&value]<std::size_t ... indexes>(std::index_sequence<indexes...>){
    ((std::cout << std::get<indexes>(value) << ' '), ...);
    }(std::make_index_sequence<csl::ag::size_v<A>>{});
    static_assert(std::same_as<
    int,
    std::tuple_element_t<0, std::remove_cvref_t<decltype(value)>>
    >); // \-> same as csl::ag::to_tuple_t<A>
    static_assert(std::same_as<
    float,
    std::tuple_element_t<1, std::remove_cvref_t<decltype(value)>>
    >);

    42 0.13

  • Example 2 : aggregate type with ref-qualified fields

    C++ code ( Try me on compiler-explorer ) Console output

    struct A{ int & i; float && f; };
    int i = 42; float f = .13f;
    /* not constexpr */ auto value = csl::ag::to_tuple(A{ .i = i, .f = std::move(f) });
    [&value]<std::size_t ... indexes>(std::index_sequence<indexes...>){
    ((std::cout << std::get<indexes>(value) << ' '), ...);
    }(std::make_index_sequence<csl::ag::size_v<A>>{});
    static_assert(std::same_as<
    int&,
    std::tuple_element_t<0, std::remove_cvref_t<decltype(value)>>
    >);
    static_assert(std::same_as<
    float&&,
    std::tuple_element_t<1, std::remove_cvref_t<decltype(value)>>
    >);

    42 0.13

Non-owning conversion (view, lightweight accessor)

This factory (as a function) creates non-owning lightweight accessors (views),
returning a non-owning tuple (std::tuple of references), for which each element represents an accessor to an aggregate value's field.

Note that in order to preserve cvref semantic, the possibly-used cvref qualifiers of the aggregate's value are propagated to qualify each of non-ref-qualified elements of the result tuple-type.
Ref-qualified fields type remain unchanged.

The conversion's result type can be access using the tuple_view(_t)<T> type-trait.

struct type { int lvalue; int & llvalue; const int & const_lvalue; int && rvalue; };
int i = 42;
{ // using a rvalue
[[maybe_unused]] auto view = csl::ag::to_tuple_view(type{ i, i, i, std::move(i) });
static_assert(std::same_as<
decltype(view),
csl::ag::tuple_view_t<type&&>
>);
static_assert(std::same_as<
decltype(view),
std::tuple<int&&, int&, const int&, int&&>
// ^^^^^ cvref-qualified (rvalue-ref) propagation
>);
}
{ // using a const-lvalue
const auto & value = type{ i, i, i, std::move(i) };
[[maybe_unused]] auto view = csl::ag::to_tuple_view(value);
static_assert(std::same_as<
decltype(view),
csl::ag::tuple_view_t<const type&>
>);
static_assert(std::same_as<
decltype(view),
std::tuple<const int &, int&, const int &, int&&>
// ^^^^^^^^^^^ cvref-qualified (const-lvalue-ref) propagation
>);
}

Try me on compiler-explorer.

Additionally, csl::ag::view_element(_t)<N,T> can be used to obtains a field's type information, by index.

struct type { int lvalue; int & llvalue; const int & const_lvalue; int && rvalue; };
// field 0 IS NOT a reference : cvref-qualifiers propagation
static_assert(std::same_as<int &&,
csl::ag::view_element_t<0, type&&>
>);
static_assert(std::same_as<int &,
csl::ag::view_element_t<0, type&>
>);
static_assert(std::same_as<const int &&,
csl::ag::view_element_t<0, const type&&>
>);
static_assert(std::same_as<const int &,
csl::ag::view_element_t<0, const type&>
>);
// field 0 IS a reference : no cvref-qualifiers propagation
static_assert(std::same_as<int &,
csl::ag::view_element_t<1, type&&>
>);
static_assert(std::same_as<int &,
csl::ag::view_element_t<1, type&>
>);
static_assert(std::same_as<int &,
csl::ag::view_element_t<1, const type&&>
>);
static_assert(std::same_as<int &,
csl::ag::view_element_t<1, const type&>
>);
// still not propagation for fields 2 and 3 ...

Try me on compiler-explorer.

tuplelike interface for aggregates

std::tuple_element

struct type{ const int i = 0; char & c; };
char c = 'c';
auto value = type{ 42, c }; // NOLINT
static_assert(std::same_as<
const int,
std::tuple_element_t<0, std::remove_cvref_t<decltype(value)>>
>);
static_assert(std::same_as<
char&,
std::tuple_element_t<1, std::remove_cvref_t<decltype(value)>>
>);

Try me on compiler-explorer.

std::get

Simple example :

struct A{ int i; float f; };
auto value = A { .i = 42, .f = 0.13f };
std::cout << std::get<0>(value) << ", " << std::get<1>(value) << '\n';
static_assert(std::same_as<
int &,
decltype(std::get<0>(value))
>);
static_assert(std::same_as<
float &,
decltype(std::get<1>(value))
>);
42, 0.13

Try me on compiler-explorer.

Slightly more advanced example :

struct A{ int i; float f; };
auto value = A{ .i = 42, .f = 0.13f };
[&value]<std::size_t ... indexes>(std::index_sequence<indexes...>){
((std::cout << std::get<indexes>(value) << ' '), ...);
}(std::make_index_sequence<csl::ag::size_v<A>>{});
42 0.13

Try me on compiler-explorer.

Note that constexpr-ness is preserved :

struct A{ int i; char c; };
constexpr auto value = A{ 42, 'c' };
static_assert(csl::ag::get<0>(value) == 42); // pass
static_assert(csl::ag::get<1>(value) == 'c'); // pass

Try me on compiler-explorer.

Pretty-printing

There are two way to pretty-print aggregate types :

  • using the legacy C++'s way : std::ostream& operator<<(std::ostream&, T&&) overload
  • using the fmt or std::format library

using std::ostream :

Simple example :

#include <csl/ag.hpp>
#include <iostream>
auto main() -> int {
using namespace csl::ag::io;
struct A{ int i; float f; };
std::cout << A{ .i = 42, .f = .13f };
}
A && : {
[0] int : 42
[1] float : 0.13
}

Try me on compiler-explorer.

Advanced example :

#include <iostream>
#include <tuple>
#include <array>
struct A{ int i; float f; };
struct B{};
auto & operator<<(std::ostream & os, B) {
return os << "user-defined operator<<(std::ostream&, const B &)";
}
struct C {
A a;
B b;
int & i;
const std::string str;
char && c;
std::tuple<bool, int> t{ true, 2 };
std::array<char, 3> arr{ 'a', 'b', 'c' };
};
#include <csl/ag.hpp>
auto main() -> int {
using namespace csl::ag::io;
int i = 42;
char c = 'c';
auto value = C {
.a = A{ 13, .12f },
.b = B{},
.i = i, .str = "str", .c = std::move(c)
};
std::cout << value;
}

Output :

C & : {
[0] A & : {
[0] int : 13
[1] float : 0.12
}
[1] B & : user-defined operator<<(std::ostream&, const B &)
[2] int & : 42
[3] const std::basic_string<char> : str
[4] char && : c
[5] std::tuple<bool, int> & : {
[0] bool : 1
[1] int : 2
}
[6] std::array<char, 3> & : {
[0] char : a
[1] char : b
[2] char : c
}
}

Try me on compiler-explorer.

std::tuple and aggregate types homogeneity

As is, it is quite easy to handle aggregates and tuple in an homogeneous way, despite limitation listed in the next section below.

void do_stuff_with_either_a_tuple_or_aggregate(csl::ag::concepts::structured_bindable auto && value) {
using value_type = std::remove_cvref_t<decltype(value)>;
constexpr auto size = []() constexpr { // work-around for ADL issue
if constexpr (csl::ag::concepts::tuplelike<value_type>)
return std::tuple_size_v<value_type>;
else if constexpr (csl::ag::concepts::aggregate<value_type>)
return csl::ag::size_v<value_type>;
else
static_assert(sizeof(value_type) and false, "Unexpected type"); // NOLINT
}();
const auto do_stuffs = [&]<size_t index>(){
auto && element_value = std::get<index>(std::forward<decltype(value)>(value));
using element_value_type = decltype(element_value);
using element_type = std::tuple_element_t<index, value_type>;
// do stuffs with element_value, element_type ...
};
[&]<std::size_t ... indexes>(std::index_sequence<indexes...>){
((do_stuffs.template operator()<indexes>()), ...);
}(std::make_index_sequence<size>{});
}

Try me on compiler-explorer.

Current limitations

As-is, this implementation internally relies on structured-binding, which design choice expose two main limitations :

  • Compile-time evaluation is limited.
  • By-default behaviors injections, using STL extension/customization point (e.g injecting in the std namespace definitions for get/tuple_element/tuple_size(_v) won't work).
  • Aggregate types with more fields than their size are currently not supported.
  • Ill-formed aggregate types using union-fields are not supported

(Internal details) Where's the magic ?

Everything has its own dirty secrets, and this library is no exception.

Internally, and for each given aggregate type, it recursively check if a value of the later is constructible from an aggregate-initialization using N implicitly-castable-to-anything parameters values.

The initial N value is sizeof(T).

If the result is a failure, then another attempt using N-1 is done, up to 1 (included).

See csl::ag::concepts::aggregate_with_n_fields<T, size>

auto main() -> int {
struct A{ char a, b, c, d, e, f, g, h; };
static_assert(sizeof(A) == 8);
static_assert(csl::ag::size_v<A> == 8);
struct B{ int a, b; };
static_assert(sizeof(B) == 8);
static_assert(csl::ag::size_v<B> == 2);
struct alignas(32) C { char c; };
static_assert(sizeof(C) == 32);
static_assert(csl::ag::size_v<C> == 1);
}

Try me on compiler-explorer.