EntriesAbout

ClojureScript contra Rust

I’ve used Rust a tad a number of years ago and found it fine, but recently decided I wanted to give it a go again and see what it’s like these days. Always fun to learn something new! I chose for my “test-bed” project a native GUI version of a little ClojureScript webapp I’d thrown together to view and edit the UDDF files for my scuba dives.

The ClojureScript version had started off as a quick little thing to spend a leisurely vacation afternoon on, just showing graphs of the dive depth and temperature (inspired by contributing to an app to export said UDDF files from the Apple Watch Ultra). I find it very satisfying to make interactive SVGs with ClojureScript and initially just wanted to make some useful visualizations. Scope creep being what it is though, I naturally then went on to make the tool display the other information that the UDDFs provide and since it could do that, of course it should edit as well (slightly more convenient than using XSLT scripts to update the dives, which is what I’d been doing).

This incremental progress meant the structure of the app was a little haphazard. Since initially it only needed to grab the data once to graph, it would parse the XML and just keep it in its “raw” form, using spectre and some custom navigators to get what was needed out of the clojure.data.xml soup. That worked, but when I started trying to edit text in large files, it became painfully slow, since the app needed to navigate through a whole forest of nodes, skipping whitespace, on every keystroke. I rewrote it to parse the XML into more structured data; that sped things up, but it had the unfortunate consequence that now I needed to write a whole bunch of code to both serialize and deserialize the data when loading and saving. Since it’s all quite ad-hoc, it was easy to miss a property when writing things out.

The annoyances with parsing made me want to see how Rust would fare. I had recollections that Sente was quite nice for this sort of thing. Rust has progressed a lot since I last wrote with it too – now it has libraries for doing GUIs! A surfeit of them, actually…determining which one to use became a bit of a challenge. I went with iced ; unstable, but generally seemed to match what I wanted.

For the Rust version, I operated in the opposite direction as I did with Clojure and started with parsing the XML into structured data. Sente made that very easy; I just had to define all the structures and then the parsing and saving “just worked” for the most part. That was nice, certainly easier and with less worries about edge-cases than the same experience in Clojure.

When it came to actually putting the GUI editor together though, I quickly started to miss Clojure’s dynamism. Iced uses an “Elm architecture”, which I do like (I’ve never used Elm, but it seemed similar enough to Clojure’s re-frame that it made sense to me). The problem is that the architecture requires representing field edits as message enum variants. The most straightforward way of doing that would be to make a variant for every single field and have a zillion nearly-identical cases setting fields to a value. Doable, but tedious and annoying enough that I immediately set about trying to find a better way.

In Clojure, I could just pass a vector of keys and a new value, using a simple assoc-in to update state. With Rust though, it’s not so straightforward. My thought was to try and find a Rust equivalent to Objective-C’s keypaths to specify arbitrary nested structure updates. That eventually led me to bevy_reflect, which sort of worked for my use-case. It let me specify the “path” to a deeply nested structure value with a string – e.g. given the structs below, ".diver.owner.firstname" would refer to diver.owner.firstname – but there were some complications.

struct UDDF {
  pub diver: Diver, // actually Option<Diver>, but we'll get to that...
  // ...
}

struct Diver {
  pub owner: Owner, // likewise
  // ...
}

struct Owner {
  pub firstname: String, // likewise
  // ...
}

Primarily, most of the fields are actually Options, since the UDDF file can start with very little in it. bevy_reflect can select through those – it would become ".diver.0.owner.0.firstname" – but while that works for editing existing values, if the optionals are None, the navigation fails. I considered taking apart the “keypaths” string and doing a get_or_insert_with(Default::default) to fill in a default value if none. That was also looking quite annoying, so I wound up making my own keypath-style trait that automatically “fills in” missing Optionals with default values. I even managed to figure out how to make the trait fn generic, so I could use the same method to get mutable references to fields of different type.

pub trait EditPath {
    fn path_mut<T: 'static>(&mut self, path: &str) -> Result<&mut T, String>;
}

fn next_component(path: &str) -> (&str, &str) {
    path.find('.')
        .map(|idx| (&path[0..idx], &path[idx + 1..]))
        .unwrap_or((path, ""))
}

impl EditPath for Uddf {
    fn path_mut<T: 'static>(&mut self, path: &str) -> Result<&mut T, String> {
        let (_, ppath) = next_component(path); // skip leading "."
        let (head, rest) = next_component(ppath);
        match head {
            "diver" => self
                .diver
                .get_or_insert_with(Default::default)
                .path_mut::<T>(rest),
            otherwise => Err(format!("Couldn't parse {:?} from UDDF", otherwise)),
        }
    }
}

