Posted on :: Tags: , , ,

In the beginning

I had originally written a base64 encoder and decoder CLI in Rust called b64 because I was annoyed with how openssl works in MacOS with file piping. Frequently I get a base64 encoded string from something and want to just quickly see what the actual value is to compare it to what I expect the value is. Rather than messing with piping and STDIN input, I used to leave open a Ruby IRB repl and just encode and decode there.

This was annoying.

I liked learning Rust, so I used this as an opportunity to write a little CLI in Rust. Then a few months later I felt like a fool when I discovered Swift wasn't just for Apps; it is a general purpose language! Well, color me surprised, so I took it upon myself to go learn about it.

Comparisons

Using Strings

Using strings and just passing them around is just easier in Swift. When not having to worry about ownership and passing strings around as just a value, such as an error message or a CLI arg, Swift makes this much nicer.

// Here we have to call `to_string()` on something that already resembles a
// a string because this is actually a `str` not a `String`. This is confusing!
Err(..) => Err("Non-UTF8 data found, use another utility".to_string()),

// Another example.
// I'm sure I had an acceptable reason for the .clone() call there but I've no
// idea what it was. Things like this are frustrating for people learning Rust,
// I think. Or at least they are for me :D
let passed_text = matches
  .get_one::<String>("text")
  .expect("Must pass text to encode or decode")
  .clone();

There are good reasons, within Rust's model for sure, why it does what it does with strings. That doesn't make it less confusing to someone new deciphering when to use a str vs &str vs String. In Swift, I can pretty safely just use String.

With one caveat.

The actual base64 encoding happens on a Data type in Swift. This was confusing initially, in large part because the initializations were very funky for it. Eventually I discovered I could call #data on a string with an encoding specified to just get the data.

let e = source.data(using: .utf8)?.base64EncodedString()

guard let encodedString = e else { return nil }

This segues nicely into something else...

Guard clause

Guard clauses in Swift are very neat. It's basically an if with an early return except the compiler requires that the else clause (where you would early return) actually does relinquish control either with return or throw. As a lover of early return statements out of functions, it was very cool and handy to me that there is both compiler support for something like this as well as a keyword so you can tell at a glance if this clause is going to relinquish flow control. No more reading a whole if block to see if there's a return; if it's a guard then it returns.

However! The actual coolest part of this is guard let. This functions like an if let except the value assigned is available after the block. Incredibly handy for unwrapping Optional values.

let foo = someThingThatReturnsNil()

guard let realFoo = foo else {
  print("didn't get any values! exiting")
  return 0
}

print("Foo is \(realFoo.name)") // Works because foo is definitely the actual object

Pattern matching

I really like Rust's pattern matching. It's just pleasant. No real notes here other than I enjoy a good:

match result {
  Ok(converted) => println!("{}", converted),
  Err(error_msg) => {
    println!("{}", error_msg);
    std::process::exit(1)
  }
}

Error handling differences

This one was fascinating to me. I'm used to "don't use exceptions as flow control" because usually exceptions are expensive. However, Swift's docs explicitly note the following:

Error handling in Swift resembles exception handling in other languages, with the use of the try, catch and throw keywords. Unlike exception handling in many languages — including Objective-C — error handling in Swift doesn’t involve unwinding the call stack, a process that can be computationally expensive. As such, the performance characteristics of a throw statement are comparable to those of a return statement.

Oh okay! Cool. So it's effectively a return but with semantics that it must be given an Error, making it very explicit that this is an error case. I like that.

I was also leery of more Java-style do...catch with try blocks, but actually found it quite pleasant. If a function can throw an error, you have to preface invocation with try, signalling to yourself and future readers that errors can happen at this point. If it's not wrapped in a do...catch you can immediately tell the error is about to be somebody else's problem.

Optional types

