3.4 A Store for Anything
A good guild does not rely on the craftsman’s memory. It relies on a document: the piece must accept, find, enumerate - the craftsman signed. The store can do everything needed. Capability without a signed obligation is usually called reliability. The accurate word is coincidence.
What You Need
We just built TaskStore, where the methods add, get, and all live in impl TaskStore. But nowhere is it written that the store must have them - remove all and
the compiler says nothing.
A named contract is needed: a description of what the store is obligated to do. Then the implementation and the requirement live separately - and any mismatch between them is caught by the compiler, not only by whoever wrote the code.
The Build
For a store to find an item by identifier, each item must be able to provide one. This is the minimum requirement for anything the store accepts:
trait HasId {
fn id(&self) -> u64;
}
For Task:
impl HasId for Task {
fn id(&self) -> u64 {
self.id
}
}
Several methods return Result with the same error type - and this repeats across
signatures. A type alias removes the repetition:
type TqResult<T> = Result<T, TqError>;
| Without alias | With alias |
|---|---|
fn add(...) -> Result<(), TqError> | fn add(...) -> TqResult<()> |
fn get(...) -> Result<&T, TqError> | fn get(...) -> TqResult<&T> |
Task::new(...) -> Result<Task, TqError> | Task::new(...) -> TqResult<Task> |
T is the success type: TqResult<()> means “nothing”, TqResult<&Task> means “a
reference to a task”.
The store’s contract is described by a trait. T is a placeholder for the item type;
: HasId says that this type knows how to provide an identifier:
trait Store<T: HasId> {
fn add(&mut self, item: impl Into<T>) -> TqResult<()>;
fn get(&self, id: u64) -> TqResult<&T>;
fn all(&self) -> &[T];
}
TaskStore implements this contract. The methods move from impl TaskStore into
impl Store<Task> for TaskStore - impl TaskStore keeps only the constructor:
impl TaskStore {
fn new() -> Self {
TaskStore {
items: Vec::new(),
next_id: 1,
}
}
}
impl Store<Task> for TaskStore {
fn add(&mut self, item: impl Into<Task>) -> TqResult<()> {
let mut task = item.into();
task.id = self.next_id;
self.next_id += 1;
self.items.push(task);
Ok(())
}
fn get(&self, id: u64) -> TqResult<&Task> {
self.items
.iter()
.find(|task| task.id() == id) // HasId
.ok_or(TqError::NotFound(id))
}
fn all(&self) -> &[Task] {
&self.items
}
}
impl Store<Task> for TaskStore - “TaskStore fulfils the Store contract for tasks.”
The compiler checks: all three methods are present, signatures match. In get, task.id()
is called - the method from HasId. In add, the field task.id is written directly:
HasId provides only reading.
The Result
In main, only the signature changes - Result<(), TqError> becomes TqResult<()>.
Everything else is untouched:
fn main() -> TqResult<()> {
The existing tests pass without changes. That is their job: to confirm that internal changes have not broken behaviour from the outside.
make ci passes. TaskStore has signed: impl Store<Task> for TaskStore - these are not
just methods, they are an obligation.
The complete
tqcode for this chapter is in3-a-voice/04-a-store-for-anything/.
Lore: Bounds on a Trait
Store<T: HasId> - the bound sits directly on the trait parameter. It is not possible to
implement Store<T> for a type without HasId; the compiler will check this on any
attempt.
When there are multiple bounds, the notation T: HasId + Clone grows long. In that case,
use where:
impl<T> Store<T> for TaskStore
where
T: HasId,
{
// ...
}
Both forms compile identically. where is more convenient with longer bounds.