Lambda Comparison

C++ vs. Rust

Jacob Xie published on
11 min, 2137 words

Categories: Post

While working on a trivial project, a simple idea hits on me: what's the difference between C++'s lambda and Rust's closure? Having an explicit listing may give me a better cognition.

Here is a quick summary of today's topic:

functionC++'s ConceptRust's ConceptMajor Variety
lambda_and_fn_ptr✔️✔️
simple_lambda✔️✔️✔️
passing_lambda_to_fn✔️✔️
generic_lambda✔️✔️
simple_capture✔️✔️
mutable_capture✔️✔️✔️
reference_capture✔️✔️✔️
ownership_capture✔️✔️✔️
mixing_capture✔️✔️
default_value_capture✔️
default_reference_capture✔️
default_mixing_capture✔️
init_var_capture✔️
copy_lambda✔️
copy_ref_lambda✔️

lambda_and_fn_ptr

Function pointer is not necessary a key difference, though they can be passed as a parameter to a function both in C++ and Rust. However, in C++, rather than using a function pointer to hold a non-capturing lambda (as its odd syntax), prefer using a list initialization with lambda syntax.

C++

void lambda_and_fn_ptr() {
  int (*lbd)(int){[](int i) { return i * 2; }};

  std::cout << lbd(2) << std::endl;
  std::cout << lbd(3) << std::endl;
}

Unlike C++, no need to explicit declare a pointer with function type. Instead, a named variable with function pointer type is a pointer variable. Identically, C++'s function pointer only works for non-capturing lambda, and Rust function pointer type can only be created for non-capturing closures.

Rust

fn lambda_and_fn_ptr() {
    let mul = |i: i32| i * 2;

    type M = fn(i32) -> i32;

    let lbd: M = mul;

    println!("{:?}", lbd(2));
    println!("{:?}", lbd(3));
}

simple_lambda

In both C++ and Rust, a simple lambda/closure is non-capturing. Note that simple_lambda2 in C++, instead of using auto keyword, use a type deduction for std::function, and prior C++17, explicit type std::function<(int)> is required.

C++

void simple_lambda1() {
  auto lbd{[](int i) { return i * 3; }};

  std::cout << lbd(2) << std::endl;
}

void simple_lambda2() {
  std::function lbd{[](int i) { return i * 4; }};

  std::cout << lbd(2) << std::endl;
}

Rust

fn simple_lambda1() {
    let lbd = |i: i32| i * 3;

    println!("{:?}", lbd(2));
}

passing_lambda_to_fn

In C++, passing a function point who represents a lambda expression, whereas in Rust, passing a pointer function type variable.

C++

void passing_lambda_to_fn(const std::function<int(int)>& fn) {
  constexpr int input = 2;

  std::cout << fn(input) << std::endl;
}

Rust

fn passing_lambda_to_fn(f: fn(i32) -> i32) {
    let input = 2;

    println!("{:?}", f(input));
}

generic_lambda

In C++, there are either auto keyword or function template can serve generic lambda.

C++

void generic_lambda0(auto value) {
  auto print{[](auto v) { std::cout << "value: " << v << std::endl; }};

  print(value);
}

template <typename T> void generic_lambda1(T value) {
  auto print{[](T v) { std::cout << "value: " << v << std::endl; }};

  print(value);
}

Rust

fn generic_lambda<T: Debug>(value: T) {
    let lbd = |v| {
        println!("value: {v:?}");
    };

    lbd(value);
}

simple_capture

The first biggest difference happens while calling simple_capture: although it looks like the same when capturing a variable is actually clone this variable, Rust acts differently on account of Copy/Clone trait implementation.

C++

void simple_capture() {
  std::string data{"halloween has come!"};

  auto rise_headless_horseman{[data](std::string_view str) { std::cout << data << " " << str; }};

  const std::string_view greet{
      "Prepare yourselves, the bells have tolled! Shelter your weak, your young and your old! Each "
      "of you shall pay the final sum. Cry for mercy, the reckoning has come!"};

  rise_headless_horseman(greet);
}

As shown below, the only difference between simple_capture2 and simple_capture3 is the #[derive(Clone)] on struct MyStr(String);. In short, a variable who does not implemented copy or clone trait will trigger move semantics, and its ownership will be passed into the closure who captured it.

Rust

fn simple_capture1() {
    let data = String::from("halloween has come!");

    let rise_headless_horseman = |s| {
        println!("{data:?} {s:?}",);
    };

    const GREET: &str = "Prepare yourselves, the bells have tolled! Shelter your weak, your young and your old! Each of you shall pay the final sum. Cry for mercy, the reckoning has come!";

    rise_headless_horseman(GREET);

    let _check_ownership = data.into_bytes();
}

fn simple_capture2() {
    struct MyStr(String);

    impl MyStr {
        fn turn(self) -> String {
            self.0
        }
    }

    let data = MyStr(String::from("halloween coming soon"));

    let prepare_pumpkins = |s| {
        println!("{:?} -> {s}", data.0);
    };

    const G: &str = "Carving pumpkins";

    prepare_pumpkins(G);

    // compile error here! since data has been moved into `prepare_pumpkins`
    // let _check_ownership = data.turn();
}

fn simple_capture3() {
    #[derive(Clone)]
    struct MyStr(String);

    impl MyStr {
        fn turn(self) -> String {
            self.0
        }
    }

    let data = MyStr(String::from("halloween coming soon"));

    let prepare_pumpkins = |s| {
        println!("{:?} -> {s}", data.0);
    };

    const G: &str = "Carving pumpkins";

    prepare_pumpkins(G);

    // Ownership hasn't been take until now.
    // Clone has been made while compiling `prepare_pumpkins` on account of `#[derive(Clone)]`
    let _check_ownership = data.turn();
}

mutable_capture

The second mainly difference is right here: C++'s mutable capture (a mutable keyword after parentheses) means granting mutability to all captured variables (remove the default const capturing limitation), and in fact these variables are still cloned from their original statements; on the other hands, Rust's mutable capture is done by mutable reference capture.

C++

void mutable_capture() {
  int quiver{8};

  auto shoot{[quiver]() mutable {
    --quiver;
    std::cout << "Arrows left: " << quiver << std::endl;
  }};

  shoot();
  shoot();
  shoot();
}

Rust

fn mutable_capture1() {
    let mut quiver = 8;

    let mut shoot = || {
        // let q = &mut quiver;
        quiver -= 1;
        println!("Arrows left {quiver:?}");
    };

    shoot();
    shoot();
    shoot();

    println!("Check the rest: {quiver:?}");
}

fn mutable_capture2() {
    struct Quiver(i32);

    impl Quiver {
        fn shoot(&mut self) {
            self.0 -= 1;
        }

        fn check(&self) -> i32 {
            self.0
        }
    }

    let mut quiver = Quiver(8);

    let mut shoot = || {
        // let q = &mut quiver;
        // q.shoot();
        quiver.shoot();
        println!("Arrows left {:?}", quiver.check());
    };

    shoot();
    shoot();
    shoot();

    println!("Check the rest: {:?}", quiver.check());
}

reference_capture

The third difference is about capturing by reference. Capture by reference in C++ is done by a & operator before captured variable, and since C++'s reference is mutable, it can be used for value change. Yet reference in Rust means immutable reference by default. Hence, value change manipulation can only done by a Rc<RefCell<T>> and etc.

C++

void reference_capture() {
  int magazine{120};

  auto fire{[&magazine]() {
    magazine -= 10;
    std::cout << "Bullets left: " << magazine << std::endl;
  }};

  fire();
  fire();
  fire();
}

Rust

fn reference_capture1() {
    // let mut quiver = 8;
    let quiver = 8;

    let check = || {
        println!("Arrows check {:?}", &quiver);
    };

    check();

    // compile error: violate borrow checker
    // quiver -= 1;
    // check();
}

fn reference_capture2() {
    use std::cell::RefCell;
    use std::rc::Rc;

    let quiver = Rc::new(RefCell::new(8));

    let check = || {
        // Note: a `.clone()` on `Rc` only clones a memory address
        println!("Arrows check {:?}", quiver.clone().borrow());
    };

    check();

    *quiver.borrow_mut() -= 1;

    check();
}

ownership_capture

The fourth difference is the moving range: C++ is moved by specified variable and Rust is moved by all captured variables. C++ should #include<memory> first, then call by std::move(), and Rust use keyword move in front of closure.

C++

void ownership_capture() {
  int quiver{8};

  auto shoot{[q = std::move(quiver)]() mutable {
    // quiver has been moved
    --q;
    std::cout << "Arrows left: " << q << std::endl;
  }};

  shoot();
  shoot();
}

Rust

fn ownership_capture1() {
    let mut quiver = 8;

    let mut shoot = move || {
        quiver -= 1;
        println!("Arrows left {quiver:?}");
    };

    shoot();
    shoot();
    shoot();

    // ⚠️ watch out! this still prints `8`, because all the primitive type in Rust has `Copy` implemented!
    println!("Check the rest {quiver:?}");
}

fn ownership_capture2() {
    struct Quiver(i32);

    impl Quiver {
        fn shoot(&mut self) {
            self.0 -= 1;
        }

        fn check(&self) -> i32 {
            self.0
        }
    }

    let mut quiver = Quiver(8);

    let mut shoot = move || {
        quiver.shoot();
        println!("Arrows left {:?}", quiver.check());
    };

    shoot();
    shoot();
    shoot();

    // compile error here! Since `quiver` has been captured by ownership,
    // which in other words `quiver` is owned by the closure.
    // println!("Check the rest {:?}", quiver.check());
}

mixing_capture