Both languages have this and honestly both are a pleasure to use in different ways. Rust's really make use of their already pleasant pattern matching as noted above. Swift, however, makes it so that Optional.none is equivalent to nil. I can see both benefits and drawbacks, but to me it was more win than loss. Swift treats pointers as entire Types you have to go out of your way to use, so nil isn't overloaded to mean both "no value" and "pointer to nowhere."

Interestingly, I prefer Rust's way here when returning values, e.g:

fn banana() -> Option<String> {
  return None
}

That return None is just so satisfying, so succinct. On the other hand, I prefer doing comparisons with Swift's quick check of banana() == nil. It just flows off the tongue.

Keys

This is a unique one to Swift. Enum keys and other kinds of keys (I'm honestly not clear) from imported classes or enums or even static functions in scope are referenced with just .keyName throughout the program. This is definitely confusing to a newcomer, but I honestly think it's about on par with importing functions in other languages and using them, making it confusing to tell if a function came from an imported package and more importantly which imported package.

An example of the keys being used:

import PackageDescription

let package = Package(
  name: "b64",
  products: [
    // .executable here is a static func on Product imported from PackageDescription
    .executable(name: "b64", targets: ["b64"]),
  ],
  // ...
)

Tooling

Ah, tooling. Swift's SublimeText LSP never quite got working well for me. I didn't get nice things around syntax completion or function definitions, even for functions defined in the files. Rust's, meanwhile, works pretty well right out of the box.

Cargo is also just pretty nice. You get your dependencies, a nice flat TOML file (though not my favorite syntax) for defining everything, it all mostly just works. Swift Package Manager also mostly just works! However, a very unexpected issue I ran into is that order matters for Package.swift. This surprised me! The Package() call at the top is an initializer accepting arguments, everything looks like keyword arguments which to my brain says order doesn't matter, but boy does it.

I will give Swift points for testing, though. It's very nice to define a testTarget for just tests that automatically reads in all yoru source files plus anything in a Tests directory. I've never been a fan of how Rust tends to default to the same file or a side-by-side file that imports the module.

Distribution

Distributing CLIs for Rust is frankly hilariously easy. You just need the Rust toolchain and a quick cargo install --git <the repo> will install it as a binary in the cargo path. Alternatively, building it and installing somewhere else requires the exact same toolchain. As a third alternative, you can install prebuilt ones!

Swift is...a little more annoying. Homebrew has an older version of swift available by default. Distribution of Swift is also tied into how you write Swift, declaring functions and products as only available for certain versions of MacOS but how does that relate to, say, a Linux system? Do I now have to use if #available flags and muddy my code? I can depend on the entirety of XCode command line tools in homebrew to install and have it built, so not the worst because Homebrew already relies on that being available. It nonetheless feels remarkably less portable, though I am hopeful that will grow and change over the next couple of years. swiftly is relatively new (and I didn't know about it before writing this) and might be a good answer there!

Overall

If this sounds like a love letter to Swift compared to Rust, it kind of is! I actually really enjoyed using Swift for this very small project. I wish Rust were just a little bit smoother to use as a beginner; I feel like the amount of Rust-workings you need to know to get started is higher than other languages. On the other hand, it's pretty well documented both in packages and standard lib. Swift docs all had this vibe that they really expected you to have done trainings with Apple or something.

Another knock is that there's bound to be more library and ecosystem support outside of Apple Apps for Rust than Swift. So the moment I need to do something that a third-party library would greatly benefit me, I'm probably going to struggle. Like, say, I don't know talking to the Github API for which there is Octocrab in Rust but nothing I can find for Swift.

I will likely continue writing little CLIs and tools for me in Swift as learning exercises, especially ones that just focus on the local file system to clean up files or rename things or whatever. Obviously as you use a language more, you get more comfortable with it. So I could also just practice Rust more! That said, I think I find just a little more joy for the tidbits I do on my personal Mac laptop that makes Swift just an all around more pleasant experience, annoyances about distribution and tooling/LSP aside.