[Pitch] Observation

TL;DNR - Was replacing the Combine Publisher with an AsyncStream considered?

In an ObservableObject an @Published is used to wrap your property into a publisher. This forces your code to depend on the Combine framework and adds the limitations as mentioned by the author.
Isn't an AsyncStream the modern concurrency equivalent of a combine publisher?

What about a StreamableObject equivalent of ObservableObject?
One whose parameters can be marked with @Streamed instead of @Published?
Then you could use an @StreamedObject in place of an @ObservedObject.

Was this considered? It seems like it would be the closest to a drop in replacement for Combine to modern concurrency.

final class Model: StreamedObject {
    @Streamed private var minutes = 1
    @Streamed private var seconds = 0

    var time: String {
        String(format: "%.2ld:%.2ld", self.minutes, self.seconds)
    }
    
    func incrementMinutes() {
        minutes += 1
    }
}

struct ExampleView: View {
    @StreamedObject var model = Model
    
    var body: some View {
        Text(model.time)

        Button {
            model.incrementMinutes()
        } label: {
            Text("Increment Minutes")  
        } 
    }
}

SwiftUI would use the AsyncStream under the hood to await changes, in the same way that it now uses publishers.
Outside of SwiftUI you could observe the model changes using modern concurrency:

let model = Model()

for await time in model.$time {
    print(time)
}

In short, the same goals when using ObservableObject, @ObservedObject, and @Published would be accomplished by using an AsyncStream instead of a Combine.Publisher. (Thereby removing the dependency on the Combine framework)

StreamableObject would use a continuation for objectWillChange instead of how an ObservableObject uses a publisher.

And the @Streamed would use an AsyncStream as its projected value similar to how the @Published uses a Publisher.

All current functionality and familiar patterns would remain. The solution would just no longer use publishers "under the hood".

The model.time property would trigger a UI update in exactly the same way as it would with an @Published on and ObservableObject.

Just wondering if this was considered. Maybe this is too small scope of a change?

1 Like

I have the exact same question. In fact we implemented exactly what you are talking about here, and even named the property wrapper @Streamed. It would be good to know if this was considered. It does seem simpler than the proposal in many ways.

Observation itself can be registered on any thread/queue/actor, however changes to values of the observed thing must manage their own safety to the value being changed (being observable does not magically make something that wasn't thread safe to be thread safe).

The observed changes via the Observer types being passed in as a registered observer must (because of ordering of will/did change events) be on the same thread/queue/actor the change originated from. However observing the trailing edge via the AsyncSequence of changes is asynchronous so therefore it is on whatever actor it is doing the iteration.

Correct. The consideration for keyPathsForValuesAffectingValue(forKey:) is more for a nuanced case of adding observers for a given field (not for the general UI case since that already handles those).

For those fields any setter of the type wrapped accessor should trigger the change in both the general changes (for UI) as well as for the specific keypath observation case.

When c2 is set the old observation is removed from c1 and a new observation is installed. So it works as you prefer/expect.

That case works as you expect.

There is nothing stopping them from doing so. However that does come at the cost of managing the cases where you have a class and dealing with mutations on many tasks.

This is not nearly as easy as you might expect... Actors run afoul from an isolation perspective with type wrappers. To support them with that mechanism we need some sort of way of saying "I inherit isolation from a specific instance". We don't yet have this mechanism; so in the initial versions actors won't be supported for automatic default implementation access.

This is in the realms of the type wrapper feature; whatever that supports this will. However I expect that likely will "just work". However @hborla or @xedin might be more versed with that nuance.

At the point we have variadic generics I think it would definitely be possible to implement; as it stands I have access to the right stuff internally to the ObservationList to build the change sets from the fields but emitting the changed values and their key paths would either mean type erasure or wait for variadics (which I am more keen to do since that seems like a better fit for a future addition).

That is almost precisely the type wrapper feature.

That is the case that is not yet supported without the whole keyPathsForValuesAffectingValue(forKey:) addition.

That pattern was definitely something I closely looked at for how SwiftUI works. I have not found a single case yet that would impact poorly. Since it only invalidates views for things that are accessed in the scope of body it means that even if you use an intermediary view to pass the Observable thing down it does not in correctly update the upper level views.

This is something that I ran across. It is something that can be a bit tricky and I have talked at length about the options of revisiting this specifically for Observable things.

Yes it was one of the considerations. However the wins don't really out weigh the impact of task creation. Effectively with that route we have to spin up tons of small Task objects to do the iteration and that cost ends up out weighing any wins we could end up getting.

Can you expand on that? Is spinning up a Task expensive? Does this only apply in the SwiftUI context or whenever you observe an AsyncSequence?

Tasks are not free to create. They cost for their allocation and other resources too. If there are designs to avoid creating tons of root tasks (not child tasks) they should be avoided if where you are doing so would be performance sensitive. It isn't as costly as creating a pthread each time, but it isn't as cheap as testing an optional or just creating a closure. From a performance standpoint it is better to deal in structured concurrency and limit unstructured tasks to specific minimums.

4 Likes

I believe this question remains for @MainActor. Do observers’ change handlers execute in the main actor’s context or are they nonisolated and execute any Task they spin up on a background thread?

Change handlers do not re-thread anything. They execute on the actor/thread/queue properties are changed upon. However actions taken from SwiftUI may (which I still have on my docket to verify designs about) correct that to the main actor. That hopefully will side-step the issue with @Published failing when assigned from non-main actor sources.

2 Likes

But in my example, the access would happen in ContentView.body, during the construction of FooUser not during FooUser.body. So are you just saying you don't expect that access to have a large impact on performance? (And I admit, I struggle to come up with a simple example of this that's not obviously buggy.)

