Swift unimplemented()
21 Mar 2021Getting into a flow state where your tools get out of the way and you are able to express your thoughts quickly is a great feeling.
This flow can be easily interrupted when things like code completion, syntax highlighting or other IDE support features start to fail.
A function that I find really helps me when I am wanting to get into a good flow and stay there is unimplemented()
.
The aim is to keep the compiler happy whilst I cut corners and avoid getting bogged down in unimportant details.
TLDR
func unimplemented<T>(message: String = "", file: StaticString = #file, line: UInt = #line) -> T {
fatalError("unimplemented: \(message)", file: file, line: line)
}
Problem
Let’s rewind and go through building the above function step by step as there is a lot in those 3 lines of code that can be applied to other APIs we build.
As an example let’s pretend we have a World
structure that contains some globally configured services
struct World {
var analytics: Analytics
var authentication: Authentication
var networkClient: NetworkClient
}
Each one of the services could be quite complicated to construct but for our unit tests we only need to construct the parts under test. We could create all the instances but this might be awkward/time consuming and also makes the tests less documenting as we are building more than required.
If we had a networkClient
that we were testing then the simplest way to get this to compile without providing an Analytics
instance and an Authentication
instance would be like this:
var Current = World(
analytics: fatalError() as! Analytics,
authentication: fatalError() as! Authentication,
networkClient: networkClient
)
The above isn’t great as the compiler will raise a great big yellow warning on each of the lines containing fatalError as!
because the cast will always fail.
Attempt 2
Having big compiler warnings breaks my flow and forces me to go and concentrate on details that are unimportant (this is a complete blocker if you treat warnings as errors).
The next attempt would be to drop the cast so that the compiler doesn’t complain.
To achieve this we need to wrap the call to fatalError
in an immediately evaluated closure:
var Current = World(
analytics: { fatalError() }(),
authentication: { fatalError() }(),
networkClient: networkClient
)
The compiler warning is gone but there are a few issues:
- Immediately evaluated closures aren’t the most common thing so people might not remember this trick off the top of their head
- There’s a lot of line noise to type with curly braces and parens
- The error you get from a
fatalError
won’t be very descriptive, which makes this technique awkward to use as a TODO list
Attempt 3
Functions are familiar and allow us to give this concept a name. I think a well named function should solve all 3 of the above complaints:
func unimplemented<T>(message: String = "") -> T {
fatalError("unimplemented: \(message)")
}
With the above our call site now looks like this:
var Current = World(
analytics: unimplemented(),
authentication: unimplemented(),
networkClient: networkClient
)
We’ve now got a descriptive function that acts as good documentation that we don’t need two of these arguments for our work.
Having a default message of unimplemented:
might not seem very useful but it gives us more indication that this was something we need to implement and not a condition that we never expected to happen (another common use case for fatalError
).
Giving this concept a name also means that we have a term we can search for throughout the codebase or logs.
In order for this version to work we’ve had to use a generic placeholder for the return type. This allows us to leverage type inference to just throw a call to our function in to plug a hole and keep the compiler happy.
Attempt 4
This last version is much nicer than where we started but it actually drops some usability that we got for free in Attempt 2.
With the last version of the code if we actually invoke this function at runtime Xcode will indicate that the unimplemented
function is to blame.
It might not be too hard to track back if you have the debugger attached but if not this doesn’t give you much to work with.
When we have the immediately evaluated closures Xcode would highlight the actual line where the fatalError
was.
Not to worry as fatalError
also accepts file
and line
arguments.
We simply collect these values and pass them along.
To achieve this we use the literal expression values provided by #file
and #line
and add them as default arguments:
func unimplemented<T>(message: String = "", file: StaticString = #file, line: UInt = #line) -> T {
fatalError("unimplemented: \(message)", file: file, line: line)
}
Conclusion
I find it really important to take a step back and examine what helps me improve my workflow. Often taking the time to work through problems helps to stimulate new ideas, find new tools or just highlight bad practices that are slowing me down.