Not sure if my previous message above was missed, would be great if someone could clarify how the autoclosure makes things more performant in this case.
The root of that is how the autoclosure impacts performance for SwiftUI's inlining inside of the cases where that closure is not on the hot path - from metrics in the specific case it was used (which might not translate many other places) it resulted in a very small but appreciable enough differential in how the code that was inside that closure would be inlined that it ensured no meaningful regression for cases that did not use observation. Which is important since this applies to all views in the hierarchy, but only a handful actually have observable contents in applications that use that. It was an important modification to ensure the SwiftUI team was satisfied with both the observing performance as well as the non-observing inline performance (not of the observation itself but of the code that would get emitted into the closure).
Thank you, that's helping. Though I still don't really get why the optimization wouldn't happen without the autoclosure. If onChange
is only accessed from within a particular code path and inlining allows to defer the creation of the closure inside of that code path, why does it matter if the closure is { return { ... } }
or { ... }
? I know I'm missing something here, maybe I'll try to replicate this and look at the generated code.
If SwiftUI is the only issue for back deployment, a solution like what @tera posted would be a totally acceptable compromise IMO. A wrapper view would look very similar to how TCA works with SwiftUI.
Regarding the topic of back deployment: whether or not a part of the Swift standard library will be back deployed is a decision for the platform vendor, not something that is part of the evolution review. It won't be decided here in this thread.
I get the reason, but doesn't it hurt Swift's image outside Apple's platforms?
The issue of back deployment only matters on ABI-stable platforms (currently only Apple platforms). On other non-ABI-stable platforms, this is a non-issue.
With the current proposal, there lacks a way for the caller to express that it no longer cares about the update from the previous withObservationTracking
(like a View is dismissed), or for the Observable object to express that changes are no longer possible (like when the underlying object is deallocated) so the observation tracking should stop.
Without those, it appears that it's not really possible for the implementation of withObservationTracking
to correctly release the resources when either situation happens (in fact it seems like the current implementation bundled with Xcode 15 beta 1 leaks the _ManagedCriticalState
on every SwiftUI view dismiss FB12294637).
Here is another example where a seemingly innocent use of the API can actually leak resources.
@Observable
class TestObject {
var value = 1
}
@MainActor
struct Renderer {
let viewModel = TestObject()
func render() {
withObservationTracking {
print(viewModel.value)
} onChange: {
Task { @MainActor in
schedule()
}
}
}
func schedule() {
render()
}
}
Here if someone creates a Renderer
object, and call render
on it, later set the reference of Renderer
to nil
, the object and associated resources will not get released.
Maybe the proposal should consider introducing some kind of scope for withObservationTracking
so the lifecycle of the observation can be better managed?
Forgive me if this question has been asked before (I looked and couldn't find anything) — but why does the pattern seem to always be:
func outer() {
withObservationTracking {
work(viewModel.value)
} onChange: {
Task { @MainActor in
outer()
}
}
}
It seems counter-intuitive that onChange
is really only called once and then the whole outer
method is called again (does this throw away or restart the previous observation in some way?)
Couldn't this be reshaped as:
func outer() {
withObservationTrackingChanges { @MainActor in
work(viewModel.value)
}
}
removing the need for onChanges
to just call the outer
method again?
I'm probably just misunderstanding something fundamental here, but I figured I'd ask.
What you found here is definitely something that needs to be addressed (primarily in the implementation it seems there is a misstep there).
Providing a cancellation token (or some other machinery to provide cancellation) out would need some additional controls exposed out and that is definitely a decent forward direction for future development of that API surface.
Do you think the cancellation/observation invalidation should be included as part of this revision? I'm a little worried that without those, with this being a language feature, we'll create a lot of opportunities for API misuses or introduce unexpected object lifecycles that adds pressure for device resource usage (on Apple platform SwiftUI might be able to workaround that with SPI calling into the tracker directly to cancel stuff but other use cases won't be able to do that)...
While it's possible to add those in as a future follow up, doing so will require changes to the API signature which may be source breaking?
I think it can be done without modifying it - I have a prototype that I am testing against your example that cancels upon deinitialization of the registered contexts. Explicit manual cancellation likely requires a slightly more involved surface and would not require altering existing stuff but might require a bit deeper dive - something we can most definitely follow-on in future discussions.
Not a big fan of the current implementation, especially given how the new @Observable macro generates non-initialized errors and breaks many property wrappers in the process. This includes the @Injected property wrapper types used in Factory and other dependency injection systems.
@Observable
class SplashScreenViewModel {
@Injected(\.loginService) private var loginService: LoginServices
...
}
In Factory, the keypath initializer points to a DI container/factory that will provide the requested service. An initialized variable isn't needed (or even possible).
The @Observable macro basically breaks any property wrapper like this one whose job it is to pull a keyed value from a container, database, or other backing store.
You can "fix" the problem with @ObservationIgnored, but that needs to be done every single time a property wrapper is used, which in turn greatly discourages their use.
@Observable
class SplashScreenViewModel {
@ObservationIgnored @Injected(\.loginService) private var loginService: LoginServices
...
}
It would be better if there was a away to mark the property wrapper itself as fulfilling any needed requirements.
And regardless of that, this entire @Observable solution seems a bit half-baked. I understand what it's trying to do, but the current implementation, especially in SwiftUI, seems riddled with exceptions. Just do this. Unless you need to this, then do that. And, of course, unless you need to do something else, in which case...
Yes, the current pitch is quite specialized - I posted some feedback a number of posts up without any comments so it seems the number of us who sees the utility of non SwiftUI specific features might be less than what I’d expect- it’s a bit of a lost opportunity perhaps as generic observation tooling would be great for dependency management.
I think it should be possible to generalize the api surface a bit while still keeping the support for the SwiftUI use case, but perhaps I’m missing something.
To circle back on the structural nature of observable types - there are definitely use cases for things to be able to be Observable
and interplay with observation that are structures; however as folks have raised there are some sharp edges. Namely of which - what happens when you copy a structure that is marked with @Observable
. To go into this we can set the issue about codable/equatable question aside - (which can have a solution by making ObservationRegistrar
conform to those protocols in a slightly degenerate but still valid manner).
Structures housed inside reference types make sense. They have a rooted form of identity and consequently those act more like reference types. Holding just raw values is pretty strange to observe but does not pose really that much of an issue until they hit the same problem as housed structures. That issue being mutation of a copy. Specifically when fetching out a copy of the structure and then subsequently mutating it will cause a fire of the mutation detection of the observation since they would share the same registrar. There is a solution for this; copy on write. However that seems to have more exposed potentials of issues associated with it. Currently I have a draft implementation that does work, seemingly correctly, for making the registrar a copy on write type - it makes the macro emission for the mutation side of the enclosing types that are structural actually mutate the registrar and that can then enforce a uniqueness check.
Since this review has dragged on a bit I am hesitant to include the CoW form. It is something we can do later. Code and complexity wise it is something we can come back to and add later if it really poses a distinct need. Hopefully with a bit more time for doing exploration into if that is the correct move.
Interestingly this does share some alterations with the AsyncSequence accessors (being able to observe the values emitted from a specific property). So it stands to reason that could be shared in a follow-up evolution of the APIs.
The summary is that after discussion; the right move here is to restrict the application of the macro (but not the protocol or manual usages of observation) to only class types.
Since this review has dragged on a bit I am hesitant to include the CoW form. It is something we can do later. Code and complexity wise it is something we can come back to and add later if it really poses a distinct need. Hopefully with a bit more time for doing exploration into if that is the correct move.
Interestingly this does share some alterations with the AsyncSequence accessors (being able to observe the values emitted from a specific property). So it stands to reason that could be shared in a follow-up evolution of the APIs.
The summary is that after discussion; the right move here is to restrict the application of the macro (but not the protocol or manual usages of observation) to only class types.
It’d be nice to push this discussion a bit further while it’s fresh. It’d be a shame to kick this can down the road and wait another whole year before the solutions can be applied to SwiftUI apps.
We’ve been heavily using @Observable
structs in SwiftUI since the beta period began with great promise and success. We’re aware of the sharp edges mentioned, but have worked around them in our own tooling around observability. If you have a snapshot of your CoW we’d even love to give it a try to see how it affects our SwiftUI apps that are leveraging observable structs.
As long as Observable
conformances aren't banned from structs I think we will have a path forward (though adding CoW later could potentially make our tooling very complicated if it has to account for pre-CoW and post-CoW worlds), but it'd be a shame to not address these things earlier.
Generally, footguns don’t make great v1 APIs. I’d there an alternative formulation (e.g. an @Observable
member macro) that would also work for the observable-struct use case?
That is included within the proposal: @ObservationTracked
can be applied ad-hoc to generate the tracking details.
Structures housed inside reference types make sense. They have a rooted form of identity and consequently those act more like reference types. Holding just raw values is pretty strange to observe but does not pose really that much of an issue until they hit the same problem as housed structures. That issue being mutation of a copy. Specifically when fetching out a copy of the structure and then subsequently mutating it will cause a fire of the mutation detection of the observation since they would share the same registrar. There is a solution for this; copy on write. However that seems to have more exposed potentials of issues associated with it. Currently I have a draft implementation that does work, seemingly correctly, for making the registrar a copy on write type - it makes the macro emission for the mutation side of the enclosing types that are structural actually mutate the registrar and that can then enforce a uniqueness check.
Since this review has dragged on a bit I am hesitant to include the CoW form. It is something we can do later. Code and complexity wise it is something we can come back to and add later if it really poses a distinct need. Hopefully with a bit more time for doing exploration into if that is the correct move.
I think supporting housed structures is actively harmful and CoW is not a solution.
Do I understand correctly that CoW support would create a new registrar instance? Then what would happen when mutated copy (with a new register instance) is written back?
@Observable
struct A {
var foo: Int
}
class B {
var a: A = A(foo: 1)
}
class C {
let b: B
func process() {
withObservationTracking {
... = b.a.foo
} onChange {
...
}
}
}
var copy = b.a
copy.foo += 1 // CoW, new registrar is created
// b.a.foo == 1
b.a = copy
// b.a.foo == 2, but no change notification
b.a.foo += 2
I see several problems here.
-
I don't see how any code from inside the observable value type can know if it mutating inside the reference type or outside.
-
Writing back into reference type won't trigger any notifications.
-
Writing back will replace the instance of registrar in the reference type. So any existing subscribers won't be notified about future updates made through the reference type.
So, the use case that can be supported - are structs with reference semantics, which provide observable access to data stored outside of the struct. Any observable setters in such struct should be nonmutating
. SwiftUI.State
is a valid candidate for this.
Maybe a wrapper for UserDefaults
could be another example:
struct ObservableUserDefaultKey<T> {
let key: String
// Correct implementation must ensure that each key has it's own registrar
let _$observationRegistrar = ObservationRegistrar<Model>()
var value: T? {
get {
self.access(keyPath: \.value)
return UserDefaults.standard.object(forKey: key) as? T
}
nonmutating set {
self.withMutation(keyPath: \.value) {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
}
I was not sure about it, but apparently ReferenceWritableKeyPath
can be used with structs with nonmutating setters.
class Storage {
var foo: Int = 0
}
struct Ref {
var storage: Storage
var foo: Int {
get {
storage.foo
}
nonmutating set {
storage.foo = newValue
}
}
}
let keyPath: ReferenceWritableKeyPath<Ref, Int> = \Ref.foo
let ref = Ref(storage: Storage())
print(ref.foo) // prints 0
ref[keyPath: keyPath] += 2
print(ref.foo) // prints 2
This can be used to express constraints for reference semantics without AnyObject
, thus supporting structs too.
public struct ObservationRegistrar: Sendable {
public init()
public func access<Subject: Observable, Member>(
_ subject: Subject,
keyPath: ReferenceWritableKeyPath<Subject, Member>
)
public func willSet<Subject: Observable, Member>(
_ subject: Subject,
keyPath: ReferenceWritableKeyPath<Subject, Member>
)
public func didSet<Subject: Observable, Member>(
_ subject: Subject,
keyPath: ReferenceWritableKeyPath<Subject, Member>
)
public func withMutation<Subject: Observable, Member, T>(
of subject: Subject,
keyPath: ReferenceWritableKeyPath<Subject, Member>,
_ mutation: () throws -> T
) rethrows -> T
}
Making the observation tracking data copy-on-write for value types seems better than making it truly shared, but it still doesn't seem right.
I don't think I can explain what I see as the right thing here without a little bit of theory. I think folks might be confusing values and locations. These are both basic formal concepts of languages, tied deeply into the language semantics. They are defined slightly different by different languages, and talking about them in the abstract can get pretty circular. Let me try to provide a quick intuition of what we mean by this in Swift.
- A value is what you can return from a call, pass as a (non-
inout
) argument, and so on. Ignoring reference types for a second, you can talk about values independently of concepts like memory. Fundamental types can be thought of as fundamental values, like particular integers and strings, andstruct
s can broken down recursively into the component values they store in their stored properties. For example, I might say that a particular value isBall(diameter: .03, color: Color.orange)
. Here I've written the value as if I were calling a memberwise initializer with all the values of the stored properties; this works to denote the value even if I didn't actually build it that way, or even if my type doesn't actually have a memberwise initializer. - A location is part of the memory of the abstract machine. Every location has a type, and it stores a value of its type. For example, when you declare a mutable local variable, a new location is created dynamically when that variable comes into scope, and it is destroyed when the variable goes out of scope (and all the captures of it go away). Creating a location of a struct type means creating locations for all the stored properties of that struct.
A value of class type is a reference to an object. When a class object is created, it includes locations for each of the stored properties of the class. When you copy the value around, it's still a reference to the same object, giving access to those same abstract locations. Now, we say that class objects have a notion of identity, and we expose that identity in the language through e.g. the ===
operator. But even if we didn't have that, class objects would have some formal measure of identity innately through the semantics of the locations of their stored properties, because the independent mutability of locations is itself a kind of identity.
Mutation in Swift is all about location. For example, the name of a local variable is tied to the location that is created dynamically for the current entry into the variable's scope; if you evaluate an expression that mutates that variable, you're changing the value stored in that location. Crucially, you do not change values stored in other locations, even if the value in this location was copied from them or vice-versa. People would be surprised if it worked any other way.
I think observation needs to work the same way. It makes sense to observe a mutable location, but what that means is that you're interested in changes to the value stored in that location, not somehow in changes to the value itself. Values cannot change! Only the value stored in a location can change. And this goes deep into the basic mechanics of values. If you copy a value out of one location and into another, where you then mutate it, observers of the original location should not be notified about those changes. If you replace the value in one location with a totally different value, any existing observers of that location should of course be notified about that change, as well as any subsequent changes to that location. The observers of a location are extrinsic to the value actually stored there.
Sometimes we think about structs as if they were just classes where everything was allocated inline, or vice-versa. And sometimes that's perfectly reasonable. But when we talk about extending observability from classes to value types, that intuition does not serve us well. Abstraction over class values naturally preserves locations within the location, so it makes sense to set up observation of a class object (or its properties) by passing around the class value. Abstraction over value types simply does not work like that: there is nothing in Swift right now that you can add to the value of a value type that will make it naturally track this abstract concept of location. Perhaps we could change that, but I don't think it's the right thing to do. Tracking semantic location for arbitrary values would be hard; there's a lot of subtle complexity there that we've intentionally not made part of Swift's basic language model.
For example, in many common situations, this abstract concept of location aligns with a physical storage address at runtime. This is because, when the language implementation allocates an abstract location into a particular place in memory, it usually doesn't have a good reason to move it. One might be tempted, then, to implement location-specific information like value-type observers by collecting it normally in the value but making value copies and relocations drop it. But there are some reasons why abstract locations need to be relocatable in memory, especially in the library. For example, the elements of an Array
are naturally locations that are identified by their index. If you add more elements to an Array
, the array storage may need to be reallocated, and so existing elements will need to be relocated. This kind of relocation must not reset observers because the abstract location remains the same. And the reverse is also a problem. For example, if you consume
a value from one variable and assign it to another, the value is semantically in a new location, and so information like observers should be reset. However, we'd really like the Swift optimizer to be smart enough to avoid relocating the value if it doesn't have to, which would be a problem if that's the only way to trigger that reset. Copy-on-write collections can exhibit both of these problems, because location-specific information will naturally be preserved if the collection is copied without copying the buffer, and then the inheritor of the buffer can be unpredictable. In brief, the design of Swift is not meant to support this kind of location-specific tracking of values; it is not part of the language model.
As a result, I'm very skeptical that we should try to include observability for value types in this release. It seems to me that there are deep conceptual problems that need to investigated before we can make any progress here. We should leave room for it in the ABI, as well as for observing objects that don't conform to a Swift AnyObject
constraint (which future move-only classes may not); in particular, we should make sure that the Observable
protocol doesn't have unnecessary constraints about the kind of value that can be observed. But I think it's perfectly acceptable to not make the macro handle types that will semantically misbehave under observation unless they're used in a way that very carefully never disturbs that innate sense of location-identity. We know how to make classes work well, and we should be content with that for now.
Hey there,
I was forwarded here from the Pitch thread.
As some of you might know, there seems to be a heavy regression in Xcode 15 Beta 2 where importing SwiftUI re-exports Observation.
This means that any app that has SwiftUI and RxSwift imported is broken and can't be compiled, since Observation.Observable conflicts with RxSwift.Observable:
RxSwift is a 7 years old project and the Observable type is that-many-years-old (or even more, if you consider Reactive Extensions as a specification), so forcing all of these thousands-to-10s of thousands of consumers to rename the type is quite unreasonable, scale-wise.
It was suggested adding a typealias Observable = RxSwift.Observable
to fix the issue but for our use case it didn't work and we aren't sure why, but it also wouldn't be scalable when you have 100s of modules.
I wondered if this is something you're aware of, and if there is any discussion of fixing this by either renaming Observable
to something else, or at least revert the re-export of Observation
.
Thanks