Swift 5.4 officially introduces @resultBuilder, which is a way to create custom DSLs and generally create more semantic APIs (think of the way you write SwiftUI today).

I wanted to play with it and create something useful for Combine developers so I thought “Would it not be handy to batch-add publisher subscriptions to an owned list of cancellables?”.

I remembered back in the day I used an extension on NSObject that adds automatically a dispose bag to all objects in case you need one created by Ash Furrow and I thought I’d put together something in the same fashion.

Here’s the completed code source, in case you’d like to skip the implementation details below: https://github.com/icanzilb/Cancellor.

An owned list of subscriptions

First I added a lock and a raw key value to NSObject - you need the latter to “assign” a dynamic property to an Objective-C object, and the former to avoid data races:

1
2
3
4
5
6
7
extension NSObject {

  private static var cancellablesLock: os_unfair_lock_s = 
    { os_unfair_lock_s() }()

  private static var cancellablesKeyRawValue: UInt8 = 0
}

Note: Arguably, using a single lock is not very efficient if you create large amounts of subscriptions all the time. But it should suffice for most cases.

Then I added a method that takes a list of AnyCancellable and adds it to a list of cancellables assigned to the current object. (And create an empty list if there is none found assigned already.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func addCancellables(_ cancellables: [AnyCancellable]) {
  os_unfair_lock_lock(&Self.cancellablesLock)
        
  var storage = objc_getAssociatedObject(self,
    &Self.cancellablesKeyRawValue) as? [AnyCancellable] ?? []
  
  storage.append(contentsOf: cancellables)
  
  objc_setAssociatedObject(self, 
    &Self.cancellablesKeyRawValue, storage,
    .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        
  os_unfair_lock_unlock(&Self.cancellablesLock)
}

Now for the fun part - adding the @resultBuilder. I played with few scenarios but in the end it turned I only ever needed the @resultBuilder to collect the cancellables and pipe them through to addCancellables() which is already fleshed out.

1
2
3
4
5
6
7
8
@resultBuilder
public struct CancellablesBuilder {
  public static func buildBlock(_ parts: AnyCancellable...) 
    -> [AnyCancellable] {

    return parts
  }
}

As evident by buildBlocks signature, the builder gets a list of AnyCancellable values and just returns them.

ownedCancellables

Next, CancellablesBuilder can be used back in NSObject to provide a DSL-like API for managing subscriptions:

1
2
3
4
5
public func ownedCancellables(
  @CancellablesBuilder content: () -> [AnyCancellable]) {

  addCancellables(content())
}

Sweet! With ownedCancellables in place you can easily batch-create all your Combine subscriptions, let’s say in one of your view controllers, like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MyController: UIViewController {
  override func viewDidLoad() {
    ownedCancellables {
      myPublisher1.filter(...).map(...).sink(...)
      myPublisher2.filter(...).map(...).assign(to:on:)
      myPublisher3.filter(...).map(...).sink(...)
      etc. etc.
    }
  }
}

This code will collect all the cancellables for your subscriptions and store them in a dynamically created cancellables list assigned to your view controller.

If you don’t cancel your subscriptions manually, at the time the view controller is popped out or otherwise dismissed your subscriptions will all be cancelled and released as well.

owned(by:)

For kicks we can also add an extension on AnyCancellable to provide more-reactive (tm) way to manage a subscription. In effect we’d like to “tie” the lifetime of the subscription to the lifetime of a random object.

With minimal effort the AnyCancellable extension looks like this:

1
2
3
4
5
extension AnyCancellable {
  public func owned(by owner: NSObject) {
    owner.addCancellables([self])
  }
}

Now, if you have a view model like so:

1
2
3
class ViewModel: NSObject {
  ... some properties ...
}

And you need for a given subscription to last as long as the view model is around you can do this:

1
2
3
4
5
6
7
let vm = ViewModel(...)

myPublisher
  .filter(...)
  .map(...)
  .assign(to:on:)
  .owned(by: vm)

When vm’s value is released from memory via setting it to nil or is otherwise released from memory, your subscription will automatically be cancelled as well.

Where to go from here?

I’ve published the code under https://github.com/icanzilb/Cancellor.

I think with some more effort this could become a handy tool. Some questions I’ve been considering are:

  • how to manage the list so that cancelled subscriptions are removed? E.g. if you have a very long-lived view controller that always creates new subscriptions how to manage the size of the list?
  • what more could a tool that manages subscriptions do?
  • what if there is an ownedCancellables parameter that automatically logs the subscriptions in Timelane?

Hit me up with your ideas on Twitter at https://twitter.com/icanzilb.