Mixing capture is not so that important in Rust, as its capturing mechanism is actually differed by copy/clone, immutable/mutable reference which is already shown above (hence Rust's example is omitted in here).

C++

void mixing_capture() {
  // capture by reference, by this means, players aiming one single target
  int boss{100};
  // capture by value, by this means, represents different players
  int player{10};

  auto melee{[&boss, player](bool offensive) {
    static int p{player};
    char direction{'>'};
    if (offensive) {
      boss -= 10;
      direction = '<';
    } else {
      p -= 1;
      direction = '>';
    }

    std::cout << "boss[ " << boss << " ] " << direction << " melee[ " << p << " ]" << std::endl;
  }};

  auto range{[&boss, player](bool offensive) {
    static int p{player};
    char direction{'>'};
    if (offensive) {
      boss -= 20;
      direction = '<';
    } else {
      p -= 3;
      direction = '>';
    }

    std::cout << "boss[ " << boss << " ]  " << direction << "  range[ " << p << " ]" << std::endl;
  }};

  melee(false);
  melee(false);
  range(true);
  range(true);
  melee(true);
  range(false);
}

default_value_capture

The fifth difference is about default value/reference capture. There is no such kink of mechanism in Rust. The default value capture means "capture by value without explicit declaring captured variables".

C++

void default_value_capture() {
  int player{10};

  auto lbd{[=]() {
    // buffed by a holy priest
    std::cout << "Player get buffed: " << player + 5 << std::endl;
  }};

  lbd();

  std::cout << "The original player: " << player << std::endl;
}

default_reference_capture

The reference capture means "capture by reference without explicit declaring captured variables".

C++

void default_reference_capture() {
  int boss{100};

  auto lbd{[&]() {
    // attacked by a hunter's aimed shot
    boss -= 20;
    std::cout << "Boss get hit: " << boss << std::endl;
  }};

  lbd();

  std::cout << "The original boss: " << boss << std::endl;
}

default_mixing_capture

A short snippet that demonstrates default mixing capture:

C++

// value capture a & b, reference capture c
[a, b, &c](){};

// reference capture c, value capture the rest
[=, &c](){};

// value capture a, reference capture the rest
[&, a](){};

// ⚠️ illegal, already reference captured all
[&, &c](){};

// ⚠️ illegal, already value captured all
[=, a](){};

// ⚠️ illegal, captured a twice
[a, &b, &a](){};

// ⚠️ illegal, default capture should always at the first
[armor, &](){};

// a full example
void default_mixing_capture() {
  int boss_health{100};
  int player_health{10};

  const int boss_dmg{3};
  const int player_dmg{1};

  // boss -> player
  auto boss_player{[=, &player_health]() {
    player_health -= boss_dmg;
    std::cout << "The boss has made " << boss_dmg << " to the player, and the player has left " << player_health
              << " health!" << std::endl;
  }};

  // player -> boss
  auto player_boss{[&, player_dmg]() {
    boss_health -= player_dmg;
    std::cout << "The play has made " << player_dmg << " to the boss, and the boss has left " << boss_health
              << " health!" << std::endl;
  }};

  boss_player();
  player_boss();
  player_boss();
  player_boss();
  boss_player();
  player_boss();
}

init_var_capture

The sixth difference. There is no such kink of mechanism in Rust.

C++

void init_var_capture() {
  int life{3};

  // &o is initialzed as life's reference
  // d is initialzed from cloned life
  auto double_life{[&o = life, d{life * 2}]() {
    o *= 2;
    std::cout << "1. Doubled life is " << o << std::endl;
    std::cout << "2. Doubled life is " << d << std::endl;
  }};

  // print 6, 6
  double_life();
  // print 12, 6
  // this is because `d{life * 2}` only initialzed once (when the first call is defined)
  double_life();

  // 12, no doubt `o *= 2;` has been called twice
  std::cout << "The original life has been changed to: " << life << std::endl;
}

copy_lambda

Additionally, in C++ lambda is a an object, which can be copy and modified. Although a mutable keyword has been used in here, stage is still captured by value (cloned). Hence, stage turns into a stateful variable who has been stored inside the lambda. It turns out, whenever a clone has been made upon step_forward, its state (stage) would have been cloned as well.

C++

void copy_lambda() {
  int stage{0};

  auto step_forward{[stage]() mutable {
    stage++;
    std::cout << "step forward: " << stage << std::endl;
  }};

  step_forward(); // stage -> 1

  auto another_sf{step_forward}; // stage: 1

  step_forward(); // stage -> 2
  another_sf();   // stage -> 2

  // no doubt, stage is still `0` right here
  std::cout << "final stage: " << stage << std::endl;
}

copy_ref_lambda

One more extra stuff:

C++

// `auto&` means type deduction of the argument must be a reference
// by the later call, it turns out to be `std::function<void()>&`
void copy_invoke(const auto& fn) { fn(); }

/**
 * @brief copy lambda by reference
 *
 * type `std::reference_wrapper` created by `std::ref`
 */
void copy_ref_lambda() {
  int stage{0};

  auto step_forward{[stage]() mutable {
    // stage is modified
    std::cout << ++stage << std::endl;
  }};

  copy_invoke(std::ref(step_forward)); // stage -> 1
  copy_invoke(std::ref(step_forward)); // stage -> 2
  copy_invoke(std::ref(step_forward)); // stage -> 3
}

Check my Github page to see the code, and leave me a comment if you have any suggestion.