SwiftUI Binding problems with protocols (Still)

Hey guys,

So, this still seems impossible, where it really shouldn't. The other option is to rely on generics, but that adds quite a bit of boiler plate.

Any one suggest a way to get this working? I feel this should work...

protocol SimpleModeSelectable: Identifiable, Hashable {
    var selectableModes: [Self] { get }
    var title: String { get }
    var id: ID { get }
}

struct SimpleModeSelectorView: View {
    var title: String
    @Binding var selected: any SimpleModeSelectable

    var body: some View {
        VStack {
            SubtitleText(title)
            Picker("Modes", selection: $selected) {
                ForEach(selected.selectableModes) { mode in
                    Text(mode.title)
                }
            }
        }
    }
}

Result is the well known Type 'any XXX' cannot conform to 'Hashable' ... why? The implementation should enforce hashable, so the reason it doesn't compile makes no sense, no matter what technical jargon you throw at it. By definition it should work, because you cannot have a non "hashable" in place.

1 Like

Can you provide the full error? It would help to know the specific piece of code that the compiler is complaining about.

Is this related to protocols not conforming to themselves? Presumably SimpleModeSelectable does not conform to Hashable in this case.

I recently encountered what might be the same problem in a different situation, the gist of which is:

protocol X: AnyObject { ... }

extension Array where Element: AnyObject
{
  func foo()
}

let a: [any X]
a.foo()             // Requires 'any X' to conform to AnyObject.

Can you use a generic version instead, if not why?

protocol SimpleModeSelectable: Identifiable, Hashable {
    var selectableModes: [Self] { get }
    var title: String { get }
    var id: ID { get }
}

struct SimpleModeSelectorView<T: SimpleModeSelectable> : View {
    var title: String
    @Binding var selected: T

    var body: some View {
        VStack {
            SubtitleText(title)
            Picker("Modes", selection: $selected) {
                ForEach(selected.selectableModes) { mode in
                    Text(mode.title)
                }
            }
        }
    }
}
2 Likes

Hashable refines Equatable. Two values of type any SimpleModeSelectable may have different underlying types; there’s no implementation of == which takes two values of different types to be compared for equality (nor does Swift provide a way for you to write one yourself), which is to say, any SimpleModeSelectable does not and cannot conform to Equatable, and therefore it does not and cannot conform to Hashable.

1 Like

I wouldn't be so pessimistic: there is a mostly reasonable implementation (attempt to cast the types to one another, compare equality if they match, and return false otherwise), and the standard library of course already provides this via AnyHashable. So I wouldn't say it's the case that any SimpleModeSelectable cannot conform to Equatable in any sort of theoretical sense.

Of course, today, the compiler doesn't automatically synthesize this conformance for protocols which inherit from Equatable (which is why any Hashable doesn't give the same ability as AnyHashable). And even if you wanted to write this conformance yourself, it's not the implementation of == that presents the sticking point, it's the ability to conform existential types to protocols.

AnyHashable does something significantly more complex than that so as to support values of different numeric types comparing equal, and even to this day there are standing bugs where the implementation violates certain invariants. Unless things have changed recently, it is also to be noted that the implementation of this type is in C++, not Swift. An end user cannot actually replicate the behavior of AnyHashable even if the declaration of a custom Hashable conformance for an existential type were utterable.

3 Likes

Indeed. Some other languages decided it's totally fine.

C#:

object x = 1;
object y = "2";
bool z = x == y; // false

Kotin:

var x: Any = 1
var y: Any = "2"
var z = x == y // false

Go:

func foo(a interface{}, b interface{}) bool {
	return a == b
}

func main() {
	z := foo(1, "2")
	fmt.Println(z) // false
}

I believe a proper check is smth like this to account for "class is not subclass" scenario:

(a as? B == b) && (b as? A == a)

Yeah, you're right that I was glossing over a lot of complexity underlying AnyHashable but as far as I understood much of that complexity is due to Objective-C bridging behavior rather than a desire for arbitrary numeric types to compare equal, no? Not that such behavior can be totally discounted, but in 'pure' Swift I think AnyHashable would be significantly simpler and not require custom runtime hooks.

The intended behavior of AnyHashable hasn’t been hashed out (:hugs:). However, two values of arbitrarily mismatched integer types can compare equal in concrete code; it wouldn’t be very nice if they compare not-equal when boxed in AnyHashable…

2 Likes

I didn't realize we can compare integers of different sizes (but not floats).

Interestingly:

var x: Int16 = 1
var y: Int = 1
print(x == y, x.hashValue == y.hashValue) // true false, swift 4.2 and higher

(was "true true" in earlier versions of swift 4)

It looks like there's a set of special cased "==", "<>", "<", etc function implementations which were taught to compare integers of different sizes; perhaps the same could theoretically be done within AnyHashable to treat those comparisons the same way.

I'm not sure this totally justifies things—after all, AnyHashable does not attempt to recover this behavior for arbitrary BinaryInteger types:

struct S: BinaryInteger {
  var wrapped: Int
  // details omitted...
}
0 as Int == 0 as S                           // true
AnyHashable(0 as Int) == AnyHashable(0 as S) // false

Of course, we can restore this by conforming the 'secret' _HasCustomAnyHashableRepresentation protocol:

extension S: _HasCustomAnyHashableRepresentation {
  public func _toCustomAnyHashable() -> AnyHashable? {
      return wrapped._toCustomAnyHashable()
  }
}

AnyHashable(0 as Int) == AnyHashable(0 as S) // true

But based on the implementation of _toCustomAnyHashable() for integer types it seems clear to me that the goal there is to match NSNumber semantics, not to attempt to provide a canonical representation based on BinaryInteger equality.

This is the exact solution I ended up going with. It feels like this is a mishmash of generics and protocols, I always felt it could/should be done in pure protocols.

In case it hasn’t been clear, I think “the ability to confirm existentials to protocols” is the feature you’re waiting for, as are many of us, to varying degrees

For reference, the final result is actually quite beautiful, even though not what I had initially desired.

See code below :

protocol SimpleModeSelectable: Identifiable, Hashable {
    static var selectableModes: [Self] { get }
    var title: String { get }
    var id: ID { get }
}

struct SimpleModeSelectorView<T: SimpleModeSelectable>: View {
    var title: String?
    @Binding var modeSelectable: T

    var body: some View {
        VStack {
            if let title {
                SubtitleText(title)
            }
            Picker("Modes", selection: $modeSelectable) {
                ForEach(T.selectableModes) { mode in
                    Text(mode.title)
                        .tag(mode)
                }
            }
        }
    }
}

Followed by something like this to implement the list of modes from an existing enum

extension DrivingLightDevice.Mode: SimpleModeSelectable {
    var title: String {
        switch self {
        case .custom:
            return R.string.localizable.main_device_driving_light_mode_custom()
        case .fourbyfour:
            return R.string.localizable.main_device_driving_light_mode_fourbyfour()
        case .dirt:
            return R.string.localizable.main_device_driving_light_mode_dirt()
        case .highway:
            return R.string.localizable.main_device_driving_light_mode_highway()
        }
    }

    var id: UInt8 {
        self.rawValue
    }

    static var selectableModes: [DrivingLightDevice.Mode] {
        return [
            .custom,
            .dirt,
            .fourbyfour,
            .highway
        ]
    }
}