cpp26-reflection · part 21

reflect_optics: Haskell-style lenses for C++26

· upd 2026-10-06· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

Correction, 6 October 2026. This post presents reflect_optics as a published library (v0.1.0, MIT), and it is not one. The repository linked below holds a README and nothing else: it has no code, release, tag or licence. What the post shows is a design sketch. The Compiler Explorer demo is a reduced stand-in: a struct optics::field_lens<^^Member> with static functions view, set and over, used once each on a field of Address. It prints view: Wroclaw, set: Warsaw and over: 50002. It has no pipe operator, no at, no prisms and no traversals, and it checks no lens laws. I checked the other code claims on GCC 16.2.0 (-std=c++26 -freflection). First, optics::field<&Team::members> | ... does not compile as written: field is a function template that takes a std::meta::info, so it is not a value, and field<^^Team::members> | field<^^Member::address> is refused as invalid operands to operator| even when an operator| for any two types is declared. Second, a Lens with a std::function<Whole(Whole, auto&&)> member is refused ('auto' parameter not permitted in this context), so over is now a free function. Third, a constexpr variable whose type is std::function is refused (is not literal), so the constexpr lenses depend on the std::function-free version that the post describes and that was not built. The field<M>() function itself compiles when called as field<^^Address::postal_code>(). Fourth, “compiles to the same machine code as the hand-written access” had no listing behind it. I checked one field_lens access only: at -O2 GCC 16.2.0 on AArch64 emits the same two instructions as a hand-written member access (listing below), and I did not build or measure a composed chain. Fifth, “the library ships compile-time proofs of the lens laws” is removed: there is no lens_laws_hold. I wrote the three laws by hand as static_asserts on one sample value for the stand-in field_lens, and they hold on GCC 16.2.0, which is a test on one value and not a proof. I also marked the dependency on reflect_dx as an untested idea, and the comparison row for this library is now a sketch. I did not re-check the comparison rows for other languages or the attributions in the text. The rest is left as published, so you can read it against this note.

No published library. github.com/wrocpp/reflect_optics is a placeholder repository with a README only. This post describes a design sketch.

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>  // sketch: there is no such header

// Sketch, does not compile as written: field, at, |, view, set and over are not
// defined in this form (field is a function template, so it is not a value).
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; });

In the design, each optics::field<...> would be a small value type and | a constexpr composition that produces a new lens type. Neither exists. What I checked is one access through the stand-in field_lens (the type the embedded demo uses) against a hand-written one, with g++-16 -std=c++26 -freflection -O2 -S (Homebrew GCC 16.2.0, AArch64):

int via_lens(Address const& a) { return optics::field_lens<^^Address::postal_code>::view(a); }
int by_hand(Address const& a)  { return a.postal_code; }
via_lens:  ldr  w0, [x0, 32]
           ret
by_hand:   ldr  w0, [x0, 32]
           ret

Both are the same two instructions. I did not build a composed chain, so the claim that a whole chain costs nothing is untested.

The shape of a lens

template <typename Whole, typename Part>
struct Lens {
    std::function<Part(Whole const&)>         view;
    std::function<Whole(Whole, Part)>         set;
};

template <typename Whole, typename Part, typename F>
Whole over(Lens<Whole, Part> const& l, Whole w, F&& f) {   // over(f) = set(f(view()))
    Part p = f(l.view(w));
    return l.set(std::move(w), std::move(p));
}

As first published, over was a std::function<Whole(Whole, auto&&)> member, which GCC 16.2.0 refuses. The free function above compiled and ran there. A std::function member also keeps a Lens from being constexpr.

A library could drop the std::function and template on function-pointer-like non-type template parameters, so that the compiler stamps out each lens. That version was not built, and the claims of no runtime allocation, no virtual dispatch and constexpr auto all the way down are about a design.

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)

A library could check the lens laws at compile time. lens_laws_hold is an idea and is not implemented:

static_assert(lens_laws_hold<optics::field<&Address::postal_code>>);

// Which expands to:
//   set(w, view(w)) == w             (Get-Put)
//   view(set(w, p)) == p             (Put-Get)
//   set(set(w, p1), p2) == set(w, p2) (Put-Put)

Such assertions would run at compile time against a few sample values, which is a test and not a proof. I wrote the three laws by hand as static_asserts for the stand-in field_lens<^^Pt::x> on one sample value (Pt{1, 2}), and they hold on GCC 16.2.0. The first law as first published was missing the w argument of set; it is fixed in the comment above.

Prisms and traversals

Neither prisms nor traversals exist: the demo and the repository have neither, and the code below is a sketch. 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 (the sketch in this post) Designed, not built Designed, not built Designed, not built

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 a library could ship (not built)

None of this exists, apart from the reduced field_lens<^^T::member> in the demo. It is the shape the sketch above points to:

  • optics::field<&T::member>: field lens.
  • optics::at(index): index lens (for std::vector, std::array).
  • optics::key(key): key lens (for std::map).
  • optics::just / optics::nothing: std::optional prisms.
  • optics::alt<I>: std::variant alternative prisms.
  • optics::traverse(): range traversal.
  • optics::iso<F, G>: isomorphism between two types (via round-trippable functions).
  • Static laws checker.
  • | composition.

The | composition might depend on reflect_dx (next post) for the compile-time ADL that finds the right combinators. That is a design idea and I did not test it.

Next in the series