From 80bdc47998923cf3ce75bf4011a6c6298c3452ba Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 19 Jul 2026 18:11:08 +0100 Subject: [PATCH 01/27] Add tutorials for type-erasure, reflection and vanishing-this --- .clang-format | 7 +- .github/workflows/cmake.yml | 22 +- CMakeLists.txt | 53 +++ CONTRIBUTING.md | 14 + cmake/xyz_add_test.cmake | 2 +- docker/Dockerfile | 10 + scripts/docker-shell.sh | 31 ++ tutorials/README.md | 9 + tutorials/reflection.cc | 562 ++++++++++++++++++++++++++++ tutorials/type_erasure.cc | 216 +++++++++++ tutorials/vanishing_this_pointer.cc | 235 ++++++++++++ 11 files changed, 1156 insertions(+), 5 deletions(-) create mode 100755 scripts/docker-shell.sh create mode 100644 tutorials/README.md create mode 100644 tutorials/reflection.cc create mode 100644 tutorials/type_erasure.cc create mode 100644 tutorials/vanishing_this_pointer.cc diff --git a/.clang-format b/.clang-format index 1a7aa7a..b59b976 100644 --- a/.clang-format +++ b/.clang-format @@ -1,5 +1,7 @@ ---- -Language: Cpp +# No `Language: Cpp` key: clang-format's language guesser classifies +# protocol_reflection.h (C++26 `^^` reflection syntax) as Objective-C, and a +# Cpp-only configuration makes it error out on that file. A single +# language-agnostic document applies to every language clang-format detects. # BasedOnStyle: Google AccessModifierOffset: -1 AlignAfterOpenBracket: Align @@ -153,4 +155,3 @@ StatementMacros: - QT_REQUIRE_VERSION TabWidth: 8 UseTab: Never -... diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index bcad3fd..71ca867 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -211,6 +211,18 @@ jobs: std: 20, }, } + - { + name: "Ubuntu GCC-16 (C++26 Reflection)", + os: ubuntu-24.04, + compiler: + { + type: GCC16, + version: 16, + cc: "gcc-16", + cxx: "g++-16", + std: 26, + }, + } steps: - uses: actions/checkout@v4 - uses: seanmiddleditch/gha-setup-ninja@master @@ -231,9 +243,17 @@ jobs: with: version: ${{ matrix.settings.compiler.version }} platform: x64 + - name: Install GCC 16 from ubuntu-toolchain-r PPA + if: matrix.settings.compiler.type == 'GCC16' + run: | + sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + sudo apt-get update + sudo apt-get install -y --no-install-recommends g++-16 gcc-16 + echo "CC=gcc-16" >> "$GITHUB_ENV" + echo "CXX=g++-16" >> "$GITHUB_ENV" - name: Install uv uses: astral-sh/setup-uv@v5 - name: Set up Python run: uv sync - name: Run CMake - run: ./scripts/cmake.sh --${{ matrix.configuration == 'Debug' && 'debug' || 'release' }} + run: ./scripts/cmake.sh --${{ matrix.configuration == 'Debug' && 'debug' || 'release' }} ${{ matrix.settings.compiler.type == 'GCC16' && '-DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON' || '' }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 0626252..5a4e1b3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,34 @@ option(ENABLE_UBSAN "Enable Undefined Behaviour Sanitizer" OFF) option(ENABLE_TSAN "Enable Thread Sanitizer" OFF) option(ENABLE_MSAN "Enable Memory Sanitizer" OFF) +option( + XYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL + "Also build and test tutorials/reflection.cc, which requires a compiler \ +with C++26 P2996 reflection support (GCC 16+ with -freflection)." + OFF) + +if(XYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL) + include(CheckCXXSourceCompiles) + set(CMAKE_REQUIRED_FLAGS "-std=c++26 -freflection") + check_cxx_source_compiles( + " + #include + constexpr std::meta::info reflection_of_int = ^^int; + int main() {} + " + XYZ_PROTOCOL_REFLECTION_SUPPORTED) + unset(CMAKE_REQUIRED_FLAGS) + if(NOT XYZ_PROTOCOL_REFLECTION_SUPPORTED) + message( + FATAL_ERROR + "XYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL is ON but the compiler " + "(${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}) does not " + "accept '-std=c++26 -freflection'. C++26 reflection currently " + "requires GCC 16 or newer; configure with e.g. " + "CXX=g++-16 CC=gcc-16 and a separate build directory (-B).") + endif() +endif() + if(ENABLE_ASAN OR ENABLE_UBSAN OR ENABLE_TSAN OR ENABLE_MSAN) set(ENABLE_SANITIZERS ON) endif() @@ -148,6 +176,31 @@ if(XYZ_PROTOCOL_IS_NOT_SUBPROJECT) target_include_directories(protocol_test PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + # Standalone teaching tutorials (tutorials/) -- see tutorials/README.md. + # Each is self-contained, with no dependency on protocol.h. + xyz_add_test( + NAME + type_erasure_tutorial + FILES + tutorials/type_erasure.cc) + + xyz_add_test( + NAME + vanishing_this_pointer_tutorial + FILES + tutorials/vanishing_this_pointer.cc) + + if(XYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL) + xyz_add_test( + NAME + reflection_tutorial + VERSION + 26 + FILES + tutorials/reflection.cc) + target_compile_options(reflection_tutorial PRIVATE -freflection) + endif() + add_executable(protocol_benchmark protocol_benchmark.cc) target_link_libraries(protocol_benchmark PRIVATE protocol benchmark::benchmark) add_dependencies(protocol_benchmark generate_protocols) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e77735..6b0699e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,6 +144,20 @@ This library is an active proof of concept and is subject to change. - Development: Use this library for understanding its concepts and contributing to its development. Avoid using it in production code. +## Interactive Docker Shell + +To launch an interactive bash shell in the pre-configured Docker container (which includes GCC 16, Python, CMake, and `uv`) without running an AI agent, use: + +```bash +./scripts/docker-shell.sh [--rebuild-docker] +``` + +Inside the shell, GCC 16 is what lets you build and run the `tutorials/reflection.cc` tutorial, which needs C++26 reflection support: + +```bash +CXX=g++-16 CC=gcc-16 ./scripts/cmake.sh --release -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON -B build-reflection +``` + ## AI Coding Sandboxes The repository includes a Docker-based sandbox script for AI coding diff --git a/cmake/xyz_add_test.cmake b/cmake/xyz_add_test.cmake index 00cf265..ff9f4a6 100644 --- a/cmake/xyz_add_test.cmake +++ b/cmake/xyz_add_test.cmake @@ -50,7 +50,7 @@ function(xyz_add_test) if(NOT XYZ_VERSION) set(XYZ_VERSION 20) else() - set(VALID_TARGET_VERSIONS 11 14 17 20 23) + set(VALID_TARGET_VERSIONS 11 14 17 20 23 26) list(FIND VALID_TARGET_VERSIONS ${XYZ_VERSION} index) if(index EQUAL -1) message(FATAL_ERROR "TYPE must be one of <${VALID_TARGET_VERSIONS}>") diff --git a/docker/Dockerfile b/docker/Dockerfile index 4966bbb..44006e4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,6 +26,16 @@ RUN wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/nul && apt-get update && apt-get install -y --no-install-recommends cmake \ && rm -rf /var/lib/apt/lists/* +# Install GCC 16 (experimental C++26 reflection support via -std=c++26 +# -freflection, see https://gcc.gnu.org/gcc-16/changes.html) from the Ubuntu +# Toolchain Test PPA, since Ubuntu 24.04's own repos only go up to GCC 14. +# This is additive: the default g++ (13.3.0) installed above remains the +# toolchain used for the default build; g++-16/gcc-16 are opt-in, invoked +# explicitly (e.g. via CMAKE_CXX_COMPILER=g++-16) for the reflection backend. +RUN add-apt-repository -y ppa:ubuntu-toolchain-r/test \ +&& apt-get update && apt-get install -y --no-install-recommends g++-16 gcc-16 \ +&& rm -rf /var/lib/apt/lists/* + # Install bazelisk. RUN ARCH=$(dpkg --print-architecture) && \ wget https://github.com/bazelbuild/bazelisk/releases/download/v1.25.0/bazelisk-linux-${ARCH} -O /usr/local/bin/bazelisk \ diff --git a/scripts/docker-shell.sh b/scripts/docker-shell.sh new file mode 100755 index 0000000..3c73b81 --- /dev/null +++ b/scripts/docker-shell.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -eu -o pipefail + +# Find workspace root +WORKSPACE_ROOT=$(git rev-parse --show-toplevel) +IMAGE_NAME="cc-protocol-sandbox" + +REBUILD=0 +for argument in "$@"; do + if [ "$argument" == "--rebuild-docker" ]; then + REBUILD=1 + fi +done + +# Check if image exists or rebuild requested +if [ "$REBUILD" -eq 1 ] || ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then + echo "--- Building Docker Image: ${IMAGE_NAME} ---" + docker build -t "$IMAGE_NAME" -f "${WORKSPACE_ROOT}/docker/Dockerfile" "$WORKSPACE_ROOT" +fi + +echo "--- Starting Interactive Docker Shell ---" +echo "Project root ${WORKSPACE_ROOT} is mounted at /workspace" +echo "" +echo "To build and test with the C++26 reflection backend (GCC 16):" +echo " CXX=g++-16 CC=gcc-16 ./scripts/cmake.sh --release -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON -B build-reflection" +echo "" + +exec docker run -it --rm \ + -v "${WORKSPACE_ROOT}:/workspace" \ + -w /workspace \ + "$IMAGE_NAME" bash diff --git a/tutorials/README.md b/tutorials/README.md new file mode 100644 index 0000000..c0854f3 --- /dev/null +++ b/tutorials/README.md @@ -0,0 +1,9 @@ +# Tutorials + +Three standalone tutorials. Each is a single GoogleTest file whose sections build up one technique step by step. + +- [`type_erasure.cc`](type_erasure.cc) — manual type erasure from first principles: a vtable of function pointers, and an eraser object built from an erased pointer plus a vtable pointer. +- [`vanishing_this_pointer.cc`](vanishing_this_pointer.cc) — a layout/casting technique for giving a data member ordinary call syntax, by recovering the address of its owning object from `this`. +- [`reflection.cc`](reflection.cc) — C++26 reflection, from `std::meta::info` up to synthesizing a type's members at compile time. Requires a P2996 compiler (`scripts/cmake.sh -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON`). Uses the vtable technique from `type_erasure.cc` and the call-syntax technique from `vanishing_this_pointer.cc` to generate that machinery at compile time instead of by hand. + +Read `reflection.cc` last, since it builds on the other two; `type_erasure.cc` and `vanishing_this_pointer.cc` can be read in any order. diff --git a/tutorials/reflection.cc b/tutorials/reflection.cc new file mode 100644 index 0000000..9c41f7e --- /dev/null +++ b/tutorials/reflection.cc @@ -0,0 +1,562 @@ +/* Copyright (c) 2025 The XYZ Protocol Authors. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +==============================================================================*/ + +// C++26 reflection, from std::meta::info to synthesizing a type at compile +// time. +// +// Seven sections, read top to bottom, each building on the primitives the +// previous one introduced: +// 1. What std::meta::info is. +// 2. Splicing an info back into a type. +// 3. Splicing an info as a call target. +// 4. Enumerating a type's members. +// 5. Writing your own consteval helpers about a member. +// 6. Synthesizing a type's data members with define_aggregate. +// 7. Enum reflection: enum_to_string with template for. +// +// Each section is scoped to a named namespace, since later sections define +// types (Point, Widget) with the same name but a different shape than +// earlier sections use. Each section is deliberately minimal for what +// it's teaching, rather than reusing an earlier section's exact shape. + +#include + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// 1. What std::meta::info is. +// +// C++26 reflection (P2996) lets code ask questions about the program at +// compile time, such as what a type's members are or whether a function is +// const, and so on. The reflection operator ^^ takes something in the program +// (a type, a variable, a function, a member...) and lifts it into a +// std::meta::info value: an opaque, compile-time value describing that +// thing, distinct from the thing itself (^^int is a value you can pass +// around, compare, and query; it is not int). This section only +// establishes that fact; turning an info back into usable code +// ("splicing") is sections 2 and 3. +// --------------------------------------------------------------------------- +namespace section_1 { + +struct Point { + int x = 0; + int y = 0; +}; + +TEST(ReflectionMetaInfo, SameEntityEqualValues) { + constexpr std::meta::info reflection_of_int = ^^int; + + // ^^int, evaluated twice, describes the same entity both times. + static_assert(reflection_of_int == ^^int); + + // Different entities reflect to different, unequal info values. + static_assert(^^int != ^^double); + static_assert(^^Point != ^^int); + + // std::meta::info values are ordinary compile-time values: they can be + // stored in constexpr variables and compared with ==, exactly like any + // other type in this respect. +} + +// ^^ isn't limited to types. It can also reflect a namespace, a function, a +// specific member, and more. This tutorial mostly needs "reflect a type" and +// "reflect a member," both shown here just to establish that both work with +// the same ^^ operator; later sections do more with each. +[[maybe_unused]] int free_function() { return 1; } + +TEST(ReflectionMetaInfo, OtherEntityKinds) { + constexpr std::meta::info reflected_namespace = ^^std; + constexpr std::meta::info reflected_function = ^^free_function; + constexpr std::meta::info reflected_member = ^^Point::x; + + // Each ^^ result carries its own entity kind, queryable with a predicate + // like std::meta::is_namespace. What you can do with each (splice it as a + // type, call it, read its name) depends on this kind, covered starting in + // section 2. + static_assert(std::meta::is_namespace(reflected_namespace)); + static_assert(std::meta::is_function(reflected_function)); + static_assert(std::meta::is_nonstatic_data_member(reflected_member)); +} + +} // namespace section_1 + +// --------------------------------------------------------------------------- +// 2. Splicing an info back into a type. +// +// Section 1 lifted a type into an opaque std::meta::info with ^^. This +// section lowers it back: typename [:some_info:] names the type that info +// reflects, wherever a type is expected: a variable declaration, a +// template argument, anywhere. This round trip (^^ then [: :]) is called +// "splicing," and it's how reflection turns a compile-time value describing +// a type back into an ordinary, usable type. +// --------------------------------------------------------------------------- +namespace section_2 { + +struct Point { + int x = 0; + int y = 0; +}; + +TEST(ReflectionSpliceType, RoundTripsToOriginal) { + constexpr std::meta::info reflection_of_int = ^^int; + + // typename [:reflection_of_int:] names int again, so this line declares an + // ordinary int variable, just spelled through a splice. + typename[:reflection_of_int:] x = 5; + EXPECT_EQ(x, 5); + + // Splicing a struct's reflection produces the exact same type, not merely + // a look-alike: std::is_same_v confirms it. + static_assert(std::is_same_v); +} + +// A spliced type is usable everywhere an ordinary type name would be, +// including as a template argument. +template +consteval bool is_class_type() { + return std::is_class_v; +} + +TEST(ReflectionSpliceType, WorksAsTemplateArgument) { + // Storing each info before splicing it, rather than splicing ^^Point + // itself, stands in for the ordinary case: the info comes from somewhere + // else (a parameter, a search result, ...) and only gets spliced at its + // point of use. + constexpr std::meta::info point_info = ^^Point; + constexpr std::meta::info int_info = ^^int; + + static_assert(is_class_type()); + static_assert(!is_class_type()); +} + +} // namespace section_2 + +// --------------------------------------------------------------------------- +// 3. Splicing an info as a call target. +// +// find_member, below, searches a type's members at compile time and +// returns the one it finds, as a std::meta::info identifying that member. +// +// A splice can stand in for a member's name in ordinary member access: +// greeter.[:member_info:] accesses whichever member member_info reflects, +// exactly as writing that member's name after the dot would. Appending +// (args) calls it, if that member is a member function; used on its own, +// with no call, it reads or writes it, if that member is a data member. +// --------------------------------------------------------------------------- +namespace section_3 { + +struct Greeter { + std::string greet(std::string_view name) const { + return "Hello, " + std::string(name); + } + + int value = 0; +}; + +consteval std::meta::info find_member(std::meta::info type, + std::string_view name) { + for (std::meta::info member : + std::meta::members_of(type, std::meta::access_context::current())) { + if (std::meta::has_identifier(member) && + std::meta::identifier_of(member) == name) { + return member; + } + } + return std::meta::info{}; +} + +TEST(ReflectionSpliceCall, MemberFunctionAsTarget) { + Greeter greeter; + constexpr std::meta::info greet_member = find_member(^^Greeter, "greet"); + + // greeter.[:greet_member:](...) calls greet(), found by reflection, + // not by writing its name at the call site. + EXPECT_EQ(greeter.[:greet_member:]("Reader"), "Hello, Reader"); + + // Calling it the ordinary way gives the identical result, confirming the + // spliced call is the same call. + EXPECT_EQ(greeter.[:greet_member:]("Reader"), greeter.greet("Reader")); +} + +TEST(ReflectionSpliceCall, DataMemberAccess) { + Greeter greeter; + constexpr std::meta::info value_member = find_member(^^Greeter, "value"); + + greeter.[:value_member:] = 42; + EXPECT_EQ(greeter.value, 42); + EXPECT_EQ(greeter.[:value_member:], 42); +} + +} // namespace section_3 + +// --------------------------------------------------------------------------- +// 4. Enumerating a type's members. +// +// std::meta::members_of and std::meta::nonstatic_data_members_of return +// every member of a type as a std::vector, computed inside +// a consteval function. That vector only exists during constant evaluation, +// so std::define_static_array copies it into static storage as an ordinary +// constexpr array, usable anywhere, not just inside the consteval function +// that computed it. The enumeration function below (data_members_of) does +// the walk; the constexpr variables below it (point_data_members, +// widget_member_functions) are where that copy happens. +// --------------------------------------------------------------------------- +namespace section_4 { + +struct Point { + int x = 0; + int y = 0; + int z = 0; +}; + +consteval std::vector data_members_of(std::meta::info type) { + std::vector result; + for (std::meta::info member : std::meta::nonstatic_data_members_of( + type, std::meta::access_context::current())) { + result.push_back(member); + } + return result; +} + +// define_static_array is what makes the enumerated members usable as a +// constexpr array outside the consteval function that found them. +constexpr auto point_data_members = + std::define_static_array(data_members_of(^^Point)); + +TEST(ReflectionEnumerate, MembersInDeclarationOrder) { + static_assert(point_data_members.size() == 3); + static_assert(std::meta::identifier_of(point_data_members[0]) == "x"); + static_assert(std::meta::identifier_of(point_data_members[1]) == "y"); + static_assert(std::meta::identifier_of(point_data_members[2]) == "z"); +} + +struct Widget { + int value() const { return 1; } + + void set_value(int) {} + + int payload = 0; +}; + +consteval std::vector member_functions_of( + std::meta::info type) { + std::vector result; + for (std::meta::info member : + std::meta::members_of(type, std::meta::access_context::current())) { + // is_function alone would also match Widget's implicit special members + // (default constructor, destructor, ...); is_special_member_function + // excludes those, leaving just the two ordinary methods below. + if (std::meta::is_function(member) && + !std::meta::is_special_member_function(member)) { + result.push_back(member); + } + } + return result; +} + +constexpr auto widget_member_functions = + std::define_static_array(member_functions_of(^^Widget)); + +TEST(ReflectionEnumerate, FilteredToFunctionsOnly) { + // payload (a data member) is excluded; value() and set_value(int) (member + // functions) are the only two results. + static_assert(widget_member_functions.size() == 2); + static_assert(std::meta::identifier_of(widget_member_functions[0]) == + "value"); + static_assert(std::meta::identifier_of(widget_member_functions[1]) == + "set_value"); +} + +} // namespace section_4 + +// --------------------------------------------------------------------------- +// 5. Writing your own consteval helpers about a member. +// +// The standard library's reflection queries (std::meta::is_const, +// std::meta::parameters_of, std::meta::return_type_of, ...) are small, +// single-fact primitives. Real code usually builds its own consteval +// helpers on top of them to answer richer questions. This section writes +// two such helpers: one classifying constness, and one building a +// signature string that distinguishes a member from other overloads of the +// same name. +// --------------------------------------------------------------------------- +namespace section_5 { + +struct Widget { + int read() const { return 1; } + + void write(int) {} + + void write(double) {} +}; + +// Unlike section 4's member_functions_of, this doesn't also filter out +// special member functions. The name check below already excludes them, +// since none of Widget's constructors, destructor, or assignment operators +// is named "read" or "write". +consteval std::vector members_named(std::meta::info type, + std::string_view name) { + std::vector result; + for (std::meta::info member : + std::meta::members_of(type, std::meta::access_context::current())) { + if (std::meta::is_function(member) && std::meta::has_identifier(member) && + std::meta::identifier_of(member) == name) { + result.push_back(member); + } + } + return result; +} + +TEST(ReflectionHelpers, ClassifiesConstCorrectly) { + constexpr auto read_candidates = + std::define_static_array(members_named(^^Widget, "read")); + constexpr auto write_candidates = + std::define_static_array(members_named(^^Widget, "write")); + + static_assert(read_candidates.size() == 1); + static_assert(std::meta::is_const(read_candidates[0])); + + static_assert(write_candidates.size() == 2); + static_assert(!std::meta::is_const(write_candidates[0])); + static_assert(!std::meta::is_const(write_candidates[1])); +} + +// A simplified signature-string builder: name plus each parameter type's +// display string, joined together, turning a member's signature into a +// distinguishable string. A real version would go on to escape that string +// into a valid C++ identifier, useful anywhere generated code needs a +// unique name per overload. +// +// A parameter's type isn't a named declaration, so std::meta::identifier_of +// (which reads a declaration's own name) doesn't apply to it. +// std::meta::display_string_of is the primitive for turning a type into a +// readable string. +consteval std::string simple_signature_string(std::meta::info member) { + std::string result(std::meta::identifier_of(member)); + result += "("; + bool first = true; + for (std::meta::info parameter : std::meta::parameters_of(member)) { + if (!first) result += ","; + first = false; + result += std::meta::display_string_of( + std::meta::dealias(std::meta::type_of(parameter))); + } + result += ")"; + return result; +} + +TEST(ReflectionHelpers, SignatureDistinguishesOverloads) { + constexpr auto write_candidates = + std::define_static_array(members_named(^^Widget, "write")); + + // define_static_array returns a span sized to exactly the string's own + // length, with no guarantee of a trailing null byte, so wrap it in a + // string_view (which carries its own length) rather than treating .data() + // as a C-string, so comparisons never read past the span's actual bounds. + constexpr auto int_overload_signature = + std::define_static_array(simple_signature_string(write_candidates[0])); + constexpr auto double_overload_signature = + std::define_static_array(simple_signature_string(write_candidates[1])); + std::string_view int_signature(int_overload_signature.data(), + int_overload_signature.size()); + std::string_view double_signature(double_overload_signature.data(), + double_overload_signature.size()); + + EXPECT_EQ(int_signature, "write(int)"); + EXPECT_EQ(double_signature, "write(double)"); + + // Different overloads of the same name produce different strings, which + // is what makes a string like this usable as part of a unique, generated + // identifier. + EXPECT_NE(int_signature, double_signature); +} + +} // namespace section_5 + +// --------------------------------------------------------------------------- +// 6. Synthesizing a type's data members with define_aggregate. +// +// Every section so far has queried an existing type; this one builds one: +// std::meta::define_aggregate takes a type that's only been forward-declared +// so far (no members, incomplete) plus a list of std::meta::data_member_spec +// values, and gives that type exactly those data members, decided at +// compile time, not written out as a struct definition in source. This is +// useful anywhere generated code needs a struct whose members (their count, +// types, and names) aren't known until compile time. A vtable of +// function pointers, one per member some other type happens to declare, is +// a typical example. +// +// define_aggregate is called from inside a `consteval { ... }` block: a +// statement, not a function, that always runs during translation regardless +// of where it appears, even directly inside an ordinary, non-constexpr +// function body, as the first test below does. +// +// The second test below combines this with the vanishing-this-pointer +// technique (tutorials/vanishing_this_pointer.cc): one of the synthesized data +// members has a type with operator(), so the synthesized type gets +// ordinary a.method_name() call syntax, generated from data rather than +// hand-written. +// --------------------------------------------------------------------------- +namespace section_6 { + +TEST(ReflectionSynthesize, SynthesizedMembersEnumerable) { + struct Incomplete; + consteval { + std::vector specs; + specs.push_back(std::meta::data_member_spec(^^int, { + .name = "count"})); + specs.push_back( + std::meta::data_member_spec(^^double, { + .name = "ratio"})); + std::meta::define_aggregate(^^Incomplete, specs); + } + + // The consteval block above already ran during translation, so by the + // time this ordinary, runtime TEST body executes, Incomplete already has + // its two members. Enumerating it works exactly like enumerating an + // ordinary, hand-written type (section 4). define_aggregate produces an + // ordinary type, usable the same way as a hand-written one. + constexpr auto members = + std::define_static_array(std::meta::nonstatic_data_members_of( + ^^Incomplete, std::meta::access_context::current())); + static_assert(members.size() == 2); + static_assert(std::meta::identifier_of(members[0]) == "count"); + static_assert(std::meta::identifier_of(members[1]) == "ratio"); +} + +// Unlike the test above, everything here, defining Incomplete's members and +// constructing and reading back an instance, is forced into a single +// constant expression, by wrapping it all in a lambda invoked directly +// inside static_assert. This confirms a synthesized type isn't only usable +// at runtime: aggregate-initializing it and reading its members both work +// in a constant expression too. +TEST(ReflectionSynthesize, MembersFromSpecList) { + static_assert([] { + struct Incomplete; + consteval { + std::vector specs; + specs.push_back(std::meta::data_member_spec(^^int, { + .name = "count"})); + specs.push_back( + std::meta::data_member_spec(^^double, { + .name = "ratio"})); + std::meta::define_aggregate(^^Incomplete, specs); + } + Incomplete instance{.count = 3, .ratio = 1.5}; + return instance.count == 3 && instance.ratio == 1.5; + }()); +} + +// A hand-written wrapper, using the same offset-zero technique taught +// standalone in tutorials/vanishing_this_pointer.cc. Reflection's job below +// is only to decide that a member of this type exists on SynthesizedGreeter +// and what it's named; recovering the owner is this wrapper's own job, +// exactly as in that tutorial. GreetWrapper declares no members and no +// constructors, so it stays default-constructible. {} below +// value-initializes it, the same way {} would for any empty type. +struct GreetWrapper { + std::string operator()(std::string_view visitor) const; +}; + +struct SynthesizedGreeter; +consteval { + std::vector specs; + specs.push_back( + std::meta::data_member_spec(^^GreetWrapper, { + .name = "greet"})); + specs.push_back( + std::meta::data_member_spec(^^std::string, { + .name = "name"})); + std::meta::define_aggregate(^^SynthesizedGreeter, specs); +} + +std::string GreetWrapper::operator()(std::string_view visitor) const { + const auto* owner = + static_cast(static_cast(this)); + return "Hello, " + std::string(visitor) + ", from " + owner->name; +} + +TEST(ReflectionSynthesize, SynthesizedMemberIsCallable) { + SynthesizedGreeter greeter{.greet = {}, .name = "Synthesized"}; + + // greeter.greet(...) reads as an ordinary call, exactly like + // owner.greet(...) does in tutorials/vanishing_this_pointer.cc, except + // that "greet" and SynthesizedGreeter's whole shape were decided by + // define_aggregate at compile time, not written by hand. + EXPECT_EQ(greeter.greet("Reader"), "Hello, Reader, from Synthesized"); +} + +} // namespace section_6 + +// --------------------------------------------------------------------------- +// 7. Enum reflection: enum_to_string with template for. +// +// std::meta::enumerators_of(type) returns every enumerator of an enum +// type, in declaration order. This is the same shape of query section 4 +// used for a struct's data members, applied to an enum instead. Splicing an +// enumerator's info ([:e:]) gives back the enum value itself, so a +// to_string function can compare a runtime value against it directly. +// +// `template for` is a compile-time loop: for (constexpr auto e : range) +// unrolls into ordinary code, once per element, entirely during +// compilation. This is unlike every for loop earlier in this file, which either +// ran inside a consteval function (computing a vector for +// define_static_array to later hand out) or walked an already-computed +// span at runtime. Here the loop body itself becomes runtime code once per +// enumerator. +// +// enum_to_string below is P2996's own example (Reflection for C++26, +// "Enum to String"), with one adaptation: enumerators_of returns a +// std::vector, and only a copy in static storage is +// usable as template for's range. This is the same reason section 4 needed +// define_static_array. +// --------------------------------------------------------------------------- +namespace section_7 { + +enum class Color { Red, Green, Blue }; + +template + requires std::is_enum_v +std::string enum_to_string(E value) { + template for (constexpr std::meta::info e : + std::define_static_array(std::meta::enumerators_of(^^E))) { + if (value == [:e:]) return std::string(std::meta::identifier_of(e)); + } + return ""; +} + +TEST(ReflectionEnumToString, NamesEachEnumerator) { + EXPECT_EQ(enum_to_string(Color::Red), "Red"); + EXPECT_EQ(enum_to_string(Color::Green), "Green"); + EXPECT_EQ(enum_to_string(Color::Blue), "Blue"); +} + +TEST(ReflectionEnumToString, UnmatchedValueFallsThrough) { + // A value with no matching enumerator falls through every generated + // comparison to the fallback return, reached here via an explicit + // cast, since Color itself declares no such value. + EXPECT_EQ(enum_to_string(static_cast(99)), ""); +} + +} // namespace section_7 diff --git a/tutorials/type_erasure.cc b/tutorials/type_erasure.cc new file mode 100644 index 0000000..c9709aa --- /dev/null +++ b/tutorials/type_erasure.cc @@ -0,0 +1,216 @@ +/* Copyright (c) 2025 The XYZ Protocol Authors. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +==============================================================================*/ + +// Type erasure, from first principles. +// +// Three sections, read top to bottom, each building on the last: +// 1. The problem type erasure solves. +// 2. A vtable of function pointers. +// 3. The eraser object. +// +// Each section defines its own small Circle/Square pair, scoped to a named +// namespace: two trivial, unrelated types that just happen to share a +// draw() method with the same signature. + +#include + +#include +#include +#include + +// --------------------------------------------------------------------------- +// 1. The problem. +// +// A function template can call draw() polymorphically: it works for any T +// with a draw() method. Each instantiation, though, is a distinct function. +// render and render are two different functions with two +// different addresses; there is no single type you could use to store +// "either a Circle or a Square, decided at runtime" and call draw() on it. +// The rest of this file builds one: type erasure. +// --------------------------------------------------------------------------- +namespace section_1 { + +class Circle { + public: + std::string draw() const { return "○"; } +}; + +class Square { + public: + std::string draw() const { return "□"; } +}; + +template +std::string render(const Shape& shape) { + return shape.draw(); +} + +TEST(TypeErasureProblem, TemplatesArentUniform) { + Circle circle; + Square square; + EXPECT_EQ(render(circle), "○"); + EXPECT_EQ(render(square), "□"); + + // render and render are different types once + // instantiated, so no single function pointer type could point at both. + using RenderCircle = std::string (*)(const Circle&); + using RenderSquare = std::string (*)(const Square&); + static_assert(!std::is_same_v); +} + +} // namespace section_1 + +// --------------------------------------------------------------------------- +// 2. A vtable of function pointers. +// +// Circle and Square each get called through a small static object, a +// "vtable", built from exactly the same struct type. The two casts below +// (erasing to const void* at the call site, un-erasing back inside the +// function) are the cast-and-call pattern type erasure relies on. +// --------------------------------------------------------------------------- +namespace section_2 { + +class Circle { + public: + std::string draw() const { return "○"; } +}; + +class Square { + public: + std::string draw() const { return "□"; } +}; + +// One function pointer, one method. A real interface with several methods +// would just repeat this shape once per method it needs to expose. +struct draw_vtable { + std::string (*draw)(const void* erased); +}; + +// One static instance per concrete type. Each is initialized with a +// function that does the two casts: the incoming pointer arrives already +// erased to const void* (the caller did that), and this function un-erases +// it back to the one concrete type it actually knows how to handle, then +// calls draw() on it normally. +template +inline constexpr draw_vtable vtable_for = { + .draw = [](const void* erased) -> std::string { + return static_cast(erased)->draw(); + }}; + +TEST(TypeErasureVtable, SameVtableDifferentTypes) { + Circle circle; + Square square; + + // Two different concrete objects are called through the same vtable + // type (draw_vtable) via the same cast-and-call pattern; only the + // static instance and the erased pointer differ. + EXPECT_EQ(vtable_for.draw(&circle), "○"); + EXPECT_EQ(vtable_for.draw(&square), "□"); + + // Both static vtable instances share one C++ type, unlike the two + // distinct render instantiations from section 1. + static_assert(std::is_same_v), + decltype(vtable_for)>); +} + +} // namespace section_2 + +// --------------------------------------------------------------------------- +// 3. The eraser object. +// +// Package section 2's erased pointer and vtable pointer into one small +// class, with a constructor template that captures whatever concrete type +// it's given. Circle and Square can both be stored behind the result, and both +// go in one std::vector. This is the minimal, one-method analogue of +// xyz::protocol_view: a non-owning, type-erased handle with forwarding +// methods that dispatch through the vtable. +// --------------------------------------------------------------------------- +namespace section_3 { + +class Circle { + public: + std::string draw() const { return "○"; } +}; + +class Square { + public: + std::string draw() const { return "□"; } +}; + +struct draw_vtable { + std::string (*draw)(const void* erased); +}; + +template +inline constexpr draw_vtable vtable_for = { + .draw = [](const void* erased) -> std::string { + return static_cast(erased)->draw(); + }}; + +// erased_shape only stores a pointer to a Shape it doesn't own. The +// concrete object (the Circle or Square passed to the constructor) must +// outlive every erased_shape that refers to it. +class erased_shape { + public: + template + explicit erased_shape(const Shape& shape) + : erased_(&shape), vtable_(&vtable_for) {} + + std::string draw() const { return vtable_->draw(erased_); } + + private: + const void* erased_; + const draw_vtable* vtable_; +}; + +TEST(TypeErasureEraser, UnifiesUnrelatedTypes) { + Circle circle; + Square square; + + erased_shape erased_circle(circle); + erased_shape erased_square(square); + + EXPECT_EQ(erased_circle.draw(), "○"); + EXPECT_EQ(erased_square.draw(), "□"); + + // Both are the same C++ type, unlike Circle and Square themselves. + static_assert( + std::is_same_v); +} + +TEST(TypeErasureEraser, BothTypesInOneVector) { + Circle circle; + Square square; + + // One vector holds both a Circle and a Square, dispatched uniformly; + // neither class was touched or related to the other. + std::vector shapes; + shapes.emplace_back(circle); + shapes.emplace_back(square); + + std::vector drawn; + for (const erased_shape& shape : shapes) { + drawn.push_back(shape.draw()); + } + + EXPECT_EQ(drawn, (std::vector{"○", "□"})); +} + +} // namespace section_3 diff --git a/tutorials/vanishing_this_pointer.cc b/tutorials/vanishing_this_pointer.cc new file mode 100644 index 0000000..dc38138 --- /dev/null +++ b/tutorials/vanishing_this_pointer.cc @@ -0,0 +1,235 @@ +/* Copyright (c) 2025 The XYZ Protocol Authors. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +==============================================================================*/ + +// The vanishing `this` pointer. +// +// This technique is adopted from https://ryanjk5.github.io/posts/rjk-duck/. +// +// A technique for giving a data member ordinary call syntax, built up in +// four sections, read top to bottom: +// 1. The offset-zero layout guarantee. +// 2. Recovering the owner pointer. +// 3. Ordinary call syntax from a data member. +// 4. What breaks when offset zero is violated, and the guard against it. +// +// Each section defines its own small Owner/Wrapper pair, scoped to a named +// namespace, since each section's version is deliberately minimal for what +// it's teaching rather than reusing the previous section's exact shape. + +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// 1. The offset-zero layout guarantee. +// +// A class's first non-static data member starts at the same address as the +// object that contains it. This is called offset zero. +// --------------------------------------------------------------------------- +namespace section_1 { + +struct Wrapper { + int tag = 0; +}; + +struct Owner { + Wrapper wrapper; // first data member + int other_data = 0; +}; + +TEST(ThisPointerOffsetZero, FirstMemberSharesAddress) { + Owner owner; + + EXPECT_EQ(static_cast(&owner), static_cast(&owner.wrapper)); + EXPECT_EQ(offsetof(Owner, wrapper), 0u); + + // other_data is not first, so it does not share the owner's address. + EXPECT_NE(static_cast(&owner), static_cast(&owner.other_data)); + EXPECT_NE(offsetof(Owner, other_data), 0u); +} + +// An empty Wrapper, stored as a data member rather than inherited from, +// still costs no extra storage: [[no_unique_address]] is the data-member +// counterpart of the empty base optimization, and lets an empty member +// overlap entirely with the owner's own storage. +struct EmptyWrapper {}; + +struct OwnerWithEmptyWrapper { + [[no_unique_address]] EmptyWrapper wrapper; + int payload = 0; +}; + +TEST(ThisPointerOffsetZero, EmptyWrapperAddsNoStorage) { + EXPECT_EQ(sizeof(OwnerWithEmptyWrapper), sizeof(int)); + EXPECT_EQ(offsetof(OwnerWithEmptyWrapper, wrapper), 0u); +} + +} // namespace section_1 + +// --------------------------------------------------------------------------- +// 2. Recovering the owner pointer, "the vanishing this pointer" itself. +// +// Because a Wrapper sitting at offset zero of an Owner shares the Owner's +// address (section 1), a method defined on Wrapper can compute a pointer to +// its owner just by casting its own `this`, without Wrapper storing a +// back-pointer anywhere. +// --------------------------------------------------------------------------- +namespace section_2 { + +struct Wrapper { + int recover_owner_value() const; +}; + +struct Owner { + Wrapper wrapper; + int value = 0; +}; + +int Wrapper::recover_owner_value() const { + const auto* owner = static_cast(static_cast(this)); + return owner->value; +} + +TEST(ThisPointerRecoverOwner, ReachesOwnersData) { + Owner owner; + owner.value = 42; + + EXPECT_EQ(owner.wrapper.recover_owner_value(), 42); +} + +TEST(ThisPointerRecoverOwner, NoExtraStorage) { + // sizeof(Wrapper) == 1 (the minimum for any object, empty or not), not 8 + // (a pointer's worth): confirms no back-pointer is stored. + EXPECT_EQ(sizeof(Wrapper), 1u); +} + +} // namespace section_2 + +// --------------------------------------------------------------------------- +// 3. Ordinary call syntax from a data member. +// +// Give Wrapper an operator() instead of an oddly-named method, make it a +// data member of Owner instead of a method, and owner.member_name(...) +// becomes an ordinary function call, even though member_name is a value of +// class type rather than a function. Combined with section 2's owner +// recovery, that call can reach and mutate the real owner. Section 6 of +// tutorials/reflection.cc combines this same technique with C++26 reflection to +// synthesize the wrapper's type and name at compile time, rather than +// writing them by hand as this file does. +// --------------------------------------------------------------------------- +namespace section_3 { + +struct GreetWrapper { + std::string operator()(std::string_view visitor) const; // defined below +}; + +struct Owner { + GreetWrapper greet; + std::string name = "World"; +}; + +std::string GreetWrapper::operator()(std::string_view visitor) const { + const auto* owner = static_cast(static_cast(this)); + return "Hello, " + std::string(visitor) + ", from " + owner->name + "!"; +} + +TEST(ThisPointerCallSyntax, BehavesAsOrdinaryCall) { + Owner owner; + owner.name = "Owner"; + + EXPECT_EQ(owner.greet("Reader"), "Hello, Reader, from Owner!"); +} + +TEST(ThisPointerCallSyntax, IsADataMemberNotMethod) { + Owner owner; + + // owner.greet() calls that member's operator(). + static_assert(std::is_class_v); + static_assert(!std::is_function_v); +} + +} // namespace section_3 + +// --------------------------------------------------------------------------- +// 4. What breaks when offset zero is violated, and the guard against it. +// +// Everything in sections 2-3 depends on Wrapper being Owner's first data +// member. This section shows what happens if it isn't: the address a +// Wrapper method would recover as "the owner" no longer matches the real +// owner's address, and is off by exactly sizeof(preceding_member) -- enough +// to land on preceding_member's storage instead of Owner's real first +// member. Every check below computes and compares addresses only; none +// dereferences the miscomputed pointer, since actually reading through it +// would be undefined behavior. +// --------------------------------------------------------------------------- +namespace section_4 { + +struct Wrapper { + int tag = 0; +}; + +// BadOwner puts another member before wrapper, so wrapper is no longer at +// offset zero. +struct BadOwner { + int preceding_member = 0; + Wrapper wrapper; +}; + +TEST(ThisPointerOffsetViolated, AddressNoLongerMatches) { + BadOwner owner; + + EXPECT_NE(static_cast(&owner), static_cast(&owner.wrapper)); + EXPECT_NE(offsetof(BadOwner, wrapper), 0u); +} + +TEST(ThisPointerOffsetViolated, MisrecoveredAddressLandsOnPrecedingMember) { + BadOwner owner; + + // wrong_owner is the address a Wrapper method would recover as "the + // owner." Only the distance to real_owner is computed; that address is + // never dereferenced. + const auto* wrong_owner = + static_cast(static_cast(&owner.wrapper)); + const auto* real_owner = + static_cast(static_cast(&owner)); + EXPECT_EQ(wrong_owner - real_owner, + static_cast(sizeof(owner.preceding_member))); +} + +// GoodOwner asserts its layout precondition instead of assuming it: a +// static_assert here fails at compile time if a future change moves wrapper +// away from being GoodOwner's first member. +struct GoodOwner { + Wrapper wrapper; + int payload = 0; +}; + +static_assert(offsetof(GoodOwner, wrapper) == 0, + "wrapper must be GoodOwner's first data member for the " + "vanishing-this-pointer cast to be valid"); + +TEST(ThisPointerOffsetViolated, GuardConfirmsGoodCase) { + EXPECT_EQ(offsetof(GoodOwner, wrapper), 0u); +} + +} // namespace section_4 From 6dd069a1adbc2e2ae71c45e3260e9e12960794df Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Wed, 22 Jul 2026 23:17:49 +0100 Subject: [PATCH 02/27] Simplify code --- tutorials/reflection.cc | 119 ++++++++-------------------- tutorials/vanishing_this_pointer.cc | 11 +-- 2 files changed, 37 insertions(+), 93 deletions(-) diff --git a/tutorials/reflection.cc b/tutorials/reflection.cc index 9c41f7e..68a6c52 100644 --- a/tutorials/reflection.cc +++ b/tutorials/reflection.cc @@ -83,7 +83,7 @@ TEST(ReflectionMetaInfo, SameEntityEqualValues) { // specific member, and more. This tutorial mostly needs "reflect a type" and // "reflect a member," both shown here just to establish that both work with // the same ^^ operator; later sections do more with each. -[[maybe_unused]] int free_function() { return 1; } +int free_function() { return 1; } TEST(ReflectionMetaInfo, OtherEntityKinds) { constexpr std::meta::info reflected_namespace = ^^std; @@ -107,9 +107,13 @@ TEST(ReflectionMetaInfo, OtherEntityKinds) { // Section 1 lifted a type into an opaque std::meta::info with ^^. This // section lowers it back: typename [:some_info:] names the type that info // reflects, wherever a type is expected: a variable declaration, a -// template argument, anywhere. This round trip (^^ then [: :]) is called -// "splicing," and it's how reflection turns a compile-time value describing -// a type back into an ordinary, usable type. +// template argument, anywhere. typename is required because [:some_info:] +// alone is an expression (the form section 3 uses to splice a value); +// nothing about info's own type says whether it reflects a type or a +// value, so the parser needs typename to pick the type-naming parse. This +// round trip (^^ then [: :]) is called "splicing," and it's how reflection +// turns a compile-time value describing a type back into an ordinary, +// usable type. // --------------------------------------------------------------------------- namespace section_2 { @@ -193,9 +197,6 @@ TEST(ReflectionSpliceCall, MemberFunctionAsTarget) { // greeter.[:greet_member:](...) calls greet(), found by reflection, // not by writing its name at the call site. EXPECT_EQ(greeter.[:greet_member:]("Reader"), "Hello, Reader"); - - // Calling it the ordinary way gives the identical result, confirming the - // spliced call is the same call. EXPECT_EQ(greeter.[:greet_member:]("Reader"), greeter.greet("Reader")); } @@ -295,8 +296,8 @@ TEST(ReflectionEnumerate, FilteredToFunctionsOnly) { // // The standard library's reflection queries (std::meta::is_const, // std::meta::parameters_of, std::meta::return_type_of, ...) are small, -// single-fact primitives. Real code usually builds its own consteval -// helpers on top of them to answer richer questions. This section writes +// single-fact primitives. Answering richer questions means building +// consteval helpers on top of them. This section writes // two such helpers: one classifying constness, and one building a // signature string that distinguishes a member from other overloads of the // same name. @@ -403,34 +404,31 @@ TEST(ReflectionHelpers, SignatureDistinguishesOverloads) { // values, and gives that type exactly those data members, decided at // compile time, not written out as a struct definition in source. This is // useful anywhere generated code needs a struct whose members (their count, -// types, and names) aren't known until compile time. A vtable of -// function pointers, one per member some other type happens to declare, is -// a typical example. +// types, and names) aren't known until compile time, such as a vtable of +// function pointers with one entry per member some other type happens to +// declare. // // define_aggregate is called from inside a `consteval { ... }` block: a // statement, not a function, that always runs during translation regardless // of where it appears, even directly inside an ordinary, non-constexpr // function body, as the first test below does. -// -// The second test below combines this with the vanishing-this-pointer -// technique (tutorials/vanishing_this_pointer.cc): one of the synthesized data -// members has a type with operator(), so the synthesized type gets -// ordinary a.method_name() call syntax, generated from data rather than -// hand-written. // --------------------------------------------------------------------------- namespace section_6 { +// Shared by both tests below, so each only has to spell out the one line +// that actually differs: the consteval block calling define_aggregate. +consteval std::vector count_ratio_specs() { + std::vector specs; + specs.push_back(std::meta::data_member_spec(^^int, { + .name = "count"})); + specs.push_back(std::meta::data_member_spec(^^double, { + .name = "ratio"})); + return specs; +} + TEST(ReflectionSynthesize, SynthesizedMembersEnumerable) { struct Incomplete; - consteval { - std::vector specs; - specs.push_back(std::meta::data_member_spec(^^int, { - .name = "count"})); - specs.push_back( - std::meta::data_member_spec(^^double, { - .name = "ratio"})); - std::meta::define_aggregate(^^Incomplete, specs); - } + consteval { std::meta::define_aggregate(^^Incomplete, count_ratio_specs()); } // The consteval block above already ran during translation, so by the // time this ordinary, runtime TEST body executes, Incomplete already has @@ -445,66 +443,15 @@ TEST(ReflectionSynthesize, SynthesizedMembersEnumerable) { static_assert(std::meta::identifier_of(members[1]) == "ratio"); } -// Unlike the test above, everything here, defining Incomplete's members and -// constructing and reading back an instance, is forced into a single -// constant expression, by wrapping it all in a lambda invoked directly -// inside static_assert. This confirms a synthesized type isn't only usable -// at runtime: aggregate-initializing it and reading its members both work -// in a constant expression too. +// Unlike the test above, this one constructs an instance of the synthesized +// type and reads its members back, not just enumerates them. constexpr on +// instance forces that construction and read to happen in a constant +// expression, confirming a synthesized type isn't only usable at runtime. TEST(ReflectionSynthesize, MembersFromSpecList) { - static_assert([] { - struct Incomplete; - consteval { - std::vector specs; - specs.push_back(std::meta::data_member_spec(^^int, { - .name = "count"})); - specs.push_back( - std::meta::data_member_spec(^^double, { - .name = "ratio"})); - std::meta::define_aggregate(^^Incomplete, specs); - } - Incomplete instance{.count = 3, .ratio = 1.5}; - return instance.count == 3 && instance.ratio == 1.5; - }()); -} - -// A hand-written wrapper, using the same offset-zero technique taught -// standalone in tutorials/vanishing_this_pointer.cc. Reflection's job below -// is only to decide that a member of this type exists on SynthesizedGreeter -// and what it's named; recovering the owner is this wrapper's own job, -// exactly as in that tutorial. GreetWrapper declares no members and no -// constructors, so it stays default-constructible. {} below -// value-initializes it, the same way {} would for any empty type. -struct GreetWrapper { - std::string operator()(std::string_view visitor) const; -}; - -struct SynthesizedGreeter; -consteval { - std::vector specs; - specs.push_back( - std::meta::data_member_spec(^^GreetWrapper, { - .name = "greet"})); - specs.push_back( - std::meta::data_member_spec(^^std::string, { - .name = "name"})); - std::meta::define_aggregate(^^SynthesizedGreeter, specs); -} - -std::string GreetWrapper::operator()(std::string_view visitor) const { - const auto* owner = - static_cast(static_cast(this)); - return "Hello, " + std::string(visitor) + ", from " + owner->name; -} - -TEST(ReflectionSynthesize, SynthesizedMemberIsCallable) { - SynthesizedGreeter greeter{.greet = {}, .name = "Synthesized"}; - - // greeter.greet(...) reads as an ordinary call, exactly like - // owner.greet(...) does in tutorials/vanishing_this_pointer.cc, except - // that "greet" and SynthesizedGreeter's whole shape were decided by - // define_aggregate at compile time, not written by hand. - EXPECT_EQ(greeter.greet("Reader"), "Hello, Reader, from Synthesized"); + struct Incomplete; + consteval { std::meta::define_aggregate(^^Incomplete, count_ratio_specs()); } + constexpr Incomplete instance{.count = 3, .ratio = 1.5}; + static_assert(instance.count == 3 && instance.ratio == 1.5); } } // namespace section_6 diff --git a/tutorials/vanishing_this_pointer.cc b/tutorials/vanishing_this_pointer.cc index dc38138..191955d 100644 --- a/tutorials/vanishing_this_pointer.cc +++ b/tutorials/vanishing_this_pointer.cc @@ -132,10 +132,7 @@ TEST(ThisPointerRecoverOwner, NoExtraStorage) { // data member of Owner instead of a method, and owner.member_name(...) // becomes an ordinary function call, even though member_name is a value of // class type rather than a function. Combined with section 2's owner -// recovery, that call can reach and mutate the real owner. Section 6 of -// tutorials/reflection.cc combines this same technique with C++26 reflection to -// synthesize the wrapper's type and name at compile time, rather than -// writing them by hand as this file does. +// recovery, that call can reach and mutate the real owner. // --------------------------------------------------------------------------- namespace section_3 { @@ -176,9 +173,9 @@ TEST(ThisPointerCallSyntax, IsADataMemberNotMethod) { // Everything in sections 2-3 depends on Wrapper being Owner's first data // member. This section shows what happens if it isn't: the address a // Wrapper method would recover as "the owner" no longer matches the real -// owner's address, and is off by exactly sizeof(preceding_member) -- enough -// to land on preceding_member's storage instead of Owner's real first -// member. Every check below computes and compares addresses only; none +// owner's address, off by exactly sizeof(preceding_member): enough to land +// on preceding_member's storage instead of Owner's real first member. +// Every check below computes and compares addresses only; none // dereferences the miscomputed pointer, since actually reading through it // would be undefined behavior. // --------------------------------------------------------------------------- From 0cfcda44eb4ba645c363a7161ee9d9d6b8bf3c85 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Thu, 23 Jul 2026 10:51:55 +0100 Subject: [PATCH 03/27] Address review comments --- CMakeLists.txt | 8 ++--- CONTRIBUTING.md | 2 +- .../{type_erasure.cc => 1_type_erasure.cc} | 0 ...pointer.cc => 2_vanishing_this_pointer.cc} | 0 tutorials/{reflection.cc => 3_reflection.cc} | 31 ++++++++----------- tutorials/README.md | 8 ++--- 6 files changed, 21 insertions(+), 28 deletions(-) rename tutorials/{type_erasure.cc => 1_type_erasure.cc} (100%) rename tutorials/{vanishing_this_pointer.cc => 2_vanishing_this_pointer.cc} (100%) rename tutorials/{reflection.cc => 3_reflection.cc} (96%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a4e1b3..772266e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,7 +31,7 @@ option(ENABLE_MSAN "Enable Memory Sanitizer" OFF) option( XYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL - "Also build and test tutorials/reflection.cc, which requires a compiler \ + "Also build and test tutorials/3_reflection.cc, which requires a compiler \ with C++26 P2996 reflection support (GCC 16+ with -freflection)." OFF) @@ -182,13 +182,13 @@ if(XYZ_PROTOCOL_IS_NOT_SUBPROJECT) NAME type_erasure_tutorial FILES - tutorials/type_erasure.cc) + tutorials/1_type_erasure.cc) xyz_add_test( NAME vanishing_this_pointer_tutorial FILES - tutorials/vanishing_this_pointer.cc) + tutorials/2_vanishing_this_pointer.cc) if(XYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL) xyz_add_test( @@ -197,7 +197,7 @@ if(XYZ_PROTOCOL_IS_NOT_SUBPROJECT) VERSION 26 FILES - tutorials/reflection.cc) + tutorials/3_reflection.cc) target_compile_options(reflection_tutorial PRIVATE -freflection) endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b0699e..32c31a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,7 +152,7 @@ To launch an interactive bash shell in the pre-configured Docker container (whic ./scripts/docker-shell.sh [--rebuild-docker] ``` -Inside the shell, GCC 16 is what lets you build and run the `tutorials/reflection.cc` tutorial, which needs C++26 reflection support: +Inside the shell, GCC 16 is what lets you build and run the `tutorials/3_reflection.cc` tutorial, which needs C++26 reflection support: ```bash CXX=g++-16 CC=gcc-16 ./scripts/cmake.sh --release -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON -B build-reflection diff --git a/tutorials/type_erasure.cc b/tutorials/1_type_erasure.cc similarity index 100% rename from tutorials/type_erasure.cc rename to tutorials/1_type_erasure.cc diff --git a/tutorials/vanishing_this_pointer.cc b/tutorials/2_vanishing_this_pointer.cc similarity index 100% rename from tutorials/vanishing_this_pointer.cc rename to tutorials/2_vanishing_this_pointer.cc diff --git a/tutorials/reflection.cc b/tutorials/3_reflection.cc similarity index 96% rename from tutorials/reflection.cc rename to tutorials/3_reflection.cc index 68a6c52..865f86e 100644 --- a/tutorials/reflection.cc +++ b/tutorials/3_reflection.cc @@ -39,6 +39,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #include #include +#include #include #include #include @@ -232,12 +233,8 @@ struct Point { }; consteval std::vector data_members_of(std::meta::info type) { - std::vector result; - for (std::meta::info member : std::meta::nonstatic_data_members_of( - type, std::meta::access_context::current())) { - result.push_back(member); - } - return result; + return std::meta::nonstatic_data_members_of( + type, std::meta::access_context::current()); } // define_static_array is what makes the enumerated members usable as a @@ -262,18 +259,16 @@ struct Widget { consteval std::vector member_functions_of( std::meta::info type) { - std::vector result; - for (std::meta::info member : - std::meta::members_of(type, std::meta::access_context::current())) { - // is_function alone would also match Widget's implicit special members - // (default constructor, destructor, ...); is_special_member_function - // excludes those, leaving just the two ordinary methods below. - if (std::meta::is_function(member) && - !std::meta::is_special_member_function(member)) { - result.push_back(member); - } - } - return result; + // is_function alone would also match Widget's implicit special members + // (default constructor, destructor, ...); is_special_member_function + // excludes those, leaving just the two ordinary methods below. + const auto is_member_function = [](std::meta::info member) { + return std::meta::is_function(member) && + !std::meta::is_special_member_function(member); + }; + return std::meta::members_of(type, std::meta::access_context::current()) | + std::ranges::views::filter(is_member_function) | + std::ranges::to>(); } constexpr auto widget_member_functions = diff --git a/tutorials/README.md b/tutorials/README.md index c0854f3..9307449 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -2,8 +2,6 @@ Three standalone tutorials. Each is a single GoogleTest file whose sections build up one technique step by step. -- [`type_erasure.cc`](type_erasure.cc) — manual type erasure from first principles: a vtable of function pointers, and an eraser object built from an erased pointer plus a vtable pointer. -- [`vanishing_this_pointer.cc`](vanishing_this_pointer.cc) — a layout/casting technique for giving a data member ordinary call syntax, by recovering the address of its owning object from `this`. -- [`reflection.cc`](reflection.cc) — C++26 reflection, from `std::meta::info` up to synthesizing a type's members at compile time. Requires a P2996 compiler (`scripts/cmake.sh -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON`). Uses the vtable technique from `type_erasure.cc` and the call-syntax technique from `vanishing_this_pointer.cc` to generate that machinery at compile time instead of by hand. - -Read `reflection.cc` last, since it builds on the other two; `type_erasure.cc` and `vanishing_this_pointer.cc` can be read in any order. +- [`1_type_erasure.cc`](1_type_erasure.cc) — manual type erasure from first principles: a vtable of function pointers, and an eraser object built from an erased pointer plus a vtable pointer. +- [`2_vanishing_this_pointer.cc`](2_vanishing_this_pointer.cc) — a layout/casting technique for giving a data member ordinary call syntax, by recovering the address of its owning object from `this`. +- [`3_reflection.cc`](3_reflection.cc) — C++26 reflection, from `std::meta::info` up to synthesizing a type's members at compile time. Requires a P2996 compiler (`scripts/cmake.sh -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON`). From 2695373cf69e70bea8f6c2c61eb77a78624da057 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Thu, 23 Jul 2026 11:16:04 +0100 Subject: [PATCH 04/27] Remove redundant text --- tutorials/1_type_erasure.cc | 4 ---- tutorials/2_vanishing_this_pointer.cc | 4 ---- tutorials/3_reflection.cc | 5 ----- 3 files changed, 13 deletions(-) diff --git a/tutorials/1_type_erasure.cc b/tutorials/1_type_erasure.cc index c9709aa..4731740 100644 --- a/tutorials/1_type_erasure.cc +++ b/tutorials/1_type_erasure.cc @@ -24,10 +24,6 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // 1. The problem type erasure solves. // 2. A vtable of function pointers. // 3. The eraser object. -// -// Each section defines its own small Circle/Square pair, scoped to a named -// namespace: two trivial, unrelated types that just happen to share a -// draw() method with the same signature. #include diff --git a/tutorials/2_vanishing_this_pointer.cc b/tutorials/2_vanishing_this_pointer.cc index 191955d..3116c0f 100644 --- a/tutorials/2_vanishing_this_pointer.cc +++ b/tutorials/2_vanishing_this_pointer.cc @@ -28,10 +28,6 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // 2. Recovering the owner pointer. // 3. Ordinary call syntax from a data member. // 4. What breaks when offset zero is violated, and the guard against it. -// -// Each section defines its own small Owner/Wrapper pair, scoped to a named -// namespace, since each section's version is deliberately minimal for what -// it's teaching rather than reusing the previous section's exact shape. #include diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 865f86e..4ff2cde 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -30,11 +30,6 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // 5. Writing your own consteval helpers about a member. // 6. Synthesizing a type's data members with define_aggregate. // 7. Enum reflection: enum_to_string with template for. -// -// Each section is scoped to a named namespace, since later sections define -// types (Point, Widget) with the same name but a different shape than -// earlier sections use. Each section is deliberately minimal for what -// it's teaching, rather than reusing an earlier section's exact shape. #include From 45446e3acf8fa1e6eed287f94a4891e06d95904e Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Thu, 23 Jul 2026 11:25:54 +0100 Subject: [PATCH 05/27] Remove redundant text --- tutorials/1_type_erasure.cc | 4 +--- tutorials/2_vanishing_this_pointer.cc | 3 +-- tutorials/3_reflection.cc | 9 +-------- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/tutorials/1_type_erasure.cc b/tutorials/1_type_erasure.cc index 4731740..5b8019f 100644 --- a/tutorials/1_type_erasure.cc +++ b/tutorials/1_type_erasure.cc @@ -18,9 +18,7 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ==============================================================================*/ -// Type erasure, from first principles. -// -// Three sections, read top to bottom, each building on the last: +// Type erasure, from first principles: // 1. The problem type erasure solves. // 2. A vtable of function pointers. // 3. The eraser object. diff --git a/tutorials/2_vanishing_this_pointer.cc b/tutorials/2_vanishing_this_pointer.cc index 3116c0f..c1b747d 100644 --- a/tutorials/2_vanishing_this_pointer.cc +++ b/tutorials/2_vanishing_this_pointer.cc @@ -22,8 +22,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // // This technique is adopted from https://ryanjk5.github.io/posts/rjk-duck/. // -// A technique for giving a data member ordinary call syntax, built up in -// four sections, read top to bottom: +// A technique for giving a data member ordinary call syntax: // 1. The offset-zero layout guarantee. // 2. Recovering the owner pointer. // 3. Ordinary call syntax from a data member. diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 4ff2cde..1bbcdd7 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -19,10 +19,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ==============================================================================*/ // C++26 reflection, from std::meta::info to synthesizing a type at compile -// time. -// -// Seven sections, read top to bottom, each building on the primitives the -// previous one introduced: +// time: // 1. What std::meta::info is. // 2. Splicing an info back into a type. // 3. Splicing an info as a call target. @@ -376,10 +373,6 @@ TEST(ReflectionHelpers, SignatureDistinguishesOverloads) { EXPECT_EQ(int_signature, "write(int)"); EXPECT_EQ(double_signature, "write(double)"); - - // Different overloads of the same name produce different strings, which - // is what makes a string like this usable as part of a unique, generated - // identifier. EXPECT_NE(int_signature, double_signature); } From 3cd0bcd8736173afeac7a2c1a91ea61f613bef15 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Fri, 24 Jul 2026 17:56:27 +0100 Subject: [PATCH 06/27] Add additional examples for reflection: substitute and extract --- tutorials/3_reflection.cc | 129 +++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 1bbcdd7..7449500 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -26,10 +26,14 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // 4. Enumerating a type's members. // 5. Writing your own consteval helpers about a member. // 6. Synthesizing a type's data members with define_aggregate. -// 7. Enum reflection: enum_to_string with template for. +// 7. Converting an enum value to its name with template for. +// 8. Instantiating a template from infos with substitute. +// 9. Reading a constant out of an info with extract. #include +#include +#include #include #include #include @@ -440,7 +444,7 @@ TEST(ReflectionSynthesize, MembersFromSpecList) { } // namespace section_6 // --------------------------------------------------------------------------- -// 7. Enum reflection: enum_to_string with template for. +// 7. Converting an enum value to its name with template for. // // std::meta::enumerators_of(type) returns every enumerator of an enum // type, in declaration order. This is the same shape of query section 4 @@ -490,3 +494,124 @@ TEST(ReflectionEnumToString, UnmatchedValueFallsThrough) { } } // namespace section_7 + +// --------------------------------------------------------------------------- +// 8. Instantiating a template from infos with substitute. +// +// A template specialization built by splicing needs one [:e:] slot per +// argument, each already sitting at a fixed position in source, e.g. +// Boxed. That breaks down when the argument list +// itself is only known as a range of infos computed at compile time. +// std::meta::substitute(template_info, argument_infos) instantiates a class, +// alias, or function template directly from such a range instead, as an +// ordinary consteval function call, and returns an info reflecting the +// resulting specialization. Splicing that result, as in section 2, turns it +// into a usable type. +// --------------------------------------------------------------------------- +namespace section_8 { + +template +struct Boxed { + T value = T(); +}; + +TEST(ReflectionSubstitute, InstantiatesTemplateFromInfos) { + constexpr std::meta::info boxed_int_info = + std::meta::substitute(^^Boxed, { + ^^int}); + + // The substitution names the same type as writing Boxed directly. + static_assert(std::is_same_v>); + + typename[:boxed_int_info:] boxed{.value = 5}; + EXPECT_EQ(boxed.value, 5); +} + +struct Widget { + int compute(double) const { return 1; } +}; + +template +using fn_ptr_t = R (*)(Args...); + +consteval std::meta::info find_member(std::meta::info type, + std::string_view name) { + for (std::meta::info member : + std::meta::members_of(type, std::meta::access_context::current())) { + if (std::meta::has_identifier(member) && + std::meta::identifier_of(member) == name) { + return member; + } + } + return std::meta::info{}; +} + +// A member's return type plus its parameter types, gathered by reflection +// rather than named in source, are exactly the pieces substitute needs to +// build the matching function pointer type below. +consteval std::vector fn_ptr_arguments( + std::meta::info member) { + std::vector types{ + std::meta::dealias(std::meta::return_type_of(member))}; + for (std::meta::info parameter : std::meta::parameters_of(member)) { + types.push_back(std::meta::dealias(std::meta::type_of(parameter))); + } + return types; +} + +TEST(ReflectionSubstitute, BuildsFunctionPointerTypeFromAMember) { + constexpr std::meta::info compute_member = find_member(^^Widget, "compute"); + constexpr auto arguments = + std::define_static_array(fn_ptr_arguments(compute_member)); + constexpr std::meta::info fn_ptr_info = + std::meta::substitute(^^fn_ptr_t, arguments); + + static_assert(std::is_same_v); +} + +} // namespace section_8 + +// --------------------------------------------------------------------------- +// 9. Reading a constant out of an info with extract. +// +// std::meta::extract(info) converts a std::meta::info that reflects a +// value back into an ordinary T (a real value you can return, store, or +// pass around), rather than something you can only splice into source +// code at a fixed spot. +// +// member_array_extent, below, takes a struct's info and a member name, +// finds that member, and reads N off its array type: std::array. +// std::meta::template_arguments_of gives back N only as an info, held in +// an ordinary local variable rather than spelled at a splice site, so it +// can't be spliced. extract is what turns it into an actual size_t. +// --------------------------------------------------------------------------- +namespace section_9 { + +struct Row { + std::array values{}; +}; + +consteval std::meta::info find_member(std::meta::info type, + std::string_view name) { + for (std::meta::info member : + std::meta::members_of(type, std::meta::access_context::current())) { + if (std::meta::has_identifier(member) && + std::meta::identifier_of(member) == name) { + return member; + } + } + return std::meta::info{}; +} + +consteval size_t member_array_extent(std::meta::info type, + std::string_view name) { + std::meta::info member_type = std::meta::type_of(find_member(type, name)); + std::meta::info extent = std::meta::template_arguments_of(member_type)[1]; + return std::meta::extract(extent); +} + +TEST(ReflectionExtract, ReadsANonTypeTemplateArgument) { + static_assert(member_array_extent(^^Row, "values") == 4); +} + +} // namespace section_9 From e25ff0632d272e365878f99e9955a4b790d9e4d8 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Fri, 24 Jul 2026 19:30:04 +0100 Subject: [PATCH 07/27] Add multiple inheritance example with empty base classes --- tutorials/2_vanishing_this_pointer.cc | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tutorials/2_vanishing_this_pointer.cc b/tutorials/2_vanishing_this_pointer.cc index c1b747d..98bf372 100644 --- a/tutorials/2_vanishing_this_pointer.cc +++ b/tutorials/2_vanishing_this_pointer.cc @@ -27,6 +27,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // 2. Recovering the owner pointer. // 3. Ordinary call syntax from a data member. // 4. What breaks when offset zero is violated, and the guard against it. +// 5. Composing several such members through inheritance. #include @@ -225,3 +226,74 @@ TEST(ThisPointerOffsetViolated, GuardConfirmsGoodCase) { } } // namespace section_4 + +// --------------------------------------------------------------------------- +// 5. Composing several such members through inheritance. +// +// Owner in section 3 has one wrapper data member, greet. Giving it a +// second, shout, the same way needs each in its own small base struct, +// GreetBase and ShoutBase below, combined by having Owner inherit from +// both. +// +// Each wrapper's own this only has to recover its own base's address: +// GreetWrapper is GreetBase's sole member, so it sits at offset zero of +// GreetBase unconditionally, exactly as section 2's Wrapper sits at +// offset zero of Owner. Getting from GreetBase's address to Owner's is +// then a base-to-derived static_cast, which the compiler performs +// correctly regardless of GreetBase's actual offset within Owner. +// [[no_unique_address]] on greet and shout still matters for size, same +// as sections 1 and 4, but no longer for correctness: with flat data +// members, an unhonored [[no_unique_address]] misplaces every member +// after the first (section 4's failure mode); here it only costs Owner +// some otherwise-unnecessary bytes. This is the technique Duck (cited +// above) uses to build its vtable_wrapper, one base per interface member. +// --------------------------------------------------------------------------- +namespace section_5 { + +struct GreetWrapper { + std::string operator()(std::string_view visitor) const; +}; + +struct GreetBase { + [[no_unique_address]] GreetWrapper greet; +}; + +struct ShoutWrapper { + std::string operator()(std::string_view visitor) const; +}; + +struct ShoutBase { + [[no_unique_address]] ShoutWrapper shout; +}; + +struct Owner : GreetBase, ShoutBase { + std::string name = "World"; +}; + +std::string GreetWrapper::operator()(std::string_view visitor) const { + const auto* base = + static_cast(static_cast(this)); + const auto* owner = static_cast(base); + return "Hello, " + std::string(visitor) + ", from " + owner->name + "!"; +} + +std::string ShoutWrapper::operator()(std::string_view visitor) const { + const auto* base = + static_cast(static_cast(this)); + const auto* owner = static_cast(base); + return std::string(visitor) + ", " + owner->name + " SHOUTS HELLO!"; +} + +TEST(ThisPointerInheritance, EachMemberReachesTheSameOwner) { + Owner owner; + owner.name = "Owner"; + + EXPECT_EQ(owner.greet("Reader"), "Hello, Reader, from Owner!"); + EXPECT_EQ(owner.shout("Reader"), "Reader, Owner SHOUTS HELLO!"); +} + +TEST(ThisPointerInheritance, NoExtraStorage) { + EXPECT_EQ(sizeof(Owner), sizeof(std::string)); +} + +} // namespace section_5 From 9b58f6f3a394d5361713bf730945c7fcac283959 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Fri, 24 Jul 2026 22:02:49 +0100 Subject: [PATCH 08/27] Add --reflection flag to scripts/cmake.py Replaces -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON as the way to opt into building and testing tutorials/3_reflection.cc. --- GEMINI.md | 3 ++- scripts/cmake.py | 8 ++++++++ tutorials/README.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 5e03711..6b4f6e2 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -23,7 +23,8 @@ general defaults for this repository. - **Tooling:** Always use `uv` for Python dependency management (`uv run ...`). - **Build & Test:** Use `scripts/cmake.sh` for all build and test operations. The `scripts/cmake.sh` entrypoint supports `--debug`, `--release`, - `--asan`, `--ubsan`, `--tsan`, and `--msan`. + `--asan`, `--ubsan`, `--tsan`, `--msan`, and `--reflection` (builds and + tests `tutorials/3_reflection.cc`, requires a P2996 reflection compiler). - **Compiler Preferences:** Prefer Clang 19+ for sanitizer-based verification and CI, as it provides superior support for MSAN and TSAN compared to older GCC versions. diff --git a/scripts/cmake.py b/scripts/cmake.py index ecfca13..bb84e48 100644 --- a/scripts/cmake.py +++ b/scripts/cmake.py @@ -37,6 +37,12 @@ def main() -> None: ) parser.add_argument("--tsan", action="store_true", help="Enable Thread Sanitizer") parser.add_argument("--msan", action="store_true", help="Enable Memory Sanitizer") + parser.add_argument( + "--reflection", + action="store_true", + help="Build and test tutorials/3_reflection.cc (requires a P2996 " + "reflection compiler, e.g. CXX=g++-16 CC=gcc-16)", + ) parser.add_argument("-B", "--build-dir", help="Build directory") parser.add_argument( "--clean", action="store_true", help="Fresh configuration and clean-first build" @@ -74,6 +80,8 @@ def log(msg: Any) -> None: f"-DENABLE_UBSAN={'ON' if args.ubsan else 'OFF'}", f"-DENABLE_TSAN={'ON' if args.tsan else 'OFF'}", f"-DENABLE_MSAN={'ON' if args.msan else 'OFF'}", + "-DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=" + + ("ON" if args.reflection else "OFF"), ] if args.build_dir: configure_args.extend(["-B", args.build_dir]) diff --git a/tutorials/README.md b/tutorials/README.md index 9307449..a2f45b0 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -4,4 +4,4 @@ Three standalone tutorials. Each is a single GoogleTest file whose sections buil - [`1_type_erasure.cc`](1_type_erasure.cc) — manual type erasure from first principles: a vtable of function pointers, and an eraser object built from an erased pointer plus a vtable pointer. - [`2_vanishing_this_pointer.cc`](2_vanishing_this_pointer.cc) — a layout/casting technique for giving a data member ordinary call syntax, by recovering the address of its owning object from `this`. -- [`3_reflection.cc`](3_reflection.cc) — C++26 reflection, from `std::meta::info` up to synthesizing a type's members at compile time. Requires a P2996 compiler (`scripts/cmake.sh -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON`). +- [`3_reflection.cc`](3_reflection.cc) — C++26 reflection, from `std::meta::info` up to synthesizing a type's members at compile time. Requires a P2996 compiler (`scripts/cmake.sh --reflection`). From 30c553a9b29a0e439c0b95cb8def3e9278f0b390 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sat, 25 Jul 2026 11:07:58 +0100 Subject: [PATCH 09/27] Remove needless comment --- docker/Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 44006e4..7b89f3a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,9 +29,6 @@ RUN wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/nul # Install GCC 16 (experimental C++26 reflection support via -std=c++26 # -freflection, see https://gcc.gnu.org/gcc-16/changes.html) from the Ubuntu # Toolchain Test PPA, since Ubuntu 24.04's own repos only go up to GCC 14. -# This is additive: the default g++ (13.3.0) installed above remains the -# toolchain used for the default build; g++-16/gcc-16 are opt-in, invoked -# explicitly (e.g. via CMAKE_CXX_COMPILER=g++-16) for the reflection backend. RUN add-apt-repository -y ppa:ubuntu-toolchain-r/test \ && apt-get update && apt-get install -y --no-install-recommends g++-16 gcc-16 \ && rm -rf /var/lib/apt/lists/* From dbebfc020d17f6b10ee1de08eb929ad1981fd54f Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sat, 25 Jul 2026 13:53:28 +0100 Subject: [PATCH 10/27] Use --reflection flag to set the compiler --- scripts/cmake.py | 12 +++++++++++- scripts/docker-shell.sh | 4 +--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/cmake.py b/scripts/cmake.py index bb84e48..d17f7c5 100644 --- a/scripts/cmake.py +++ b/scripts/cmake.py @@ -2,6 +2,7 @@ """CMake helper script for building and testing the project.""" import argparse +import os import subprocess from typing import Any @@ -90,8 +91,17 @@ def log(msg: Any) -> None: configure_args.extend(extra) + # A P2996 reflection compiler is required to configure with + # XYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON. CMake only reads CXX/CC + # from the environment, not from -D cache variables, so set them here + # rather than as configure_args. + configure_env = os.environ.copy() + if args.reflection: + configure_env["CXX"] = "g++-16" + configure_env["CC"] = "gcc-16" + log(f"Running: {' '.join(configure_args)}") - subprocess.check_call(configure_args) + subprocess.check_call(configure_args, env=configure_env) # Build step (required for build, test, benchmark) build_args = ["cmake", "--build"] diff --git a/scripts/docker-shell.sh b/scripts/docker-shell.sh index 3c73b81..d6dcc3a 100755 --- a/scripts/docker-shell.sh +++ b/scripts/docker-shell.sh @@ -1,7 +1,6 @@ #!/bin/bash set -eu -o pipefail -# Find workspace root WORKSPACE_ROOT=$(git rev-parse --show-toplevel) IMAGE_NAME="cc-protocol-sandbox" @@ -12,7 +11,6 @@ for argument in "$@"; do fi done -# Check if image exists or rebuild requested if [ "$REBUILD" -eq 1 ] || ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then echo "--- Building Docker Image: ${IMAGE_NAME} ---" docker build -t "$IMAGE_NAME" -f "${WORKSPACE_ROOT}/docker/Dockerfile" "$WORKSPACE_ROOT" @@ -22,7 +20,7 @@ echo "--- Starting Interactive Docker Shell ---" echo "Project root ${WORKSPACE_ROOT} is mounted at /workspace" echo "" echo "To build and test with the C++26 reflection backend (GCC 16):" -echo " CXX=g++-16 CC=gcc-16 ./scripts/cmake.sh --release -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON -B build-reflection" +echo " CXX=g++-16 CC=gcc-16 ./scripts/cmake.sh --release -B build-reflection" echo "" exec docker run -it --rm \ From 8e640591231c353562ef392f11196aa7f605df57 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 15:37:17 +0100 Subject: [PATCH 11/27] No need to specify CC or CXX --- scripts/docker-shell.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/docker-shell.sh b/scripts/docker-shell.sh index d6dcc3a..cbe1b88 100755 --- a/scripts/docker-shell.sh +++ b/scripts/docker-shell.sh @@ -20,7 +20,7 @@ echo "--- Starting Interactive Docker Shell ---" echo "Project root ${WORKSPACE_ROOT} is mounted at /workspace" echo "" echo "To build and test with the C++26 reflection backend (GCC 16):" -echo " CXX=g++-16 CC=gcc-16 ./scripts/cmake.sh --release -B build-reflection" +echo " ./scripts/cmake.sh --release --reflection -B build-reflection" echo "" exec docker run -it --rm \ From 90e2950c51c5ff61ec9864b4e4ec97808bcb5744 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 15:44:27 +0100 Subject: [PATCH 12/27] Add UB mention and missing no_unique_address attributes --- tutorials/2_vanishing_this_pointer.cc | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tutorials/2_vanishing_this_pointer.cc b/tutorials/2_vanishing_this_pointer.cc index 98bf372..020f47a 100644 --- a/tutorials/2_vanishing_this_pointer.cc +++ b/tutorials/2_vanishing_this_pointer.cc @@ -88,7 +88,8 @@ TEST(ThisPointerOffsetZero, EmptyWrapperAddsNoStorage) { // Because a Wrapper sitting at offset zero of an Owner shares the Owner's // address (section 1), a method defined on Wrapper can compute a pointer to // its owner just by casting its own `this`, without Wrapper storing a -// back-pointer anywhere. +// back-pointer anywhere. This is undefined behavior unless Wrapper is the first +// data member of Owner, and Owner is a standard layout type. // --------------------------------------------------------------------------- namespace section_2 { @@ -137,7 +138,7 @@ struct GreetWrapper { }; struct Owner { - GreetWrapper greet; + [[no_unique_address]] GreetWrapper greet; std::string name = "World"; }; @@ -185,7 +186,7 @@ struct Wrapper { // offset zero. struct BadOwner { int preceding_member = 0; - Wrapper wrapper; + [[no_unique_address]] Wrapper wrapper; }; TEST(ThisPointerOffsetViolated, AddressNoLongerMatches) { From 4cf496e84e72769de3b144fc74afc7f3ea8f08b7 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 16:26:28 +0100 Subject: [PATCH 13/27] Add discussion of access_context --- tutorials/3_reflection.cc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 7449500..041e75e 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -175,6 +175,17 @@ struct Greeter { int value = 0; }; +// members_of takes an access_context controlling which members it is +// allowed to see. access_context::current() applies ordinary C++ access +// rules as they hold at this point in the source (a member or friend +// function sees private members here; free-standing code like find_member +// does not, so private members stay invisible to it, just as they would to +// ordinary code written at this call site). access_context::unprivileged() +// always restricts the query to public members, however privileged the +// calling code actually is, useful for reflecting on a type the way an +// arbitrary external caller would see it. access_context::unchecked() +// removes access checking entirely, useful for tooling that must reach +// every member regardless of access, such as a serializer. consteval std::meta::info find_member(std::meta::info type, std::string_view name) { for (std::meta::info member : From 666f64f9bd04bff24328eab8b283dfba4aab7873 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 16:35:17 +0100 Subject: [PATCH 14/27] Improve message for substitute --- tutorials/3_reflection.cc | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 041e75e..b31f898 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -509,15 +509,17 @@ TEST(ReflectionEnumToString, UnmatchedValueFallsThrough) { // --------------------------------------------------------------------------- // 8. Instantiating a template from infos with substitute. // -// A template specialization built by splicing needs one [:e:] slot per -// argument, each already sitting at a fixed position in source, e.g. -// Boxed. That breaks down when the argument list -// itself is only known as a range of infos computed at compile time. -// std::meta::substitute(template_info, argument_infos) instantiates a class, -// alias, or function template directly from such a range instead, as an -// ordinary consteval function call, and returns an info reflecting the -// resulting specialization. Splicing that result, as in section 2, turns it -// into a usable type. +// A splice like typename[:int_info:] needs its operand to be a constant +// expression, e.g. a constexpr variable, not just any info that happens to +// exist while a consteval function runs. A range-based for loop over a +// std::vector doesn't give you that: the loop variable is +// rebound on each iteration, so it isn't a constant expression, even though +// the whole loop runs at compile time. A template specialization whose +// argument list is only known as such a range can't be built one splice at +// a time. std::meta::substitute(template_info, argument_infos) takes +// the whole range as an ordinary function argument instead, and returns an +// info for the resulting specialization directly. Splicing that result, as +// in section 2, turns it into a usable type. // --------------------------------------------------------------------------- namespace section_8 { From 1f9763f1ecbde29e2f6e97936732b718ea8725cd Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 16:45:15 +0100 Subject: [PATCH 15/27] Add reflect_constant example --- tutorials/3_reflection.cc | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index b31f898..15ee856 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -540,6 +540,19 @@ TEST(ReflectionSubstitute, InstantiatesTemplateFromInfos) { EXPECT_EQ(boxed.value, 5); } +template +struct Sized {}; + +TEST(ReflectionSubstitute, SubstitutesANonTypeTemplateArgument) { + // substitute's argument list isn't limited to type infos: reflect_constant + // turns an ordinary value into an info for a non-type template argument. + constexpr std::meta::info sized_five_info = + std::meta::substitute(^^Sized, { + std::meta::reflect_constant(5)}); + + static_assert(std::is_same_v>); +} + struct Widget { int compute(double) const { return 1; } }; From 7f2e41efd4ebc5d936b7ec34534a19962a2ba616 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 16:48:20 +0100 Subject: [PATCH 16/27] Add comment showing data_member_options fields --- tutorials/3_reflection.cc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 15ee856..13cd68b 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -413,6 +413,16 @@ TEST(ReflectionHelpers, SignatureDistinguishesOverloads) { // --------------------------------------------------------------------------- namespace section_6 { +// Adapted from +// https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2996r13.html +// +// struct data_member_options { +// optional name; // name_type is a string +// optional alignment; +// optional bit_width; +// bool no_unique_address = false; +// }; + // Shared by both tests below, so each only has to spell out the one line // that actually differs: the consteval block calling define_aggregate. consteval std::vector count_ratio_specs() { From 9f955b89b4f60beabee51579685b5833cdc7039f Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 17:33:17 +0100 Subject: [PATCH 17/27] Add alias example --- tutorials/3_reflection.cc | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 13cd68b..3e39145 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -76,6 +76,19 @@ TEST(ReflectionMetaInfo, SameEntityEqualValues) { // other type in this respect. } +using AliasForPoint = Point; + +TEST(ReflectionMetaInfo, AliasesCompareDistinctUntilDealiased) { + // ^^ reflects the name as written, not what it eventually names: an + // alias and its underlying type are different entities to ^^, even + // though AliasForPoint is nothing more than another name for Point. + static_assert(^^Point != ^^AliasForPoint); + + // std::meta::dealias strips away any aliasing, giving back the info + // you'd get by reflecting the underlying type directly. + static_assert(std::meta::dealias(^^AliasForPoint) == ^^Point); +} + // ^^ isn't limited to types. It can also reflect a namespace, a function, a // specific member, and more. This tutorial mostly needs "reflect a type" and // "reflect a member," both shown here just to establish that both work with From ebc02bfa10da30fe718b98fe492e4f2175da0a01 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 17:46:14 +0100 Subject: [PATCH 18/27] Use ADL --- tutorials/3_reflection.cc | 112 +++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 62 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 3e39145..46b3ece 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -86,7 +86,7 @@ TEST(ReflectionMetaInfo, AliasesCompareDistinctUntilDealiased) { // std::meta::dealias strips away any aliasing, giving back the info // you'd get by reflecting the underlying type directly. - static_assert(std::meta::dealias(^^AliasForPoint) == ^^Point); + static_assert(dealias(^^AliasForPoint) == ^^Point); } // ^^ isn't limited to types. It can also reflect a namespace, a function, a @@ -104,9 +104,9 @@ TEST(ReflectionMetaInfo, OtherEntityKinds) { // like std::meta::is_namespace. What you can do with each (splice it as a // type, call it, read its name) depends on this kind, covered starting in // section 2. - static_assert(std::meta::is_namespace(reflected_namespace)); - static_assert(std::meta::is_function(reflected_function)); - static_assert(std::meta::is_nonstatic_data_member(reflected_member)); + static_assert(is_namespace(reflected_namespace)); + static_assert(is_function(reflected_function)); + static_assert(is_nonstatic_data_member(reflected_member)); } } // namespace section_1 @@ -202,9 +202,8 @@ struct Greeter { consteval std::meta::info find_member(std::meta::info type, std::string_view name) { for (std::meta::info member : - std::meta::members_of(type, std::meta::access_context::current())) { - if (std::meta::has_identifier(member) && - std::meta::identifier_of(member) == name) { + members_of(type, std::meta::access_context::current())) { + if (has_identifier(member) && identifier_of(member) == name) { return member; } } @@ -253,8 +252,7 @@ struct Point { }; consteval std::vector data_members_of(std::meta::info type) { - return std::meta::nonstatic_data_members_of( - type, std::meta::access_context::current()); + return nonstatic_data_members_of(type, std::meta::access_context::current()); } // define_static_array is what makes the enumerated members usable as a @@ -264,9 +262,9 @@ constexpr auto point_data_members = TEST(ReflectionEnumerate, MembersInDeclarationOrder) { static_assert(point_data_members.size() == 3); - static_assert(std::meta::identifier_of(point_data_members[0]) == "x"); - static_assert(std::meta::identifier_of(point_data_members[1]) == "y"); - static_assert(std::meta::identifier_of(point_data_members[2]) == "z"); + static_assert(identifier_of(point_data_members[0]) == "x"); + static_assert(identifier_of(point_data_members[1]) == "y"); + static_assert(identifier_of(point_data_members[2]) == "z"); } struct Widget { @@ -283,10 +281,9 @@ consteval std::vector member_functions_of( // (default constructor, destructor, ...); is_special_member_function // excludes those, leaving just the two ordinary methods below. const auto is_member_function = [](std::meta::info member) { - return std::meta::is_function(member) && - !std::meta::is_special_member_function(member); + return is_function(member) && !is_special_member_function(member); }; - return std::meta::members_of(type, std::meta::access_context::current()) | + return members_of(type, std::meta::access_context::current()) | std::ranges::views::filter(is_member_function) | std::ranges::to>(); } @@ -298,10 +295,8 @@ TEST(ReflectionEnumerate, FilteredToFunctionsOnly) { // payload (a data member) is excluded; value() and set_value(int) (member // functions) are the only two results. static_assert(widget_member_functions.size() == 2); - static_assert(std::meta::identifier_of(widget_member_functions[0]) == - "value"); - static_assert(std::meta::identifier_of(widget_member_functions[1]) == - "set_value"); + static_assert(identifier_of(widget_member_functions[0]) == "value"); + static_assert(identifier_of(widget_member_functions[1]) == "set_value"); } } // namespace section_4 @@ -335,9 +330,9 @@ consteval std::vector members_named(std::meta::info type, std::string_view name) { std::vector result; for (std::meta::info member : - std::meta::members_of(type, std::meta::access_context::current())) { - if (std::meta::is_function(member) && std::meta::has_identifier(member) && - std::meta::identifier_of(member) == name) { + members_of(type, std::meta::access_context::current())) { + if (is_function(member) && has_identifier(member) && + identifier_of(member) == name) { result.push_back(member); } } @@ -351,11 +346,11 @@ TEST(ReflectionHelpers, ClassifiesConstCorrectly) { std::define_static_array(members_named(^^Widget, "write")); static_assert(read_candidates.size() == 1); - static_assert(std::meta::is_const(read_candidates[0])); + static_assert(is_const(read_candidates[0])); static_assert(write_candidates.size() == 2); - static_assert(!std::meta::is_const(write_candidates[0])); - static_assert(!std::meta::is_const(write_candidates[1])); + static_assert(!is_const(write_candidates[0])); + static_assert(!is_const(write_candidates[1])); } // A simplified signature-string builder: name plus each parameter type's @@ -369,14 +364,13 @@ TEST(ReflectionHelpers, ClassifiesConstCorrectly) { // std::meta::display_string_of is the primitive for turning a type into a // readable string. consteval std::string simple_signature_string(std::meta::info member) { - std::string result(std::meta::identifier_of(member)); + std::string result(identifier_of(member)); result += "("; bool first = true; - for (std::meta::info parameter : std::meta::parameters_of(member)) { + for (std::meta::info parameter : parameters_of(member)) { if (!first) result += ","; first = false; - result += std::meta::display_string_of( - std::meta::dealias(std::meta::type_of(parameter))); + result += display_string_of(dealias(type_of(parameter))); } result += ")"; return result; @@ -440,28 +434,27 @@ namespace section_6 { // that actually differs: the consteval block calling define_aggregate. consteval std::vector count_ratio_specs() { std::vector specs; - specs.push_back(std::meta::data_member_spec(^^int, { - .name = "count"})); - specs.push_back(std::meta::data_member_spec(^^double, { - .name = "ratio"})); + specs.push_back(data_member_spec(^^int, { + .name = "count"})); + specs.push_back(data_member_spec(^^double, { + .name = "ratio"})); return specs; } TEST(ReflectionSynthesize, SynthesizedMembersEnumerable) { struct Incomplete; - consteval { std::meta::define_aggregate(^^Incomplete, count_ratio_specs()); } + consteval { define_aggregate(^^Incomplete, count_ratio_specs()); } // The consteval block above already ran during translation, so by the // time this ordinary, runtime TEST body executes, Incomplete already has // its two members. Enumerating it works exactly like enumerating an // ordinary, hand-written type (section 4). define_aggregate produces an // ordinary type, usable the same way as a hand-written one. - constexpr auto members = - std::define_static_array(std::meta::nonstatic_data_members_of( - ^^Incomplete, std::meta::access_context::current())); + constexpr auto members = std::define_static_array(nonstatic_data_members_of( + ^^Incomplete, std::meta::access_context::current())); static_assert(members.size() == 2); - static_assert(std::meta::identifier_of(members[0]) == "count"); - static_assert(std::meta::identifier_of(members[1]) == "ratio"); + static_assert(identifier_of(members[0]) == "count"); + static_assert(identifier_of(members[1]) == "ratio"); } // Unlike the test above, this one constructs an instance of the synthesized @@ -470,7 +463,7 @@ TEST(ReflectionSynthesize, SynthesizedMembersEnumerable) { // expression, confirming a synthesized type isn't only usable at runtime. TEST(ReflectionSynthesize, MembersFromSpecList) { struct Incomplete; - consteval { std::meta::define_aggregate(^^Incomplete, count_ratio_specs()); } + consteval { define_aggregate(^^Incomplete, count_ratio_specs()); } constexpr Incomplete instance{.count = 3, .ratio = 1.5}; static_assert(instance.count == 3 && instance.ratio == 1.5); } @@ -508,8 +501,8 @@ template requires std::is_enum_v std::string enum_to_string(E value) { template for (constexpr std::meta::info e : - std::define_static_array(std::meta::enumerators_of(^^E))) { - if (value == [:e:]) return std::string(std::meta::identifier_of(e)); + std::define_static_array(enumerators_of(^^E))) { + if (value == [:e:]) return std::string(identifier_of(e)); } return ""; } @@ -552,9 +545,8 @@ struct Boxed { }; TEST(ReflectionSubstitute, InstantiatesTemplateFromInfos) { - constexpr std::meta::info boxed_int_info = - std::meta::substitute(^^Boxed, { - ^^int}); + constexpr std::meta::info boxed_int_info = substitute(^^Boxed, { + ^^int}); // The substitution names the same type as writing Boxed directly. static_assert(std::is_same_v>); @@ -570,8 +562,8 @@ TEST(ReflectionSubstitute, SubstitutesANonTypeTemplateArgument) { // substitute's argument list isn't limited to type infos: reflect_constant // turns an ordinary value into an info for a non-type template argument. constexpr std::meta::info sized_five_info = - std::meta::substitute(^^Sized, { - std::meta::reflect_constant(5)}); + substitute(^^Sized, { + std::meta::reflect_constant(5)}); static_assert(std::is_same_v>); } @@ -586,9 +578,8 @@ using fn_ptr_t = R (*)(Args...); consteval std::meta::info find_member(std::meta::info type, std::string_view name) { for (std::meta::info member : - std::meta::members_of(type, std::meta::access_context::current())) { - if (std::meta::has_identifier(member) && - std::meta::identifier_of(member) == name) { + members_of(type, std::meta::access_context::current())) { + if (has_identifier(member) && identifier_of(member) == name) { return member; } } @@ -600,10 +591,9 @@ consteval std::meta::info find_member(std::meta::info type, // build the matching function pointer type below. consteval std::vector fn_ptr_arguments( std::meta::info member) { - std::vector types{ - std::meta::dealias(std::meta::return_type_of(member))}; - for (std::meta::info parameter : std::meta::parameters_of(member)) { - types.push_back(std::meta::dealias(std::meta::type_of(parameter))); + std::vector types{dealias(return_type_of(member))}; + for (std::meta::info parameter : parameters_of(member)) { + types.push_back(dealias(type_of(parameter))); } return types; } @@ -612,8 +602,7 @@ TEST(ReflectionSubstitute, BuildsFunctionPointerTypeFromAMember) { constexpr std::meta::info compute_member = find_member(^^Widget, "compute"); constexpr auto arguments = std::define_static_array(fn_ptr_arguments(compute_member)); - constexpr std::meta::info fn_ptr_info = - std::meta::substitute(^^fn_ptr_t, arguments); + constexpr std::meta::info fn_ptr_info = substitute(^^fn_ptr_t, arguments); static_assert(std::is_same_v); } @@ -643,9 +632,8 @@ struct Row { consteval std::meta::info find_member(std::meta::info type, std::string_view name) { for (std::meta::info member : - std::meta::members_of(type, std::meta::access_context::current())) { - if (std::meta::has_identifier(member) && - std::meta::identifier_of(member) == name) { + members_of(type, std::meta::access_context::current())) { + if (has_identifier(member) && identifier_of(member) == name) { return member; } } @@ -654,9 +642,9 @@ consteval std::meta::info find_member(std::meta::info type, consteval size_t member_array_extent(std::meta::info type, std::string_view name) { - std::meta::info member_type = std::meta::type_of(find_member(type, name)); - std::meta::info extent = std::meta::template_arguments_of(member_type)[1]; - return std::meta::extract(extent); + std::meta::info member_type = type_of(find_member(type, name)); + std::meta::info extent = template_arguments_of(member_type)[1]; + return extract(extent); } TEST(ReflectionExtract, ReadsANonTypeTemplateArgument) { From 3549321926e191c0508ca98efea8288f0ee7e989 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Sun, 26 Jul 2026 17:52:04 +0100 Subject: [PATCH 19/27] Add clarifying comments --- tutorials/3_reflection.cc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 46b3ece..ce21d24 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -207,6 +207,9 @@ consteval std::meta::info find_member(std::meta::info type, return member; } } + // A default-constructed std::meta::info is a valid, comparable sentinel value + // that specifically means "nothing here," rather than describing any real + // type, function, variable, or namespace. return std::meta::info{}; } @@ -362,7 +365,8 @@ TEST(ReflectionHelpers, ClassifiesConstCorrectly) { // A parameter's type isn't a named declaration, so std::meta::identifier_of // (which reads a declaration's own name) doesn't apply to it. // std::meta::display_string_of is the primitive for turning a type into a -// readable string. +// readable string. The output of std::meta::display_string_of is +// implementation-defined and should not be used outside of debug output. consteval std::string simple_signature_string(std::meta::info member) { std::string result(identifier_of(member)); result += "("; @@ -417,6 +421,8 @@ TEST(ReflectionHelpers, SignatureDistinguishesOverloads) { // statement, not a function, that always runs during translation regardless // of where it appears, even directly inside an ordinary, non-constexpr // function body, as the first test below does. +// +// In C++26, define_aggregate, is the only avenue available for code generation. // --------------------------------------------------------------------------- namespace section_6 { From 7bb0c6e37dedbbba16714bc07f7ee854466581b6 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Mon, 27 Jul 2026 00:48:52 +0100 Subject: [PATCH 20/27] Simplify cmake.yml to make use of --reflection flag --- .github/workflows/cmake.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 71ca867..e66776d 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -249,11 +249,9 @@ jobs: sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test sudo apt-get update sudo apt-get install -y --no-install-recommends g++-16 gcc-16 - echo "CC=gcc-16" >> "$GITHUB_ENV" - echo "CXX=g++-16" >> "$GITHUB_ENV" - name: Install uv uses: astral-sh/setup-uv@v5 - name: Set up Python run: uv sync - name: Run CMake - run: ./scripts/cmake.sh --${{ matrix.configuration == 'Debug' && 'debug' || 'release' }} ${{ matrix.settings.compiler.type == 'GCC16' && '-DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON' || '' }} + run: ./scripts/cmake.sh --${{ matrix.configuration == 'Debug' && 'debug' || 'release' }} ${{ matrix.settings.compiler.type == 'GCC16' && '--reflection' || '' }} From 9c3cd23216bc255440fed9c3d7170fce832dcf81 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Mon, 27 Jul 2026 00:59:03 +0100 Subject: [PATCH 21/27] Remove needless comment --- tutorials/3_reflection.cc | 2 -- 1 file changed, 2 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index ce21d24..ba21ed6 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -436,8 +436,6 @@ namespace section_6 { // bool no_unique_address = false; // }; -// Shared by both tests below, so each only has to spell out the one line -// that actually differs: the consteval block calling define_aggregate. consteval std::vector count_ratio_specs() { std::vector specs; specs.push_back(data_member_spec(^^int, { From 430f350b87ea696ecdb143d670e45e3f0afb28d3 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Mon, 27 Jul 2026 01:01:40 +0100 Subject: [PATCH 22/27] Improve comment flow --- tutorials/3_reflection.cc | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index ba21ed6..7c486e4 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -461,10 +461,11 @@ TEST(ReflectionSynthesize, SynthesizedMembersEnumerable) { static_assert(identifier_of(members[1]) == "ratio"); } -// Unlike the test above, this one constructs an instance of the synthesized -// type and reads its members back, not just enumerates them. constexpr on -// instance forces that construction and read to happen in a constant -// expression, confirming a synthesized type isn't only usable at runtime. +// This test goes further than the one above: it constructs an instance and +// reads its members back, not just enumerates them. Declaring that instance +// constexpr forces its construction to happen in a constant expression, +// showing the synthesized type supports compile-time use like an ordinary +// type. TEST(ReflectionSynthesize, MembersFromSpecList) { struct Incomplete; consteval { define_aggregate(^^Incomplete, count_ratio_specs()); } From df25de46352c907e94ae090bee977d462702785f Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Mon, 27 Jul 2026 01:37:06 +0100 Subject: [PATCH 23/27] Clean up comments --- tutorials/3_reflection.cc | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 7c486e4..05efa80 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -237,14 +237,12 @@ TEST(ReflectionSpliceCall, DataMemberAccess) { // --------------------------------------------------------------------------- // 4. Enumerating a type's members. // -// std::meta::members_of and std::meta::nonstatic_data_members_of return -// every member of a type as a std::vector, computed inside -// a consteval function. That vector only exists during constant evaluation, -// so std::define_static_array copies it into static storage as an ordinary -// constexpr array, usable anywhere, not just inside the consteval function -// that computed it. The enumeration function below (data_members_of) does -// the walk; the constexpr variables below it (point_data_members, -// widget_member_functions) are where that copy happens. +// std::meta::members_of and std::meta::nonstatic_data_members_of enumerate a +// type's members as a std::vector, but that vector exists +// only during constant evaluation, so it can only be built inside a +// consteval function. std::define_static_array copies the vector into +// static storage as a constexpr array, extending its lifetime past the +// return of that consteval function. // --------------------------------------------------------------------------- namespace section_4 { @@ -422,7 +420,7 @@ TEST(ReflectionHelpers, SignatureDistinguishesOverloads) { // of where it appears, even directly inside an ordinary, non-constexpr // function body, as the first test below does. // -// In C++26, define_aggregate, is the only avenue available for code generation. +// In C++26, define_aggregate is the only avenue available for code generation. // --------------------------------------------------------------------------- namespace section_6 { From 19fbf0edddc8b55708ef1ae917cef5907aa331b1 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Mon, 27 Jul 2026 01:41:17 +0100 Subject: [PATCH 24/27] Remove premature changes to .clang-format --- .clang-format | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.clang-format b/.clang-format index b59b976..1a7aa7a 100644 --- a/.clang-format +++ b/.clang-format @@ -1,7 +1,5 @@ -# No `Language: Cpp` key: clang-format's language guesser classifies -# protocol_reflection.h (C++26 `^^` reflection syntax) as Objective-C, and a -# Cpp-only configuration makes it error out on that file. A single -# language-agnostic document applies to every language clang-format detects. +--- +Language: Cpp # BasedOnStyle: Google AccessModifierOffset: -1 AlignAfterOpenBracket: Align @@ -155,3 +153,4 @@ StatementMacros: - QT_REQUIRE_VERSION TabWidth: 8 UseTab: Never +... From c3ffa1e4967c5dd998c3e754e49bd36e0ab55bb2 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Mon, 27 Jul 2026 01:46:21 +0100 Subject: [PATCH 25/27] Suppress the worst clang-mis-formatting --- tutorials/3_reflection.cc | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 05efa80..77f1f9f 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -436,10 +436,10 @@ namespace section_6 { consteval std::vector count_ratio_specs() { std::vector specs; - specs.push_back(data_member_spec(^^int, { - .name = "count"})); - specs.push_back(data_member_spec(^^double, { - .name = "ratio"})); + // clang-format off + specs.push_back(data_member_spec(^^int, {.name = "count"})); + specs.push_back(data_member_spec(^^double, {.name = "ratio"})); + // clang-format on return specs; } @@ -548,8 +548,9 @@ struct Boxed { }; TEST(ReflectionSubstitute, InstantiatesTemplateFromInfos) { - constexpr std::meta::info boxed_int_info = substitute(^^Boxed, { - ^^int}); + // clang-format off + constexpr std::meta::info boxed_int_info = substitute(^^Boxed, {^^int}); + // clang-format on // The substitution names the same type as writing Boxed directly. static_assert(std::is_same_v>); @@ -564,10 +565,10 @@ struct Sized {}; TEST(ReflectionSubstitute, SubstitutesANonTypeTemplateArgument) { // substitute's argument list isn't limited to type infos: reflect_constant // turns an ordinary value into an info for a non-type template argument. + // clang-format off constexpr std::meta::info sized_five_info = - substitute(^^Sized, { - std::meta::reflect_constant(5)}); - + substitute(^^Sized, {std::meta::reflect_constant(5)}); + // clang-format on static_assert(std::is_same_v>); } From 9a0f907103ff4620876455d97c5fba55d33c6aa3 Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Mon, 27 Jul 2026 02:14:04 +0100 Subject: [PATCH 26/27] Use --reflection flag --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32c31a4..0a936be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -155,7 +155,7 @@ To launch an interactive bash shell in the pre-configured Docker container (whic Inside the shell, GCC 16 is what lets you build and run the `tutorials/3_reflection.cc` tutorial, which needs C++26 reflection support: ```bash -CXX=g++-16 CC=gcc-16 ./scripts/cmake.sh --release -DXYZ_PROTOCOL_BUILD_REFLECTION_TUTORIAL=ON -B build-reflection +./scripts/cmake.sh --release --reflection -B build-reflection ``` ## AI Coding Sandboxes From 92c091c0e3b52c984409075337cb08b0839e246a Mon Sep 17 00:00:00 2001 From: "Jonathan B. Coe" Date: Tue, 28 Jul 2026 21:08:08 +0100 Subject: [PATCH 27/27] Address review comment - use ranges not raw loops --- tutorials/3_reflection.cc | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tutorials/3_reflection.cc b/tutorials/3_reflection.cc index 77f1f9f..963d3ad 100644 --- a/tutorials/3_reflection.cc +++ b/tutorials/3_reflection.cc @@ -201,16 +201,19 @@ struct Greeter { // every member regardless of access, such as a serializer. consteval std::meta::info find_member(std::meta::info type, std::string_view name) { - for (std::meta::info member : - members_of(type, std::meta::access_context::current())) { - if (has_identifier(member) && identifier_of(member) == name) { - return member; - } + auto const members = members_of(type, std::meta::access_context::current()); + auto const is_member = [&](std::meta::info member) { + return has_identifier(member) && identifier_of(member) == name; + }; + + if (auto const member = std::ranges::find_if(members, is_member); + member != members.end()) { + return *member; } // A default-constructed std::meta::info is a valid, comparable sentinel value // that specifically means "nothing here," rather than describing any real // type, function, variable, or namespace. - return std::meta::info{}; + return {}; } TEST(ReflectionSpliceCall, MemberFunctionAsTarget) {