reflect_optics: Haskell-style lenses for C++26
Library repo: github.com/wrocpp/reflect_optics · v0.1.0 · MIT.
The pitch: you have a Company with departments which have teams which have members which have addresses. You want to update one member’s postal code. In plain C++:
company.departments[2].teams[0].members[5].address.postal_code = 54321;
Fine if it’s one place. Painful if you need the same access pattern across twenty call sites, or conditional (“update if the member exists, otherwise skip”), or collection-wide (“update every member’s postal code in-place”). The Haskell answer has been lenses:
-- Haskell, using the `lens` library
company & departments . ix 2 . teams . ix 0 . members . ix 5 . address . postalCode .~ 54321
Each of departments, teams, members, address, postalCode is a lens, a composable first-class getter/setter pair. You can build them, compose them, pipe them, and the compiler proves the whole chain type-correct.
C++ has tried: Boost.Fusion, graninas/cpp_lenses, Scelta, various macro-heavy attempts. Every one required generating per-field boilerplate. Reflection removes the generator.
Targeted API
#include <reflect_optics/optics.hpp>
struct Address { std::string city; int postal_code; };
struct Member { std::string name; Address address; };
struct Team { std::vector<Member> members; };
Team team{...};
// Compose lenses with |
constexpr auto fifth_member_postal =
optics::field<&Team::members>
| optics::at(5)
| optics::field<&Member::address>
| optics::field<&Address::postal_code>;
// Get
int code = optics::view(team, fifth_member_postal);
// Set (returns modified copy)
Team team2 = optics::set(team, fifth_member_postal, 54321);
// Modify (apply a function)
Team team3 = optics::over(team, fifth_member_postal, [](int p) { return p + 1; });
Each optics::field<...> is a small value type (pointer-to-member plus reflection metadata). | is a constexpr composition that produces a new lens type. The whole chain compiles to the same machine code as the hand-written access, genuinely zero-overhead.
The shape of a lens
template <typename Whole, typename Part>
struct Lens {
std::function<Part(Whole const&)> view;
std::function<Whole(Whole, Part)> set;
std::function<Whole(Whole, auto&&)> over; // over(f) = set(f(view()))
};
In our real library the std::function goes away. We template on function-pointer-like non-type template parameters so the whole thing is stamped out by the compiler per lens. No runtime allocation; no virtual dispatch. constexpr auto all the way down.
Reflection’s job
The field<M> constructor takes a reflection of a data member:
template <std::meta::info M>
constexpr auto field() {
using Whole = [: std::meta::parent_of(M) :];
using Part = [: std::meta::type_of(M) :];
return Lens<Whole, Part>{
.view = [](Whole const& w) -> Part const& { return w.[:M:]; },
.set = [](Whole w, Part v) -> Whole { w.[:M:] = std::move(v); return w; },
};
}
No macros. No code generation. The member access w.[:M:] is a splicer consumed once at template instantiation; the resulting lambda is a direct field-of-struct access.
Lens laws (static_assert)
The library ships compile-time proofs that every synthesised lens satisfies the lens laws:
static_assert(lens_laws_hold<optics::field<&Address::postal_code>>);
// Which expands to:
// set(view(w)) == w (Get-Put)
// view(set(w, p)) == p (Put-Get)
// set(set(w, p1), p2) == set(w, p2) (Put-Put)
These assertions run at compile time against a few sample values. If you synthesise a “lens” that doesn’t obey the laws, the build fails. (Mostly affects custom lenses; the reflection-derived ones always pass.)
Prisms and traversals
Lenses handle “always-present” parts (a struct’s field). Prisms handle “maybe-present” (a variant case, std::optional content):
constexpr auto just_some = optics::prism<std::optional<int>>(
[](std::optional<int> o) { return o ? std::make_optional(*o) : std::nullopt; },
[](int v) { return std::make_optional(v); });
Traversals handle “multi-present” (every element of a container):
constexpr auto every_member_postal =
optics::field<&Team::members>
| optics::traverse()
| optics::field<&Member::address>
| optics::field<&Address::postal_code>;
// Modify every member's postal code
Team team_norm = optics::over(team, every_member_postal,
[](int p) { return (p / 1000) * 1000; });
A traversal is “zero or more” access. The composition of a traversal with a lens produces another traversal. The type system tracks this.
Comparison
| Language / library | Lens-like available? | Auto-derived from struct? | Composable with pipe? |
|---|---|---|---|
Haskell optics / lens |
Yes | Yes (Template Haskell / Generic) | Yes |
Scala monocle |
Yes | Yes (via macro) | Yes |
Rust druid / accesso |
Partial | Via macro derive | Yes |
TypeScript monocle-ts |
Yes | Partial | Yes |
Python lenses library |
Yes | Runtime reflection | Yes |
| Pre-reflect C++ (Boost.Fusion) | Awkward | Only with macros | Limited |
C++26 reflect_optics |
Yes | Yes (reflection) | Yes |
Why this is now tractable
Barry Revzin pointed out in his 2025 CppCon talk that the splicer + expansion statement combination finally makes structural types viable as non-type template parameters. Lenses need exactly that: a way to “name” a member inside a type at compile time. std::meta::info is the NTTP you always needed.
What the library ships
Under github.com/wrocpp/reflect_optics:
optics::field<&T::member>: field lens.optics::at(index): index lens (forstd::vector,std::array).optics::key(key): key lens (forstd::map).optics::just/optics::nothing:std::optionalprisms.optics::alt<I>:std::variantalternative prisms.optics::traverse(): range traversal.optics::iso<F, G>: isomorphism between two types (via round-trippable functions).- Static laws checker.
|composition.
Depends on reflect_dx (next post) for the compile-time ADL-fu that makes | find the right combinators.
Next in the series
- Post 22: reflect_dx: pretty-printers + documentation generator.
- Companion Monday short: “A Haskell lens primer for C++ developers”.