Skip to content

Strategy in Rust: Functions, Closures and Traits

Strategy separates a varying decision from the algorithm that uses it. In Rust, that decision may be a function, a closure or a named trait implementation; it need not become an inheritance hierarchy. Choose how to represent the variation from its state, ownership and runtime-selection requirements.

Rust Strategy requirements before choosing a type

Read closures, traits and dispatch, and the Part 5 checkpoint. These independent fixtures use stable Rust edition 2024 and C++20, with no additional dependencies.

Count accepted readings from the unchanged input [-500, 0, 46700]. Acceptance means reading >= minimum, inclusive. Nonnegative readings use minimum zero; warm readings use 40000. A lower runtime threshold of -1000 accepts all three. Zero is a real reading, not absence, and an empty input accepts none.

The counting algorithm calls its decision exactly once per input, in input order. That explicit contract matters when a policy records call counts. This is filtering, not parsing: inputs are already valid signed integers and comparisons do not add or subtract them. Both languages should print:

1
2
3
4
5
6
7
nonnegative=2
warm=1
captured=2
dynamic=1
changed=3
stateful=2, calls=3
retained=3

C++: a classic Strategy and a callable alternative

Save this complete program as main.cpp. The Policy interface is a conventional Strategy family, with a virtual destructor for potential owned use. Here its implementations live on the stack and are borrowed. count_with also accepts a function or lambda without requiring that interface.

#include <array>
#include <cassert>
#include <cstddef>
#include <iostream>
#include <limits>
#include <span>

struct Policy {
    virtual bool accept(int reading) const = 0;
    virtual ~Policy() = default;
};

struct NonNegative : Policy {
    bool accept(int reading) const override { return reading >= 0; }
};

struct AtLeast : Policy {
    int minimum;
    explicit AtLeast(int value) : minimum(value) {}
    bool accept(int reading) const override { return reading >= minimum; }
};

bool nonnegative(int reading) { return reading >= 0; }

template<class Predicate>
std::size_t count_with(std::span<const int> readings, Predicate predicate) {
    std::size_t accepted = 0;
    for (int reading : readings) {
        if (predicate(reading)) ++accepted;
    }
    return accepted;
}

std::size_t count_policy(std::span<const int> readings, const Policy& policy) {
    return count_with(readings, [&policy](int reading) { return policy.accept(reading); });
}

int main() {
    const std::array readings{-500, 0, 46700};
    const NonNegative ordinary;
    const AtLeast warm(40000);
    assert(count_with(readings, nonnegative) == 2);
    std::cout << "nonnegative=" << count_with(readings, nonnegative) << '\n';
    assert(count_policy(readings, warm) == 1);
    std::cout << "warm=" << count_policy(readings, warm) << '\n';
    int threshold = 0;
    auto captured = [threshold](int reading) { return reading >= threshold; };
    assert(count_with(readings, captured) == 2);
    std::cout << "captured=" << count_with(readings, captured) << '\n';

    // All pointers are non-null and their stack owners outlive this selection.
    const std::array<const Policy*, 2> candidates{&ordinary, &warm};
    bool choose_warm = true;
    const Policy& selected = *candidates[choose_warm ? 1 : 0];
    std::cout << "dynamic=" << count_policy(readings, selected) << '\n';
    assert(count_policy(readings, *candidates[0]) == 2);
    assert(count_policy(readings, *candidates[1]) == 1);
    const AtLeast changed(-1000);
    std::cout << "changed=" << count_policy(readings, changed) << '\n';
    assert(count_policy(readings, changed) == 3);

    std::size_t calls = 0;
    auto counted = [&calls](int reading) { ++calls; return reading >= 0; };
    auto accepted = count_with(readings, counted);
    assert(accepted == 2 && calls == readings.size());
    std::cout << "stateful=" << accepted << ", calls=" << calls << '\n';
    assert((readings == std::array{-500, 0, 46700}));
    std::cout << "retained=" << readings.size() << '\n';

    const std::array<int, 0> empty{};
    assert(count_policy(empty, ordinary) == 0);
    assert(count_with(empty, counted) == 0 && calls == 3);
    const std::array limits{std::numeric_limits<int>::min(), 0, std::numeric_limits<int>::max()};
    assert(count_policy(limits, ordinary) == 2);
    assert(count_policy(std::array{40000}, warm) == 1);
}

Compile with assertions enabled:

1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 main.cpp -o strategy
./strategy
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 main.cpp -o strategy_release
./strategy_release

The virtual interface permits calling a chosen implementation through Policy. That requirement does not mandate heap allocation. The callable template is an alternative, not an implementation of the same inheritance relationship. A captured lambda keeps state in its closure; lambda conversion rules do not turn an ordinary capturing lambda into a plain function pointer.

