Unclear mechanics of Swift 5.7's 'any' keyword

I have a question about the mechanics of the existential any keyword that I couldn't find in the proposal. Consider the following two protocols:

protocol Animal {
    associatedtype Food
    func eat(_ food: Food)
}

protocol FoodProvider {
    func getFood<T: Animal>(for animal: T) -> T.Food
}

The following method erases the Animal protocol in order to feed multiple (irrelevant) animals at once:

func feedAll(_ animals: [any Animal], provider: FoodProvider) {
    animals.forEach {
        let food = provider.getFood(for: $0)
        $0.eat(food)
        // Member 'eat' cannot be used on value of type 'any Animal'
    }
}

Even though the context seems to imply that the output of getFood is of the same type of eat(_:)'s input, the compiler will forbid you from calling eat(_:) in this context unless the existential is explicitly unboxed. Is this a missing feature or am I incorrect and the compiler cannot determine in compile-time that these types are indeed the same?

func feedAll(_ animals: [any Animal], provider: FoodProvider) {
    animals.forEach {
        feed($0, provider: provider)
        // Now works, but isn't it the same thing?
    }
}

func feed<T: Animal>(_ animal: T, provider: FoodProvider) {
    let food = provider.getFood(for: animal)
    animal.eat(food)
}
1 Like

It's right before Moving between any and some.

To have the effects of opening persist over multiple statements, factor that code out into a generic function


Applied here, what you're looking for is $0 to transform into a more magical version of this:

func animal(_ animal: some Animal) -> some Animal {
  animal
}

AKA this, without a named constant:

let animal: some Animal = animal

But it won't do that. When you input an "any" into something like that, you get an any back out, despite what the keyword on the return type might seem to suggest.

let animal = animal($0) // any Animal
let food = provider.getFood(for: animal) // Any

You also cannot get around the problem using a closure, i.e.

func unbox<A: Animal>(_ animal: A, _ ƒ: (A) -> Void) {
  ƒ(animal)
}
unbox($0) {
  let food = provider.getFood(for: $0)
  $0.eat(food)
}

But you can define the necessary function locally:

animals.forEach {
  func feed(_ animal: some Animal) {
    animal.eat(provider.getFood(for: animal))
  }
  feed($0)
}

It looks ridiculous in context! No clue if it's solvable.

2 Likes

And the concrete thing the compiler sees is (any Animal instance).eat(any Food instance), which without other context could be cat.eat(hay). Even though the food was retrieved for the animal in question, that effect doesn’t carry over multiple statements in Swift. (This is at least partly because var animal: any Animal could be a cow when you retrieve the food and a cat later on. But the language could be further changed like it was for explicit functions.)

11 Likes

In this case $0 is of type any Animal, and let’s assume its underlying (concrete) type is T.

When calling let food = provider.getFood(for: $0), SE-0352 makes the magic to “unbox” $0 before passing it to provider.getFood(for:), so provider sees a T instead. Then the returned value, whose type was T.Food, is erased to the upper bound — any Any — because Animal.Food is totally unconstrained.

So far the relationship between $0 and food has completely lost. The compiler didn’t know about the relationship within underlying types, and instead it sees any Animal and any Any, which are totally irrelevant. This is described in "Losing" constraints when type-erasing resulting values.

Then let’s see $0.eat(food). In fact, such call alone cannot be resolved. $0 is any Animal, and Animal.eat references an associated type Food which cannot be resolved to any concrete type. This is described as member usability limitation in SE-0309.

So, although the message you see is resulted from SE-0309, the key blocker is related to SE-0352 instead. To prevent constraints between Animal and Food from getting lost, you should consume the Food in the same context you previously produced it with an unboxed Animal. By doing so you just won’t touch the SE-0309 restriction.

4 Likes

I feel it's so complicated here with protocols. But classes and generics solve the problem just by using class inheritance.

At the end of the day, protocols have become so bloated and incomprehensible in their management of some, any and associated types, that it takes a PhD thesis to understand their interoperability. Rendering them nothing more than code novelties in the museum of the weird and wonderful code tricks...

While I agree in general that existentials & generics (when coupled with protocol constraints) can be rather complicated I don't think that's an entirely fair comparison.

You'd have a similar problem with class inheritance here. If the getFood method returned a common superclass of Food you couldn't simply use that to pass the correct subclass of it to eat either. Instead you'd have to cast (which can fail) or rewrite eat to accept the superclass and throw errors if the concrete subclass is then wrong, which is at least as ugly in the end.

This example, in my opinion, just shows that what we as humans perceive as the "obviously correct intent" is actually way more complex than our natural understanding lends you to think: The semantic relationships of treating a thing according to various categories it belongs to is difficult to concisely express in a formal, written way.

12 Likes

It's also worth noting that in Swift, you can always avoid using associated types, by defining protocol requirements to traffic in type-erased types (like any P, or a base class, or even just Any) instead. The compiler will not guarantee static type relationships, so you will instead need to insert dynamic casts where appropriate.

You will lose static type safety, but gain (possibly illusory) simplicity. Sometimes dynamic casts are the only choice, and that's fine, but honestly the conceptual model here is not too difficult (I don't have a PhD, unlike what the original poster implies ;) ) and anyone who is writing Swift code on a daily basis should at least try to invest some effort into trying to learn how it all works.

6 Likes

Yes, IMO the general principal here is a tradeoff between effort required to express abstraction and how much static checking you get. Swift lets me go further in the static checking direction than PLs I knew before. But it's not free. Horses for courses.
The hardest part for me is that I sometimes cannot understand why something won't compile. It's gotten better, but opportunity remains for improvement.

1 Like