← Table of contents

2.1 The Absent Task

A negative result is still a result - as everyone knows. It is easy to forget this, especially if you are a tool. A tool that cannot report one tips over the workbench. From the outside, the difference between “not found” and “broken” is obvious. From the inside - not at all.

What You Need

tq can add tasks, iterate through them, and mark them complete. It cannot yet find one specific task by its identifier. The list can be accessed directly: tasks[0], tasks[1]. But that is an index, not an id - and if the requested entry is not there, the list tips over the workbench.

Create a get function: it takes a list and an identifier, and returns the task - or honestly reports that it is not there.

The Build

Rust has a type for this: Option<T> - two variants:

Option<&Task> and &Task are different types. To reach the task inside, both cases must be handled explicitly. With this return type, accidentally ignoring the absence is not possible.

fn get(tasks: &[Task], id: u64) -> Option<&Task> {
    for task in tasks {
        if task.id == id {
            return Some(task);
        }
    }
    None
}

The form above is expanded so that Some and None are visible where they appear. The working version is shorter:

fn get(tasks: &[Task], id: u64) -> Option<&Task> {
    tasks.iter().find(|task| task.id == id)
}

To the compiler both forms are identical: find can be read as the loop above - the same iteration, the same condition, the same result.

Building the habit of reaching for iterators instead of for starts now. In this specific case the difference is only in size - the real power shows in chains: .filter(), .map(), .find() one after another without nested loops. When we get there - the hand will already know where to reach. |task| task.id == id is a closure, a topic of its own.

The call goes through match - each variant gets its own path:

match get(&tasks, 1) {
    Some(task) => println!("#{}: {}", task.id, task.title),
    None => println!("not found"),
}

Two tests - two paths:

#[test]
fn get_finds_existing_task() {
    let mut tasks = Vec::new();
    add(&mut tasks, Task::new(1, "Buy coffee"));
    assert!(get(&tasks, 1).is_some());
}

#[test]
fn get_returns_none_for_absent_task() {
    let tasks: Vec<Task> = Vec::new();
    assert!(get(&tasks, 99).is_none());
}

The full test suite is in tq/.

The Result

main below shows only what is new - two calls to get. The full code with list and everything accumulated so far is in tq/.

fn main() {
    let mut tasks: Vec<Task> = Vec::new();

    add(&mut tasks, Task::new(1, "Buy coffee"));
    add(&mut tasks, Task::new(2, "Buy milk"));
    add(&mut tasks, Task::new(3, "Buy eggs"));

    match get(&tasks, 1) {
        Some(task) => println!("#{}: {}", task.id, task.title),
        None => println!("not found"),
    }

    match get(&tasks, 99) {
        Some(task) => println!("#{}: {}", task.id, task.title),
        None => println!("not found"),
    }
}
#1: Buy coffee
not found

make ci passes. get returns either a task or its absence - the calling code decides what to do with either.

The complete tq code for this chapter is in 2-honest/01-the-absent-task/.


Lore: if let

match requires handling both variants. When only one is needed, if let is shorter:

if let Some(task) = get(&tasks, 1) {
    println!("#{}: {}", task.id, task.title);
}

If the task is not found, nothing happens. For cases where absence requires no separate action, this is more precise than a match with an empty None => {}.