Skip to content

sergio-eld/omnirefl

Repository files navigation

Omnirefl

Omnirefl
Obligatory self-reflection meta joke.

A C++ reflection tool built for a seamless experience without macros or UB.

Sneak Peek

Minimal CMake setup:

# 3.18.2 is the current project floor for CMake APIs used by the package and
# reflected target integration.
cmake_minimum_required(VERSION 3.18.2 FATAL_ERROR)

project(example LANGUAGES CXX)

find_package(omnirefl CONFIG REQUIRED)

add_executable(example main.cpp)
set_property(TARGET example PROPERTY CXX_STANDARD 20)

# Reflection is not transitive: only this target's own .cpp files are
# instrumented. Call omni_reflected_target for each target that should be
# reflected.
omni_reflected_target(example)

Instrumentation can also be triggered explicitly through <target>.omni (example.omni for the example target):

cmake --build build -t example.omni

The same target shape is used by tests/tool/example/CMakeLists.txt.

omni_reflected_target(...) is a convenience wrapper; omnirefl itself does not require CMake. An equivalent direct invocation is:

# Generate the reflection header, then force-include it when compiling the same
# translation unit.
flags="-std=c++20 -I/path/to/omnirefl/include"
omnirefl -o example.omnirefl.hpp -c main.cpp -- c++ $flags
c++ $flags -include example.omnirefl.hpp main.cpp -o example && ./example

-c selects the instrumented source; compiler output options after -- are ignored. When a compilation database is available, ccdb_query can select its matching compiler command for use after --.

#include <omnirefl/reflection.hpp>

#include <iostream>
#include <string>
#include <string_view>
#include <tuple>

struct record {
  int foo;
  std::string bar;
};

int main() {
  using namespace std::string_view_literals;

  record value{
    .foo = 1,
    .bar = "before",
  };

  std::cout << "before: foo=" << value.foo << " bar=" << value.bar << '\n';

  const auto write = [](omni::binding auto b)
    // Generic lambdas used as reflected visitors must spell the return type.
    -> void {
      // Bindings and field bindings are trivial to copy.
      std::apply(
        [](omni::field_binding auto... field) -> void {
          const auto write = [](omni::field_binding auto field) -> void {
            constexpr std::string_view name = field.name();

            // value() is read-only; ref() exposes a writable reference.
            if constexpr ("foo"sv == name)
              field.ref() = 8;

            // operator* and operator-> are QoL accessors.
            if constexpr ("bar"sv == name)
              *field = "after";
          };

          (write(field), ...);
        },
        b.public_fields());
    };

  omni::reflected_call(write, value);

  std::cout << "after: foo=" << value.foo << " bar=" << value.bar << '\n';
}

The snippet above is available as tests/tool/example/main.cpp and is included in the package. See tests/tool/comprehensive_guide/comprehensive_guide.cpp for the full executable guide. It targets limited C++20 support: omnirefl concepts are used, but <concepts> and C++20 ranges algorithms are avoided.

Seamless Experience

  1. Add omni_reflected_target(...) for the CMake target.
  2. Use omni::reflected_call(...) where reflection is needed.

Everything else remains regular C++. Omnirefl discovers the argument types and supported dependencies, force-includes the generated header, and emits compiler-style dependency files (.d) so Ninja reruns it when the source or any included header changes (tested with Ninja only as of this writing). No macros, compiler-specific UB, or manual regeneration are required.

Supported Scope

Omnirefl focuses on POD-like records and enums.

  • C++11 through C++23; C++20 concepts provide the most ergonomic interface.
  • Globally accessible named records and enums.
  • Nested named records/enums of supported globally accessible parents.
  • Unconstrained primary record templates, including use as CRTP bases.
  • Public data fields: names, type names, documentation, value retrieval, mutation of writable fields, reference access when safe, has_value_access()/has_reference_access() capability queries, and is_const()/is_mutable()/is_volatile()/is_deprecated() traits.
  • Enum documentation, plus enumerator names and values.

Dependency Protocols

Additional reflected types are discovered through:

  • public field types
  • public bases and transitive public bases
  • public fields of primary template records
  • supported public member aliases:
    • error_type
    • first_type
    • key_type
    • mapped_type
    • second_type
    • type
    • value
    • value_type
  • template-pack routes named tuple or variant

