Skip to content

Commit

Permalink
New slides: Dealing with Unwrap (#251)
Browse files Browse the repository at this point in the history
* add dealing with unwraps to Good Design Practices

* test for rebase need

* new slides: dealing with unwrap()

* Typo fixes and light editing of dealing-with-unwraps.md

* move #Dealing With Unwraps to alphabetical order

* add ## Dealing With Unwraps to cpp-cheatsheet

* Apply suggestions from code review

Co-authored-by: Andrei Listochkin (Андрей Листочкин) <[email protected]>

* don't put whole function in slide - it doesn't fit

* ok_or_else takes a closure inside a snippet

* phrasing for Here -> There are

* put .map_err() before code block

* awkward phrasing around conflating errors and Options

* mention Option and Result transposition

* Cleaner phrasing around not applying ? blindly

* rephrase harms of legacy unwrap calls

* only mention Effective Rust's diagram, don't just plaster it here

* rephrase bare Err advice

* Change phrasing around application complexity growth and Results

---------

Co-authored-by: Andrei Listochkin (Андрей Листочкин) <[email protected]>
  • Loading branch information
miguelraz and listochkin authored Feb 12, 2025
1 parent b5f2d9d commit 226f8f4
Show file tree
Hide file tree
Showing 3 changed files with 258 additions and 0 deletions.
1 change: 1 addition & 0 deletions training-slides/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Topics that go beyond [Applied Rust](#applied-rust).

* [Advanced Strings](./advanced-strings.md)
* [Building Robust Programs with Kani](./kani.md)
* [Dealing with Unwrap](./dealing-with-unwrap.md)
* [Debugging Rust](./debugging-rust.md)
* [Deconstructing Send, Arc, and Mutex](./deconstructing-send-arc-mutex.md)
* [Dependency Management with Cargo](./dependency-management.md)
Expand Down
1 change: 1 addition & 0 deletions training-slides/src/cpp-cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ for value in &list {
# Advanced Rust
## Advanced Strings
## Building Robust Programs with Kani
## Dealing with Unwrap
## Debugging Rust
## Deconstructing Send, Arc, and Mutex
## Dependency Management with Cargo
Expand Down
256 changes: 256 additions & 0 deletions training-slides/src/dealing-with-unwrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Dealing with Unwrap

## Handling your errors

* Rust is *intentionally strict*: when failue modes happen, you have to decide how to handle them *right there*
* Recall:
* `Option<T>` gives you information on if your operation produced something or nothing
* `Result<T, E>` lets you know if something succeeded or something else (`E`) happened
* We can propagate the appropriate error context by transforming one into the other and vice versa

## Unwrap -> ?

* `.unwrap()`'ing both `Option` and `Result` *seems* like an the easy way out
* Switching from `.unwrap()` calls often leads to changes in function signatures, and the refactoring becomes
wider and difficult with time and code

Instead, prefer using the early return `?` operator where possible, or at least `.expect()`

## `?` Examples

Let's see how we can get to `?` as quickly as possible in cases where

* You have many eager returns
* You have `match` statements where all cases must succeed to go forward

## `?` vs Eager Returns

`?` turns this

```rust [], ignore
fn write_info(info: &Info) -> io::Result<()> {
// Early return on error
let mut file = match File::create("my_best_friends.txt") {
Err(e) => return Err(e),
Ok(f) => f,
};
if let Err(e) = file.write_all(format!("name: {}\n", info.name).as_bytes()) {
return Err(e)
}
if let Err(e) = file.write_all(format!("age: {}\n", info.age).as_bytes()) {
return Err(e)
}
if let Err(e) = file.write_all(format!("rating: {}\n", info.rating).as_bytes()) {
return Err(e)
}
Ok(())
}
```

## `?` vs Eager Returns 2

Into this

```rust [], ignore
fn write_info(info: &Info) -> io::Result<()> {
let mut file = File::create("my_best_friends.txt")?;
// Early return on error
file.write_all(format!("name: {}\n", info.name).as_bytes())?;
file.write_all(format!("age: {}\n", info.age).as_bytes())?;
file.write_all(format!("rating: {}\n", info.rating).as_bytes())?;
Ok(())
}
```

## `?` vs Pattern Matching

As well as this

```rust []
fn add_last_numbers(stack: &mut Vec<i32>) -> Option<i32> {
let a = stack.pop();
let b = stack.pop();

match (a, b) {
(Some(x), Some(y)) => Some(x + y),
_ => None,
}
}
```

## `?` vs Pattern Matching 2

```rust []
fn add_last_numbers(stack: &mut Vec<i32>) -> Option<i32> {
Some(stack.pop()? + stack.pop()?)
}
```

We prefer using `?` instead of highly nested pattern matching

## Option into Result

* Sometimes we may have the absence of a value, but we want to add more context to the handler
* In essence: we have `Option`, but we want a `Result`:

```rust [], ignore
fn find_user(username: &str) -> Option<&str> {
let f = match std::fs::File::open("/etc/password") {

}
// ...
}
```

* Use the `.ok_or_else()` function to change a `Option<T>` into `Result<T, E>` lazily

## Option into Result 2

```rust [], ignore
pub fn find_user(username: &str) -> Result<UserId, Err> {
let f = std::fs::File::open("/etc/passwd")
.ok_or_else(|| Err(0))?;
// ...
}
```

* Because `Result<T, E>` provides more information than `Option<T>`, growing applications tend to encompass more `Result`s
* This means there is a tendency to take `Option`s that arise in your code and you must transform them into `Result<T, E>`
* There are stdlib functions to handle those transitions

## Result to Result

* We want to avoid bare `Err`s - the error type isn't conveying any information on how to proceed if you're the handler
* As you propagate the error, process the context to transform the error type into something more precise
* Basically, if we have `Result<_, A>` but want `Result<_, B>`, we can use `.map_err()`

```rust [], ignore
pub fn find_user(username: &str) -> Result<UserId, String> {
let f = std::fs::File::open("/etc/passwd")
.map_err(|e| format!("Failed to open password file: {:?}", e))?;
// ...
}
```


## Result to Result 2

* The `String`y based errors are not ideal.
* We prefer idiomatic error types:

```rust [9], ignore
pub type MyError = String;
impl std::error::Error for MyError {}
enum MyError {
BadPassword(String),
IncorrectID,
// ...
}
pub fn find_user(username: &str) -> Result<UserId, MyError> {
let f = std::fs::File::open("/etc/passwd")
.map_err(|e| MyError::BadPassword(format!("Failed to open password file: {:?}", e)))?;
// ...
}
```

## To be `?` or not to be `?`


* Using `?` means we deal with the error right now, but not *right here*
* Don't apply `?` blindly. There may be cases where other choices make sense
* Undesirable for long-running processes, or if we don't care about the failure
* Handling the error instead instead of propagating it
* Combining multiple `Result`s/`Option`s via pattern matching

## When to not `?`

```rust [], ignore
for stream in tcp_listener.incoming() {
// Should I use `stream?` here?
// No, because my whole server would stop accepting connections
let Ok(stream) = stream else {
eprintln("Bad connection");
continue;
}
}
```

## When to not `?` 2

```rust [], ignore
if let (Ok(a), Ok(b)) = (job_a(), job_b()) {
// run this code only when both jobs succeeded
}
```

If you only care about moving on in the happy path, try judicious pattern matching with `if let`s

## Iterators: `Result` into `Option`

* Iterators usually just care about processing or finding certain elements and throwing out the uninteresting data
* Use `.filter_map()` for this:

```rust [], ignore
let a = ["1", "two", "NaN", "four", "5"];

// I don't care about bad results, I filter them out
let mut iter = a.iter().filter_map(|s| s.parse::<i32>().ok());
// Instead of
let mut iter = a.iter().map(|s| s.parse()).filter(|s| s.is_ok()).map(|s| s.unwrap());
```

* Concretely, this means turning `Result<T, E>` into an `Option<T>` by using the `.ok()` method

## Iterators and collecting errors

* `Option` and `Result` support transposition: they can wrap collections or be elements of them
* If you want to process each error separately, use `Vec<Result<T, _>>`:

```rust [], ignore
let vec_of_results: Vec<Result<i32, _>> = inputs.iter()
.map(|s| s.parse::<i32>())
.collect();
```

## Iterators and collecting errors 2

* If you only care about all of them succeeding, wrap it with `Result<Vec<i32>, _>`:

```rust [], ignore
let result_of_vec: Result<Vec<i32>, _> = inputs.iter()
.map(|s| s.parse::<i32>())
.collect()?;
```



## Which way to wrap?

In general, we prefer wrapping the collection with an error (`Result<Vec<T>, _>` and `Option<Vec<T>>` )
rather than the other way around

## Recap

We've gone over many transformations:

* `Option<T>` to `Result<T, E>` and vice versa
* `Result<T, E>` to `Result<T, U>`

Many more variants exist depending on if you ignore the error, replace its value, provide a default, etc.

To deal with references, use `.as_ref()`.

## Useful References

* [Result's stdlib docs](https://doc.rust-lang.org/stable/std/result/index.html)
* [Option's stdlib docs](https://doc.rust-lang.org/stable/std/option/index.html)
* A **very useful** [diagram](https://docs.google.com/drawings/u/1/d/1EOPs0YTONo_FygWbuJGPfikO9Myt5HwtiFUHRuE1JVM/preview) is given in [the Effective Rust book](https://effective-rust.com/transform.html) for all these conversions and methods

## Conclusion

Worry about

* `Result<T, E>` <=> `Option<T>` and
* `Result<T, E>` <=> `Result<T, F>`

until you need something else

0 comments on commit 226f8f4

Please sign in to comment.