[Second review] SE-0395: Observability

Ideally no, but I'm not sure I see how it's possible to accomplish. How can SwiftUI detect that when one does model.episode.title in the view that only the title should be observed and not the full episode? It would need to be able to see the entire composed key path \.episode.title at once rather than the individual components, \.episode and \.title.

Maybe it is possible, I'm just not seeing it right now.

But also, I don't think this will be a huge deal in practice. @Observable is already so much better than what we currently have today and I doubt one needs to micro-optimize these things very often. I would just prefer that if one does discover a performance problem that one would have the tools to fix it.

Following up on this, in the official wwdc 23 slack someone mentioned that @Observable isn't intended to be used with structs here. Is this not right then? Seems like it would work just fine (excpet the current limitations around e.g. Equatable conformances).

I'm curious to see how this would work in practice.

If a struct has a reference to an ObservationRegistrar at its top-level, what should happen to the registrar if the value (struct) is copied elsewhere. Is there some copy-on-write behaviour that clears the registrar? Does the new copy have a fresh registrar? Or, does the copy start notifying the original value's observers when it now gets modified, too?

Now, take this problem and apply it to a tree of Observable structs. What happens to a second-level Observable's registrar if its parent is overwritten. Does the parent somehow grab the previous registrar of the child and merge the registrar of the old child with the new child?

To me, value types seem like they'd be an uncomfortable fit to be Observable. The hacks with Equatable/Hashable seem to highlight this.

3 Likes

Well, that's "key path" vs just "key", right? The new observation machinery uses keyPaths so this should be possible.

The first reaction to the new observation: we gained some, we lost some, overall as a first reaction it feels somewhat better than before, yet not ideal; I wonder if this compromise worth taking or/and should we still be seeking for the ideal solution. We had this precedent: while we could have done async/await kind of functionality via closures (promises / features) completely in the library with no extra language support, instead we decided to spend more time and bake concurrency deep into the language core to get to the ideal solution as close as possible. The fear of adopting this new somewhat better yet not perfect observation solution is: would it prevent us from looking for the perfect solution, as we'd now be in the "it's good enough already, let's just cope with it drawbacks and if you are careful enough to navigate through it avoiding this pitfall and that gotcha you should be fine" situation.

This is the correct behavior, these aren't hacks. The right way to think about this thing is that it has only a single value. It's like an empty tuple.

3 Likes

Fair point.

I'm still not clear on how an Observable value type should behave when it's copied, though. Does each copy create a fresh ObservationRegistrar? Does overwriting an Observable value type, somehow retain the ObservationRegistrar of itself and any nested Observable value types registrars?

The reference vs. value semantics seem muddy here.

3 Likes

But that is not true. There can be multiple instances of the registrar, and they cannot be substituted one for another. Correct semantics of equality would be comparing registrar identity (which is context.state.id).

1 Like

I was trying to investigate this part, and was able to create a crash. The following code crashes on the first pass, before any changes in the Source's:

@Observable
class Source {
    let name: String
    var count: Int = 0
    
    init(name: String, count: Int) {
        self.name = name
        self.count = count
    }
}

@Observable
class Combiner {
    let sources: [Source]
    var data: [String: Bool] = [:]
    
    init(sources: [Source]) {
        self.sources = sources
        updateAll()
    }
    
    func updateAll() {
        for src in sources {
            update(src: src)
        }
    }
    
    func update(src: Source) {
        print("Begin update: \(src.name)")
        withObservationTracking({
            print("  Begin read data")
            var dataCopy = data
            print("  End read data")
            dataCopy[src.name] = src.count > 0
            print("  Begin write data")
            data = dataCopy
            print("  End write data")
        }, onChange: { [weak self] in
            print("    Combiner.onChange: \(src.name)")
            DispatchQueue.main.async {
                self?.update(src: src)
            }
        })
        print("End update: \(src.name)")
    }
}

Before crashing on null pointer access in Observation.ObservationTracking._AccessList.merge() it prints:

Begin update: a
  Begin read data
  End read data
  Begin write data
  End write data
End update: a
Begin update: b
  Begin read data
  End read data
  Begin write data
    Combiner.onChange: a
  End write data

Theoretically it is possible, and achievable by invoking the underlying APIs directly. But the default behavior generated by the macro is to go through the properties that apply observation one key path component at a time, and so this is the behavior 99% of folks are going to see.

It definitely was part of the design to permit being used with structures.

2 Likes

So do copies of Observable structs all share the same registrar?

I suspect the behavior would be like what you expected if a) struct supports Observable, and b) observable can be chained/embeded. BTW, maybe that's a good reason for struct to support Observable?

Anyone knows?

Observation introduces new types such as ObservationRegistrar, which can't be back deployed, sadly.

What's the ultimate (terminal) blocker exactly that prevents back deploying (of Observation / ObservationRegistrar, etc)? Is that macros? or something else?

It's not macro related and it's mentioned in the future direction of the Function Back Deployment proposal.

I still don't get it. If I take the source code of ObservationRegistrar (if that's publicly available) and paste it into my project, then try to compile it it will reveal some other types that are missing. I repeat this process for them, and do this recursively until either of these:

  1. the whole thing will compile (and hopefully work!) on previous OS versions.

  2. there's some underlying missing functionality fundamentally missing under the old OS.

If that's (1) - fine, I can do this. I just wonder if it's (1) or (2), and in case of (2) what is the underlying blocker that'd stop me.

Can anyone point me to the right current sources of Observation, so I could try this process myself.

1 Like

Thank you, BTW, do you know how to find the corresponding git commit / branch given the version number like this "swiftlang-5.9.0.114.6"?


I didn't encounter any major blockers and managed to compile Observation sources for older OS relatively easy with some unimportant changes having to do with compilation directly from my app. When the app runs I can see that accesses properly recorded on getting state bits and withMutation keypaths properly recorded when state is changed. I can not test it fully though, as SwiftUI on that older OS release doesn't know it now needs to call "ObservationTracking._AccessList._installTracking" (perhaps among other things).

Hopefully SwiftUI with the new Observation can be relatively easily back deployed to the previous OS releases, it's just up to Apple as SwiftUI is not open source.

2 Likes

It cannot be easily back deployed - this was an early consideration but turned out to be quite intractable. Making @Observable able to be back-deployed would leave the expectation that it would work with SwiftUI - since that is just not possible it made the most sense to relegate this to new adoption and new development.

3 Likes