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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With