Skip to content

Instantly share code, notes, and snippets.

@henrymai
Last active April 16, 2024 05:53
Show Gist options
  • Save henrymai/d4acab983cd42c71ae0578ab9199e9d0 to your computer and use it in GitHub Desktop.
Save henrymai/d4acab983cd42c71ae0578ab9199e9d0 to your computer and use it in GitHub Desktop.
Functional_Rust_Guide.md

Functional and Declarative Rust Programming Guide

Introduction

This guide illustrates how to transition from imperative to functional programming patterns in Rust through examples. This guide is not comprehensive and will be updated with more examples over time.

Table of Contents

  1. Functional Approach to Collection Transformation
  2. Handling Futures and Async Programming
  3. Error Handling in a Pipeline
  4. Short-Circuiting Loops
  5. When to Consider an Imperative Approach
  6. More Examples (Placeholder)

Functional Approach to Collection Transformation

Example 1: Functional Approach to Collection Transformation

  • Do Not:
    // HashMap
    use std::collections::HashMap;
    let mut map = HashMap::new();
    let data = vec![("key1", "1"), ("key2", "2")];
    for (key, value_str) in data {
        let value: i32 = value_str.parse().unwrap() + 10;
        map.insert(key, value);
    }
    
    // Vector
    let numbers = vec![1, 2, 3, 4, 5];
    let mut squares = Vec::new();
    for num in numbers {
        squares.push(num * num);
    }
  • Do:
    // HashMap
    use std::collections::HashMap;
    let data = vec![("key1", "1"), ("key2", "2")];
    let map: HashMap<_, _> = data.into_iter()
        .map(|(key, value_str)| {
            let value: i32 = value_str.parse().unwrap() + 10;
            (key, value)
        })
        .collect();
    
    // Vector
    let numbers = vec![1, 2, 3, 4, 5];
    let squares: Vec<_> = numbers.iter().map(|&num| num * num).collect();
  • Why:
    • The functional approach using .map().collect() is concise and expressive for both HashMap and Vec.
    • It minimizes mutable state and reduces the risk of side effects, making the code easier to reason about and maintain.
    • This pattern promotes a declarative style of programming, focusing on what needs to be done, rather than how to do it.

Handling Futures and Async Programming

Example 2: Efficiently Managing a Chain of Async RPCs

  • Do Not:
    async fn long_running_rpc1(id: i32) -> String { /* ... */ }
    async fn long_running_rpc2(id: String) -> SomeStruct { /* ... */ }
    
    let ids = vec![1, 2, 3];
    let mut results = Vec::new();
    
    for id in ids {
        let result1 = long_running_rpc1(id).await;
        let result2 = long_running_rpc2(result1).await;
        results.push(result2);  // Each loop iteration blocks before continuing to the next.
    }
  • Do:
    use futures::future::join_all;
    
    async fn long_running_rpc1(id: i32) -> String { /* ... */ }
    async fn long_running_rpc2(id: String) -> SomeStruct { /* ... */ }
    
    let ids = vec![1, 2, 3];
    let futures = ids.into_iter().map(|id| {
        long_running_rpc1(id)
            .and_then(long_running_rpc2)
    });
    let results = join_all(futures).await;
  • Why:
    • The "Do Not" approach requires awaiting each async operation within the loop, forcing each iteration to block before continuing to the next. This sequential approach significantly limits concurrency.
    • Each iteration has to block because the code is inserting into a mutable results vector, which is not concurrent-safe. This echoes the issues with mutable state as highlighted in Example 1.
    • The functional approach, as shown in the "Do" section, defers the materialization of results and allows for all iterations to happen concurrently. It creates the result vector without relying on mutating a shared state. This pattern enhances both concurrency and code safety, reflecting the functional paradigm of avoiding mutable state for clearer, more efficient code.

Error Handling in a Pipeline

