ExamplesBy LevelBy TopicLearning Paths
323 Intermediate

323: async blocks and Lazy Evaluation

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "323: async blocks and Lazy Evaluation" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. `async fn` creates a function that returns a `Future`. Key difference from OCaml: 1. **Explicit laziness**: Rust async blocks are explicitly lazy — nothing runs until `.await`; OCaml's `Lwt.t` values trigger computation when bound.

Tutorial

The Problem

async fn creates a function that returns a Future. async { } blocks create anonymous futures inline. The key property of both is laziness: the code inside does not execute until the future is .awaited or driven by an executor. This is fundamentally different from eager evaluation — a value computed immediately. Understanding this laziness is essential for avoiding bugs where code runs at the wrong time or doesn't run at all.

🎯 Learning Outcomes

  • • Understand that async { } blocks create futures that are lazy — code doesn't run until .awaited
  • • Recognize the analogy between closures (lazy functions) and async blocks (lazy computations)
  • • Use async move to capture values by ownership into an async block
  • • Understand why forgetting to .await a future causes silent bugs (the computation never runs)
  • Code Example

    fn lazy_comp<F: FnOnce() -> T, T>(label: &str, f: F) -> impl FnOnce() -> T + '_ {
        println!("Creating: {label}");
        move || { println!("Executing: {label}"); f() }
    }

    Key Differences

  • Explicit laziness: Rust async blocks are explicitly lazy — nothing runs until .await; OCaml's Lwt.t values trigger computation when bound.
  • Move semantics: async move { } captures environment by value; regular async { } may borrow — ownership rules apply to async blocks.
  • Forgotten futures: In Rust, not .await-ing a future silently discards it; in OCaml, not binding a Lwt.t has similar silent effects.
  • Type: An async { } block has type impl Future<Output = T> where T is the last expression's type.
  • OCaml Approach

    OCaml's Lwt promises are also lazy in a sense — a Lwt.t value represents a pending computation, and only resolving it (via Lwt.bind or >>=) triggers continuation:

    (* Thunk: lazy value in OCaml *)
    let lazy_comp label f =
      Printf.printf "Creating: %s\n" label;
      fun () ->
        Printf.printf "Executing: %s\n" label;
        f ()
    

    Full Source

    #![allow(clippy::all)]
    //! # Async Blocks and Lazy Evaluation
    //!
    //! Demonstrates lazy evaluation with closures as a synchronous analogy
    //! for async blocks. Work is described but not executed until invoked.
    
    /// Creates a lazy computation that prints a message when created and another when executed.
    /// Analogous to `async { }` blocks which describe work without running it.
    pub fn lazy_comp<'a, F, T>(label: &'a str, f: F) -> impl FnOnce() -> T + 'a
    where
        F: FnOnce() -> T + 'a,
    {
        println!("Creating: {}", label);
        move || {
            println!("Executing: {}", label);
            f()
        }
    }
    
    /// Conditionally run a lazy computation.
    /// Analogous to: `if cond { fut.await } else { None }`
    pub fn run_if<F, T>(cond: bool, thunk: F) -> Option<T>
    where
        F: FnOnce() -> T,
    {
        if cond {
            Some(thunk())
        } else {
            None
        }
    }
    
    /// Create multiple lazy tasks that capture a value by move.
    /// Analogous to `async move { }` blocks.
    pub fn create_tasks_with_capture(multiplier: i32, count: usize) -> Vec<Box<dyn FnOnce() -> i32>> {
        (1..=count as i32)
            .map(|x| -> Box<dyn FnOnce() -> i32> { Box::new(move || x * multiplier) })
            .collect()
    }
    
    /// A more idiomatic approach using iterators and Option.
    pub fn lazy_filter_map<T, U, F>(items: impl IntoIterator<Item = T>, pred: F) -> Vec<U>
    where
        F: Fn(&T) -> Option<U>,
    {
        items.into_iter().filter_map(|x| pred(&x)).collect()
    }
    
    /// Chain multiple lazy computations.
    pub fn chain_lazy<A, B, C, F, G>(first: F, second: G) -> impl FnOnce() -> C
    where
        F: FnOnce() -> A,
        G: FnOnce(A) -> B,
        B: Into<C>,
    {
        move || second(first()).into()
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
        use std::cell::Cell;
    
        #[test]
        fn test_lazy_not_called_until_invoked() {
            let called = Cell::new(false);
            let thunk = || {
                called.set(true);
                42
            };
    
            assert!(!called.get(), "should not be called yet");
            let result = thunk();
            assert!(called.get(), "should be called now");
            assert_eq!(result, 42);
        }
    
        #[test]
        fn test_run_if_skips_when_false() {
            let called = Cell::new(false);
            let result = run_if(false, || {
                called.set(true);
                panic!("should not reach here")
            });
            assert!(!called.get());
            assert!(result.is_none());
        }
    
        #[test]
        fn test_run_if_executes_when_true() {
            let result = run_if(true, || 42);
            assert_eq!(result, Some(42));
        }
    
        #[test]
        fn test_create_tasks_with_capture() {
            let tasks = create_tasks_with_capture(7, 5);
            assert_eq!(tasks.len(), 5);
    
            let results: Vec<i32> = tasks.into_iter().map(|t| t()).collect();
            assert_eq!(results, vec![7, 14, 21, 28, 35]);
        }
    
        #[test]
        fn test_lazy_filter_map() {
            let items = vec![1, 2, 3, 4, 5, 6];
            let evens_doubled =
                lazy_filter_map(items, |&x| if x % 2 == 0 { Some(x * 2) } else { None });
            assert_eq!(evens_doubled, vec![4, 8, 12]);
        }
    
        #[test]
        fn test_chain_lazy() {
            let computation = chain_lazy::<i32, i32, i32, _, _>(|| 5, |x| x * 2);
            assert_eq!(computation(), 10);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
        use std::cell::Cell;
    
        #[test]
        fn test_lazy_not_called_until_invoked() {
            let called = Cell::new(false);
            let thunk = || {
                called.set(true);
                42
            };
    
            assert!(!called.get(), "should not be called yet");
            let result = thunk();
            assert!(called.get(), "should be called now");
            assert_eq!(result, 42);
        }
    
        #[test]
        fn test_run_if_skips_when_false() {
            let called = Cell::new(false);
            let result = run_if(false, || {
                called.set(true);
                panic!("should not reach here")
            });
            assert!(!called.get());
            assert!(result.is_none());
        }
    
        #[test]
        fn test_run_if_executes_when_true() {
            let result = run_if(true, || 42);
            assert_eq!(result, Some(42));
        }
    
        #[test]
        fn test_create_tasks_with_capture() {
            let tasks = create_tasks_with_capture(7, 5);
            assert_eq!(tasks.len(), 5);
    
            let results: Vec<i32> = tasks.into_iter().map(|t| t()).collect();
            assert_eq!(results, vec![7, 14, 21, 28, 35]);
        }
    
        #[test]
        fn test_lazy_filter_map() {
            let items = vec![1, 2, 3, 4, 5, 6];
            let evens_doubled =
                lazy_filter_map(items, |&x| if x % 2 == 0 { Some(x * 2) } else { None });
            assert_eq!(evens_doubled, vec![4, 8, 12]);
        }
    
        #[test]
        fn test_chain_lazy() {
            let computation = chain_lazy::<i32, i32, i32, _, _>(|| 5, |x| x * 2);
            assert_eq!(computation(), 10);
        }
    }

    Deep Comparison

    OCaml vs Rust: Async Blocks and Lazy Evaluation

    Lazy Computation Creation

    OCaml:

    let lazy_comp label f =
      Printf.printf "Creating: %s\n" label;
      fun () -> Printf.printf "Executing: %s\n" label; f ()
    

    Rust:

    fn lazy_comp<F: FnOnce() -> T, T>(label: &str, f: F) -> impl FnOnce() -> T + '_ {
        println!("Creating: {label}");
        move || { println!("Executing: {label}"); f() }
    }
    

    Conditional Execution

    OCaml:

    let run_if cond thunk = if cond then Some (thunk ()) else None
    

    Rust:

    fn run_if<F: FnOnce() -> T, T>(cond: bool, t: F) -> Option<T> {
        if cond { Some(t()) } else { None }
    }
    

    Key Differences

    AspectOCamlRust
    Thunk typeunit -> 'aimpl FnOnce() -> T
    Closure syntaxfun () -> ...\|\| { ... }
    Move semanticsImplicit (GC)Explicit move keyword
    Type constraintsInferredExplicit trait bounds
    LazinessExplicit thunksImplicit in async, explicit here

    Exercises

  • Create two async blocks (simulated as closures) and demonstrate that their creation and execution are separate events.
  • Implement run_if(cond: bool, thunk: impl FnOnce() -> T) -> Option<T> and show its analogy to if cond { Some(fut.await) } else { None }.
  • Show with a test that a lazy_comp closure that captures a String by move can only be called once (FnOnce semantics).
  • Open Source Repos