4

Using Xcode 10, but did not migrate to Swift 4.2, so my project is still running with Swift 4.1.

Lets assume i have the following extension on Dictionary:

extension Dictionary where Key: ExpressibleByStringLiteral {
    func find<T>(key: Key) -> T? {
        return self[key] as? T
    }
}

I use this function to access values in a hashmap in a type safe manner like:

 let dict: [String: Any] = ["foo": "bar"]
 let foo: String? = dict.find(key: "foo") // prints "bar"

My problem surfaces, when i would like to have Any type returned from my find function, like:

 let bar: Any? = dict.find(key: "bar")

Pre Xcode 10, this function used to return to me plain and simple nil if the key was not found in the hashmap.

However, post Xcode 10, it returns Optional.some(nil).

I understand, that Any types can be initialised like the following:

let foo: Any = Optional<String>.none

I guess in my case something similar happens. Does anybody has an idea, how to work around it and still return nil from the find function?

Hamish
  • 78,605
  • 19
  • 187
  • 280
dirtydanee
  • 6,081
  • 2
  • 27
  • 43
  • 2
    See https://bugs.swift.org/browse/SR-8704 – Hamish Sep 21 '18 at 14:59
  • Until the bug gets fixed, seems you're stuck with using explicit, concrete types, instead of `Any?` :) – Cristik Sep 21 '18 at 19:55
  • 2
    @Cristik The behaviour change itself isn't a bug, the new behaviour is here to stay (hopefully). The bug is that the new behaviour wasn't properly restricted to `-swift-version 5` to best preserve compatibility. – Hamish Sep 21 '18 at 23:03

1 Answers1

4

This is due to an intentional change (#13910) where the compiler is now more conservative with unwrapping an optional value that's being cast to a generic placeholder type. Now, the results you get are more consistent with those that you would get in a non-generic context (see SR-8704 for further discussion).

For example:

// note the constraint `where Key : ExpressibleByStringLiteral` is needlessly restrictive.
extension Dictionary where Key : ExpressibleByStringLiteral {
  func find<T>(key: Key) -> T? {
    return self[key] as? T
  }
}

let dict: [String: Any] = ["foo": "bar"]

let genericBar: Any? = dict.find(key: "bar")
print(genericBar as Any) // in Swift 4.1: nil, in Swift 4.2: Optional(nil)

// `T` in the above example is inferred to be `Any`.
// Let's therefore substitute `as? T` with `as? Any`.
let nonGenericBar = dict["bar"] as? Any
print(nonGenericBar as Any) // in both versions: Optional(nil)

As you can see, you now get Optional(nil) regardless of whether a generic placeholder was used in order to perform the cast, making the behaviour more consistent.

The reason why you end up with Optional(nil) is because you're performing a conditionally casting an optional value. The conditional cast on its own results in an optional value in order to indicate success or failure, and putting the resulting optional value in the success case gives you a doubly wrapped optional. And because you're casting to Any, which can represent an Optional value, no unwrapping needs to be done in order to "fit" the value in the resultant type.

If you wish to flatten the resulting optional into a singly wrapped optional, you can either coalesce nil:

extension Dictionary {
  func find<T>(key: Key) -> T? {
    return (self[key] as? T?) ?? nil
  }
}

Or unwrap the value before casting, for example using a guard:

extension Dictionary {
  func find<T>(key: Key) -> T? {
    guard let value = self[key] else { return nil }
    return value as? T
  }
}

or, my preferred approach, using flatMap(_:):

extension Dictionary {
  func find<T>(key: Key) -> T? {
    return self[key].flatMap { $0 as? T }
  }
}

That all being said, I often find the usage of [String: Any] to be a code smell, strongly indicating that a stronger type should be used instead. Unless the keys can really be arbitrary strings (rather than a fixed set of statically known keys), and the values can really be of any type for a particular key – there's almost certainly a better type you can be using to model your data with.

Hamish
  • 78,605
  • 19
  • 187
  • 280
  • Maybe I'm missing something, but why `let bar: String? = dict.find(key: "bar")` doesn't end up with two level of optionals (it prints `nil`, not `Optional(nil)`? It's the same code, the same `as?` applied to an already optional value. It's the same code, just with `String` instead of `Any`, but behaves differently. – Cristik Sep 22 '18 at 14:48
  • @Cristik In that case, `T` is inferred to be `String`, so you're doing `dict["bar"] as? String`. Because you're casting to a non-optional concrete type, Swift will unwrap the value returned from the subscript in order to see if it is a `String`. If it is, it will return `Optional(value)` as the successful result of the cast. If it isn't, it will return `nil` as the result of a failed cast. – Hamish Sep 22 '18 at 15:21
  • The difference with `as? Any` is that Swift won't unwrap the value. Why? Because anything can be typed as `Any`, including `Optional`. So Swift will happily box an optional in `Any` and then wrap that in another `Optional` as the successful result of the cast. – Hamish Sep 22 '18 at 15:21
  • In general, it's best to avoid mixing `Optional` and `Any` together, as the behaviour can get quite confusing quite quickly – an `Optional` can get erased to an `Any`, and an `Any` can get promoted to an `Optional`. – Hamish Sep 22 '18 at 15:24
  • Thanks, so it's the fact that `Any` can hold an optional is the reason why the compiler doesn't unwrap it, as it doesn't know it the caller expects or not an optional. – Cristik Sep 23 '18 at 07:11