0

In Swift, I am learning the method @escaping return type and I know it is for async calls. The question is: do we need to make sure the completionHandler is handled in all code paths? Consider the following code sample:

func getData(){
    testEscaping { data in
        print("I get the data")
    }
}

func testEscaping(completionHandler: @escaping (_ data: Data) -> ()) {
    return;
}

Seems like the print method will be stuck since the completionHandler is never being called in the testEscaping method. Is this an issue or it should be OK?

The initial thought was if the above code has some memory leak issue. Why the compiler doesn't warn me? In other words, do we need to be very careful to make sure the completionHandler is called in all code paths when using escapting? If the code logic is complex, how should we find the missing completionHandler ?

func testEscaping(completionHandler: @escaping (_ data: Data) -> ()) {
    guard { /* ... */ } else {
        // easy to know to call completionHandler
        completionHandler(nil)
        return
    }

    // ... some complex logic which might cause exceptions and fail at the middle
    // ... should we catch all possbile errors and call completionHandler or it should OK 
    // ... miss the completionHandler and throw the error out?

    completionHandler(goodData)
}

------Updated-----
Thanks for answering the question. I just found this WWDC video (https://developer.apple.com/videos/play/wwdc2021/10132/) that talked about the same and I found it is very helpful. Post it here in case someone else has the same confusion.

Zera Zinc
  • 29
  • 4
  • The only downside to not calling the completion handler is that the caller never gets to handle the result. That's it. Also, your first example doesn't need to use `@escaping`. Your 2nd example may not either depending on what code you have where the comments are at the moment. See [Escaping Closures in Swift](https://stackoverflow.com/questions/39504180/escaping-closures-in-swift). – HangarRash May 06 '23 at 22:08
  • It's no problem at all (except the code is pointless) if the completion handler is not called at all. In case of asynchronous data processing consider to use `async/await` where the compiler doesn't let you exit the function without returning the specified type.. – vadian May 07 '23 at 05:00

2 Answers2

2

do we need to make sure the completionHandler is handled in all code paths?

For escaping closures, no.

For completion handlers in asynchronous methods, probably yes.

The escaping closures aren't necessarily for asynchronous tasks. It merely indicates that the closure may outlive the lifetime of the callee. It can be stored as a property of callee, some global variable, etc. Since it, by itself, has nothing to do with asynchronous task, it doesn't make sense to warn for unhandled escaping closures. We don't even know which escaping closures are meant to be completion handlers!

When it comes to asynchronous methods with completion handler, it's probably a good idea to call it once and only once on each possible execution path, as that's how Swift Concurrency async method works. If you start using new concurrency features and port existing completion-based asynchronous methods into async methods, calling it more than once will result in crash (assuming you're using CheckedContinuation), and not calling it will result in leak of the Task closure and variables it captures.

The initial thought was if the above code has some memory leak issue.

Since @escaping indicates that the closure may outlive the context, it won't leak anything unless you actually make it outlive and somehow make a reason to be leaked. In case of example you provided, the closure has no reference to it after testEscaping finishes executing so it gets deallocated immediately.

func getData(){
    testEscaping { data in
        print("I get the data")
    }
}

func testEscaping(completionHandler: @escaping (_ data: Data) -> ()) {
    return;
}

If the code logic is complex, how should we find the missing completionHandler ?

There is no simple answer to this. defer may help, but it's ultimately on implementer's hand.

This is one of reasons why Swift came up with new async/await concept.

It's quite easy to bail-out of the asynchronous operation early by simply returning without calling the correct completion-handler block. When forgotten, the issue is very hard to debug — https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md#problem-4-many-mistakes-are-easy-to-make

With new async method, completion of asynchronous task is expressed by returning the method. Just like you can't miss returning in synchronous methods, you can't miss returning in asynchronous methods.

yujingaya
  • 21
  • 3
1

To answer your question, depending on your use case, it could be bad if your testEscaping method didn't invoke the completion handler on all paths. Assuming it's an async method, the caller may never be notified that testEscaping completed.

I personally use defer statements for this sort of thing. A defer will ensure that certain code will run at the end of a scope (such as a method, closure, do block, loop, etc).

Here's an example:

enum Error: Swift.Error {
    case dataMissing
    case dataTooLarge
    case notReady
    case `internal`
    case unknown
    case other(Swift.Error)
}

func testEscaping(completionHandler: @escaping (Result<Data, Error>) -> ()) {

    someAsyncMethod { data in

        guard isReady else {
            return completionHandler(.failure(.notReady))
        }

        var result: Result<Data, Error> = .failure(.unknown)

        defer {
            completionHandler(result)
        }

        guard let data else {
            return { result = .failure(.dataMissing) }()
        }

        guard data.count < 100 else {
            return { result = .failure(.dataTooLarge) }()
        }

        do {
            let finalData = try self.someThrowingMethod(data: data)
            if finalData.first == 0 {
                result = .success(finalData)
            }
        } catch let error as Error {
            print("Error", error)
            result = .failure(error)
        } catch {
            print("Other Error", error)
            result = .failure(.other(error))
        }
    }
}

Notice how I defined an Error enum that can be used along with Swift's built in Result type. That way we can pass more helpful info to the caller if something goes wrong.

The completion handler is only called in two places. Right after the isReady conditional and inside the defer statement. I did this to illustrate that the defer statement will only execute if control reaches the end of scope after the defer statement is defined.

The defer statement will not execute if control reaches the end of scope inside the first guard statement. This is because the guard statement is defined before the defer statement.

Hope this helps.

Rob C
  • 4,877
  • 1
  • 11
  • 24