Is there a difference between using `any` and `some` if I'm assigning the value to an existential type in the end anyway?

For example, are these initialisers below semantically mean the same thing?

struct Foo {
    var anyHashable: any Hashable

    init(_ value: some Hashable) {
        anyHashable = value
    }

    init(_ value: any Hashable) {
        anyHashable = value
    }
}

Or should I choose one over another?

Side Note

I know that some is just a shorthand for generics and could be rewritten like this:

init<T: Hashable>(_ value: T) {
    anyHashable = value
}

So I could've asked the question like "difference between using existentials and generics" but chose to ask it this way for simplicity.

2 Likes

I think the answer could be different depending on how you may evolve the type in the future.

They are similar in concept and how you can use them in source code, but from a mechanical/perf perspective, they have notably different ABIs. Taking the object in the native format you'll store it in is often more efficient. With some Hashable version your initializer under the hood will have to take a concrete value and a type metadata pointer, then box the value into an any . Even with the any Hashable version that may still have to happen on the caller side (someone has to box it at some point if you need an existential) but consider this:

public func someFunc(_ hashable: any Hashable) -> Foo {
    Foo(hashable)
}

With this type of flow where the caller already has it in a box, then if your initializer takes some Hashable, the caller will open up the box, call you with the concrete value, then you'll re-box it. So a bit of redundant work.

From the perspective of evolving the type though, I'd ask if there's any chance in the future you won't actually need to store the type-erased value? For example maybe you'll just call value.hash(into:) and store the result of that, or you'll pass the type to some other generic type which never has to manifest it into an existential box. Avoiding existential altogether is often the best case for performance, and taking it as some Hashable leaves the door open for that.

Last note: Assuming this isn't perf critical code, probably none of the above matters and either option is fine :smiley:

4 Likes

This is one of the rare cases I would prefer the existential overload, since it’s immediately being stored anyway.

To understand why, consider the case where you call the generic version, but you’re passing an existential. This requires opening it, copying the payload, and then wrapping this payload in a new existential inside the initializer. If the initializer takes an existential on the other hand, the existing value does not need to be converted.

And if you start with a concrete value, you’re wrapping it in an existential either case.

9 Likes

And if you'd like a "visualization" of what I think Slava was describing (or at least part of it), you can see the difference between the SIL of these two forms here: Compiler Explorer. In the (optimized) SIL you can see there's an extra init_existential_addr instruction in the generic case. This appears to translate into a decent-looking code size increase in the generated assembly if you emit that instead.

3 Likes

I can't explain this. Why is the second case ambiguous? Foo has an initialiser which already takes some Hashable.

func f (_ u: any Hashable) {
    let v = Foo (u) // Okay
}
func g (_ u: some Hashable) {
    let v = Foo (u) // Ambiguous use of 'init(_:)'
}

This isn't something the compiler can optimize away? I suppose it might if the function gets inlined? Or does the language require opening and rewrapping for some reason?

I noticed this further slightly surprising behavior:

struct Foo {
    var anyHashable: any Hashable

    init(_ value: any Hashable) {
        anyHashable = value
    }

    init(_ value: some Hashable) {
        anyHashable = value
    }
}

func g(_ u: some Hashable) {
    let _ = Foo.init(u) // okay – picks existential (why??)
    let _ = Foo(u) // Ambiguous use of 'init(_:)'
}

When poking through the constraint solver's debug logging to compare the two cases, it seems that in the explicit Foo.init case, the solver finds the two viable overloads, compares the two init declarations directly, and favors the existential overload for some reason (it's a separate, interesting question why the existential init would be chosen over the generic one in this case).

In the "short-form constructor" case, it seems the solver again ends up with two solutions of equal score, but this time, instead of comparing the two init declarations to each other to find the better one to use, it attempts to compare both solutions to see if one is better than another. One of the solutions appears to introduce an existential erasure, but this then causes the solution comparison to fail because it seems that erased type needs to be used in a position within the solution where it requires any Hashable be a subtype of some Hashable, which it is not. I.e. the ultimate failure in the debug logs looks like this:

comparing solutions 1 and 0
  (found solution: <default 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0>)
(failed constraint any Hashable subtype some Hashable @ locator@0x5aab4a7bad20 [])
- incomparable
comparing solutions 0 and 1
(failed constraint any Hashable subtype some Hashable @ locator@0x5aab4a7b7c40 [])
  (found solution: <default 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0>)
- incomparable

After writing that all out, I'm not sure it's actually a very illuminating answer to "why" we end up with ambiguity here... but maybe it's better than nothing? I do wonder if it's a bug that the two forms of constructor don't end up typechecking the same, and why the existential constructor appears to be favored over the opaque one when passing an opaque parameter.

3 Likes

That part is easy: the initializer that takes an existential is non-generic and the other one is generic, so non-generic wins as 'more specific' (and now changing the behavior is a source compatibility issue).

2 Likes

Yeah, there are several differences in behavior between method calls and the implicit constructor call syntax. It might be worth filing a bug report for this one though, and adding some tests for the current behavior; even if this is something we can change later, it should be an intentional change and lot as a result of some refactoring.

1 Like

I always thought X() and X.init() were equivalent. Is there a reason they aren't, or is it historical? Are there any other noticeable differences?

Mostly historical, for example sometimes fixes are made to disambiguate overloads in specific situations and then the behavior becomes load-bearing over time. Another dimension is that single argument unlabeled calls have some special behaviors not shared by calls to other initializers. This was a consequence of an earlier performance optimization which has been removed for example, but the behavior is simulated for source compatibility reasons.

They're also explicitly not equivalent for types expressible by literals. That is an explicit decision in SE-0213:

T(literal) should construct T using the appropriate literal protocol if possible.
. . .
This behavior could be avoided by spelling initializer call verbosely e.g. UInt32.init(42).

2 Likes

It looks like the conversation ended so I think I can step in without distrupting anyone. Well, I don't have anything else meaningful to add to this conversation. I was going to ask if there were any downsides for providing both but it looks like it has been made clear there is going to be ambiguity problems.

Thank you for your extensive answers/explenations.