I'm just curious: what would be the impediments to having classes observable by default? (i.e. built-in to the language) The performance implications? Could static analysis be used to infer which class types aren't ever part of observation?
Asking because @Observable kinda feels like noise. I say it's noise because were SwiftUI to constantly reevaluate the view bodies, it wouldn't need such annotations. Therefore it's not essential to expressing the intent of the UI.
Plus, I've had a client struggle with how even though their model class was observable, it referenced some other class which wasn't.
From my discussion with GPT on this topic:
If SwiftUI evolves to integrate deeper automatic observability, akin to languages like Elm or frameworks that implicitly track data dependencies, it could significantly reduce the cognitive load on developers, making the framework more beginner-friendly.
Everything goes through some calls on the registrar: access, willSet, didSet and/or withMutation.
The registrar is a Sendable, locked struct. Reads/writes from different threads will have to be serialized and incur corresponding costs.
Ther registrar stores an array of observers and loops through them on every access, so notifying observers is O(n). All of this data also has to be allocated somewhere, too. See source code.
So, if everything is @Observable, you're paying the cost of allocating the registrar's inner states at init and taking locks into it every time you're touching your observable, even if there are no registered observers.
Remember also that this "touching your observable" is transitive: it affects not only direct property reads/writes "from the outside", but every single method on your data type, and all the callers of those methods, and so on. Essentially, every single class that has at least one property would have to carry an additional locked bag of state that you're constantly pinging and looping over.
Could static analysis be used to infer which class types aren't ever part of observation?
I'd wager that this is unfeasible. Also, the language philosophy overall is that such mechanisms have to be explicitly opt-in.
So how does that work, not needing any code in the observed object? Well it all happens through the power of the Objective-C runtime. When you observe an object of a particular class for the first time, the KVO infrastructure creates a brand new class at runtime that subclasses your class. In that new class, it overrides the set methods for any observed keys. It then switches out the isa pointer of your object (the pointer that tells the Objective-C runtime what kind of object a particular blob of memory actually is) so that your object magically becomes an instance of this new class.
The overridden methods are how it does the real work of notifying observers. The logic goes that changes to a key have to go through that key's set method. It overrides that set method so that it can intercept it and post notifications to observers whenever it gets called.
The good thing is that it makes observation overhead per-instance.
The bad thing is that we lose the ability to devirtualise accesses to the class' properties. We always need to make the virtual function call, because we have to assume it may be overridden.
FWIW here's a very crude test to see how much Observable pessimizes updating a var:
import Observation
import Foundation
class Model1 {
var value = 0
}
@Observable
class Model2 {
var value = 0
}
let n = 1_000_000
let m1 = Model1()
let start = Date()
for _ in 0..<n {
m1.value += 1
}
// 0.2 on my machine, optimizer off
print("elapsed time w/o observation: \(Date().timeIntervalSince(start))")
let m2 = Model2()
let start2 = Date()
for _ in 0..<n {
m2.value += 1
}
// 0.36 on my machine, optimizer off
print("elapsed time w/@Observable: \(Date().timeIntervalSince(start2))")
So 0.2s vs 0.36s on my machine, optimization off. Problem with this test is when you turn on the optimizer it gets rid of the whole loop in the first case (AFAICT by checking on godbolt: Compiler Explorer).
It's worse than that if you update to something that can't be optimized away and don't use globals:
final class Model1 {
var string = "0"
}
@Observable
final class Model2 {
var string = "0"
}
do {
let n = 1_000_000
let m1 = Model1()
let start = Date()
for i in 0..<n {
m1.string = "\(i)"
}
print("elapsed time w/o observation: \(Date().timeIntervalSince(start))")
let m2 = Model2()
let start2 = Date()
for i in 0..<n {
m2.string = "\(i)"
}
print("elapsed time w/@Observable: \(Date().timeIntervalSince(start2))")
}
On my M1 Ultra, in Release mode, Xcode 16.1, it prints:
elapsed time w/o observation: 0.015753984451293945
elapsed time w/@Observable: 0.14439702033996582
(Yes, there's some string interpolation in here, but it should be the same between the two loops.)
Setting performance aside for a sec, making everything observable by default to me personally doesn't look like a good idea design-wise or even philosophically. It feels very similar to @Atomic property wrappers that I see people sometimes trying to implement (or used to try, in the preconcurrency era), assuming that simply annotating every stored property with @Atomic will make their classes properly thread-safe.
"Everything observable" can have similar effects that observation callbacks may trigger mid-transaction, and the handler will see broken invariants. The language shouldn't suggest a pattern that can be so heavily misused by making it the default.
One of the original design considerations with @Observable was that the previous mechanism KVO did not have any documentation per what classes participated. Having the demarcation not only ensures that performance is known but also that behavior is known by self-documenting code. How does one know a type participates in Observation? - It declares itself as @Observable.
In practice we've seen this become important because people would publish classes that accidentally supported KVO, then discover that their internal implementation details (e.g. which properties were updated when, in what order, and on what thread) were now compatibility requirements.
Even more alarming, in some cases the type didn't support (or didn't fully support) KVO, but people still added observers. When KVO support was then added/fixed, it was revealed that those previously-never-observing observers crashed as soon as they observed anything.
I've used KVO long ago and it didn't occur to me that Observation is the successor to it. I thought of Observation as the successor to ObservableObject, because I replaced the latter with the former in lots of SwiftUI code. Do the experiences from KVO really transfer over to the new world in which this is (correct me if I'm wrong) primarily a mechanism for SwiftUI knowing when to update? (how many developers will actually use withObservationTracking directly?)
I was asking about perf because if perf can't be good then design considerations are moot.
On the philosophical side, don't you want a reactive UI system that is actually correct? That is: if the UI is a function of some state, and that state changes, then the UI system knows to update. This is not the case with SwiftUI, since things must be correctly annotated.
(by the way, I built a prototype in Rust that comes very close: GitHub - audulus/rui: Declarative Rust UI library. you'd have to really try to get it not to update I think. Other Rust UI efforts have similar strong guarantees for reactivity, I think)
So with correctness in mind, can such a system exist in Swift while still satisfying the other design constraints? (i.e. not unexpected code running at times which could mutate things, breaking local invariants, or causing perf problems) Maybe not.
By the way, I'm satisfied with the current performance of @Observable, now that the issue I previously reported has been fixed :) I was just talking about perf in the imaginary world of everything being observable.
I never asked that. I am fully aware that @Observable is defined in Observation because I've had to type import Observation many times. I don't appreciate you putting words in my mouth.
FWIW, there are a lot of uses of withObservationTracking to build library tools that have nothing to do with SwiftUI, though I don't think it's a particularly useful too for directly in app code. You can use it to create powerful, state-driven navigation APIs for UIKit and even non-Apple platforms, such as Windows/Linux/Wasm.