[Pitch] Observation (Revised)

I said this above but: the fact that changes occur asynchronously, does not actually imply that they should be propagated asynchronously. That sounds like an oxymoron, but that's what Combine and KVO already achieve.

Let's take it back down to basics, and imagine we've discovered callbacks. A callback can be called synchronously. It doesn't need to be asynchronous. The important part is that the sending of the event, and the delivery of the event happens synchronously.

Or another example: Swift's property observers willSet and didSet. Also called synchronously. Because if they weren't and the closure was called asynchronously, the implied order in their name would become meaningless.

These are all examples of changes that happen asynchronously, but for which observers are notified synchronously.

1 Like

I'm not sure I understand. If my model is:

@Observable final class Car {
    var name: String
    var awards: [Award]
    var mass: Double
}

Then I can observe name and awards independently (and suffer from broken invariants if I merge the results of both observations together).

I'm not lead into observing Car as a whole, or declaring a struct for the (name, awards) pair. Or am I?

1 Like

Yep, that is true if you are observing each value independently across actors. Like this broken invariant:

Task {
  for await (name, awards) in merge(car.values(for: \.name), car.values(for: \.awards)) {
  }
}

but to do that you have to send car across the boundary of the Task.

If we alter the class just a smidge to address some of the issue:

@Observable final class Car {
    @MainActor var name: String
    @MainActor var awards: [Award]
    var mass: Double
}

We can observe the changes to the car - those properties then (as long as a suspension does not happen in-between the setting of them) will emit together; but again across an actor boundary that won't jive (since the ObservedChanges are only Sendable if the observed type is Sendable).

But if then the observation is done on the same actor it will await the change of both properties since they are set without a suspension point in-between them from the main actor.

I think the intent of the values(for:) is for independent values - things that can be sent across domains and don't need any sort of consistency to any other state.

The problem is of course not that someone can observe name and awards independently. Some people need this.

The problem is that it is harder for people who observe both to do the correct thing, which is to observe both in one shot.

// Maybe not so great
car.values(for: \.name)
car.values(for: \.awards)
// car.values(for: ???) Nothing for name+awards

// Suggested api
car.track { $0.name }
car.track { $0.awards }
car.track { ($0.name, $0.awards) }
car.track { /* whatever can be built from a car */ }

With a closure argument, you'd make the observation of complex values as easy as the observation of properties. Users are no longer lured by a convenience api into observing individual keypaths, merge the results (and see broken invariants).

1 Like

I agree with @tcldr, the fact that changes happen over time doesn't mean they have to be propagated asynchronously. If you want a first hand example of how this can go wrong, @christianselig mentioned a similar issue when using the notification async API, as he explained on mastodon and here.

3 Likes

It was very difficult to understand. I'll need to come back. Thanks for the replies so far.

Yea that is very similar to one of the earlier prototypes; the rub there is that you really want just a sub-set of properties to be available as a "snapshot"

It's not just across actors it's true on the same actor, too.

A MainActor object graph, for example, will inevitably end up with many independent observations in separate long running Tasks. This will cause problems. Of course, invariants can happen with many synchronous observations, but they're far easier to reason about when you know the observation will be fully applied by the end of the current run loop cycle. Trying to reason about observation event propagation that will occur piecemeal across time is going to be really painful.

I don't get it either. Maybe please say it differently? Is it a rebuttal of the argumented closure suggestion, or not?

It is a confirmation that is a valid approach from a high level; I initially went down that path but it has a catch to it. The catch is that to do that you really end up needing a concept of snapshotting else you fall into the concept of observing the entire object again; which is not ideal.

Snapshotting requires some advanced features from variadic generics that if I understand correctly really isnt on the table just yet.

What about things that do need consistency to other state – specifically on the same actor?

Clearly this is something that SwiftUI needs, hence ObservationTracking.withTracking. Unfortunately, that isn't really suitable for general purpose observation.

So ObservationTracking.withTracking can "magically" observe all accessed properties, but ObservedValues can't?

But with a closure-based tracking api, ObservedValues would be generic on the type returned by the closure, right? It might need to hide inner information that is currently difficult to express, but is it impossible?

The magic is that during the apply a thread local is set such that accesses to fields will put their keypath into the list of things to be observed. That has two restrictions: 1) access is bound to that scope 2) it cannot be async.

That does give me an idea of how that could be approached; but it is dramatically different than the current outlined approaches.

2 Likes

The thing is, as long as values(for:) and changes(for:) are asynchronous sequences, and as long as people are observing across multiple long-running Tasks (as they'd be prone to doing across a typically sized app object graph) you'll still have issues with invariants.

Especially given Tasks are indeterminate as to which order they resume in, and when they'll resume. You'll still be managing a bunch of state from different points in the programs execution and trying to make it all work together.

That's to be expected when working across actor boundaries, but on the same actor? I think that's a lot to ask people to reason about.

2 Likes

You make me glad :-)

