Functional OOP and Immutability

Learning Objectives

Combine OOP with pure functions, immutable value objects and higher-order functions.

Apply "functional core, imperative shell".

Use algebraic data types and pattern matching where available.

Prerequisites: Modules 10, 18, 20.

Intuition and Definition

Pure function: output depends only on inputs; no side effects.

Immutable object: state never changes after construction; "modification" returns a new object.

Higher-order function: takes or returns functions.

Algebraic data types: *sum types* (one of several cases — std::variant, sealed hierarchies, TS unions) and *product types* (structs/records).

Functional core, imperative shell: keep decisions and calculations pure (easy to test), and push I/O, time and mutation to a thin outer layer.

Why It Matters

Mutable shared state is the root of many bugs, especially with concurrency. Immutable values and pure functions make code easier to test, reason about and parallelize — and fit naturally *inside* objects.

Syntax and Language Notes

public sealed record Reading(string Sensor, double Value);   // immutable
var hot = readings.Where(r => r.Value > 100).Select(r => r with { Value = Math.Round(r.Value) });
type Event = { kind: "start" } | { kind: "stop"; reason: string };   // sum type
function describe(e: Event) { return e.kind === "stop" ? `stopped: ${e.reason}` : "started"; }

Complete Example

#include <algorithm>
#include <iostream>
#include <numeric>
#include <ranges>
#include <string>
#include <variant>
#include <vector>

// immutable value object: no mutators; "changes" return new values.
// (Members are NOT declared const: const members would delete assignment,
//  and std::accumulate needs to assign the running result.)
class Trip {
    double km_;
    double litres_;
public:
    Trip(double km, double l) : km_(km), litres_(l) {}
    Trip plus(const Trip& o) const { return {km_ + o.km_, litres_ + o.litres_}; }
    double consumption() const { return km_ > 0 ? litres_ / km_ * 100 : 0; }
    double km() const { return km_; }
};

// sum type for events + pure decision function (functional core)
struct Refuel { double litres; };
struct Drive  { double km; double litres; };
struct Reset  {};
using Event = std::variant<Refuel, Drive, Reset>;
template <class... F> struct overloaded : F... { using F::operator()...; };

struct State { double tank; Trip trip; };

State apply(const State& s, const Event& e) {               // pure: no I/O, no mutation of s
    return std::visit(overloaded{
        [&](const Refuel& r) { return State{std::min(55.0, s.tank + r.litres), s.trip};
…
tank 43 l, trip 400 km, 6.35 l/100km
over the limit by: 11 7 22
start state unchanged: tank 40

Step-by-Step Walkthrough

Trip is immutable; plus returns a new trip.

apply is a pure function from (state, event) to new state — trivially testable with plain values.

std::accumulate folds the event log into the final state; replaying the same log always gives the same result (the basis of event sourcing).

Ranges compose filter and transform lazily without temporary vectors.

s0 is untouched — no hidden mutation.

Common Mistakes

Pseudo-immutability — const objects holding pointers to mutable data.

const data members for immutability in C++ — they delete copy/move *assignment*, so the type can't be used with algorithms or containers that assign (std::accumulate, std::sort, vector::erase). Make the type immutable through its interface (private members, no setters) instead.

Copying huge structures on every change — use structural sharing (persistent data structures) or move semantics where needed.

Pure core leaking I/O (logging, time, randomness inside "pure" functions).

Forcing everything functional — stateful resources (connections, devices) are naturally objects.

Trade-offs and Alternatives

Immutability costs allocations/copies but buys thread safety, easy undo/time travel and simpler tests. Mutable objects with strong encapsulation remain right for resources and performance-critical loops.

Real-World Use

Redux/Elm architecture (state = fold of actions), event sourcing, React's immutable props, LINQ/streams pipelines, financial calculations (pure pricing functions), simulation replays.

Practice

Q1. (Easy) What makes a function pure?

Answer: Same inputs always give the same output, and it has no observable side effects.

Q2. (Medium) Why are immutable objects naturally thread-safe?

Answer: No thread can modify them, so concurrent reads need no synchronization.

Q3. (Advanced) What does "functional core, imperative shell" mean for testing?

Answer: Most logic lives in pure functions that are tested with plain inputs/outputs (fast, deterministic); the thin shell that does I/O is tested with a few integration tests.

Interview Question

Q1. "How do OOP and functional programming fit together?"

Answer: They are complementary: objects provide modules, encapsulation of resources and polymorphic boundaries; functional techniques provide immutable values, pure functions and composition inside those boundaries. A good design uses immutable value objects and pure domain calculations, with mutable objects reserved for identity and resources (entities, connections), and pushes side effects to the edges.

Quick Quiz

Q1. C++ type for a sum type?

Answer: std::variant.

Q2. What is a fold?

Answer: Combining a sequence into one value by repeatedly applying a function (e.g. accumulate).

Q3. Name a UI architecture built on folding actions into state.

Answer: Redux (or the Elm architecture).

Summary

Use pure functions and immutable values inside OO designs; keep side effects at the edges.

Sum types + pattern matching model closed sets of cases.

Related: Modules 18, 49, 60.