XCode 10 Swift-ObjC Dictionary compatibility

Weird situation I've stumbled upon once upgrading to XCode 10.

I have an ObjectiveC class which I initialize in Swift . The initializer accepts NSDictionary as a parameter. If I pass in the dictionary directly into the initializer like so:

let car = Car(parts: [myKey : myValue])

Then, back in ObjC world, when printing the contents of the dictionary it shows the value as "(null)" .

Everything works fine if I do:

let parts = [myKey : myValue]
let car = Car(parts: parts)

This issue exists on any version of Swift on XCode 10. The issue doesn't exist on XCode 9.

I am attaching a sample project where this is illustrated. Would love if anyone can shed some light on the situation.

I do not have an explanation, but note that this only happens if the key is an optional (in your case: let myKey: String? = "key").

The expanded Type of the dictionary being passed is Dictionary<Optional<String>, String>.

Unfortunately Objective-C cannot interact with Swift enums that have associated values which is how Optional is defined.

Simple work around is to unwrap it first or change the signature of myKey to String

I get all of that. The question is why it wasn’t happening on XCode 9.

Note that the attached sample project doesn't compile in Swift 4.1 or below -- Optional<String> has only became Hashable in Swift 4.2, so it couldn't be used as a Dictionary key in older versions.

However, the behavior you found is definitely a bug! Car.init does not specify Objective-C generic type parameters for its NSDictionary argument, so it gets imported as taking Dictionary<AnyHashable, Any>. Unfortunately in Swift 4.2, String keys in such dictionaries do not compare the same as String? keys holding the same text, which is why you can't use NSString instances to look up values in the bridged dictionary instance.

Details:

Swift 4.2 introduces conditional Hashable conformance for Optional. In the implementation, Optional<T> does not hash the same way as T -- to ensure that its two cases generate unique hash encodings, Optional.some feeds a constant value to the hasher in addition to its wrapped value. This is somewhat pedantic, but it's the correct behavior: nil should not hash the same as any non-nil value.

This has some interesting implications, though. Consider this experiment:

let a: String = "Hello"
let b: String? = "Hello"

a == b // ⟹ true
a.hashValue == b.hashValue // ⟹ (usually) false

This may look like a violation of Hashable requirements, but it actually isn't! In a == b, a suffers implicit promotion to String? in order to make it Equatable to b. This promotion does not happen when we compare hash values, and this is why hashes differ -- there is no requirement for two values of distinct types to produce the same hash value.

However, when converted to AnyHashable, a and b can be reasonably expected to compare and hash the same. Unfortunately, they don't:

// Swift 4.2
(a as AnyHashable) == (b as AnyHashable) // ⟹ false
(a as AnyHashable).hashValue == (b as AnyHashable).hashValue // ⟹ (usually) false

This behavior is consistent with Hashable requirements, but it isn't semantically correct. Optional should have a custom AnyHashable representation that makes non-nil optionals interchangeable with their wrapped value.

I filed [SR-9047] Optional needs to have a custom AnyHashable representation · Issue #51550 · apple/swift · GitHub to track the resolution of this issue.

Meanwhile, I suggest one or both of the following workarounds:

  • If you know Car.parts only ever has String keys, add Objective-C generic type parameters to the NSDictionary initializer parameter and the corresponding property. Car.parts will then be imported as [String: Any].
  • If you create parts dictionaries in Swift, make sure they have String and not String? as their Key type. Note that NSDictionary doesn't support nil keys, so String? keys aren't very practical in this context.
3 Likes