Standard-library record types are not traversed as reflectable records outside those protocol routes.

Limitations

  • reflected_call is the instrumentation boundary. The visitor must be either a generic lambda or a type with a templated operator(). Its return type must not depend on instantiating the visitor body during the tool run; for lambdas, this means an explicit trailing return type, including -> void. Consequently, a lambda cannot currently return a type declared inside its body. Supporting such local result types without supporting recursive reflected_call is technically possible and under consideration. constexpr auto result = reflected_call(...) is not supported: it forces evaluation and breaks that instrumentation boundary.

  • reflected_call accepts reflected records and enums only. The caller must sanitize pointers, arrays, and compound inputs before the call; use std::visit or mpark::visit for variants. Compound types remain valid dependency routes as listed above. Invalid-input detection is best effort.

  • Records with direct or inherited virtual bases are not supported. They are rejected as reflected_call inputs and skipped with a warning when found as dependencies.

  • Constrained primary record templates are not supported. The force-included generated header would have to repeat equivalent constraints before their source-level dependencies are declared, and concepts cannot be forward-declared.

  • Direct recursive reflected_call is not supported inside a reflected scope. A nested reflection call can only work if that reflected path was already instantiated independently. Sema-based recursive instrumentation is technically possible, but is not intended for now: it would severely complicate the implementation and cause a combinatorial expansion of the required test coverage.

  • Reflection queries are valid only inside the reflected scope. The tool reports out-of-scope queries as errors on a best-effort basis.

  • Public data members only. Private/protected fields are skipped, including fields inherited through public bases. Member functions are not reflected.

  • GCC 16 diagnoses the intentional incomplete-type SFINAE used for nested reflection with -Wsfinae-incomplete. Generated headers suppress this warning only within generated declarations. This workaround should be monitored; current nested record and enum tests do not reproduce the unstable specialization selection targeted by the warning.

  • Deprecated public fields can emit compiler deprecation diagnostics while their metadata is formed, before is_deprecated() can filter them. Deferring this access would require replacing the natural typename Field::type interface with a template query.

  • Local and unnamed types are not supported. Experimental indexed support exists for investigation (omni_reflected_target(... ENABLE_INDEX_MODE)), but is not part of the release contract: it has proven unstable because function template specializations are instantiated lazily, and a dependent return type can postpone instantiating the function body until the specialization is required.

  • CMake-generated sources are skipped by default. Targets commonly contain helper translation units such as Qt moc output, which must not be instrumented; explicit opt-in for generated sources is not available yet.

  • The CMake wrapper only instruments concrete C++ source entries. Source generator expressions such as this are not supported:

    target_sources(example PRIVATE
        "$<$<CONFIG:Debug>:${CMAKE_CURRENT_SOURCE_DIR}/debug.cpp>")
    omni_reflected_target(example)

    CMake resolves the selected source while generating the buildsystem, after omni_reflected_target() has configured each source's generated header and force-include option. Those source properties cannot be attached to the resolved path retroactively.

  • C translation units are ignored; if no C++ source remains, reflection is skipped with a configuration warning.

Linters and language servers such as clangd can report temporary "ghost" diagnostics between edits/tool runs, because reflected .cpp files depend on the generated header that is force-included during normal compilation. Build the affected .cpp file normally, or refresh its metadata explicitly through the <target>.omni CMake target.

During AST creation, invalid C++ in the reflected translation unit is reported as Clang compilation errors. Compiler warnings are not reported by omnirefl.

Install

Install options:

  • Release archives: browse published releases. Linux release packages use .deb and .tar.gz; Windows packages use .zip.
  • Latest CI artifact: open the latest successful CI workflow run on master and use the artifacts produced by Build package / linux-x86_64, Build package / linux-aarch64, or Build package / windows-x86_64. GitHub Actions artifacts do not provide a stable direct "latest artifact" download URL; look for packages-linux-<arch>-musl-<short-sha> or packages-windows-x86_64-ucrt-<short-sha>.
  • Build locally using the prepared Docker images; see Build and Develop Locally.

The examples below assume a standard install under /usr/local.

Packaged Tests and Examples

Assuming a standard install, the packaged test/example sources are available under share/omnirefl/tests. Copy them into a writable directory before configuring:

