Projects · Lesson 25.9

Inventory

Source
Keep a shop's stock as structs in a vector and apply a day of orders, refusing the ones that cannot be carried out.
You'll need: Parts 1–17 — this project is a checkpoint for Collections, and leans on Absence to error, Error sum, Typed pattern and Vector.

This program keeps the stock list of a small shop — apples, bread, candles, milk — and applies a day of orders to it. Some orders are fine. Others ask for more than is on the shelf, for an item the shop has never stocked, or for a negative amount, and those are refused with a reason.

It is a checkpoint for Part 17: Collections, but most of what it practises comes from earlier: structs, variants with data, and above all fallible functions. Its rule is that a refused order changes nothing, so the stock is never left half-updated.

How it is put together

The data is three types. A StockItem is one line of the list, an Order is one thing that can happen to it, and a StockError is one reason an order can be refused, carrying the details that explain it:

variant StockError {
    UnknownItem(char8[..]),
    BadAmount(int32),
    NotEnough { name: char8[..]; have: int32; wanted: int32; },
    StillInStock { name: char8[..]; count: int32; }
}

The functions are layered. Main hands each order to Apply, which dispatches to one function per kind of order; all of them look items up through Find:

flowchart LR
    main(["Main<br/>for each order"]) --> apply["Apply<br/>match order"]
    apply --> sell["Sell"]
    apply --> deliver["Deliver"]
    apply --> retire["Retire"]
    sell --> find["Find<br/>uint?"]
    deliver --> find
    retire --> find
    deliver -- "new item" --> push["Vector.Push<br/>may fail: CollectionError"]
PieceLessons it uses
StockItem, Order, StockErrorStruct, Variant, Error variant
The stock as Vector<StockItem>Vector, Mutable reference
Find returning uint?Optional, Presence
?? fail in Sell and RetireAbsence to error
Apply failing with two error typesError sum, Propagate
Taking orders and errors apartStruct pattern, Typed pattern

Finding an item, or not

Find answers with the item's position in the vector, or none. Names are compared with EqualsSlice, because == on two slices is refused — it would ask whether they are the same view, not whether they hold the same text:

func Find(stock: &Vector<StockItem>, name: char8[..]) -> uint? {
    let items = stock.AsSlice();
    for i in 0..items.length {
        if EqualsSlice(items[i].name, name) {
            return i;
        }
    }
    return none;
}

In Sell, an absent position is a refusal. ?? fail turns the none into a failure on the spot, so the line after it can use index as a plain uint:

let index = Find(stock, name) ?? fail StockError::UnknownItem(name);

Check everything, then change

Sell makes every check before it touches the stock. Only when all of them pass does the count change, which is what makes a refused order harmless:

func Sell(stock: &var Vector<StockItem>, name: char8[..], amount: int32) -> ! StockError {
    if amount <= 0 {
        fail StockError::BadAmount(amount);
    }
    // An absent position becomes a failure on the spot.
    let index = Find(stock, name) ?? fail StockError::UnknownItem(name);
    var items = stock.AsMutableSlice();
    if items[index].count < amount {
        fail StockError::NotEnough { name: name, have: items[index].count, wanted: amount };
    }
    items[index].count -= amount;
}

AsMutableSlice gives a writable view of the vector's elements, so items[index].count -= amount changes the stock in place. Sell returns ! StockError with no success value: reaching the end of the body is the success.

Two kinds of failure, kept apart

A delivery of something new adds an item, and adding to a vector can run out of memory. That is a CollectionError — a different kind of failure altogether, and nobody's fault. So Deliver and Apply fail with the sum of the two error types, and every ? passes either kind through unchanged:

