Does 'assign(to:)' produce memory leaks?

Hello there!

I'm playing around with Combine and I've found a possible memory leak when using 'assign(to:)' to store stream result in current object. Let me show a couple of examples using 'assign(to:)' and 'sink' to compare:

final class Bar: ObservableObject {
  @Published var input: String = ""
  @Published var output: String = ""

  private var subscription: AnyCancellable?

  init() {
    subscription = $input
        .filter { $0.count > 0 }
        .map { "\($0) World!" }
        .sink { [weak self] input in
            self?.output = input
        }
  }

  deinit {
    subscription?.cancel()
    print("\(self): \(#function)")
  }
}

// Ussage
var bar: Bar? = Bar()
let foo = bar?.$output.sink { print($0) }
bar?.input = "Hello"
bar = nil
foo?.cancel()

When executed this code produces:

Hello World!
__lldb_expr_29.Bar: deinit

But, when I use 'assign', deinit is never called:

final class Bar: ObservableObject {
  @Published var input: String = ""
  @Published var output: String = ""

  private var subscription: AnyCancellable?

  init() {
    subscription = $input
        .filter { $0.count > 0 }
        .map { "\($0) World!" }
        .assign(to: \.output, on: self)
  }

  deinit {
    subscription?.cancel()
    print("\(self): \(#function)")
  }
}

// Ussage
var bar: Bar? = Bar()
let foo = bar?.$output.sink { print($0) }
bar?.input = "Hello"
bar = nil
foo?.cancel()

This only prints:

Hello World!

So, my question here is: Am I doing something wrong with 'assign' or there is a real memory leak with it?

Hello,

Indeed there is a retain cycle with the sample code that uses assign:

The Bar retains the AnyCancellable (1), which retains the Subscribers.Assign subscription (2), which retains the Bar (3) in order to be able to perform the assignment. Cycle completed: the Bar is never deinited.

Your other sample code captures self weakly. This avoids (3), breaks the cycle, and fixes the memory leak.

It would be really nice to be able to pass a weak self into Assign like so:

subscription = $input
        .filter { $0.count > 0 }
        .map { "\($0) World!" }
        .assign(to: \.output, on: weak self) // or [weak self]

Indeed there is a retain cycle with the sample code that uses assign

Yeah, you're absolutely right @gwendal.roue. So, is this the use case for assign? if not, what is the use case for it to avoid the retain cycles?

Something like .assign(to: \.output, on: weak self) as suggested by @clayellis would be a good option.

I'm surprised if Subscribers.Assign didn't capture its target weakly. Sounds like a bug or design flaw to me.

Subscribers.Assign<Root, Input> indeed requires a ReferenceWritableKeyPath, a "key path that supports reading from and writing to the resulting value with reference semantics". But the Root generic argument is not constrained to AnyObject: the subscriber and its subscriptions can not capture the target weakly.

I don't know if this is a flaw or not. But we can reasonably deduce this behavior from the definition of the type, and adjust our expectations.

1 Like

Well, not the one you are trying to make ;-)

Maybe, in your specific case, where I assume that only output really needs to be published, input could be a simple property which updates the published output in its didSet?

Yeah, I was pretty sure that was my lack of understanding more than a design flaw. Using didSet we have the same behaviour w/o any memory leak:

final class Bar: ObservableObject {
    var input: String = "" {
        didSet {
            Just(input)
                .filter { $0.count > 0 }
                .map { "\($0) World!" }
                .assign(to: \.output, on: self)
                .store(in: &subscriptions)
        }
    }
    @Published var output: String = ""
    
    private var subscriptions = Set<AnyCancellable>()
    
    deinit {
        print("\(self): \(#function)")
    }
}

Output:

Hello World!
__lldb_expr_5.Bar: deinit

Again, I'm not sure if this is the expected use of combine or not... but it works better ¯\(ツ)

Terms of Service

Privacy Policy

Cookie Policy