count_with takes its callable by value. Here the counter lambda captures a reference, so its copied closure still updates the external counter. An internally owned counter would be copied with the lambda and needs a different API if the caller expects to observe its accumulated state. Borrowing and copying callable objects are different ownership contracts.

Rust: callables first, named traits when useful

Run cargo new strategy --edition 2024, then replace src/main.rs with the complete source below. count_with accepts FnMut because the contract allows the decision to update captured state on repeated calls. Read-only closures and function pointers also satisfy this bound.

trait Policy {
    fn accept(&self, reading: i32) -> bool;
}

struct NonNegative;

impl Policy for NonNegative {
    fn accept(&self, reading: i32) -> bool {
        reading >= 0
    }
}

struct AtLeast {
    minimum: i32,
}

impl Policy for AtLeast {
    fn accept(&self, reading: i32) -> bool {
        reading >= self.minimum
    }
}

fn nonnegative(reading: i32) -> bool {
    reading >= 0
}

fn count_with<F: FnMut(i32) -> bool>(readings: &[i32], mut predicate: F) -> usize {
    let mut accepted = 0;
    for &reading in readings {
        if predicate(reading) {
            accepted += 1;
        }
    }
    accepted
}

fn count_policy<P: Policy + ?Sized>(readings: &[i32], policy: &P) -> usize {
    count_with(readings, |reading| policy.accept(reading))
}

fn main() {
    let readings = [-500, 0, 46700];
    let ordinary = NonNegative;
    let warm = AtLeast { minimum: 40000 };
    assert_eq!(count_with(&readings, nonnegative), 2);
    println!("nonnegative={}", count_with(&readings, nonnegative));
    assert_eq!(count_policy(&readings, &warm), 1);
    println!("warm={}", count_policy(&readings, &warm));
    let threshold = 0;
    let captured = move |reading| reading >= threshold;
    assert_eq!(count_with(&readings, captured), 2);
    println!("captured={}", count_with(&readings, captured));

    let candidates: [&dyn Policy; 2] = [&ordinary, &warm];
    let choose_warm = true;
    let selected = candidates[usize::from(choose_warm)];
    println!("dynamic={}", count_policy(&readings, selected));
    let changed = AtLeast { minimum: -1000 };
    assert_eq!(count_policy(&readings, &changed), 3);
    println!("changed={}", count_policy(&readings, &changed));

    let mut calls = 0;
    let counted = |reading| {
        calls += 1;
        reading >= 0
    };
    let accepted = count_with(&readings, counted);
    assert_eq!((accepted, calls), (2, readings.len()));
    println!("stateful={accepted}, calls={calls}");
    assert_eq!(readings, [-500, 0, 46700]);
    println!("retained={}", readings.len());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn inclusive_threshold_retains_zero_and_boundary() {
        assert_eq!(count_with(&[-500, 0, 46700], nonnegative), 2);
        assert_eq!(
            count_policy(&[39999, 40000], &AtLeast { minimum: 40000 }),
            1
        );
        assert_eq!(count_policy(&[i32::MIN, 0, i32::MAX], &NonNegative), 2);
    }

    #[test]
    fn concrete_and_runtime_selected_policies_agree() {
        let readings = [-500, 0, 46700];
        let ordinary = NonNegative;
        let warm = AtLeast { minimum: 40000 };
        let candidates: [&dyn Policy; 2] = [&ordinary, &warm];
        for (choose_warm, expected) in [(false, 2), (true, 1)] {
            assert_eq!(
                count_policy(&readings, candidates[usize::from(choose_warm)]),
                expected
            );
        }
        assert_eq!(
            count_policy(&readings, &warm),
            count_with(&readings, |value| value >= 40000)
        );
    }

    #[test]
    fn runtime_threshold_data_changes_the_decision() {
        let readings = [-500, 0, 46700];
        for (minimum, expected) in [(-1000, 3), (0, 2), (40000, 1), (46701, 0)] {
            assert_eq!(count_policy(&readings, &AtLeast { minimum }), expected);
            assert_eq!(count_with(&readings, |value| value >= minimum), expected);
        }
    }

    #[test]
    fn stateful_decision_observes_each_input_once_in_order() {
        let readings = [-500, 0, 46700];
        let mut visited = Vec::new();
        let accepted = count_with(&readings, |value| {
            visited.push(value);
            value >= 0
        });
        assert_eq!(accepted, 2);
        assert_eq!(visited, readings);
        assert_eq!(readings, [-500, 0, 46700]);
    }

    #[test]
    fn empty_input_does_not_call_the_decision() {
        let mut calls = 0;
        assert_eq!(
            count_with(&[], |_| {
                calls += 1;
                true
            }),
            0
        );
        assert_eq!(calls, 0);
        assert_eq!(count_policy(&[], &NonNegative), 0);
    }
}

Inside the project:

1
2
3
4
5
6
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --release --quiet