impl EditPath for Diver {
    fn path_mut<T: 'static>(&mut self, path: &str) -> Result<&mut T, String> {
        let (head, rest) = next_component(path);
        match head {
            "" => (self as &mut dyn Any)
                .downcast_mut::<T>()
                .ok_or("Wrong type for diver".to_string()),
            "owner" => self
                .owner
                .get_or_insert_with(Default::default)
                .path_mut::<T>(rest),
            // etc...
        }
    }
}

// etc for all the other structs

The remaining annoyance is adding to vectors. What I want is to be able to just use the keypath to get the vec and do vec_field.push(Default::default()). Unfortunately, it seems that I can’t do so fully generically, as I need to know the actual type of the vector’s contents – impl Default or whatever doesn’t suffice. I’ve resorted to looking at the keypath and duplicating the code, changing just the type of the vector’s contents. Tedious.

if path.ends_with("buddy") {
    if let Ok(vec_field) = self
        .file
        .as_mut()
        .unwrap()
        .content
        .path_mut::<Vec<uddf::Buddy>>(path)
    {
        vec_field.push(Default::default());
    } else {
        println!("Error appending to vector buddy");
    }
} else if path.ends_with("variouspieces") {
    if let Ok(vec_field) = self
        .file
        .as_mut()
        .unwrap()
        .content
        .path_mut::<Vec<uddf::EquipmentPiece>>(path)
    {
        vec_field.push(Default::default());
    } else {
        println!("Error appending to vector variouspieces");
    }
} else {
    println!("Unknown vector to append to: {}", path);
}

The UI stuff itself is fine. I find it a little “busy”, but I’m probably just spoiled by reagent. Less annoying than Swift UI, at least, not as deeply nested as Flutter gets.

I have some ideas for ways that I might be able to relieve the tedium by writing a macro to generate some of those trait methods. Maybe constructs enums for the fields as well, so I could use those instead of strings and still have type safety (e.g. UDDFField::Diver(DiverField::Owner(OwnerField::FirstName)) or something (as hideous as that looks)). I haven’t done so yet though, since macros in Rust seem to be much heavier-weight than they are in Lisps – seems they have to live in a whole separate crate, really seem geared for public consumption, not private convenience.

Having taken both apps to something like 80% of basic functionality, I think the experience has been kind of an interesting contrast: Clojurescript was tedious and annoying parsing, trivial to update state; Rust was trivial parsing, tedious and annoying to update state. In both cases, UI layout was basically “fine”; mostly necessary complexity, more to do with the specific libraries in use than the languages themselves. I think I could probably generate structure definitions from the XSD schema for UDDF…but I think that would benefit Clojure a lot more than Rust, since it would mostly make the parsing/serializing bit easier. Which experience was better? It’s not exactly a fair comparison, since I’m much more familiar with ClojureScript than Rust, plus I was targeting very different platforms. I think I can say though that Clojure was very straightforward; nothing particularly interesting on the meta level, so I was able to concentrate on questions of UI, how to display this data. When writing the Rust application, it felt like I was trying to solve all these complex problems, even it was ultimately to do basically the same thing.

That is, for me, the nub of programming in languages with strong static type systems: One spends a lot of time solving these interesting and complex type-level problems, to end up at the same place as “dynamic” languages – albeit, one must admit, with some more guarantees. I remember when I went through the effort of reading and understanding the “Profunctor Optics” paper, realizing that it boiled down to, more or less, type-safe assoc-in & friends, and being a tad underwhelmed. I’m sure there are contexts where having such strong assurances are vital – they’re certainly nice to have – but I have this idle thought that that a big part of the appeal of such languages/type systems is that they give programmers the opportunity to feel smart and engage in interesting problem solving when building even the most quotidian of programs. I don’t think that’s a bad thing per se, but I do wonder how much of the love people feel for these programming paradigms can be attributed to this effect. It’s undeniable that when people put a lot of effort into something, they will tend to exhibit something of a “sunk cost” effect and be not just unwilling to admit that such effort was unnecessary, but advocate for everyone going through the same strenuous experience. Certainly not everyone, certainly not all the time…but I do wonder.

Anyway, with this experience, I continue to enjoy Clojure and REPL-driven development. Being able to incrementally change & test code without having to restart the whole thing is very nice. On the other hand, having the static guarantees that Rust provides, assuring me I haven’t missed a variant is also pleasant to have. On the whole though, for me personally, I value the flexibility that Clojure and its ilk give me more than the costly assurances of Rust. For the right problem, I would certainly return to Rust in the future, even if at the moment I’m uncertain what the problem would be.