Does `assign(to published: inout Published<Self.Output>.Publisher)` produce a memory leak

Hi -

I'm working with Combine and SwiftUI. Recently I've come across an issue with Combine and assign(to published: inout Published<Self.Output>.Publisher) which is producing some results that are confusing to me and I'm hoping someone in the community can help explain if I doing something wrong or if there is possible a memory leak.

FYI I came across this topic Does ‘assign(to:)’ produce memory leaks? which is similar but different (as you'll see further on).

Here is a very simplified version of what I'm doing:

import Combine

class ChildViewModel {
    @Published var done: Void = ()
    
    init(){
        print("init ChildViewModel - \(Unmanaged.passUnretained(self).toOpaque())")
    }
    
    deinit { print("deinit ChildViewModel - \(Unmanaged.passUnretained(self).toOpaque())") }
}

class ParentViewModel {
    @Published var childViewModel: ChildViewModel? = nil
    private var cancellableSet = Set<AnyCancellable>()

    init(useAssign: Bool) {
        if useAssign {
            $childViewModel
                .compactMap { $0 }
                .eraseToAnyPublisher()
                .flatMap { $0.$done }
                .map { _ in nil }
                .assign(to: &$childViewModel)
        } else {
            $childViewModel
                .compactMap { $0 }
                .eraseToAnyPublisher()
                .flatMap { $0.$done }
                .map { _ in nil }
                .sink { [weak self] in self?.childViewModel = $0 }
                .store(in: &cancellableSet)
        }
    }

    deinit { print("deinit ParentViewModel") }
}

print("ParentViewModel test - use `assign`")
var viewModel: ParentViewModel? = ParentViewModel(useAssign: true)
viewModel?.childViewModel = ChildViewModel()
viewModel?.childViewModel = ChildViewModel()
print("`viewModel` set to nil")
viewModel = nil

print()
print("ParentViewModel test - use `sink` & `store`")
viewModel = ParentViewModel(useAssign: false)
viewModel?.childViewModel = ChildViewModel()
viewModel?.childViewModel = ChildViewModel()
print("`viewModel` set to nil")
viewModel = nil

What I'm trying to say in the code is that I have a class ParentViewModel. It has an optional member childViewModel. When ever childViewModel has a value I'd like to listen to a publishing member of ChildViewModel called done. If done produces a value I'de like to clear childViewModel by setting its value to nil.

running this code produces the following results

ParentViewModel test - use `assign`
init ChildViewModel - 0x0000600000a18360
init ChildViewModel - 0x0000600000a279a0
deinit ChildViewModel - 0x0000600000a18360
`viewModel` set to nil
deinit ParentViewModel

ParentViewModel test - use `sink` & `store`
init ChildViewModel - 0x0000600000a09900
init ChildViewModel - 0x0000600000a097a0
deinit ChildViewModel - 0x0000600000a09900
`viewModel` set to nil
deinit ParentViewModel
deinit ChildViewModel - 0x0000600000a097a0

as you can see, if I use assign(to:) then the latest ChildViewModel doesn't get deinited. If I use sink and store, both ChildViewModels get deisted properly. It appears that only if I change ParentViewModel deinit as follows

    deinit {
        childViewModel = nil
        print("deinit ParentViewModel")
    }

does the current value of childViewModel get properly deinited when I use 'assign'.

Am I wrong in expecting that the assign(to:) version of this code should clear away childViewModel when parentViewModel is set to nil just as the sink and store version does?

Any insight would be much appreciated. Cheers!

I think, yes, you do have a retain cycle. Your code is creating a subscription between the $childViewModel publisher and itself.

To illustrate, you can create a similar, but maybe more obvious cycle like so (extra selfs for clarity):

self.$childViewModel
  .filter { _ in false } // do nothing
  .assign(to: \.childViewModel, on: self)
  .store(in: &self.cancellableSet)

Your cycle is the same, except "self" is the $childViewModel publisher (or some internal object that it manages).

Here it is with the same, simplified chain:

$childViewModel
  .filter { _ in false } // do nothing
  .assign(to: &$childViewModel)

As you've noted, to break that cycle, you need to introduce a non-strong reference somewhere. The most straightforward way to do that is to use sink instead of assign.

1 Like