FnMut permits repeated calls with mutable access to the callable. Fn is a narrower receiver requirement, not a guarantee of mathematical purity: interior mutability or external effects are separate questions. This counter closure borrows calls mutably, so the caller observes its value after the closure has been consumed and the borrow ends. To retain an internally owned closure state across multiple counts, pass a mutable borrow of that closure.

The named Policy trait is useful when the domain needs a documented policy contract or several related operations. It is not required for every one-function variation. count_policy accepts concrete types or a borrowed trait object; ?Sized permits that latter argument. With a dyn Policy argument, the method call uses dynamic dispatch even though count_policy is generic. A generic function does not make every operation inside it statically dispatched.

Changed requirements: state, runtime choice and ownership

A captured threshold is runtime data without requiring dynamic dispatch: each invocation can supply a closure of a concrete type holding that threshold. An enum is also appropriate when configuration defines a closed, stable set of policy alternatives. Adding a new enum variant changes exhaustive handling; adding a new trait implementation can extend an open family without adding a variant to that enum.

Our two dyn references can choose between existing owners at runtime, but cannot outlive them. If a context must independently own a chosen open policy, it can store Box with an appropriate lifetime bound. A C++ context may similarly own a unique_ptr. Allocation, construction failure and object lifetime are separate choices from the decision interface.

If the policy records per-call history, define whether the count may repeat, cache, parallelise or short-circuit decisions. This fixture promises exactly once in input order; replacing it with an optimisation that skips calls changes the contract. A mutable trait method, FnMut or explicit returned statistics may express stateful behaviour more clearly than hidden shared mutation. Thread-safe execution requires additional bounds and synchronisation, not merely a Fn or const receiver.

Intentional failure: a function pointer cannot store captures

Compile this Rust program independently. A plain fn pointer has no storage for the captured threshold:

1
2
3
4
5
6
7
fn make(minimum: i32) -> fn(i32) -> bool {
    move |reading| reading >= minimum
}

fn main() {
    let _ = make(0);
}

Repair the return type with impl Fn(i32) -> bool to return this single concrete closure type, and test both a nonnegative and a negative threshold. Alternatively, return a named value whose threshold field is explicit. Box bool> is useful when independently owned heterogeneous callables require one erased interface; it is not mandatory just because the threshold is runtime data.

This C++ program likewise fails to convert a capturing lambda to a function pointer:

1
2
3
4
5
int main() {
    int minimum = 0;
    bool (*predicate)(int) = [minimum](int reading) { return reading >= minimum; };
    (void)predicate;
}

Use a deduced callable type for a fixed lambda expression or a suitable owning wrapper if the interface requires type erasure. Making minimum global would change the state/ownership contract rather than repairing it transparently.

Selection criteria and exercises

Variation requirement Candidate Main obligation
No captured state, fixed signature Function pointer Document decision semantics
Runtime data in one concrete callable Generic closure / opaque closure return Specify capture ownership and call receiver
Named domain behaviour with concrete implementation Trait bound / callable or constrained C++ template Define semantics beyond method signatures
Closed runtime configuration alternatives Enum / closed tagged representation Handle new alternatives explicitly
Borrowed open policy family selected at runtime &dyn Policy / virtual interface reference Keep concrete owners alive
Independently owned open policy family Box / unique_ptr Define lifetime, construction failure and destruction
  1. Add a policy with a threshold of 46701. Both concrete and dynamically selected paths must count zero without modifying the input.
  2. Change >= to > and retain the zero/boundary tests. The changed result is a contract failure, not a speed improvement.
  3. Repair the capturing-function-pointer example and test two thresholds. Explain why runtime threshold data does not require a trait object.
  4. Retain one mutable closure across two count operations. Assert six calls and preserve the accepted counts; distinguish borrowed state from copying a C++ closure that owns its counter.
  5. Choose an enum for a closed configuration format and a trait for an externally extended policy family. Identify what must change when adding a policy, and define who owns the selected value.

Verification and next step

On October 10, 2026, the extracted sources passed on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust/Cargo 1.99.0 and GCC g++ 14.2.0. Rust check, exact-source formatting, five debug/release tests and exact outputs passed. The opaque closure repair and retained-counter variant passed assertions; capturing-function-pointer and Fn/FnMut mismatches were rejected with E0308/E0525. A deliberate >= to > change compiled but failed the inclusive-boundary test. C++20 normal output/assertions, captured-threshold repair, copied internal state and borrowed counter preservation passed at -O0/-O2; capturing-lambda conversion to a function pointer was rejected. Sources/logs were retained and generated Cargo targets cleaned. No dispatch timing, allocation-efficiency or code-size comparison is claimed.

Next: runtime State representations and typestate, comparing which operations are available and what survives a failed transition.

Previous: address stability and Pin · Next: State and typestate · Course overview

Donate