Custom operators in Swift
Few Swift features cause as much heated debate as the use of custom operators. While some people find them really useful in order to reduce code verbosity, or to implement lightweight syntax extensions, others think that they should be avoided completely.
Love 'em or hate 'em - either way there are some really interesting things that we can do with custom operators - whether we are overloading existing ones or defining our own. This week, let's take a look at a few situations that custom operators could be used in, and some of the pros & cons of using them.
Numeric containers
Sometimes we define value types that are essentially just containers for other, more primitive, values. For example, in a strategy game I'm working on, the player can gather two kinds of resources - wood & gold. To model these resources in code, I use a Resources
struct that acts as a container for a pair of wood & gold values, like this:
struct Resources {
var gold: Int
var wood: Int
}
Whenever I'm referring to a set of resources, I'm then using this struct - for instance to keep track of a player's currently available resources:
struct Player {
var resources: Resources
}
One thing you can spend your resources on in the game is to train new units for your army. When such an action is performed, I simply subtract the gold & wood cost for that unit from the current player's resources:
func trainUnit(ofKind kind: Unit.Kind) {
let unit = Unit(kind: kind)
board.add(unit)
currentPlayer.resources.gold -= kind.cost.gold
currentPlayer.resources.wood -= kind.cost.wood
}
Doing the above totally works, but since there are many actions in the game that affects a player's resources, there are many places in the codebase where the two subtractions for gold & wood have to be duplicated.
Not only does that make it easy to miss subtracting one of these values, but it makes it much harder to introduce a new resource type (say, silver), since I'd have to go through the entire code base and update all the places where resources are dealt with.
Operator overloading
Let's try using operator overloading to solve the above problem. When working with operators in most languages (Swift included), you have two options. Either, you overload an existing operator, or you create a new one. An overload works just like a method overload, in that you create a new version of an operator with either new input or output.
In this case, we'll define an overload of the -=
operator, that works on two Resources
values, like this:
extension Resources {
static func -=(lhs: inout Resources, rhs: Resources) {
lhs.gold -= rhs.gold
lhs.wood -= rhs.wood
}
}
Just like when conforming to Equatable
, operator overloads in Swift are just normal static functions that can be declared on a type. In the case of -=
, the left hand side of the operator is an inout
parameter, which is the value that we are mutating.
With our operator overload in place, we can now simply call -=
directly on the current player's resources, just like we would on any primitive numeric value:
currentPlayer.resources -= kind.cost
Not only does that read pretty nicely, it also helps us eliminate our code duplication problem. Since we always want all outside logic to mutate Resources
instances as a whole, we can go ahead and make the gold
and wood
properties readonly for all other types:
struct Resources {
private(set) var gold: Int
private(set) var wood: Int
init(gold: Int, wood: Int) {
self.gold = gold
self.wood = wood
}
}
The above works thanks to a change in Swift 4, which gave extensions defined in the same file private
privileges. So our -=
operator overload (and any other operators or APIs that we define for Resources
) can mutate properties without needing them to be publicly mutable. Pretty sweet 👍!
Mutating functions as an alternative
Another way we could've solved the Resouces
problem above would be to use a mutating function instead of an operator overload. We could've added a function that reduces a Resources
value's properties by another instance, like this:
extension Resources {
mutating func reduce(by resources: Resources) {
gold -= resources.gold
wood -= resources.wood
}
}
Both solutions have their merits, and you could argue that the mutating function approach is more explicit. However, you also wouldn't want the standard subtraction API for numbers to be something like 5.reduce(by: 3)
, so perhaps this is a case where overloading an operator makes perfect sense.
Layout calculations
Let's take a look at another scenario in which using operator overloading can be quite nice. Even though we have Auto Layout and its powerful layout anchors API, sometimes we find ourselves in situations when we need to do manual layout calculations.
In situations like these, it's very common to have to do math on two dimensional values - like CGPoint
, CGSize
and CGVector
. For example, we might need to calculate the origin of a label by using the size of an image view and some additional margin, like this:
label.frame.origin = CGPoint(
x: imageView.bounds.width + 10,
y: imageView.bounds.height + 20
)
Instead of having to always expand points and sizes to use their underlying components, wouldn't it be nice if we could simply add them up (just like we did with our Resources
struct)? 🤔
To be able to do that, we could start by overloading the +
operator to accept two CGSize
instances as input, and output a CGPoint
value:
extension CGSize {
static func +(lhs: CGSize, rhs: CGSize) -> CGPoint {
return CGPoint(
x: lhs.width + rhs.width,
y: lhs.height + rhs.height
)
}
}
With the above in place, we can now write our layout calculation like this:
label.frame.origin = imageView.bounds.size + CGSize(width: 10, height: 20)
That's pretty cool, but it feels a bit odd to have to create a CGSize
for our margins. One way to make this a bit nicer could be to define another +
overload that accepts a size and a tuple containing two CGFloat
values, like this:
extension CGSize {
static func +(lhs: CGSize, rhs: (x: CGFloat, y: CGFloat)) -> CGPoint {
return CGPoint(
x: lhs.width + rhs.x,
y: lhs.height + rhs.y
)
}
}
Which let's us write our layout calculation in either of these two ways:
// Using a tuple with labels:
label.frame.origin = imageView.bounds.size + (x: 10, y: 20)
// Or without:
label.frame.origin = imageView.bounds.size + (10, 20)
That's very compact and nice! 👍 But now we are approaching the core of the issue that causes so much debate about operators - balancing verbosity and readability. Since we are still dealing with numbers, I think most people will find the above pretty easy to read & understand, but it gets more complex as we move on to more custom uses, especially when we start introducing brand new operators.
For more operator overloads for Core Graphics types, check out CGOperators
A custom operator for error handling
So far we have simply added overloads to existing operators. But in case we want to start using operators for functionality that can't really be mapped to an existing one, we need to define our own.
Let's take a look at another example. Swift's do, try, catch
error handling mechanism is super nice when dealing with failable, synchronous operations. It lets us easily and safety exit a function as soon as an error occurs, such as when loading models saved on disk:
class NoteManager {
func loadNote(fromFileNamed fileName: String) throws -> Note {
let file = try fileLoader.loadFile(named: fileName)
let data = try file.read()
let note = try Note(data: data)
return note
}
}
The only major downside of doing something like the above is that we are directly throwing any underlying errors to the caller of our function. Like I wrote about in my very first blog post - "Providing a unified Swift error API" - it's usually a good idea to reduce the amount of errors that an API can throw, otherwise doing meaningful error handling and testing becomes really difficult.
Ideally, what we want is a finite set of errors that a given API can throw, so that we can easily handle each case separately. Let's say we also want to capture all underlying errors as well, giving us the best of both worlds. So we define an error enum with explicit cases, that each use associated values for the underlying error, like this:
extension NoteManager {
enum LoadingError: Error {
case invalidFile(Error)
case invalidData(Error)
case decodingFailed(Error)
}
}
However, capturing the underlying errors and transforming them into our own type is trickier. Using only the standard error handling mechanism we'd have to write something like this:
class NoteManager {
func loadNote(fromFileNamed fileName: String) throws -> Note {
do {
let file = try fileLoader.loadFile(named: fileName)
do {
let data = try file.read()
do {
return try Note(data: data)
} catch {
throw LoadingError.decodingFailed(error)
}
} catch {
throw LoadingError.invalidData(error)
}
} catch {
throw LoadingError.invalidFile(error)
}
}
}
I don't think anyone wants to read code like the above 😅. One option is to introduce a perform
function (like I did in the above mentioned post), which we can use to transform one error into another:
class NoteManager {
func loadNote(fromFileNamed fileName: String) throws -> Note {
let file = try perform(fileLoader.loadFile(named: fileName),
orThrow: LoadingError.invalidFile)
let data = try perform(file.read(),
orThrow: LoadingError.invalidData)
let note = try perform(Note(data: data),
orThrow: LoadingError.decodingFailed)
return note
}
}
Better, but we still have lots of error transformation code cluttering up our actual logic. Let's see if introducing a new operator can help us clean up this code a bit.
Adding a new operator
We'll start by defining our new operator. In this case we'll pick ~>;
as the symbol (with the motivation that this is an alternate return type, so we're looking for something similar to ->;
). Since this is an operator that will work on two sides, we define it as infix
, like this:
infix operator ~>
What makes operators so powerful is that they can automatically capture the context on both sides of them. Combine that with Swift's @autoclosure
feature and we can build ourselves something pretty cool.
Let's implement ~>;
as an operator that takes a throwing expression and an error transform, and either throws or returns the same type as the original expression:
func ~><T>(expression: @autoclosure () throws -> T,
errorTransform: (Error) -> Error) throws -> T {
do {
return try expression()
} catch {
throw errorTransform(error)
}
}
So what does the above let us do? Since enum cases with associated values are also static functions in Swift, we can simply add the ~>;
operator between our throwing expression and the error case we wish to transform any underlying error into, like this:
class NoteManager {
func loadNote(fromFileNamed fileName: String) throws -> Note {
let file = try fileLoader.loadFile(named: fileName) ~> LoadingError.invalidFile
let data = try file.read() ~> LoadingError.invalidData
let note = try Note(data: data) ~> LoadingError.decodingFailed
return note
}
}
That's pretty cool! 🎉 By using an operator we have removed lots of "cruft" and syntax from our logic, giving our code more focus. However, the downside is that we have introduced a new sort of syntax for error handling, which will probably be completely unfamiliar to any new developers who might join our project in the future.
Conclusion
Custom operators and operator overloading is a very powerful feature that can let us build really interesting solutions. It can let us reduce verbosity without the need for nested function calls, which may give us cleaner code. However, it can also be a slippery slope that can lead us to cryptic, hard-to-read code that becomes very intimidating and confusing for other developers.
Just like when using first class functions in a more advanced way, I think it's important to think twice before introducing a new operator or when creating additional overloads. Getting feedback from other developers can also be super valuable, as a new operator that makes total sense to you might feel completely alien to someone else. As with so many things it comes down to understanding the tradeoffs and trying to pick the most appropriate tool for each situation.
What do you think? Let me know - along with any questions, comments or feedback that you might have - on Twitter @johnsundell. For more Swift content, head over to the Categories Page or check out the Swift by Sundell Podcast.
Thanks for reading! 🚀