Combinators and Ownership in Rust
In this post
- Borrow while building an owned value
- Use reference adapters before mapping
- Move only when the caller gives up ownership
- Use
and_thenfor another optional step - Recover with
or_else - Choose a clear shape over a clever chain
Methods such as map, filter, and and_then can make an Option or Result easier to transform. These methods, called combinatorsCombinatory logic is a notation to eliminate the need for quantified variables in mathematical logic. It was introduced by Moses Schönfinkel and Haskell Curry, and has more recently been used in computer science as a theoretical model of computation and also as a basis for the design of functional programming languages. It is based on combinators, which were introduced by Schönfinkel in 1920 with the idea of providing an analogous way to build up functions — and to remove any mention of variables — particularly in predicate logic. A combinator is a higher-order function that uses only function application and earlier defined combinators to define a result from its arguments.Read on Wikipedia →, each take an outcome and produce the next one. Most of these methods take ownership of the value they are called on. The as_* adapters borrow it instead. This difference matters when the value is stored inside a borrowed struct.
This post uses one small example: build a Profile from string settings, then read and update that profile without cloning data unnecessarily. Rust ownership determines whether each operation borrows, changes, or moves the profile’s strings. Borrowing reads a value without taking it, moving transfers it to a new owner, and cloning makes a second copy. An Option contains either Some(value) or None; a Result contains either Ok(value) or Err(error). A closure
In programming languages, a closure, also lexical closure or function closure, is a technique for implementing lexically scoped name binding in a language with first-class functions. Operationally, a closure is a record storing a function together with an environment. The environment is a mapping associating each free variable of the function with the value or reference to which the name was bound when the closure was created. Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.Read on Wikipedia → is a small function that can use values from its surrounding code. The earlier Option vs. Result post introduced the outcome types. Here the focus is what each method does to ownership.
Borrow while building an owned value
A settings map owns its strings. A Profile should own the values it keeps after the map is gone. The useful sequence is to borrow while checking the input, then allocate when the profile stores valid input. An error may make one copy for its message.
use std::collections::HashMap;
#[derive(Debug)]
enum ProfileError {
MissingEmail,
InvalidEmail { value: String },
}
struct Profile {
display_name: Option<String>,
email: String,
}
type Settings = HashMap<String, String>;
impl Profile {
fn from_settings(settings: &Settings) -> Result<Self, ProfileError> {
let email = settings
.get("email")
.map(String::as_str)
.ok_or(ProfileError::MissingEmail)?;
if !email.contains('@') {
return Err(ProfileError::InvalidEmail {
value: email.to_owned(),
});
}
let display_name = settings
.get("name")
.filter(|name| !name.trim().is_empty())
.cloned();
Ok(Self {
display_name,
email: email.to_owned(),
})
}
}
The important ownership point is where each value changes form. map(String::as_str) borrows the email, filter removes an empty or whitespace-only name, and cloned creates an owned String only when a usable name exists.
The email is copied once, at the point where it becomes part of the new profile. If it is invalid, the error owns a copy for its message. The settings map keeps ownership of its strings; the function only borrows them.
The following snippets extend the same Profile definition; they are not separate types.
Use reference adapters before mapping
Option::map consumes the Option. A reference adapter is a method that creates a borrowed view before another method runs. Calling map directly on a field through &self would try to move that field out of a borrowed profile. Option::as_ref changes &Option<T> into Option<&T> first:
impl Profile {
fn display_name_length(&self) -> Option<usize> {
self.display_name.as_ref().map(String::len)
}
}
The profile is still usable after this call. The method borrows the String and returns its length; it does not move or clone the name.
Option::as_deref goes one step further for types such as String. It creates an Option<&str>:
impl Profile {
fn label(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.email)
}
}
label returns the name when it exists and the email otherwise. Neither branch allocates. unwrap_or expects a &str, so Rust coerces the fallback &String to that borrowed string-slice type.
The same distinction applies to Result. result.as_ref() borrows the success or error value, while result.map(...) consumes the result. Use the reference adapter when the original container must remain available.
Move only when the caller gives up ownership
A method that consumes self can move fields without cloning them. This is appropriate when the caller will not use the profile again:
impl Profile {
fn into_label(self) -> String {
self.display_name.unwrap_or(self.email)
}
}
into_label moves the selected String into the result. The default email is also moved into unwrap_or; when the name is present, that unused email is dropped inside the method. No clone is needed because ownership has been transferred to the function.
For a mutable profile, Option::as_mut provides a mutable reference to the value inside Some:
impl Profile {
fn trim_display_name_end(&mut self) {
if let Some(name) = self.display_name.as_mut() {
let length = name.trim_end().len();
name.truncate(length);
}
}
}
This removes trailing whitespace in the existing string. If the caller wants to remove the name and take ownership of it, Option::take replaces the field with None and returns the old value:
fn take_display_name(profile: &mut Profile) -> Option<String> {
profile.display_name.take()
}
The choice is visible in the method signature. &self produces borrowed data, &mut self can change the stored value, and self transfers ownership.
Use and_then for another optional step
map is useful when one value becomes another value. If the next function already returns Option, map creates a nested Option<Option<T>>. Option::and_then joins the two steps:
impl Profile {
fn first_name_word(&self) -> Option<&str> {
self.display_name
.as_deref()
.and_then(|name| name.split_whitespace().next())
}
}
as_deref gives the method an optional &str. split_whitespace().next() also returns an Option, because a string may contain no words. and_then keeps the result as one Option<&str> instead of nesting the two possibilities.
The same idea applies to Result::and_then: use it when the next operation can fail and already returns a Result. Use map_err to transform an Err while leaving Ok untouched.
Recover with or_else
or_else handles a failed operation with another operation. A caller can try a backup settings source only after the primary source fails:
fn profile_from_sources(
primary: &Settings,
backup: &Settings,
) -> Result<Profile, ProfileError> {
Profile::from_settings(primary).or_else(|error| match error {
ProfileError::MissingEmail => Profile::from_settings(backup),
error => Err(error),
})
}
The fallback is tried only for the error that the caller has decided is recoverable. The error => Err(error) arm moves other errors forward without cloning them. An invalid email is returned instead of being hidden by the backup.
Choose a clear shape over a clever chain
Combinators are a good fit for a short, linear transformation. They become harder to read when a function has several independent decisions, side effects, or different errors for different branches.
Use let ... else, match, or an early return when a failure branch needs its own action. Use ? when the operation should simply pass an error to its caller. A chain should make it easier to see what is borrowed, what is moved, and what can fail. If a reader must reconstruct those facts from several nested callbacks, use named variables instead.
When reviewing a combinator-based function, check three things: whether a method consumes the container, whether a reference adapter is needed first, and where an owned allocation or clone is actually required. Those checks usually matter more than reducing the number of lines.