Day 26: Semantic drift

Today’s session involved building towards actually downloading the audio files pointed to by the items in an RSS feed. Exciting! But also not yet complete. Ahead of that, I thought I’d share an interesting problem I ran into during implementation.

Syntax is not semantics

Syntax and semantics are closely related by separate concepts. The syntax of some code is the structure of text you write, the semantics is what that syntax means. The rules of syntax can sometimes be simpler than the underlying semantics, which is a cognitive trick that can make the whole system more approachable. For example, the ancient Greek mathematicians like Euclid made incredible breakthroughs, but were somewhat limited by considering everything in terms of the underlying geometry — imagining the square root as the actual side length of a square, for example. This works to a point, but you can only keep so many shapes in your head or construct on papyrus. The introduction of algebraic notation, eg. y = x2 greatly simplified this. By closely associating the rules that bind the syntax to the rules that govern the underlying semantics, it lets you perform mechanical syntactic changes (eg. subtracting from both sides, as you learned in school) that remain semantically correct. This topic is covered in more depth in Kenneth Iverson’s Turing Award lecture Notation as a Tool of Thought.

Syntax and semantics are still different, though, and not every syntactic change maps cleanly on to a semantic change. Here’s an example I ran into today: I want to display the state of every channel as it is loaded, which means mapping each enum variant to a Ratatui widget. The first version I wrote looked like this:

match channel {
    Channel::Broken => {
        frame.render_widget(
            Line::raw("broken channel"),
            channel_rect
        )
    },
    Channel::Loading(url) => {
        frame.render_widget(
            Line::raw(format!("loading {url}")),
            channel_rect
        )
    },
    Channel::Loaded { title } => {
        frame.render_widget(
            Paragraph::raw(title),
            channel_rect
        )
    }
};

There is some obvious repetition here. In each branch I call the same method, frame.render_widget, and pass the same second argument, channel_rect. Thankfully in Rust everything is an expression, including branching statements like match, so I decided to invert the structure: I’ll make it so that only the bit that changes, the widget is goverend by the conditional:

frame.render_widget(match channel {
    Channel::Broken => Line::raw("broken channel"),
    Channel::Loading(url) => Line::raw(format!("loading {url}")),
    Channel::Loaded { title } => Paragraph::raw(title),
}, channel_rect);

This is an ingrained syntactic transformation that I am used to applying. Unfortunately, in this case the semantics don’t match up for a subtle reason: in Rust everything is an expression, but also every expression has a type. So what type does the above match expression have? There isn’t one that works! The frame.render_widget call from Ratatui is generic, so when we give it different types, Rust will perform monomorphization under the hood, generating a separate function for each type you use. But when we pull the frame.render_widget call out, now every branch has to have the same type, which they don’t. In some languages the expression would automatically take on a new combined union type, such as Line | Paragraph, but that would be a non-zero-cost abstraction, which Rust avoids. I could do the same thing manually by wrapping Box::new() around each branch, making the overall expression type Box<dyn Widget>, but that’s a very short lived allocation that I definitely don’t need.

In this case though, the solution is simple: the Paragraph type is only used instead of Line as a remnant of a previous refactoring. If I update it to be Line as well, then the refactor works as I’d hoped. However, this is somewhat fragile, as I’ll have to revert back if I ever need to change one of the branches types.