Let Chains (&&)
Tutorial Video
Text description (accessibility)
This video demonstrates the "Let Chains (&&)" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Multiple sequential pattern checks create nesting: `if let Some(x) = a { if let Ok(y) = b { if condition { ... Key difference from OCaml: 1. **Stabilization**: Rust let chains were stabilized in 1.88 — a relatively recent addition; OCaml's `let*` notation for monadic chaining has been available since 4.08.
Tutorial
The Problem
Multiple sequential pattern checks create nesting: if let Some(x) = a { if let Ok(y) = b { if condition { ... } } }. This pyramid of doom is hard to read and hard to maintain. Let chains (stabilized in Rust 1.88) allow combining multiple let bindings and Boolean conditions in a single flat if expression using &&. This is the Rust equivalent of Haskell's do notation or OCaml's match nesting, enabling linear "happy path" code for multi-step extraction.
🎯 Learning Outcomes
if let P = x && let Q = y && cond { } chains multiple pattern checkslet pattern bindings with Boolean conditions in one chainif let without requiring let-elseCode Example
fn process(s: &str) -> Option<i32> {
if let Ok(n) = s.parse::<i32>()
&& n > 0
&& n % 2 == 0
{
Some(n * 2)
} else {
None
}
}Key Differences
let* notation for monadic chaining has been available since 4.08.let P = x and cond in the same chain; OCaml's let* is purely monadic, requiring conditions to be lifted into Option.Option.bind short-circuit on the first failure.let* bindings are visible to the continuation.OCaml Approach
OCaml achieves the same with Option.bind chains or nested match:
let process s =
let open Option in
let* n = int_of_string_opt s in
let* () = if n > 0 && n mod 2 = 0 then Some () else None in
Some (n * 2)
OCaml 4.08+ let* (monadic bind) provides a similar linear chaining style.
Full Source
#![allow(clippy::all)]
//! # Let Chains (&&)
//!
//! Chain multiple pattern checks with `&&` — combine pattern matching
//! and boolean conditions without nesting.
//!
//! Requires Rust 1.88+ for stable let chains.
/// Process a string, returning doubled value if it's a positive even number.
///
/// Uses let chains to combine parsing, positivity check, and evenness check
/// in a single flat condition.
pub fn process(s: &str) -> Option<i32> {
if let Ok(n) = s.parse::<i32>()
&& n > 0
&& n % 2 == 0
{
Some(n * 2)
} else {
None
}
}
/// Alternative approach using traditional nested if-let (pre-1.88 style).
pub fn process_nested(s: &str) -> Option<i32> {
if let Ok(n) = s.parse::<i32>() {
if n > 0 {
if n % 2 == 0 {
return Some(n * 2);
}
}
}
None
}
/// Alternative approach using Option combinators.
pub fn process_combinators(s: &str) -> Option<i32> {
s.parse::<i32>()
.ok()
.filter(|&n| n > 0)
.filter(|&n| n % 2 == 0)
.map(|n| n * 2)
}
/// Configuration with optional host and port.
#[derive(Debug, Clone)]
pub struct Config {
pub host: Option<String>,
pub port: Option<u16>,
}
impl Config {
pub fn new(host: Option<String>, port: Option<u16>) -> Self {
Self { host, port }
}
}
/// Create an address string from config using let chains.
///
/// Validates that both host and port exist and are valid.
pub fn make_addr(cfg: &Config) -> Option<String> {
if let Some(ref host) = cfg.host
&& let Some(port) = cfg.port
&& !host.is_empty()
&& port > 0
{
Some(format!("{}:{}", host, port))
} else {
None
}
}
/// Alternative using Option::zip and filter.
pub fn make_addr_combinators(cfg: &Config) -> Option<String> {
cfg.host
.as_ref()
.zip(cfg.port)
.filter(|(host, port)| !host.is_empty() && *port > 0)
.map(|(host, port)| format!("{}:{}", host, port))
}
/// Find the first positive even number in a slice of string representations.
pub fn first_positive_even(data: &[&str]) -> Option<i32> {
for &s in data {
if let Ok(n) = s.parse::<i32>()
&& n > 0
&& n % 2 == 0
{
return Some(n);
}
}
None
}
/// Alternative using iterators.
pub fn first_positive_even_iter(data: &[&str]) -> Option<i32> {
data.iter()
.filter_map(|s| s.parse::<i32>().ok())
.find(|&n| n > 0 && n % 2 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_process_valid_positive_even() {
assert_eq!(process("4"), Some(8));
assert_eq!(process("8"), Some(16));
assert_eq!(process("100"), Some(200));
}
#[test]
fn test_process_negative() {
assert_eq!(process("-2"), None);
assert_eq!(process("-4"), None);
}
#[test]
fn test_process_odd() {
assert_eq!(process("3"), None);
assert_eq!(process("7"), None);
}
#[test]
fn test_process_invalid_string() {
assert_eq!(process("abc"), None);
assert_eq!(process(""), None);
assert_eq!(process("4.5"), None);
}
#[test]
fn test_process_approaches_equivalent() {
let test_cases = ["4", "-2", "3", "abc", "8", "0", "10"];
for s in test_cases {
assert_eq!(process(s), process_nested(s), "Mismatch for input: {}", s);
assert_eq!(
process(s),
process_combinators(s),
"Mismatch for input: {}",
s
);
}
}
#[test]
fn test_make_addr_valid() {
let cfg = Config::new(Some("localhost".into()), Some(8080));
assert_eq!(make_addr(&cfg), Some("localhost:8080".to_string()));
}
#[test]
fn test_make_addr_empty_host() {
let cfg = Config::new(Some("".into()), Some(8080));
assert_eq!(make_addr(&cfg), None);
}
#[test]
fn test_make_addr_missing_fields() {
let cfg1 = Config::new(None, Some(80));
let cfg2 = Config::new(Some("host".into()), None);
assert_eq!(make_addr(&cfg1), None);
assert_eq!(make_addr(&cfg2), None);
}
#[test]
fn test_make_addr_zero_port() {
let cfg = Config::new(Some("localhost".into()), Some(0));
assert_eq!(make_addr(&cfg), None);
}
#[test]
fn test_make_addr_approaches_equivalent() {
let configs = [
Config::new(Some("localhost".into()), Some(8080)),
Config::new(Some("".into()), Some(8080)),
Config::new(None, Some(80)),
Config::new(Some("host".into()), None),
];
for cfg in &configs {
assert_eq!(make_addr(cfg), make_addr_combinators(cfg));
}
}
#[test]
fn test_first_positive_even() {
assert_eq!(first_positive_even(&["x", "3", "-4", "6", "8"]), Some(6));
assert_eq!(first_positive_even(&["1", "3", "5"]), None);
assert_eq!(first_positive_even(&["-2", "-4"]), None);
assert_eq!(first_positive_even(&["2"]), Some(2));
}
#[test]
fn test_first_positive_even_approaches_equivalent() {
let test_cases: &[&[&str]] = &[
&["x", "3", "-4", "6", "8"],
&["1", "3", "5"],
&["-2", "-4"],
&["2"],
&[],
];
for data in test_cases {
assert_eq!(first_positive_even(data), first_positive_even_iter(data));
}
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_process_valid_positive_even() {
assert_eq!(process("4"), Some(8));
assert_eq!(process("8"), Some(16));
assert_eq!(process("100"), Some(200));
}
#[test]
fn test_process_negative() {
assert_eq!(process("-2"), None);
assert_eq!(process("-4"), None);
}
#[test]
fn test_process_odd() {
assert_eq!(process("3"), None);
assert_eq!(process("7"), None);
}
#[test]
fn test_process_invalid_string() {
assert_eq!(process("abc"), None);
assert_eq!(process(""), None);
assert_eq!(process("4.5"), None);
}
#[test]
fn test_process_approaches_equivalent() {
let test_cases = ["4", "-2", "3", "abc", "8", "0", "10"];
for s in test_cases {
assert_eq!(process(s), process_nested(s), "Mismatch for input: {}", s);
assert_eq!(
process(s),
process_combinators(s),
"Mismatch for input: {}",
s
);
}
}
#[test]
fn test_make_addr_valid() {
let cfg = Config::new(Some("localhost".into()), Some(8080));
assert_eq!(make_addr(&cfg), Some("localhost:8080".to_string()));
}
#[test]
fn test_make_addr_empty_host() {
let cfg = Config::new(Some("".into()), Some(8080));
assert_eq!(make_addr(&cfg), None);
}
#[test]
fn test_make_addr_missing_fields() {
let cfg1 = Config::new(None, Some(80));
let cfg2 = Config::new(Some("host".into()), None);
assert_eq!(make_addr(&cfg1), None);
assert_eq!(make_addr(&cfg2), None);
}
#[test]
fn test_make_addr_zero_port() {
let cfg = Config::new(Some("localhost".into()), Some(0));
assert_eq!(make_addr(&cfg), None);
}
#[test]
fn test_make_addr_approaches_equivalent() {
let configs = [
Config::new(Some("localhost".into()), Some(8080)),
Config::new(Some("".into()), Some(8080)),
Config::new(None, Some(80)),
Config::new(Some("host".into()), None),
];
for cfg in &configs {
assert_eq!(make_addr(cfg), make_addr_combinators(cfg));
}
}
#[test]
fn test_first_positive_even() {
assert_eq!(first_positive_even(&["x", "3", "-4", "6", "8"]), Some(6));
assert_eq!(first_positive_even(&["1", "3", "5"]), None);
assert_eq!(first_positive_even(&["-2", "-4"]), None);
assert_eq!(first_positive_even(&["2"]), Some(2));
}
#[test]
fn test_first_positive_even_approaches_equivalent() {
let test_cases: &[&[&str]] = &[
&["x", "3", "-4", "6", "8"],
&["1", "3", "5"],
&["-2", "-4"],
&["2"],
&[],
];
for data in test_cases {
assert_eq!(first_positive_even(data), first_positive_even_iter(data));
}
}
}
Deep Comparison
OCaml vs Rust: Let Chains
Chained Validation
OCaml (using let* monadic binding)
let (let*) = Option.bind
let process s =
let* n = (try Some(int_of_string s) with _ -> None) in
let* _ = (if n > 0 then Some () else None) in
let* _ = (if n mod 2 = 0 then Some () else None) in
Some (n * 2)
Rust (let chains - Rust 1.88+)
fn process(s: &str) -> Option<i32> {
if let Ok(n) = s.parse::<i32>()
&& n > 0
&& n % 2 == 0
{
Some(n * 2)
} else {
None
}
}
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Syntax | let* x = expr in ... | if let pattern = expr && cond |
| Mechanism | Monadic binding (requires let* definition) | Built-in syntax |
| Boolean conditions | Require wrapping in Some()/None | Direct && condition |
| Multiple bindings | Each needs let* x = ... | && let pattern = expr |
| Scope | Inside the monadic chain | Body of the if block |
| Fallback | Implicitly returns None | Explicit else branch |
Multiple Pattern Bindings
OCaml
let make_addr cfg =
let* host = cfg.host in
let* port = cfg.port in
let* _ = if String.length host > 0 then Some () else None in
let* _ = if port > 0 then Some () else None in
Some (host ^ ":" ^ string_of_int port)
Rust
fn make_addr(cfg: &Config) -> Option<String> {
if let Some(ref host) = cfg.host
&& let Some(port) = cfg.port
&& !host.is_empty()
&& port > 0
{
Some(format!("{}:{}", host, port))
} else {
None
}
}
When to Use Each
Rust let chains are ideal when:
*OCaml let is ideal when:**
Exercises
fn get_server_addr(config: &Config) -> Option<String> using let chains to extract and validate host, port, and max_conn from an Option<DbConfig>.fn parse_coord_pair(s: &str) -> Option<(f64, f64)> using let chains to split on comma, parse both parts, and check they are in valid range.process function using nested if let and explain why let chains improve readability — count the nesting levels.