BlogHome

Elevate Your Rust Code: The Art of Separating Actions and Calculations

2023-08-31

In functional programming, the terms actions and calculations are often used to differentiate between different kinds of functions based on their characteristics and usage.

Let's look at each:

Calculations

  • Pure Functions: These are functions where the output is determined solely by its input values, without observable side effects.
  • Referential Transparency: An expression is called referentially transparent if it can be replaced with its value without changing the program's behavior. This is closely related to the concept of a function being "pure."
  • Examples: Mathematical operations like addition, string manipulation, data transformation, etc.
fn add(a: i32, b: i32) -> i32 {
    a + b  // Purely based on the input, no side-effects
}

Actions

  • Side Effects: Functions that perform actions usually have side effects like reading from or writing to a database, modifying a global state, etc.
  • Non-Deterministic: The same input can yield different outputs depending on a variety of factors, like current time, external state, etc.
  • Examples: Logging to a file, making a network request, manipulating global or shared state, etc.
use std::fs::File;
use std::io::Write;

fn write_to_file(data: &str) -> std::io::Result<()> {
    let mut file = File::create("example.txt")?;
    file.write_all(data.as_bytes())  // Has a side-effect (writes to a file)
}

Differences between Actions and Calculations

  1. Predictability: Calculations are predictable and easily testable, whereas actions are less so.
  2. Composability: Calculations are generally easier to compose together in various ways without unintended interactions.
  3. Order of Execution: The order in which calculations are executed doesn't matter, but the order often matters a lot for actions.
  4. Concurrency: Calculations, being free of side effects, are often more straightforward to parallelize.

Understanding the distinction between actions and calculations can help you write more functional and clean code, a key skill if you're aiming to excel in Rust or any other functional programming language. Knowing how to apply the concepts of actions and calculations effectively can make your Rust code more maintainable, easier to reason about, and more testable. Here are some guidelines and examples for each.

Calculations

Isolate Pure Functions

Whenever you're working with logic that doesn't involve side effects, try to isolate it into pure functions. This makes testing easier since you don't have to mock anything.

// Calculation: Pure function for summing an array
fn sum(arr: &[i32]) -> i32 {
    arr.iter().fold(0, |acc, &x| acc + x)
}

// You can easily test this
#[test]
fn test_sum() {
    assert_eq!(sum(&[1, 2, 3, 4]), 10);
}

Use Enums for Better Pattern Matching

In functional programming, pattern matching is often used for branching logic. In Rust, you can take advantage of enums to make this elegant.

enum Shape {
    Circle(f64),
    Square(f64),
}

// Calculation: Calculate area based on the shape
fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(radius) => 3.14159 * radius * radius,
        Shape::Square(side) => side * side,
    }
}

Actions

Use Result Type for Error Handling

Actions, especially those involving IO, can fail. Use Rust's Result type to handle this gracefully.

use std::fs::File;
use std::io::prelude::*;

// Action: Writes a string to a file
fn write_to_file(filename: &str, content: &str) -> std::io::Result<()> {
    let mut file = File::create(filename)?;
    file.write_all(content.as_bytes())
}

Use Option and Result in Function Signatures

When you have functions that might not return a value or might fail, it’s a good idea to indicate this clearly in the function signature by using Option or Result.

// Action: Trying to find an element in a list
fn find_element(arr: &[i32], key: i32) -> Option<i32> {
    for &item in arr.iter() {
        if item == key {
            return Some(item);
        }
    }
    None
}

Separating Actions and Calculations

To follow functional programming principles more closely, you should aim to separate actions and calculations as much as possible.

  1. Data Transformation Pipelines: Use pipelines of calculations to transform data step by step.
  2. Action Wrappers: Enclose actions in wrapper functions to isolate side effects.
  3. High-Level Orchestration: At a higher level in your code, combine actions and calculations to perform complex tasks.
// Calculation
fn calculate_tax(income: f64) -> f64 {
    income * 0.2
}

// Action
fn save_to_database(data: f64) -> Result<(), &'static str> {
    println!("Saving {} to database...", data); // Simulated action
    Ok(())
}

// High-Level Orchestration
fn process_income(income: f64) -> Result<(), &'static str> {
    let tax = calculate_tax(income); // Calculation
    save_to_database(tax) // Action
}

Conclusion

Understanding the divide between actions and calculations is fundamental to writing effective, maintainable, and functional Rust code. By categorizing your functions into these two realms, you not only make your code easier to reason about, but you also open up opportunities for optimizations and testing. Actions bring your applications to life, handling real-world operations and side effects, while calculations form the pure, deterministic backbone that’s easily testable and reliable.

When combined thoughtfully, these two aspects serve as the yin and yang of functional programming in Rust, each complementing the other to create a well-balanced, robust application. As you continue to refine your Rust programming skills, keeping this dichotomy in mind will serve as a valuable guidepost on your journey to becoming not just a Rustacean, but a more effective software engineer overall.

This work is licensed under CC BY-NC-SA 4.0.