Example 3: Handling Errors Functionally

  • Do Not:
    fn parse_number(s: &str) -> Result<i32, std::num::ParseIntError> {
        s.parse()
    }
    
    let strings = vec!["10", "20", "a", "40"];
    let mut numbers = Vec::new();
    
    for s in strings {
        match parse_number(s) {
            Ok(num) => numbers.push(num),
            Err(_) => continue,
        }
    }
  • Do:
    fn parse_number(s: &str) -> Result<i32, std::num::ParseIntError> {
        s.parse()
    }
    
    let strings = vec!["10", "20", "a", "40"];
    let numbers: Vec<_> = strings.into_iter()
        .filter_map(|s| parse_number(s).ok())
        .collect();
  • Why:
    • The functional approach using filter_map elegantly combines mapping and filtering, allowing for concise handling of Result types.
    • This pattern is more declarative and avoids explicit error handling in the loop, leading to cleaner code.
    • Such functional patterns are particularly useful in Rust, where error handling is common and critical.

Short-Circuiting Loops

Example 4: Using find_map for Short-Circuiting

  • Do Not:
    let numbers = vec![1, 2, 3, 4, 5];
    let mut found = None;
    
    for &num in &numbers {
        if num > 3 {
            found = Some(num * num);
            break;
        }
    }
  • Do:
    let numbers = vec![1, 2, 3, 4, 5];
    let found = numbers.iter().find_map(|&num| {
        if num > 3 { Some(num * num) } else { None }
    });
  • Why:
    • Using find_map allows for a concise and functional way to perform a short-circuiting search with transformation.
    • This approach eliminates the need for mutable state and manual loop control, simplifying the logic.
    • It's a cleaner, more Rust-idiomatic way of handling such patterns, improving code readability and maintainability.

When to Consider an Imperative Approach

While the functional programming style in Rust is generally preferred for its clarity, safety, and maintainability, there are specific, less common scenarios where an imperative approach might be more suitable due to clear performance advantages.

Example 5: In-Place Data Manipulation for Performance

  • Situation: Suppose you are working with a large, complex data structure, such as a multi-dimensional array or a deeply nested structure. You need to perform an operation that changes the state of this data in a way that is not easily or efficiently expressed functionally.

  • Functional Approach:

    let matrix = large_matrix(); // Assume this is a large 2D array or Vec of Vecs
    
    let transformed_matrix: Vec<Vec<_>> = matrix.into_iter()
        .map(|row| {
            row.into_iter()
                .map(|cell| {
                    if some_complex_condition(&cell) {
                        transform_value(&cell)
                    } else {
                        cell
                    }
                })
                .collect()
        })
        .collect();
    • In this approach, each cell is transformed and a new matrix is constructed. While this method is more declarative, it involves creating a completely new data structure, which can be inefficient for large datasets.
  • Imperative Approach:

    let mut matrix = large_matrix(); // Assume this is a large 2D array or Vec of Vecs
    
    // In-place modification for performance
    for row in matrix.iter_mut() {
        for cell in row.iter_mut() {
            if some_complex_condition(cell) {
                *cell = transform_in_place(cell);
            }
        }
    }
    • This approach modifies the matrix in place, making it more memory-efficient and potentially faster for large data structures. It allows direct manipulation of elements based on complex conditions.
    • This approach can be more memory efficient, as it avoids the overhead of additional allocations that are often involved in functional transformations.
  • Identifying When to Use the Imperative Approach:

    • Complex Data Structures and In-Place Modifications: This approach is occasionally more beneficial in scenarios with complex or deeply nested data structures, especially when in-place modifications can be executed without costly reallocations or transformations. However, it's crucial to confirm the actual performance gains through profiling, as assumptions about efficiency may not always hold true in practice.
    • Large Datasets and Efficient Processing: Although not the usual preference, imperative methods become more suitable when handling very large datasets where the overhead of functional transformations is demonstrably substantial. Profiling and benchmarking should be employed to verify that the imperative approach does indeed offer significant performance improvements in these specific cases.

More Examples

(Additional examples and topics will be added in future updates.)

@drcapybara
Copy link

this is awesome Henry, thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment