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
§ 01

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}); // ok
speed(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;
}
§ 02

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);
}
§ 03

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;
}
}
§ 04

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());
§ 05

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::drop
rv::take_while rv::drop_while rv::reverse rv::join
rv::split rv::zip rv::enumerate rv::chunk
rv::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);
§ 06

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{});
§ 07

Higher-order functions

Partial application, both ends.

auto add = [](int a, int b) { return a + b; };
auto inc = std::bind_front(add, 1); // C++20
auto halve = std::bind_back(divide, 2); // C++23
inc(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);
};
§ 08

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) && ...); }
§ 09

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
§ 10

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.