func Apply(stock: &var Vector<StockItem>, order: Order) -> ! (StockError | CollectionError) {

Main opens the outcome with typed patterns. A StockError is explained and counted; a CollectionError ends the day, because no order can be blamed for it:

match Apply(stock, order) {
    .Success(()) => PrintLine("    done"),
    .Failure(error: StockError) => {
        Explain(error);
        refused += 1;
    },
    // Running out of memory is no fault of the order, so it ends the day.
    .Failure(error: CollectionError) => fail error
}

Main itself is declared -> ! CollectionError, so fail error ends the program with exit status 1.

Retiring a sold-out item

Retire refuses to drop an item that is still on the shelf. Once it has sold out, RemoveAt takes it out of the vector. In the run above, retiring milk is refused with 6 still in stock, candles (at 0) are removed, and later honey — delivered as a new item — appears at the end of the closing list.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// Inventory: the stock list of a small shop, kept as structs in a `Vector`, and a day of orders
// applied to it, some of which cannot be carried out.
//
// Every change to the stock is a fallible function, and each way it can be refused is a case of
// `StockError` carrying the details: which item is unknown, how many were wanted and how many
// were there. A refused order changes nothing, so the stock is never left half-updated.
//
// A delivery of something new adds an item to the vector, and adding can run out of memory. That
// is a `CollectionError`, a different kind of failure altogether, so `Apply` fails with the sum
// `StockError | CollectionError` and both kinds pass through its `?` unchanged.
import Allocator::{ Allocator, SystemAllocator };
import Collections::{ CollectionError, EqualsSlice, Vector };
import Io::PrintLine;

struct StockItem {
    name: char8[..];
    count: int32;
}

variant StockError {
    UnknownItem(char8[..]),
    BadAmount(int32),
    NotEnough { name: char8[..]; have: int32; wanted: int32; },
    StillInStock { name: char8[..]; count: int32; }
}

variant Order {
    Sell { name: char8[..]; amount: int32; },
    Deliver { name: char8[..]; amount: int32; },
    Retire(char8[..])
}

// Where the item sits in the vector, or `none` if the shop has never stocked it. Names are
// compared with `EqualsSlice`: `==` on two slices is refused, because it would ask whether they
// are the same view rather than whether they hold the same text.
func Find(stock: &Vector<StockItem>, name: char8[..]) -> uint? {
    let items = stock.AsSlice();
    for i in 0..items.length {
        if EqualsSlice(items[i].name, name) {
            return i;
        }
    }
    return none;
}

func Sell(stock: &var Vector<StockItem>, name: char8[..], amount: int32) -> ! StockError {
    if amount <= 0 {
        fail StockError::BadAmount(amount);
    }
    // An absent position becomes a failure on the spot.
    let index = Find(stock, name) ?? fail StockError::UnknownItem(name);
    var items = stock.AsMutableSlice();
    if items[index].count < amount {
        fail StockError::NotEnough { name: name, have: items[index].count, wanted: amount };
    }
    items[index].count -= amount;
}

func Deliver(stock: &var Vector<StockItem>, name: char8[..], amount: int32)
    -> ! (StockError | CollectionError) {
    if amount <= 0 {
        fail StockError::BadAmount(amount);
    }
    match Find(stock, name) {
        index? => {
            var items = stock.AsMutableSlice();
            items[index].count += amount;
        },
        none => stock.Push(StockItem { name: name, count: amount })?
    }
}

// An item leaves the list only once it has sold out.
func Retire(stock: &var Vector<StockItem>, name: char8[..]) -> ! StockError {
    let index = Find(stock, name) ?? fail StockError::UnknownItem(name);
    let count = stock.AsSlice()[index].count;
    if count > 0 {
        fail StockError::StillInStock { name: name, count: count };
    }
    // `RemoveAt` hands back the item it took out, as an optional; it is not needed here.
    stock.RemoveAt(index);
}

func Apply(stock: &var Vector<StockItem>, order: Order) -> ! (StockError | CollectionError) {
    match order {
        .Sell { name, amount } => {
            PrintLine("sell {} {}", amount, name);
            Sell(stock, name, amount)?;
        },
        .Deliver { name, amount } => {
            PrintLine("deliver {} {}", amount, name);
            Deliver(stock, name, amount)?;
        },
        .Retire(name) => {
            PrintLine("retire {}", name);
            Retire(stock, name)?;
        }
    }
}

func Explain(error: StockError) {
    match error {
        .UnknownItem(name) => PrintLine("    refused: no such item as {}", name),
        .BadAmount(amount) => PrintLine("    refused: {} is not an amount", amount),
        .NotEnough { name, have, wanted } =>
            PrintLine("    refused: wanted {} {}, only {} left", wanted, name, have),
        .StillInStock { name, count } =>
            PrintLine("    refused: {} {} still on the shelf", count, name)
    }
}

func PrintStock(stock: &Vector<StockItem>) {
    for item in stock {
        PrintLine("    {:10} {:3}", item.name, item.count);
    }
}

func Main() -> ! CollectionError {
    var system = SystemAllocator();
    let allocator: Allocator = system;

    var stock = Vector<StockItem>(allocator);
    stock.Push(StockItem { name: "apples", count: 12 })?;
    stock.Push(StockItem { name: "bread", count: 3 })?;
    stock.Push(StockItem { name: "candles", count: 0 })?;
    stock.Push(StockItem { name: "milk", count: 6 })?;
    PrintLine("opening stock");
    PrintStock(stock);

    let orders = [
        Order::Sell { name: "apples", amount: 5 },
        Order::Sell { name: "bread", amount: 4 },
        Order::Sell { name: "honey", amount: 1 },
        Order::Deliver { name: "bread", amount: 10 },
        Order::Sell { name: "bread", amount: 4 },
        Order::Deliver { name: "honey", amount: 2 },
        Order::Sell { name: "milk", amount: -2 },
        Order::Retire("milk"),
        Order::Retire("candles"),
        Order::Sell { name: "milk", amount: 6 }
    ];
    var refused = 0;
    for order in orders {
        match Apply(stock, order) {
            .Success(()) => PrintLine("    done"),
            .Failure(error: StockError) => {
                Explain(error);
                refused += 1;
            },
            // Running out of memory is no fault of the order, so it ends the day.
            .Failure(error: CollectionError) => fail error
        }
    }

    PrintLine("closing stock, after {} refused orders", refused);
    PrintStock(stock);
}

Besides Io, its Rux.toml lists Allocator and Collections under [Dependencies].

Run it

cd Examples/Projects/Inventory
rux run
opening stock
    apples      12
    bread        3
    candles      0
    milk         6
sell 5 apples
    done
sell 4 bread
    refused: wanted 4 bread, only 3 left
sell 1 honey
    refused: no such item as honey
deliver 10 bread
    done
sell 4 bread
    done
deliver 2 honey
    done
sell -2 milk
    refused: -2 is not an amount
retire milk
    refused: 6 milk still on the shelf
retire candles
    done
sell 6 milk
    done
closing stock, after 4 refused orders
    apples       7
    bread        9
    milk         0
    honey        2

Common mistakes

Comparing names with ==.
items[i].name == name is refused: error: operator '==' is not defined for slice type 'char8[..]', with the note that comparing the views would compare addresses rather than elements. Use EqualsSlice from Collections.
Letting a CollectionError into a function that fails with StockError only.
Declare Deliver as -> ! StockError and its stock.Push(…)? stops compiling: error: '?' propagates error type 'CollectionError', but the enclosing function fails with 'StockError'. ? never converts one error into another; the function has to admit both.
Handling only the errors you expected.
Remove the .Failure(error: CollectionError) arm from Main and the match is incomplete: error: match on '! (CollectionError | StockError)' is not exhaustive; missing .Failure(_: CollectionError). Every member of the sum needs an answer, even if the answer is "give up".

Try it yourself

  1. Add an order Return { name; amount; } for goods a customer brings back. It should behave like a delivery, but refuse an item the shop has never stocked.
  2. Give each item a price in cents and print the value of the closing stock. Which functions have to change, and which do not?
  3. Add a LowStock warning: after each successful sale, print a note if fewer than three are left. Is that a new case of StockError, or something else?
  4. Apply all of a customer's orders together or none of them: take a small array of orders, check them all first, and only then change the stock.

Learn more