Correct use of defer in Swift

  • 2020-06-15 10:21:39
  • OfStack

What is defer for

Very simply, in one sentence, the code in defer block is executed before the function return, regardless of which branch it comes from, return has throw, or it comes to the last line naturally.

This keyword is similar to ES12en-ES13en-ES14en's finally1, and is executed before the function return regardless of which branch try catch takes. And one thing that makes it even more powerful than Java's finally is that it can exist independently of try catch, so it can also be a small helper in organizing the flow of functions. What would have been done before return, however, could have been put into this block to make the code look cleaner

In fact, the reason for this article is that during the reconstruction of Kingfisher, my understanding of defer was not accurate enough, leading to one bug. Therefore, I want to explore 1 edge case of the keyword defer under 1 by this article.

Typical usage

You should be familiar with defer in Swift, and block as stated by defer is called after the exit of the current code execution. Because it provides a way to make delayed calls, general 1 is used for resource release or destruction, which is especially useful when a function has multiple return exits. For example, here's how to open a file via FileHandle:


func operateOnFile(descriptor: Int32) {
let fileHandle = FileHandle(fileDescriptor: descriptor)

let data = fileHandle.readDataToEndOfFile()

if /* onlyRead */ {
fileHandle.closeFile()
return
}

let shouldWrite = /*  Whether you need to write a file  */
guard shouldWrite else {
fileHandle.closeFile()
return
}

fileHandle.seekToEndOfFile()
fileHandle.write(someData)
fileHandle.closeFile()
}

We need to call fileHandle.closeFile () in different places to close the file, but a better approach here is to use defer for unified 1 processing. Not only does this allow us to declare a release close to where the resource is requested, but it also reduces the likelihood of forgetting to release the resource in future code additions:


func operateOnFile(descriptor: Int32) {
let fileHandle = FileHandle(fileDescriptor: descriptor)
defer { fileHandle.closeFile() }
let data = fileHandle.readDataToEndOfFile()

if /* onlyRead */ { return }

let shouldWrite = /*  Whether you need to write a file  */
guard shouldWrite else { return }

fileHandle.seekToEndOfFile()
fileHandle.write(someData)
}

Scope of defer

When doing the Kingfisher refactoring, I chose to use NSLock to ensure thread safety. In short, there would be a method like this:


let lock = NSLock()
let tasks: [ID: Task] = [:]

func remove(_ id: ID) {
lock.lock()
defer { lock.unlock() }
tasks[id] = nil
}

The operation on tasks may occur in different threads, using lock() to acquire the lock and keep the current thread exclusive, then using unlock() to release the resources after the operation is complete. This is typical of defer.

However, there is a situation that we have obtained the lock in caller of the same thread before calling the remove method, such as:


func doSomethingThenRemove() {
lock.lock()
defer { lock.unlock() }

//  operation  `tasks`
// ...

//  And finally, remove  `task`
remove(123)
}

This obviously creates a deadlock in remove (deadlock) : lock() in remove is waiting for doSomethingThenRemove to do the unlock() operation, and this unlock is blocked by remove and can never be reached.

There are about three solutions:

Switching to NSRecursiveLock: NSRecursiveLock can be obtained multiple times on the same thread without causing deadlock problems. unlock before calling remove. Pass for remove as per condition, avoid locking in it.

Both 1 and 2 incur an additional performance penalty, and while such locking performance is minimal in general, it doesn't seem too onerous to use Option 3. So I happily changed remove to this:


func remove(_ id: ID, acquireLock: Bool) {
if acquireLock {
lock.lock()
defer { lock.unlock() }
}
tasks[id] = nil
}

Good. Now calling remove(123, acquireLock: false) will no longer deadlock. But I soon discovered that the lock also failed at acquireLock to true. Read Swift Programming Language's description of defer again carefully:

[

A defer statement is used for executing code just before transferring program control outside of the scope that the defer statement appears in.

]

So, the above code is really equivalent to:


func remove(_ id: ID, acquireLock: Bool) {
if acquireLock {
lock.lock()
lock.unlock()
}
tasks[id] = nil
}

GG Smitha...

This error was caused by the simplistic view that defer is called when a function exits, not by the fact that scope is currently called when it exits. You should pay special attention to this point when using defer in if, guard, for, try.

defer and closures

Another interesting fact is that although defer is followed by a closure, it is more like a syntactic sugar, unlike the familiar closure feature, and does not hold the value inside. Such as:


func foo() {
var number = 1
defer { print("Statement 2: \(number)") }
number = 100
print("Statement 1: \(number)")
}

Output will be:

[

Statement 1: 100
Statement 2: 100

]

In defer, if you want to rely on the value of a variable, you need to copy by yourself:


func foo() {
var number = 1
var closureNumber = number
defer { print("Statement 2: \(closureNumber)") }
number = 100
print("Statement 1: \(number)")
}

// Statement 1: 100
// Statement 2: 1

Timing of defer's execution

The execution time of defer is immediately after leaving the scope, but before any other statements. This feature brings some "subtle" ways to use defer. Such as autoincrement from 0:


class Foo {
var num = 0
func foo() -> Int {
defer { num += 1 }
return num
}

//  There is no  `defer`  We might have to write it this way 
// func foo() -> Int {
// num += 1
// return num - 1
// }
}

let f = Foo()
f.foo() // 0
f.foo() // 1
f.num // 2

The output foo() returns num before +1 and ES157en.num after +1 in defer. Without defer, it would be very difficult to achieve this "do it after return" effect.

Although very specific, this type of side effect is strongly not recommended in defer.

[

This means that a defer statement can be used, for example, to perform manual resource management such as closing file descriptors, and to perform actions that need to happen even if an error is thrown.

]

From a language design point of view, defer is designed to clean up resources and avoid repeating code that needs to be executed before it can be returned, rather than to implement some functionality in a clever way. Doing so will only make the code less readable.

conclusion


Related articles: