u/LHLaurini

Poor man's define_aggregate
▲ 29 r/cpp

Poor man's define_aggregate

TLDR: Try it on Compiler Explorer.


While waiting for Clang to support define_aggregate, I got curious about whether it's possible to do something similar in C++23. Turns out it kinda is.

Rules:

  • Only C++23 features;
  • No external programs;
  • No macros;
  • Generated code should be similar to just using a struct.

We start with some helper types:

#include <algorithm>
#include <array>
#include <concepts>
#include <functional>
#include <print>
#include <ranges>
#include <string_view>
#include <tuple>
#include <type_traits>

namespace detail
{

// See `type`
template <typename T>
struct FieldType
{
	using Type = T;
};

// Helper for using a string as a template parameter
template <std::size_t size>
struct ConstexprStringHelper
{
	std::array<char, size - 1> array;

	constexpr ConstexprStringHelper(const char (&c_array)[size])
	{
		std::copy_n(c_array, size - 1, std::begin(array));
	}
};

// See `operator""_field`
template <auto name>
struct FieldByName
{
};

We calculate the layout of our fake aggregate (sizes, alignment, offsets, ...) at compile time, like so:

// Layout information
template <auto... fields>
struct MetaAggregateInfo
{
	static consteval auto calc_align(std::size_t offset, std::size_t align)
	{
		return (offset + align - 1) & ~(align - 1);
	}

	static constexpr std::array names{ std::string_view(fields.name)... };
	static constexpr std::array sizes{ sizeof(typename decltype(fields)::Type)... };
	static constexpr std::array aligns{ alignof(typename decltype(fields)::Type)... };
	static constexpr auto max_align = std::ranges::max(aligns);
	static constexpr auto offsets = [] {
		std::remove_const_t<decltype(sizes)> offsets;
		std::size_t next_offset = 0;

		for (auto [size, align, offset] : std::views::zip(sizes, aligns, offsets))
		{
			offset = calc_align(next_offset, align);
			next_offset = offset + size;
		}

		return offsets;
	}();
	static constexpr auto total_size = calc_align(offsets.back() + sizes.back(), max_align);
};

I found it simpler to just use a partial specialization for the case where the aggregate has no members:

template <>
struct MetaAggregateInfo<>
{
	static constexpr std::array<std::string_view, 0> names{};
	static constexpr std::array<std::size_t, 0> sizes{};
	static constexpr std::array<std::size_t, 0> aligns{};
	static constexpr auto max_align = 1uz;
	static constexpr std::array<std::size_t, 0> offsets{};
	static constexpr auto total_size = 1uz;
};

}

A few more helpers:

// Use to declare the type of a field. See example below.
template <typename T>
constexpr detail::FieldType<T> type;

// Type and name of a field
template <typename TheType, std::size_t size>
struct Field
{
	using Type = TheType;

	detail::FieldType<TheType> type;
	std::array<char, size> name;
};

// Use to declare the name of a field
template <detail::ConstexprStringHelper helper>
consteval auto operator""_name()
{
	return helper.array;
}

// Use with operator[] to access a field by name
template <detail::ConstexprStringHelper helper>
consteval auto operator""_field() -> detail::FieldByName<helper.array>
{
	return {};
}

And now the meat of the code:

template <auto... fields>
class MetaAggregate
{
public:
	static constexpr detail::MetaAggregateInfo<fields...> info{};

We define our constructors, copy/move operators and destructor. We use the offsets to get a pointer on which we can do a placement new. Other than that, this part is not very interesting.

	MetaAggregate()
	requires(std::default_initializable<typename decltype(fields)::Type> && ...)
	{
		std::apply(
			[&](auto... offset) { (new (storage.data() + offset) decltype(fields)::Type(), ...); },
			info.offsets
		);
	}

	MetaAggregate(const MetaAggregate& other)
	requires(std::copy_constructible<typename decltype(fields)::Type> && ...)
		: MetaAggregate(other.refs())
	{
	}

	MetaAggregate(MetaAggregate&& other)
	requires(std::move_constructible<typename decltype(fields)::Type> && ...)
		: MetaAggregate(other.refs())
	{
	}

	MetaAggregate& operator=(const MetaAggregate& other)
	requires(std::copyable<typename decltype(fields)::Type> && ...)
	{
		std::apply(
			[&](const auto&... from) {
				std::apply(
					[&]<typename... To>(To&&... to) { ((std::forward<To>(to) = from), ...); },
					refs()
				);
			},
			other.refs()
		);
		return *this;
	}

	MetaAggregate& operator=(MetaAggregate&& other)
	requires(std::movable<typename decltype(fields)::Type> && ...)
	{
		std::apply(
			[&](auto&&... from) {
				std::apply(
					[&](auto&&... to) {
						((std::forward<decltype(to)>(to) = std::move(from)), ...);
					},
					refs()
				);
			},
			other.refs()
		);
		return *this;
	}

