Obligatory self-reflection meta joke.
A C++ reflection tool built for a seamless experience without macros or UB.
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.omniThe 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.
- Add
omni_reflected_target(...)for the CMake target. - 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.
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, andis_const()/is_mutable()/is_volatile()/is_deprecated()traits. - Enum documentation, plus enumerator names and values.
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_typefirst_typekey_typemapped_typesecond_typetypevaluevalue_type
- template-pack routes named
tupleorvariant
Standard-library record types are not traversed as reflectable records outside those protocol routes.
-
reflected_callis the instrumentation boundary. The visitor must be either a generic lambda or a type with a templatedoperator(). 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 recursivereflected_callis technically possible and under consideration.constexpr auto result = reflected_call(...)is not supported: it forces evaluation and breaks that instrumentation boundary. -
reflected_callaccepts reflected records and enums only. The caller must sanitize pointers, arrays, and compound inputs before the call; usestd::visitormpark::visitfor 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_callinputs 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_callis 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 naturaltypename Field::typeinterface 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 options:
- Release archives:
browse published releases.
Linux release packages use
.deband.tar.gz; Windows packages use.zip. - Latest CI artifact:
open the latest successful
CIworkflow run onmasterand use the artifacts produced byBuild package / linux-x86_64,Build package / linux-aarch64, orBuild package / windows-x86_64. GitHub Actions artifacts do not provide a stable direct "latest artifact" download URL; look forpackages-linux-<arch>-musl-<short-sha>orpackages-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.
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-failureOn 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-failureThe 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.
The repository uses prepared Alpine Docker images for local and CI builds:
ghcr.io/sergio-eld/omnirefl-build-alpine-x86_64-musl-ucrtfor x86_64 Linux and Windows packagesghcr.io/sergio-eld/omnirefl-build-alpine-aarch64-muslfor AArch64 Linux packages
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-windowsNote
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-aarch64The 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- tests/tool/example/main.cpp is the small runnable README example.
- tests/tool/comprehensive_guide/comprehensive_guide.cpp
is the executable usage guide with C++20, compatibility, dependency,
template, documentation, schema, and write examples. It is written for limited
C++20 support and avoids
<concepts>plus C++20 ranges algorithms.
Current package/install coverage is reported by the CI workflow.
- (+)
Linux:Alpine GCCcovered by CI package matrix - (+)
Linux:Alpine Clangcovered by CI package matrix - (+)
Linux:Alpine MinGW GCCcovered by CI package matrix (build-only for Windows test binaries) - (+)
Linux:Ubuntu 18.04 GCCcovered by CI package matrix - (+)
Linux:Ubuntu 18.04 Clangcovered by CI package matrix - (+)
Linux:Ubuntu 20.04 GCCcovered by CI package matrix - (+)
Linux:Ubuntu 20.04 Clangcovered by CI package matrix - (+)
Linux:Ubuntu 22.04 GCCcovered by CI package matrix - (+)
Linux:Ubuntu 22.04 Clangcovered by CI package matrix - (+)
Linux:Ubuntu 18.04/20.04/22.04 MinGW GCCcovered by CI package matrix (build-only for Windows test binaries) - (+)
Linux:AArch64 muslcovered by CI package build matrix - (+)
Windows:MSVCcovered by CI package matrix - (+)
Windows:clang-clcovered by CI package matrix - (+)
Windows:MSYS2 MinGWcovered by CI package matrix - (+)
Windows:MSYS2 clangcovered by CI package matrix
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.
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
- reflection/tool wall time for
- 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.
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 omniThis 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.
- A public nested type inside a private parent is emitted through an inaccessible qualified name.
- Public-base traversal loses a private nested type exposed through a public field.
- Anonymous unions emit an empty-named container field and omit promoted members.
- Compiler-packed misaligned raw arrays have no safe whole-field
accessor.
This is an implementation-dependent
layout case. Use an aligned field representation such as
std::arraywhen whole-field access is required. - Pointer fields whose external pointee is only forward-declared are silently ignored instead of being skipped with a warning while the enclosing record is reflected best-effort.
- Primary templates with class-type non-type template parameters
(
template <Struct value>) whenStructcannot itself be forward-declared.
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.txtIf 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.
Omnirefl is available under the MIT License.