Skip to content

libfn/functional

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

233 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

functional

Functional programming in C++

codecov FOSSA Status Quality Gate Status

Why

The purpose of this library is to exercise an approach to functional programming in C++ on top of the existing standard vocabulary types (such as std::expected and std::optional), with the aim of eventually extending future revisions of the C++ standard library with the functionality found to work well.

Example

// Various error types.
enum class NotANumber {};
enum class DivByZero {};
enum class Overflow {};

// Operations on rational numbers.
enum class Add {};
enum class Sub {};
enum class Mul {};
enum class Div {};

// `parse` turns a '/' delimited string into a pair of numbers (a numerator and denominator)
constexpr auto parse(std::string_view s) noexcept
    -> fn::expected<fn::pack<int, int>, fn::sum<NotANumber>>;

class Rational {
  int n_, d_;
  constexpr Rational(int n, int d) noexcept : n_(n), d_(d) {}

public:
  constexpr bool operator==(Rational const &) const noexcept = default;
  constexpr int num() const noexcept { return n_; }
  constexpr int den() const noexcept { return d_; }

  // The invariants live in the type: `make` is the only way to build a `Rational`, and every one is
  // reduced, sign-normalized and representable. Callers receive a value they never need re-check.
  static constexpr struct make_t {
    constexpr auto operator()(long long n, long long d) const noexcept
        -> fn::expected<Rational, fn::sum_for<DivByZero, Overflow>>
    {
      if (d == 0) return fn::unexpected{fn::sum{DivByZero{}}};
      if (n == std::numeric_limits<long long>::min() || d == std::numeric_limits<long long>::min())
        return fn::unexpected{fn::sum{Overflow{}}};

      auto const g = (d < 0 ? -1 : 1) * std::gcd(n, d);
      n /= g;
      d /= g;
      if (n < std::numeric_limits<int>::min() || n > std::numeric_limits<int>::max()
          || d > std::numeric_limits<int>::max()) {
        return fn::unexpected{fn::sum{Overflow{}}};
      }

      return Rational(static_cast<int>(n), static_cast<int>(d));
    }

    constexpr auto operator()(std::string_view s) const noexcept
    {
      return parse(s) | fn::and_then(*this);
    }
  } make{};

  constexpr auto neg() const noexcept { return make(-1LL * n_, d_); }
  constexpr auto inv() const noexcept { return make(d_, n_); }
  constexpr auto add(Rational const &other) const noexcept
  {
    return make(1LL * n_ * other.d_ + 1LL * other.n_ * d_, //
                1LL * d_ * other.d_);
  }
  constexpr auto sub(Rational const &other) const noexcept
  {
    return other.neg() | fn::and_then([*this](Rational y) { return add(y); });
  }
  constexpr auto mul(Rational const &other) const noexcept
  {
    return make(1LL * n_ * other.n_, 1LL * d_ * other.d_);
  }
  constexpr auto div(Rational const &other) const noexcept
  {
    return other.inv() | fn::and_then([*this](Rational y) { return mul(y); });
  }
};

// `evaluate` parses each operand, applies the operator, and lets `make` re-check the result.
// Each stage fails its own way, and the library folds error types into one sum of types.
constexpr auto evaluate(std::string_view a, fn::sum_for<Add, Sub, Mul, Div> op,
                        std::string_view b) noexcept
{
  using Op = fn::expected<decltype(op), fn::sum<>>;
  return (Rational::make(a) & Op{op} & Rational::make(b)) //
         | fn::and_then(fn::overload{[](Rational x, Add, Rational y) { return x.add(y); },
                                     [](Rational x, Sub, Rational y) { return x.sub(y); },
                                     [](Rational x, Mul, Rational y) { return x.mul(y); },
                                     [](Rational x, Div, Rational y) { return x.div(y); }});
}

// The error type of a sequence is the derived sum of all failure modes, never spelled by hand:
static_assert(std::is_same_v<decltype(Rational::make("1/1")),
                             fn::expected<Rational, fn::sum<DivByZero, NotANumber, Overflow>>>);
static_assert(std::is_same_v<decltype(evaluate("1/2", Add{}, "3/4")),
                             fn::expected<Rational, fn::sum<DivByZero, NotANumber, Overflow>>>);
// Constant evaluated calculations used to verify both values and errors during compilation:
static_assert(evaluate("1/2", Add{}, "1/3").value() == Rational::make(5, 6));
static_assert(evaluate("2/3", Div{}, "0/1").error().has_value<DivByZero>());

What

