blog :: Post "functional-programming-in-cpp"
Functional Programming in C++
2026-08-16 · 8 min
Most of what you reach for in Haskell or OCaml has a C++23 spelling. Here they are, side by side with what they replace.
Everything below compiles with this.
$ g++ -std=c++23 -O2 main.cpp$ clang++ -std=c++23 -O2 main.cpp
Values that behave like values
An aggregate with a defaulted comparison is a product type with structural equality and ordering. No boilerplate, no virtuals.
struct Config {std::string host;int port;auto operator<=>(const Config&) const = default;};constexpr Config a{.host = "localhost", .port = 8080};
A strong typedef, so two doubles that mean different things stop being interchangeable.
struct Meters { double v; auto operator<=>(const Meters&) const = default; };struct Seconds { double v; auto operator<=>(const Seconds&) const = default; };constexpr auto speed(Meters d, Seconds t) { return d.v / t.v; }speed(Meters{100}, Seconds{9.58}); // okspeed(Seconds{9.58}, Meters{100}); // does not compile
Return new values rather than mutating in place. The copy is usually elided, and const members make the intent checkable.
[[nodiscard]] constexpr Config with_port(Config c, int p) {c.port = p;return c;}
Sum types and pattern matching
std::variant is a closed sum. The overloaded idiom turns a set of lambdas into one visitor.
template <class... Ts>struct overloaded : Ts... { using Ts::operator()...; };struct Circle { double r; };struct Rect { double w, h; };struct Tri { double b, h; };using Shape = std::variant<Circle, Rect, Tri>;
std::visit is the match. Omit a case and it fails to compile, which is the exhaustiveness check.
constexpr double area(const Shape& s) {return std::visit(overloaded{[](const Circle& c) { return std::numbers::pi * c.r * c.r; },[](const Rect& r) { return r.w * r.h; },[](const Tri& t) { return 0.5 * t.b * t.h; },}, s);}
Matching on two scrutinees at once. visit is variadic.
std::visit(overloaded{[](const Circle& a, const Circle& b) { return a.r == b.r; },[](const Rect& a, const Rect& b) { return a.w == b.w && a.h == b.h; },[](const auto&, const auto&) { return false; },}, lhs, rhs);
A recursive sum needs indirection, since the variant's size must be known. This is the expression tree you would write as a data declaration elsewhere.
struct Expr;using ExprPtr = std::shared_ptr<const Expr>;struct Lit { int n; };struct Add { ExprPtr l, r; };struct Mul { ExprPtr l, r; };struct Expr { std::variant<Lit, Add, Mul> node; };int eval(const ExprPtr& e) {return std::visit(overloaded{[](const Lit& l) { return l.n; },[](const Add& a) { return eval(a.l) + eval(a.r); },[](const Mul& m) { return eval(m.l) * eval(m.r); },}, e->node);}
optional, monadically
C++23 gives optional and_then, transform, and or_else. This is bind, fmap, and the fallback, under different names.
std::optional<int> parse(std::string_view);std::optional<int> positive(int n) {return n > 0 ? std::optional{n} : std::nullopt;}int port = parse(arg).and_then(positive) // optional<int> -> optional<int>.transform([](int n) { return n * 2; }) // int -> int.value_or(8080);
The same thing before C++23, for contrast.
int port = 8080;if (auto n = parse(arg)) {if (*n > 0) {port = *n * 2;}}
expected: errors as values
std::expected<T, E> is a right-biased either. The error type is yours, and nothing throws.
enum class Error { NotFound, Malformed, OutOfRange };std::expected<std::string, Error> read_file(std::string_view path);std::expected<Config, Error> parse_config(std::string_view);std::expected<Config, Error> validate(Config);
The chain short-circuits on the first error, carrying it through untouched.
std::expected<int, Error> load_port(std::string_view path) {return read_file(path).and_then(parse_config).and_then(validate).transform([](const Config& c) { return c.port; });}
Recover, or map the error into something else.
auto port = load_port("app.ini").or_else([](Error e) -> std::expected<int, Error> {return e == Error::NotFound ? std::expected<int, Error>{8080}: std::unexpected{e};}).value_or(0);
Consuming it. transform_error maps the failure side without touching the success side.
auto msg = load_port("app.ini").transform_error([](Error e) { return describe(e); });if (!msg) std::println("failed: {}", msg.error());
Lazy pipelines
Range views compose with | and compute nothing until iterated. No intermediate vectors.
namespace rv = std::views;auto pipeline = xs| rv::filter([](int n) { return n % 2 == 0; })| rv::transform([](int n) { return n * n; })| rv::take(5);for (int n : pipeline) std::println("{}", n);
An infinite source, made finite downstream. iota with one argument never ends.
auto first_ten_squares = rv::iota(1)| rv::transform([](int n) { return n * n; })| rv::take(10);
Materialize only when you need to, with C++23 ranges::to.
auto v = xs| rv::transform([](int n) { return n * 2; })| std::ranges::to<std::vector>();
The adaptors that carry their weight.
rv::filter rv::transform rv::take rv::droprv::take_while rv::drop_while rv::reverse rv::joinrv::split rv::zip rv::enumerate rv::chunkrv::slide rv::adjacent rv::cartesian_product
zip and enumerate, which remove most index arithmetic.
for (auto [i, name] : rv::enumerate(names))std::println("{}: {}", i, name);for (auto [a, b] : rv::zip(xs, ys))std::println("{} {}", a, b);
Folds
C++23 fold_left is the fold you expect, with the accumulator explicit.
int total = std::ranges::fold_left(xs, 0, std::plus{});std::string joined = std::ranges::fold_left(words, std::string{},[](std::string acc, std::string_view w) {return acc.empty() ? std::string{w} : acc + ", " + std::string{w};});
fold_left_first has no initial value and returns an optional, since an empty range has no answer.
std::optional<int> largest =std::ranges::fold_left_first(xs, [](int a, int b) { return a > b; });
Folding a pipeline, without building anything in between.
int sum_of_even_squares = std::ranges::fold_left(xs | rv::filter([](int n) { return n % 2 == 0; })| rv::transform([](int n) { return n * n; }),0, std::plus{});
Higher-order functions
Partial application, both ends.
auto add = [](int a, int b) { return a + b; };auto inc = std::bind_front(add, 1); // C++20auto halve = std::bind_back(divide, 2); // C++23inc(41); // 42
Composition. The generic lambda makes it arity-agnostic.
template <class F, class G>constexpr auto compose(F f, G g) {return [f = std::move(f), g = std::move(g)]<class... As>(As&&... as) {return f(g(std::forward<As>(as)...));};}constexpr auto shout = compose(exclaim, uppercase);
Variadic composition, folding over the pack.
template <class... Fs>constexpr auto pipe(Fs... fs) {return [=](auto x) {return (x | ... | fs);};}
A recursive lambda, via deducing this. Before C++23 this needed a Y combinator or a named function.
auto fact = [](this auto&& self, int n) -> int {return n <= 1 ? 1 : n * self(n - 1);};
Compile-time evaluation
constexpr functions run at compile time when their inputs allow it. static_assert is the test.
constexpr int fib(int n) {return n < 2 ? n : fib(n - 1) + fib(n - 2);}static_assert(fib(20) == 6765);
consteval forces it: this function cannot be called at runtime at all.
consteval std::size_t checked_width(std::size_t n) {return n > 0 && n <= 4096 ? n : throw "width out of range";}
Fold expressions over parameter packs.
template <class... Ts>constexpr auto sum(Ts... ts) { return (ts + ... + 0); }template <class... Ts>constexpr bool all_positive(Ts... ts) { return ((ts > 0) && ...); }
Persistent data structures
The standard containers copy wholesale. immer gives structural sharing, so an update is O(log n) and the old value stays valid.
$ vcpkg install immer
Every operation returns a new container. v0 is untouched, and the two share almost all their storage.
#include <immer/vector.hpp>immer::vector<int> v0;auto v1 = v0.push_back(1);auto v2 = v1.push_back(2);auto v3 = v2.set(0, 99);// v0.size() == 0, v1.size() == 1, v3[0] == 99
Which makes sharing across threads free, with no locks and no defensive copies.
std::atomic<immer::vector<int>> state;auto snapshot = state.load(); // cheap, and stable while you read it
Lazy sequences
std::generator, from C++23, is a coroutine that yields a range. This is where you get genuinely lazy production rather than lazy adaptation.
#include <generator>std::generator<int> naturals() {for (int i = 0;; ++i) co_yield i;}std::generator<int> fibs() {int a = 0, b = 1;while (true) { co_yield a; std::tie(a, b) = std::pair{b, a + b}; }}
It is a range, so it composes with everything above.
auto v = fibs()| rv::filter([](int n) { return n % 2 == 0; })| rv::take(10)| std::ranges::to<std::vector>();
Recursive generators flatten themselves, which makes tree traversal a few lines.
std::generator<const Node&> walk(const Node& n) {co_yield n;for (const auto& child : n.children)co_yield std::ranges::elements_of(walk(child));}
The gaps that remain: no exhaustive match on anything but variant, no higher-kinded abstraction over optional and expected despite their identical shape, and no tail-call guarantee, so deep recursion still needs a loop.