FWIW, the closure technique is also used by GRDB's ValueObservation. It is required to evaluate the closure in order to discover the tracked values. But that's totally OK, since some people (me included) expect an initial value anyway :-)

Since I'm sharing experience, here's a gotcha. When the user tracks a value built from a non-constant set of observable properties, it is important not to reify the set of observed properties for the whole duration of the observation.

For example, let's track the result of this function, which does not always access the same properties:

func value(_ state: MyState) -> String {
  if state.someBool { 
    return state.foo
  } else {
    return state.bar
  }
}

In the case of ObservationTracking.withTracking, there's no problem, because this method is only supposed to notify the first change:

let state: MyState
ObservationTracking.withTracking {
  print(value(state))
} onChange: { 
 }

But for a closure-based version of value(for:), it is different. The set of tracked properties needs to be refreshed on each closure evaluation, so that changing someBool can alternate between foo and bar tracking:

let state: MyState
let sequence = state.track { value($0) }

This reevaluation of the set of tracked properties takes a performance toll, and it makes it impractical to reify anything at the type level :grimacing:

But it fosters a correct use of the api. (w.r.t. composition and invariants, as discussed above).

For interested people, how GRDB (humbly) handles changing vs. constant set of tracked values

In order to remove the performance toll of reevaluating the tracked "region", I had to make ValueObservation api a little more complex.

The attractive case is for people who do not want to think or optimize:

// Reevaluates the tracked region on each evaluation
ValueObservation.tracking { db in
  if /* fetch some bool from db */ {
    return /* fetch some string from db */
  } else {
    return /* fetch some other string from db */
  }
}

Demanding users may opt in for an optimized observation that performs a single evaluation of the tracked region, at the beginning of the observation:

// User opts in for optimized observation when they
// know what they are doing:
ValueObservation.trackingConstantRegion { db in
  //             ~~~~~~~~~~~~~~~~~~~~~~
  // With this api, it is important to always access
  // the same db region, or some changes will be missed.
  let someBool = /* fetch some bool from db */
  let a = /* fetch some string from db */
  let b = /* fetch some other string from db */
  return someBool ? a : b
}

// Variant that performs less fetches:
ValueObservation.trackingConstantRegion { db -> String in
  try db.registerAccess(to: /* where the first string comes from */)
  try db.registerAccess(to: /* where the second string comes from */)
  if /* fetch some bool from db */ { // automatically registers the bool region
    return /* fetch some string from db */
  } else {
    return /* fetch some other string from db */
  }
}

The optimization is real: in the non-optimized case, GRDB must fetch new values from an exclusive write access. Only optimized observations can leave the exclusive write access (so that other writes can start) and fetch concurrently.

I was so proud of the optimized observation that I initially made it the default.

I quickly reverted this decision :rofl: Of course requiring users to think a lot wasn't the best option. Principle of least astonishment and correctness first, but optimization is possible for experts.

3 Likes

Small point - the text mentions changes(for:) but the code uses values(for:). Should they match?

There are so many good ideas here - thank you for the work on this pitch.

I have a question and a comment and I appologize if the pitch answers them -

(1) If there are multiple listeners to the same property will they both get all of the values or will it behave like async stream where values go to one listener or the other but not both.

(2) The computed property requirement if it depends on private stored properties will cause me to rethink my code. Currently, I can have a private published property which announces that something in the ObservableObject will change and a listener knows to refresh any computed property. My comment is that I was concerned about the mechanism proposed but think that it addresses my issues and more and is very cleverly done. I think writing code like dependencies will make this more difficult for people to learn than @Published and hope that the documentation will clearly describe the motivation and mechanism in an approachable way.

Anyway - thank you and, as we used to say in radio, I'll take my answer off the air

Daniel

1 Like

This really is a great pitch and I'm looking forward to being able to use it, in particular because I welcome any generalizing of APPLE-only Combine features, since they make me have to use a lot of #if/#else in my (platform independent) code.

4 Likes

Hi Daniel,
Regarding (2): Upon reading the proposal I was also thinking that all my ObservableObjects would need to be rewritten. But when used in conjunction with SwiftUI (which will use the ObservationTracking APIs), this is not the case, as computed properties will indeed cause re-renders here. It’s ‘only’ the AsyncStream APIs where computed properties don’t trigger change events.
I’m on mobile, but otherwise I could have found a quote about the difference in this thread. :slight_smile:

I was wondering will this allow me to observe the various gestures of UIKit without having to subclass them or I have to subclass them to observe them? Alternatively, the ability to observe any and all of UIKit as well as any of Apple APIs that are currently difficult or impossible to observe...?