If you can see the overall picture from a distance, you can understand the essence at close range. Concepts that seemed far away to me and, frankly, strange while experimenting with Haskell and Scala, when programming in Swift, become dazzlingly obvious solutions for a wide range of problems.
Take error handling here. A concrete example is the division of two numbers, which should cause an exception if the divisor is zero. In Objective C, I would solve the problem like this:
NSError *err = nil; CGFloat result = [NMArithmetic divide:2.5 by:3.0 error:&err]; if (err) { NSLog(@"%@", err) } else { [NMArithmetic doSomethingWithResult:result] }
Over time, this began to seem like the most familiar way of writing code. I don’t notice what kind of squiggles you have to write and how indirectly they are related to what I really want from the program:
')
Give me back the value. If it does not work out, then let me know so that the error can be processed.I pass the parameters, dereference the pointers, return the value in any case, and in some cases ignore it later. This is unorganized code for the following reasons:
- I speak machine language - pointers, dereferencing.
- I have to give the method myself the way in which it notifies me about the error.
- The method returns a certain result even in case of an error.
Each of these points is a source of possible bugs, and Swift solves all these problems in its own way. The first item, for example, in Swift does not exist at all, since it hides all the work with pointers under the hood. The remaining two points are solved with the help of transfers.
If an error may occur during the calculation, there may be two results:
- Successful - with return value
- Unsuccessful - preferably with an explanation of the cause of the error
These options are mutually exclusive - in our example, dividing by 0 causes an error, and everything else returns a result. Swift expresses mutual exclusion through "
enumerations ". Here is the description of the result of the calculation with a possible error:
enum Result<T> { case Success(T) case Failure(String) }
An instance of this type can be either a
Success
label with a value or a
Failure
with a message describing the cause. Each case keyword describes a constructor: the first accepts an instance of
T
(the value of the result), and the second
String
(the text of the error). This is how the earlier Swift code would look like:
var result = divide(2.5, by:3) switch result { case Success(let quotient): doSomethingWithResult(quotient) case Failure(let errString): println(errString) }
Slightly longer, but much better! The
switch
construction allows you to associate values with names (
quotient
and
errString
) and access them in code, and the result can be processed depending on the occurrence of an error. All problems solved:
- No pointers, and dereferencing and even more so
- You do not need to pass extra parameters to the
divide
function. - The compiler checks if all enumeration options are processed.
- Since the
quotient
and errString
are enumerated, they are declared only in their branches and it is impossible to access the result in case of an error.
But the most important thing is that this code does exactly what I want - it calculates the value and handles the errors. It is directly related to the task.
Now let's look at a more serious example. Suppose I want to process the result — get the magic number from the result, find the smallest prime factor from it and get its logarithm. There is nothing magical in the calculation itself - I just chose random operations. The code would look like this:
func magicNumber(divisionResult:Result<Float>) -> Result<Float> { switch divisionResult { case Success(let quotient): let leastPrimeFactor = leastPrimeFactor(quotient) let logarithm = log(leastPrimeFactor) return Result.Success(logarithm) case Failure(let errString): return Result.Failure(errString) } }
Looks easy. But what if I want to get from a magic number ... a magic spell that corresponds to it? I would write like this:
func magicSpell(magicNumResult:Result<Float>) -> Result<String> { switch magicNumResult { case Success(let value): let spellID = spellIdentifier(value) let spell = incantation(spellID) return Result.Success(spell) case Failure(let errString): return Result.Failure(errString) } }
Now, however, I have a
switch
expression for each function, and they are about the same. Moreover, both functions handle only the successful value, while error handling is a constant distraction.
When things begin to repeat, it is worth thinking about the method of abstraction. And again, Swift has the right tools. Enums can have methods, and I can get rid of the need for these
switch
expressions using the
map
method for the
Result
enumeration:
enum Result<T> { case Success(T) case Failure(String) func map<P>(f: T -> P) -> Result<P> { switch self { case Success(let value): return .Success(f(value)) case Failure(let errString): return .Failure(errString) } } }
The map method is named like this because it converts
Result<T>
to
Result<P>
, and it works very simply:
- If there is a result, the function
f
is applied to it. - If there is no result, the error is returned as is.
Despite its simplicity, this method allows you to work wonders. Using error handling inside it, you can rewrite our methods using primitive operations:
func magicNumber(quotient:Float) -> Float { let lpf = leastPrimeFactor(quotient) return log(lpf) } func magicSpell(magicNumber:Float) { var spellID = spellIdentifier(magicNumber) return incantation(spellID) }
Now you can get the spell like this:
let theMagicSpell = divide(2.5, by:3).map(magicNumber) .map(magicSpell)
Although you can get rid of the methods altogether:
let theMagicSpell = divide(2.5, by:3).map(findLeastPrimeFactor) .map(log) .map(spellIdentifier) .map(incantation)
Isn't it cool? All the need for error handling is removed inside the abstraction, and I only need to specify the necessary calculations - the error will be automatically forwarded.
This, on the other hand, does not mean that I will never again have to use the
switch
expression. At some point, you will have to either output an error or transfer the result to somewhere. But it will be the only expression at the very end of the processing chain, and intermediate methods should not care about error handling.
Magic, I tell you!
This is all - not just academic "knowledge for the sake of knowledge." Error handling abstraction is very often used to transform data. For example, it is often necessary to get data from the server that comes in the form of
JSON
(an error string or result), convert it into a dictionary, then into an object, and then transfer this object to the UI level, where several more separate objects will be created from it . Our enumeration will allow us to write methods as if they always work on valid data, and errors will be thrown between
map
calls.
If you have never seen such techniques before, think about it for a while and try to tinker with the code.
(For some time, the compiler had problems with generating code for generic enums, but maybe everything is already compiled). I think you will appreciate how powerful this approach is.
If you are good at math, you probably noticed a bug in my example. The logarithm function is not declared for negative numbers, and values such as
Float
may be. In this case, the
log
will return not just
Float
, but rather
Result<Float>
. If you pass such a value in the map, then we get the nested
Result
, and working with it so simply will not work. For this, too, there is a reception - try to invent it yourself, and for those who are too lazy - I will describe in the next article.