prefix=/usr/local # Or the unpacked install root.
cp -R "$prefix/share/omnirefl/tests" ./omnirefl-tests

mkdir build && cd build

cmake ../omnirefl-tests -GNinja

ctest --output-on-failure

On Windows, run from a Visual Studio Developer PowerShell so cl.exe is configured:

$prefix = "C:\path\to\omnirefl"
Copy-Item -Recurse "$prefix\share\omnirefl\tests" .\omnirefl-tests

New-Item -ItemType Directory build | Out-Null
Set-Location build

cmake ../omnirefl-tests -GNinja `
  "-Domnirefl_DIR=$prefix/lib/cmake/omnirefl"

ctest --output-on-failure

The copied tree also contains the runnable example and comprehensive guide sources. The tests fetch their own test-only dependencies during CMake configuration. If CMake cannot find omnirefl in a non-standard install, pass -Domnirefl_DIR="$prefix/lib/cmake/omnirefl"; on Windows this is usually needed for an unpacked .zip package.

Build and Develop Locally

The repository uses prepared Alpine Docker images for local and CI builds:

Both images provide a versioned LLVM/Clang toolchain and reproducible local and CI build environment. Using them is the simplest approach; rebuilding a complete toolchain image can take close to an hour. They are defined by docker/build-alpine-x86_64-musl-ucrt.Dockerfile and docker/build-alpine-aarch64-musl.Dockerfile.

The x86_64 image is also used for the MinGW Windows cross-build. Linux's stable kernel-userspace ABI allows static musl packages to have zero target-distribution runtime library dependencies. The Windows package depends only on UCRT.

Windows ARM64 cross-compilation with MinGW was attempted but is not working as of this writing. macOS cross-compilation was also attempted without success and remains planned.

Note

A Cosmopolitan cross-build was attempted, but its C++ implementation appears to lack the C++23 support required by omnirefl as of this writing.

If configuring the build directly on the host instead of using an image, use a C++23 compiler. As of now, the prepared build images use GCC for native Linux tool builds.

Build packages:

docker compose run --rm build-linux
docker compose run --rm build-linux-aarch64
docker compose run --rm build-windows

Note

If the configured image is unavailable, for example when building a commit older than master, docker compose run may trigger a local image build. Image builds can take a while, especially the x86_64 source build, so run them explicitly before packaging:

docker compose build build-linux
docker compose build build-linux-aarch64

The packages are written to artifacts/packages/linux and artifacts/packages/windows. To work inside the same build image:

docker compose run --rm --entrypoint /bin/ash build-linux

Examples

Tested Toolchains

Current package/install coverage is reported by the CI workflow.

  • (+) Linux:Alpine GCC covered by CI package matrix
  • (+) Linux:Alpine Clang covered by CI package matrix
  • (+) Linux:Alpine MinGW GCC covered by CI package matrix (build-only for Windows test binaries)
  • (+) Linux:Ubuntu 18.04 GCC covered by CI package matrix
  • (+) Linux:Ubuntu 18.04 Clang covered by CI package matrix
  • (+) Linux:Ubuntu 20.04 GCC covered by CI package matrix
  • (+) Linux:Ubuntu 20.04 Clang covered by CI package matrix
  • (+) Linux:Ubuntu 22.04 GCC covered by CI package matrix
  • (+) Linux:Ubuntu 22.04 Clang covered by CI package matrix
  • (+) Linux:Ubuntu 18.04/20.04/22.04 MinGW GCC covered by CI package matrix (build-only for Windows test binaries)
  • (+) Linux:AArch64 musl covered by CI package build matrix
  • (+) Windows:MSVC covered by CI package matrix
  • (+) Windows:clang-cl covered by CI package matrix
  • (+) Windows:MSYS2 MinGW covered by CI package matrix
  • (+) Windows:MSYS2 clang covered by CI package matrix

Is It Slow?

Omnirefl uses a Clang frontend action: it preprocesses the translation unit and builds its AST, but does not perform object-code optimization or code generation. Frontend work commonly accounts for around 30% of a complete object build, although the ratio depends on the source, included headers, compiler, and optimization level.

The benchmark baseline is intentionally large enough to represent a meaningful translation unit and contains a reasonable amount of ordinary and reflected code. As of this writing, its complete omnirefl run takes about 20-25% of the subsequent Release object build in CI. These percentages are relative to the object build alone: a five-second compilation gains roughly one additional second when instrumentation runs, which is generally negligible at whole-build scale.

Only instrumented targets pay this cost, and reflected translation units can be isolated in dedicated targets. The impact is therefore most noticeable during initial generation. Omnirefl emits dependency files for the source and all its included headers, so Ninja reruns instrumentation only when one of those inputs changes. See the continuous benchmark for measured stages and regression tracking.

Continuous Benchmark

Benchmark runs are reported by the CI workflow.

  • Target: linux-x86_64
  • Environment: Ubuntu 22.04 GCC package-test image
  • Baseline target: benchmark.baseline
  • Raw history artifact: benchmark-history-linux-x86_64-gcc-attempt-<N>
  • Reported baseline: average of the last 5 stored runs
  • Tracked metrics:
    • reflection/tool wall time for benchmark.baseline.omni
    • build wall time for benchmark.baseline
    • reflection/tool wall time as percentage of build wall time
  • Regression warnings require an increase above 20% for reflection wall time or the tool/build percentage; wall time also requires at least a 500 ms increase. Internal stage and build timings are retained as diagnostic detail.
  • TODO: publish benchmark history at a stable URL instead of requiring artifact lookup.

How It Works

reflected_call identifies root records and enums. Omnirefl walks their public dependency protocols, then force-includes a generated header before the translation unit.

The generated header does not need to include user declarations. It forward-declares namespace-scope roots where C++ permits it; not every type can be forward-declared (see Limitations). Field access remains dependent on a template parameter, delaying instantiation until the source definition is available. Nested-type lookup uses the same mechanism through SFINAE. A simplified generated shape is:

namespace app {
struct root; // The definition may remain in the .cpp file.
}

namespace omni {
namespace detail {

// Field accessors use T, so their instantiation is delayed until app::root is
// complete.
template <typename T>
struct _reflected<struct app::root, T> {
  // Metadata omitted.
};

// _wrt means "with respect to": its type is app::root, but remains
// syntactically dependent on T so nested-name lookup is delayed.
template <typename T>
struct _reflected<T,
  typename std::enable_if<
    std::is_same<T, typename _wrt<app::root, T>::type::nested>::value,
    T>::type> {
  // Metadata omitted.
};

} // namespace detail
} // namespace omni

This permits records declared directly in one .cpp file to be reflected. Without the delayed specializations, users would need separate declaration headers and a manually ordered wrapper around generated metadata. That wrapper would still need a policy for which reachable types to reflect; Omnirefl derives roots from reflected_call and dependencies from the protocols above, enabling a seamless experience.

Note

Experimental indexed mode (omni_reflected_target(... ENABLE_INDEX_MODE)) is disabled by default. It can reflect virtually any otherwise-valid record that cannot be forward-declared, including local and unnamed records, by assigning generated indexes instead. The C++ standard does not guarantee the template instantiation timing on which those registrations rely; the mode has proven extremely brittle across compilers and also increases compile time and object size. See Limitations.

Known Regressions

Reflection Usage

Candidate Explicit Limitations

  • Primary templates with class-type non-type template parameters (template <Struct value>) when Struct cannot itself be forward-declared.

Bug Reports

Report defects through GitHub Issues. For tool crashes on Linux, please include the command line, stderr/stdout, the input .cpp, the generated header if one was produced, and a backtrace.

# Enable core dumps for the current shell, then rerun the exact failing command.
ulimit -c unlimited
omnirefl -o out.omnirefl.hpp -c source.cpp -- <compiler command...>

# If your system writes core files into the working directory:
gdb --batch -ex "thread apply all bt full" ./omnirefl ./core > omnirefl.bt.txt

# If your system uses systemd-coredump:
coredumpctl gdb omnirefl --batch \
  -ex "thread apply all bt full" > omnirefl.bt.txt

If no core file is produced, check cat /proc/sys/kernel/core_pattern; some systems route core dumps to a crash service instead of the current directory.

License

Omnirefl is available under the MIT License.

About

Seamless C++ reflection for a focused feature set, without macros or UB (before C++26).

Topics

Resources

License

Stars

4 stars

Watchers

1 watching

Forks

Contributors