	template <typename... Init>
	MetaAggregate(Init&&... init)
	requires(std::constructible_from<typename decltype(fields)::Type, Init> && ...)
		: MetaAggregate(std::forward_as_tuple(std::forward<Init>(init)...))
	{
	}

	template <typename... Init>
	MetaAggregate(std::tuple<Init...> init_tuple)
	requires(std::constructible_from<typename decltype(fields)::Type, Init> && ...)
	{
		std::apply(
			[&](Init&&... init) {
				std::apply(
					[&](auto... offset) {
						(new (storage.data() + offset) decltype(fields)::Type(
							std::forward<Init>(init)
						),
						...);
					},
					info.offsets
				);
			},
			std::move(init_tuple)
		);
	}

	~MetaAggregate()
	requires(std::destructible<typename decltype(fields)::Type> && ...)
	{
		std::apply([]<typename... T>(T&... objects) { (objects.~T(), ...); }, refs());
	}

A little function to let us check that we got the layout right:

	static void dump_layout(std::string_view struct_name = "MetaAggregate<...>")
	{
		std::array type_names = { typeid(typename decltype(fields)::Type).name()... };

		std::println("Size of {}: {}", struct_name, info.total_size);
		std::println("Alignment of {}: {}", struct_name, info.max_align);
		std::println("Fields:");

		for (auto [type_name, name, offset, size, align] :
			std::views::zip(type_names, info.names, info.offsets, info.sizes, info.aligns))
		{
			std::println(
				" - {} {} (offset: {}; size: {}; alignment: {})", type_name, name, offset, size,
				align
			);
		}
	}

Now we get to finally access the fields. First by index:

	template <
		std::size_t index, typename Self,
		typename Type = std::tuple_element_t<index, decltype(std::tuple{ fields... })>::Type>
	decltype(auto) get(this Self&& self)
	{
		using Ptr =
			std::conditional_t<std::is_const_v<std::remove_reference_t<Self>>, const Type, Type>*;
		constexpr auto offset = std::get<index>(info.offsets);
		return std::forward_like<Self>(*reinterpret_cast<Ptr>(self.storage.data() + offset));
	}

and finally by name. Note that we convert the name into an index at compile time (that's why we do all that stuff with UDLs).

	template <std::size_t size, std::array<char, size> name>
	static consteval std::size_t index(detail::FieldByName<name>)
	{
		constexpr std::array matches{ std::string_view(name) == std::string_view(fields.name)... };
		constexpr auto num_matches = std::ranges::count_if(matches, std::identity{});
		static_assert(num_matches > 0, "field not found");
		static_assert(num_matches < 2, "multiple fields match name");
		return std::distance(matches.begin(), std::ranges::find_if(matches, std::identity{}));
	}

	template <std::size_t size, std::array<char, size> name>
	decltype(auto) operator[](this auto&& self, detail::FieldByName<name>)
	{
		return self.template get<index<size, name>({})>();
	}

This method lets us avoid having index_sequences everywhere (and also gives us some structured binding support):

	template <typename Self>
	auto refs(this Self&& self)
	{
		return [&]<std::size_t... index>(std::index_sequence<index...>) {
			return std::forward_as_tuple(std::forward<Self>(self).template get<index>()...);
		}(std::make_index_sequence<sizeof...(fields)>{});
	}

And last but not least, our storage:

private:
	alignas(info.max_align) std::array<std::byte, info.total_size> storage;
};

Now to finally use it:

using Struct = MetaAggregate<
	Field{ type<int>, "integer_1"_name },
	Field{ type<int>, "integer_2"_name },
	Field{ type<std::string>, "string_1"_name },
	Field{ type<std::string>, "string_2"_name }
>;

int main()
{
	Struct::dump_layout();

	Struct blah{ 1, 2, "3", "4" };

	std::println("By name:");
	std::println("blah[\"integer_1\"_field] = {}", blah["integer_1"_field]);
	std::println("blah[\"integer_2\"_field] = {}", blah["integer_2"_field]);
	std::println("blah[\"string_1\"_field] = {}", blah["string_1"_field]);
	std::println("blah[\"string_2\"_field] = {}", blah["string_2"_field]);

	blah["string_1"_field] = "foo";

	std::println("By index:");
	std::println("blah.get<0>() = {}", blah.get<0>());
	std::println("blah.get<1>() = {}", blah.get<1>());
	std::println("blah.get<2>() = {}", blah.get<2>());
	std::println("blah.get<3>() = {}", blah.get<3>());

	std::println("Structured bindings:");
	auto [a, b, c, d] = blah.refs();
	std::println("a = {}", a);
	std::println("b = {}", b);
	std::println("c = {}", c);
	std::println("d = {}", d);
}

It's ugly, but it works.


Caveats

  • Since we rely on placement new, it cannot be made constexpr, sadly;
  • Usage is awkward compared to real structs;
  • Getting all the reference categories right is tricky, I probably missed something.

I would definitely NOT recommend using this in production, but it was kinda fun to see whether it was possible.

u/LHLaurini — 6 days ago