Soundness hole? Type of arguments in `==` are not correct

There seems to be some kind of soundness hole in Swift that can be witnessed in @State's new lazy initializer that works with the Observable protocol.

For example, consider the following very simple observable class that stores no data, but does implement Equatable using referential identity:

import SwiftUI

@Observable
final class Feature: Equatable {
  static func == (lhs: Feature, rhs: Feature) -> Bool {
    print("lhs.type", type(of: lhs))
    print("rhs.type", type(of: rhs))
    return lhs === rhs
  }
}

When == is called on Feature you would of course expect it to print "Feature" twice. After all, the lhs and rhs are explicitly typed as Feature and it is a final class.

But, if you stick this model into a view via @State like this:

struct ContentView: View {
  @State var model = Feature()
  var body: some View {
    EmptyView()
  }
}

…you get some kind of wrapper around Feature:

lhs.type DeferredValue<Feature>
rhs.type DeferredValue<Feature>

This means if you access anything on lhs you will get a runtime crash:

import SwiftUI

@Observable
final class Feature: Equatable {
  var count = 0
  static func == (lhs: Feature, rhs: Feature) -> Bool {
    lhs.count == rhs.count  // ❌ Runtime crash
  }
}

It seems really surprising that an explicitly typed value can secretly be some other value under the hood, and so accessing properties or invoking methods will crash.

Does anyone know how this is possible?

2 Likes

This is a SwiftUI bug that the team is already aware of.

Just to check, this is specifically a SwiftUI bug and not a compiler bug because SwiftUI is reinterpreting object pointers or similar, is that correct? That is, there is some unsafe code (in C++ or Swift) that sidesteps compiler/runtime protections? Because otherwise something is missing in the compiler.

3 Likes

Correct.

1 Like

Hmm, so, basically you have a class of your own:

class Foo {
    var field: Int
}

you've labeled it with some macro or a type / property wrapper, etc

@THING1
class Foo { // or struct / enum
    @THING2
    var field: Int
}

those added a bunch of hidden stuff that you are not aware of (unless you look deep inside), and then you are introducing your EQ (and, BTW, "hash") that takes into account the fields you know about and ignores the rest. Could this work at all?

No, that’s not how SwiftUI works. Brandon’s class is a regular-old, vanilla final class. The property wrapper is applied to a property on a different type that conforms to SwiftUI’s View protocol.

There’s really no need to further speculate or pick this apart. Brandon’s code is perfectly normal and should work. He’s experiencing a known bug.

5 Likes

Hi @ksluder, I just got around to checking this out again and it seems like the behavior has gotten stranger. This code:

@Observable
final class Feature: Equatable {
  static func == (lhs: Feature, rhs: Feature) -> Bool {
    lhs === rhs
  }
}

…used to compile in Xcode 15 beta 2, but as of beta 3 and later it no longer compiles with the error:

:stop_sign: Type 'Feature' does not conform to protocol 'Equatable'

This error seems impossible to fix even though the requirement of Equatable is correctly given.

I also don't really understand how such a compiler error is possible (nor the runtime crash from before) without some kind of soundness problem. How can the presence of the @Observable macro prevent a type from conforming to a protocol? Has this hole been explicitly put into the compiler so that people are not allowed to conform observable objects to Equatable ?


Update: Ok, not as weird as I thought at first, but still a little weird. Defining the Equatable conformance in an extension works:

@Observable
class Feature {}
extension Feature: Equatable {
  static func == (lhs: Feature, rhs: Feature) -> Bool {
    lhs === rhs
  }
}

Would this be worth a Swift bug report?