Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does Swift ReferenceWritableKeyPath work with an Optional property?

Ground of Being: It will help, before reading, to know that you cannot assign a UIImage to an image view outlet's image property through the keypath \UIImageView.image. Here's the property:

@IBOutlet weak var iv: UIImageView!

Now, will this compile?

    let im = UIImage()
    let kp = \UIImageView.image
    self.iv[keyPath:kp] = im // error

No!

Value of optional type 'UIImage?' must be unwrapped to a value of type 'UIImage'

Okay, now we're ready for the actual use case.


What I'm actually trying to understand is how the Combine framework .assign subscriber works behind the scenes. To experiment, I tried using my own Assign object. In my example, my publisher pipeline produces a UIImage object, and I assign it to the image property of a UIImageView property self.iv.

If we use the .assign method, this compiles and works:

URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv)
    .store(in:&self.storage)

So, says I to myself, to see how this works, I'll remove the .assign and replace it with my own Assign object:

let pub = URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)

let assign = Subscribers.Assign(object: self.iv, keyPath: \UIImageView.image)
pub.subscribe(assign) // error
// (and we will then wrap in AnyCancellable and store)

Blap! We can't do that, because UIImageView.image is an Optional UIImage, and my publisher produces a UIImage plain and simple.

I tried to work around this by unwrapping the Optional in the key path:

let assign = Subscribers.Assign(object: self.iv, keyPath: \UIImageView.image!)
pub.subscribe(assign)

Cool, that compiles. But it crashes at runtime, presumably because the image view's image is initially nil.

Now I can work around all of this just fine by adding a map to my pipeline that wraps the UIImage up in an Optional, so that all the types match correctly. But my question is, how does this really work? I mean, why don't I have to do that in the first code where I use .assign? Why am I able to specify the .image keypath there? There seems to be some trickery about how key paths work with Optional properties but I don't know what it is.


After some input from Martin R I realized that if we type pub explicitly as producing UIImage? we get the same effect as adding a map that wraps the UIImage in an Optional. So this compiles and works

let pub : AnyPublisher<UIImage?,Never> = URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()

let assign = Subscribers.Assign(object: self.iv, keyPath: \UIImageView.image)
pub.subscribe(assign)
let any = AnyCancellable(assign)
any.store(in:&self.storage)

This still doesn't explain how the original .assign works. It appears that it is able to push the optionality of the type up the pipeline into the .receive operator. But I don't see how that is possible.

like image 895
matt Avatar asked Jan 07 '20 22:01

matt


1 Answers

You (Matt) probably know at least some of this already, but here are some facts for other readers:

  • Swift infers types on one whole statement at a time, but not across statements.

  • Swift allows type inference to automatically promote an object of type T to type Optional<T>, if necessary to make the statement type-check.

  • Swift also allows type inference to automatically promote a closure of type (A) -> B to type (A) -> B?. In other words, this compiles:

      let a: (Data) -> UIImage? = { UIImage(data: $0) }
      let b: (Data) -> UIImage?? = a
    

    This came as a surprise to me. I discovered it while investigating your problem.

Now let's consider the use of assign:

let p0 = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv)

Swift type-checks this entire statement simultaneously. Since \UIImageView.image's Value type is UIImage?, and self.iv's type is UIImageView!, Swift has to do two “automatic” things to make this statement type-check:

  • It has to promote the closure { UIImage(data: $0) } from type (Data) -> UIImage? to type (Data) -> UIImage?? so that compactMap can strip off one level of Optional and make the Output type be UIImage?.

  • It has to implicitly unwrap iv, because Optional<UIImageView> has no property named image, but UIImageView does.

These two actions let Swift type-check the statement successfully.

Now suppose we break it into three statements:

let p1 = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
let a1 = Subscribers.Assign(object: self.iv, keyPath: \.image)
p1.subscribe(a1)

Swift first type-checks the let p1 statement. It has no need to promote the closure type, so it can deduce an Output type of UIImage.

Then Swift type-checks the let a1 statement. It must implicitly unwrap iv, but there's no need for any Optional promotion. It deduces the Input type as UIImage? because that is the Value type of the key path.

Finally, Swift tries to type-check the subscribe statement. The Output type of p1 is UIImage, and the Input type of a1 is UIImage?. These are different, so Swift cannot type-check the statement successfully. Swift does not support Optional promotion of generic type parameters like Input and Output. So this doesn't compile.

We can make this type-check by forcing the Output type of p1 to be UIImage?:

let p1: AnyPublisher<UIImage?, Never> = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
let a1 = Subscribers.Assign(object: self.iv, keyPath: \.image)
p1.subscribe(a1)

Here, we force Swift to promote the closure type. I used eraseToAnyPublisher because otherwise p1's type is too ugly to spell out.

Since Subscribers.Assign.init is public, we can also use it directly to make Swift infer all the types:

let p2 = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
    .subscribe(Subscribers.Assign(object: self.iv, keyPath: \.image))

Swift type-checks this successfully. It is essentially the same as the statement that used .assign earlier. Note that it infers type () for p2 because that's what .subscribe returns here.


Now, back to your keypath-based assignment:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv[keyPath: kp] = im
    }
}

This doesn't compile, with the error value of optional type 'UIImage?' must be unwrapped to a value of type 'UIImage'. I don't know why Swift can't compile this. It compiles if we explicitly convert im to UIImage?:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv[keyPath: kp] = .some(im)
    }
}

It also compiles if we change the type of iv to UIImageView? and optionalize the assignment:

class Thing {
    var iv: UIImageView? = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv?[keyPath: kp] = im
    }
}

But it does not compile if we just force-unwrap the implicitly-unwrapped optional:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv![keyPath: kp] = im
    }
}

And it does not compile if we just optionalize the assignment:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv?[keyPath: kp] = im
    }
}

I think this might be a bug in the compiler.

like image 177
rob mayoff Avatar answered Nov 03 '22 12:11

rob mayoff