The library features demonstrated by the code example above:

  • Monadic sequencesoperator| pipes a fn::expected (or fn::optional) through operations: and_then and transform act on the value, or_else, recover and transform_error on the error, with filter, inspect, fail and more besides.
  • Graded errors — each stage fails its own way — a malformed string, a zero denominator, an out-of-range result — and the library folds these into one fn::sum whose type it derives for you: here fn::sum<DivByZero, NotANumber, Overflow>, never spelled by hand.
  • Composing valuesoperator& gathers successful operands left to right: two values become a fn::pack, a third appends to it. A fn::pack is a heterogeneous product — the operands as one value, spread into the next call; for example in make, where a pack<int, int> returned from parse is passed to an overload taking two numbers.
  • Composing alternatives — when a side is a fn::sum (a co-product — one of several types, indexed by type, not by position like std::variant), & distributes over it, pairing every alternative with the other operand. Two sums yield the full cartesian product. The result type is flattened, deduplicated and sorted for you.
  • Multidispatch — the pack (or sum of packs) flows into the next stage as separate arguments. An fn::overload — or any function — dispatches on the runtime alternative by ordinary overload resolution. Dispatch is exhaustive: a missing handler is a compile error.
  • Identity monadfn::expected<T, fn::sum<>> cannot hold an error (enforced at compile time), a spelling of the identity monad; the example lifts op into it as Op.
  • No surprises — libfn throws no exceptions of its own (only value(), as the standard mandates), and composes safely with callables that do; it allocates no memory of its own and performs no I/O. Being fully constexpr, it can drive a program evaluated entirely at compile time, where the compiler diagnoses any undefined behaviour.

The example also demonstrates how well libfn works with general programming idioms. make is a smart constructor — the only way to build a Rational — enforcing the type's invariants and returning fn::expected: callers never need to re-check what the type guarantees. Treating callables as values lets operations such as and_then accept make whole, carrying its overload set.

These properties also make libfn a natural fit for asynchronous composition, such as coroutines or senders/receivers. Operations and monadic types alike are plain values: and_then(f) is a description of a step, executed only when a monad is piped into it (an input to the sequence, or the result of the preceding operation). A framework can hold the steps of a computation and apply them as results arrive, with a strongly typed error channel and no hidden control flow — exactly what such programming models need.

Beyond the example: fn::choice (a monad over fn::sum); the same operations over fn::optional as over fn::expected; tuple protocol in fn::pack (get<I>(p) or structured bindings); fn::pack and fn::sum are both structural types (a constexpr value which may be used as a template parameter); support for immovable values and callables; and more — see examples/ and the API reference.

How

The library comes as two parts in one repository:

  • pfn (include/pfn, namespace pfn) — a faithful polyfill of standard-library vocabulary types as specified for C++26: std::expected, std::optional (including the monadic functions, optional<T&> and range support), plus smaller utilities such as std::invoke_r and std::unreachable. It adds nothing of its own on top of what's mandated by the C++ standard or accepted for a future revision — such as has_error().
  • fn (include/fn, namespace fn) — the functional-programming library. It extends the vocabulary types with the facilities useful in writing functional style programs — monadic operations composable with operator|, such as and_then, transform, or_else, inspect, recover, filter — and adds new vocabulary types: sum, choice, pack.

Every fn type with a pfn counterpart is a strict superset of it: switching a valid program using pfn types to use fn instead changes neither compilation nor program behaviour.

fn builds on pfn, and all of libfn requires only a C++20-compatible compiler. The minimum supported compilers are gcc 12 and clang 16; Apple Clang 16.0 and Microsoft Visual Studio 2022 (or newer) are supported as well. See CONTRIBUTING.md for how to set up a recent enough toolchain when your OS does not ship one.

Implementation note

This library requires a total ordering of types, which the standard provides only from C++26 (std::type_order) and no compiler implements yet. The library relies on an internal, naive implementation of such a feature which is not expected to work with unnamed types, types without linkage etc. When std::type_order is implemented in available compilers, the library will offer a compilation mode to make use of this feature.

Using the library

The library is header-only. The CMake package exports two targets:

find_package(libfn CONFIG REQUIRED)
target_link_libraries(main PRIVATE libfn::fn)   # or libfn::pfn for the polyfills alone

Packaging is provided — and exercised by CI — for conan, vcpkg (an in-repo port), Nix and Bazel; plain CMake FetchContent or add_subdirectory works as well. Until the first tagged release, consume a pinned git revision — and read Backwards compatibility.

Every packaging route above except Bazel also delivers the compile options the headers require. Under Bazel — and a plain copy of include/ — these options don't arrive automatically; provide them yourself: C++20 or newer (--cxxopt=-std=c++20 in Bazel), -Wno-missing-braces on clang (fn::pack initialization elides braces by design), and with MSVC /permissive- plus _HAS_CXX23. The authoritative set is the INTERFACE options in cmake/CompilationOptions.cmake.

Backwards compatibility

The maintainers aim for compatibility with the proposed changes to the C++ standard library, rather than with the existing uses of the code in this repo. In practice, this means that all code in this repo should be considered "under intensive development and unstable" until the standardization of the proposed facilities.

Versioning and ABI

Releases are numbered 0.y.z and will stay below 1.0.0 for the foreseeable future. SemVer treats any 0.y.z version as unstable — anything may change — so libfn narrows that into a usable contract:

  • a bump in y is a breaking change (API and/or ABI);
  • a bump in z is a bug fix or a purely additive extension: the API and ABI stay compatible, but inline function definitions may change — see below.

Because the library is header-only, use a single libfn version per binary. Mixing versions in one program is an ODR violation — and that includes two z releases of the same y line, whose inline definitions may differ even though the ABI matches.

Contributing

See CONTRIBUTING.md for the development environment, building, testing, the version-bump mechanics, and the pre-commit workflow. The design history — decisions and the ideas they obsoleted — is recorded in CHANGELOG.md.

Acknowledgments

License

FOSSA Status

About

Extending C++ library for functional programming

Resources

License

Contributing

Stars

94 stars

Watchers

4 watching

Forks

Contributors