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.