Recurse Center, week 3: Making Rust's ownership work for you

November 27, 2020 • Reading time: 5 minutes

This article should more properly be titled "RefCell", that being the name of this week's project, but I didn't want to bury the lede. So, having failed to build anything with a UI last week, I set out to build something with a UI this week: an implementation of the FreeCell card game in Rust using Druid. Naturally, I named it RefCell. And... I failed.

Maybe I'm just used to the actual appearance of things being Someone Else's Problem, but I had enough trouble getting my head around Druid's GUI model that I ran out of time. At the end of the week, my GUI application looked like this:

RefCell GUI screenshot

My wife helpfully pointed out that I do not appear to be playing with a full deck.

Not to fear, though, because a few hours before my self-imposed deadline, I dashed off a small command-line application that works fine. Very small indeed.

RefCell CLI screenshot

I was gratified to discover that the Unicode Consortium has graciously provided us with code points for all of the playing cards, so I just needed a UTF-8–compatible terminal (as most are these days) and I could display a full tableau. Maybe not the easiest on the eyes, but no ASCII funny business needed.

So, when does this become about Rust? Well, this is the first time I've found myself appreciating ownership as a way of structuring my code rather than as a necessary, lesser evil to avoid the twin greater evils of garbage collection and manual allocation.

Say I have a Card and a Deck.

struct Deck { cards: Vec<Card> }

struct Card { rank: u8, suit: Suit }

enum Suit { Spades, Clubs, Hearts, Diamonds }

Now, I've dealt the Deck onto my Tableau. The Tableau, in turn, has structs for each of the components of the board: four "free" Cells, four Foundations to build on, and eight Cascades, which are the long piles where your cards start. (It's fun to be able to shortcut the classic programming problem of Naming Things when someone has already come along and named them for you.)

struct Tableau {
    cells: [Cell; 4],
    foundations: [Foundation; 4],
    cascades: [Cascade; 8],
}

struct Cell { card: Option<Card> }

struct Foundation { cards: Vec<Card> }

struct Cascade { cards: Vec<Card> }

These structs collectively will implement the business logic, that is to say, the game rules. For instance, you can only have one card in a Cell.

impl Cell {
    fn push(&mut self, card: Card) {
        if self.card.is_none() {
            self.card = Some(card);
        } else {
            panic!("A card is already present in that cell.");
        }
    }
}

Obviously, deliberately crashing the game isn't the best way to deal with the user making an illegal move. Instead, we should return a helpful message for the UI (heh) to show to the user.

impl Cell {
    fn push(&mut self, card: Card) -> Result<(), &'static str> {
        if self.card.is_none() {
            self.card = Some(card);
            Ok(())
        } else {
            Err("A card is already present in that cell.")
        }
    }
}

Uh oh – we now risk dropping a card! Because Card doesn't #[derive(Copy)], the calling code passes us a Card in the most literal sense, in that it will give the parameter away and won't get it back. Let's flesh out the example a bit to see this more clearly.

impl Cell {
    // ...

    fn take(&mut self) -> Option<Card> {
        self.card.take()
    }
}

fn main() {
    let mut source_cell = Cell {
        card: Some(Card {
            rank: 1,
            suit: Suit::Hearts,
        }),
    };
    let mut dest_cell = Cell {
        card: Some(Card {
            rank: 1,
            suit: Suit::Spades,
        }),
    };


    if let Some(card) = source_cell.take() {
        match dest_cell.push(card) {
            Ok(()) => println!("No problemo!"),
            Err(_) => {
                assert!(source_cell.card.is_none());
                println!("Uh, where did the card go?");
            },
        }
    }
}

In that case, we have to give the card back. This is where my mind started to get slowly blown.

impl Cell {
    fn push(&mut self, card: Card) -> Result<(), (Card, &'static str)> {
        if self.card.is_none() {
            self.card = Some(card);
            Ok(())
        } else {
            Err((card, "A card is already present in that cell."))
        }
    }

    // ...
}

fn main() {
    // ...

    if let Some(card) = source_cell.take() {
        match dest_cell.push(card) {
            Ok(()) => println!("No problemo!"),
            Err((card, _)) => {
                // Oops, better put that back before anyone sees.
                source_cell.push(card).ok();
                assert!(source_cell.card.is_some());
                assert!(dest_cell.card.is_some());
                println!("That was almost bad, but I fixed it!");
            },
        }
    }
}

Now, it would be better to check if a move was legal or not before attempting it, but ironically the problem of "dropping" a card in flight made me realize that I was dealing with much more tangible cards in this code than I would be in any other programming language. These cards could only ever exist in one place.

I'm not going to demo all of the code involved, but they start out in the Deck when I call Deck::shuffled(), then they move onto the Tableau when I call Tableau::deal(deck). The Tableau knows where the cards will end up, so it distributes them between the eight Cascades using Cascade::push(card). But as I deal the cards onto the Tableau, they are syntactically required to leave the deck. Non-programmers in the audience a) won't have gotten this far with the possible exception of my mother (hi mom), and b) probably won't appreciate just how strange this feels. Any conceivable bug that might result from a card being put in a place without first being taken from another place is syntactically impossible.

Anyway, if you want to take a look at the full code, it's on GitHub in all its half-finished glory. I'll go back at some point and finish the GUI, I promise.

GitHub repository