Guard let self - confused

Hi,

Overview

This is about using self?.f1() in closures vs using guard let self and then using self.f1()

Note: Apologies if this question has been asked many times but I keep getting different opinions on this.

Option 1:

Personally I prefer not to use guard let self in closures as I feel it captures self strongly. Instead I just encapsulate the logic I want to perform into a function for example processData and call that function as self?.processData(data)

Option 2:

I was told if I used self?.processData there is a chance self might get deallocated midway and so it is better to use guard let self and use self.processData(data)

Question

  1. Is it better to use Option 1 or Option 2? and Why?
  2. Is what I was told about self getting deallocated midway while self?.processData(data) is executed correct or is a misconception?

Code

final class A: Sendable {
    func fetchData() {
        
        // Just a sample request
        let request = URLRequest(url: URL(string: "")!)
        let session = URLSession(configuration: .ephemeral)
        
        session.dataTask(with: request) { [weak self] data, response, error in

            // Option 1
            self?.processData(data)

            // Option 2
            guard let self else {
                return
            }
            
            self.fetchData()
        }
    }
    
    private func processData(_ data: Data?) {
        // do something here
    }
}

guard let self does capture self strongly, but only for the duration of the closure. This is a good thing because what you heard about self being de-allocated between the null check and the method call is true. Always make sure you get a strong reference before you attempt to call methods or access properties on a weak binding.

If you only need to access the weak reference for a small part of your closure and want to drop the strong reference before the closure is finished, you could always wrap your method calls inside an if let self instead of using guard. I doubt that's the common case though.

You might also be able to use Optional.map and friends, but I'm not 100% sure how that interacts with weak references.

2 Likes

@Nobody1707 thanks for the explanation.

Assume processData loops from 1 to 10000, is there a chance that it might get deallocated midway before the loop completes?

What I mean is if the loop has started and could self be deallocated during the 200th iteration of the loop?

My understanding is that self isn't supposed to be able to be deallocated while you're mid-method, because the method requires a strong reference. Where things get hairy is if you need to call more than one method on the weak reference:

{ [weak self] in
  // option 1
  guard let self else { return }
  self.foo()
  self.bar()
  // option 2
  self?.foo()
  self?.bar()
}

With option 1, self is promoted to a strong reference for the whole closure. With option 2, self is promoted to a strong reference temporarily for foo, and then again for bar. This is not only potentially less efficient, but leaves room for self to be deallocated between foo and bar, AIUI.

6 Likes

Thanks @bbrk24

  • I could be totally wrong but based on my testing if self?.foo() started then self?.bar would also run.
  • I did a small example and ran it macOS command line project.

Code

import Foundation

class Service {
    init() {
        print("Service init")
    }
    
    func fetchData(completion: () -> ()) {
        for _ in 1...20_000_000 {}
        completion()
    }
    
    deinit {
        print("Service deinit")
    }
}

class A {
    let service: Service
    
    init(service: Service) {
        print("A init")
        self.service = service
    }
    
    func updateData() {
        service.fetchData { [weak self] in
            self?.f1()
            self?.f2()
        }
    }
    
    func f1() {
        print("f1 started")
        for _ in 1...20_000_000 {}
        print("f1 ended")
    }
    
    func f2() {
        print("f2 started")
        for _ in 1...20_000_000 {}
        print("f2 ended")
    }
    
    deinit {
        print("A deinit")
    }
}

let service = Service()
do {
    let a = A(service: service)
    Task {
        a.updateData()
    }
    print("last line of do")
}
print("outside")

RunLoop.main.run()

Output

Service init
A init
last line of do
outside

f1 started

f1 ended
f2 started

f2 ended
A deinit

There are two ways that it could become nil between the foo() and bar() calls. One is a race condition in a multithreaded environment, where one thread releases the last strong reference as another thread calls the closure. The easier one to demonstrate is that foo() itself could cause the last strong reference to be released:

var globalA: A?

class A {
  func foo() {
    print("In foo()")
    globalA = nil
  }

  func bar() {
    print("In bar()")
  }

  deinit {
    print("A deinit")
  }
}

do {
  let a = A()
  globalA = a
  Task { [weak a] in
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    a?.foo()
    a?.bar()
  }
}

try! await Task.sleep(nanoseconds: 2_000_000_000)

/* Output:
In foo()
A deinit
*/
9 Likes

Thanks a lot @bbrk24 this is a really good demonstration.

I suppose it makes a real difference when there is more than one function being called.

In this case, it's not that it's being deallocated mid method but between the null check in the optional chain and the method call itself.

1 Like

You were told wrong, self?.processData() is equivalent to:

if let self {
    self.processData()
}

so there's no fear that self could be deallocated "midway" when you use self?.processData().

7 Likes

Thanks a lot @tera and @Nobody1707 for clarifying that