I'm definitely concerned about some sort of keyPathsForValuesAffectingValue being needed for computed properties or functions to work properly with SwiftUI. With @Published those seem more explicit. Is tracking individual property accesses strictly necessary instead of just tracking any change to self? It almost feels like premature optimization.

If this feature already depends on type wrappers (or compiler synthesized equivalent), could we create a marker property wrapper which helps control the observability of individual properties?

Just saw this thread - It's worth noting that if you actually use the object name Observable, this will possibly break thousands of projects currently using RxSwift's version of it.

The proposed system here is its own module so that name collision can be disambiguated in cases where they are used together;

import RxSwift
import Observation

final class MyModel<T>: Observation.Observable {
  init(_ observable: RxSwift.Observable<T>) {
     ...
  }
}
1 Like

Ah, that's great to hear. The question is if Observation might be imported in some other popular System Framework such as Foundation; in that case, RxSwift.Observable will be disfavored over Observation.Observable.

Not sure if this is known at this point but thought it's worth pointing this out.

Imported yes, but not re-exported. Currently the primary consumer of observation is SwiftUI. I am not fully certain on the exact details of how it will be re-exported but avoiding common conflicts and requiring folks to use fully qualified module names of types is something that is understood to be a cost to be avoided if possible.

At worst developers who use RxSwift in conjunction with a framework that uses Observation will need to qualify their uses of the protocol Observable with the module prefix. In the end that seems to be an ok-ish cost.

1 Like

I'm not sure that's really an Okay-ish cost, it means doing a big find-and-replace and praying it won't break anything.

It also means that someone who doesn't know this new Observation framework will open their codebase in Swift v5.x/6.x (whenever this is released), and their project will not compile (with hundreds of errors in a medium+ project).

As mentioned, I think it's worth taking this under consideration and avoid @_exported import anywhere. (P.S. RxSwift users might use it in SwiftUI, too, with conversions to Publishers, AsyncStreams, etc.)

Happy to see pure Observation get some attention, independent of one-shot async functions and full-blown reactive foot cannons, as I've been developing SwiftObserver since forever (which addresses the exact same niche).

However, the prospect of having an Observable protocol in addition to Combine's ObservableObject is a bit unsettling.

Within the Swift eco system (edit: or "Apple eco system" for that matter), conveniences for the observer pattern clearly live in Combine. If the solution there might be improved or extended, then that's where it should be tackled.

KVO is legacy. And property observers are only a low level convenience for the most trivial property observations at a local scope. I don't think the eco system should have two different officially blessed full observation mechanisms.

The fact that different requirements for observation do indeed exist only proofs the point that observation might be too specific a thing to be fully covered by universal solutions within the eco system. Maybe the way it is – with different 3rd party frameworks serving different tastes, styles and needs – is exactly the way it should be.

Combine is not in the Swift eco system. It is in the Apple eco system.

4 Likes

Let's rewind to this part again. Why is it strictly necessary? I read that bit above, but it didn't answer my question at all. I've spent weeks working with existing AsyncSequence types and wondered about similar questions. Why is AsyncSequence not an AnyObject as many of the implementing types use reference semantics. For those types I remembered that reference semantics does not mean we have to have a reference root type. We can totally fine use structs with reference semantics at it does not imply value semantics by design!

So here we are again. Why isn't the base protocol not defined rather like the following, if all we want to enforce is identity:

protocol Observable: Identifiable { … }

We added Identifiable for a good reason into the stdlib, right? :sweat_smile:

On reference types it will generate a default with ObjectIdentifier, which leads us to the same spot, except that it burns the id property on every conforming type and it allows other non reference types to conform to the protocol and be implemented with reference semantics.

1 Like

Identifiable conformance on a value type as such doesn't make it into a reference type. It's perfectly normal for a struct to be Identifiable & Equatable and for two values of that type to have equal id but compare different; that suggests we're comparing between two versions of the same thing. What would it then even mean to observe those two structures?

I think by entity having identity Philippe was rather meant identity in being stored in a place, i.e. by being held in a reference, essentially. Value types in isolation don't have such identity tied to them.

Edited to add: So I guess I'm trying to say that I don't think Observable: Identifiable isn't a sufficient condition, there still has to be a reference at the root of the key paths.

3 Likes

Doesn't the same story applies to Hashable vs. Equatable. A hashable type doesn't have to use all fields used for the equality of that type. The identity doesn't strictly have to be rooted into a reference type. A record from a database can be identified by some kind of an identity, but the record itself doesn't need to be a reference type. All that comes to my mind in the context of observation is the ability to pin point a specific location in memory that is eventually changed through mutation. For example I recently wanted to hide all the CoreData objects that I had to vend (which could be thousands in that specific case), but I really didn't want to spawn and manage my own ObservedObject objects. I ended up with an immutable struct wrapper type with reference semantics.

struct MyRecord: Identifiable {
  let _object: CoreDataManagedObject

  var id: UUID {
    _object.pID
  }

  var name: String? {
    get {
      _object.pName
    }
    nonmutating set {
      _object.pName = newValue
    }
  }

  var willChange: AnyPublisher<Void, Never> {
    _object.objectWillChange.eraseToAnyPublisher()
  }
}

That wrapper is theoretically an observable entity.