Skip to content

Commit 1244848

Browse files
authored
Address the rounds of feedback with some more behavioral refinements and an adjustment to the naming of Observed to now name as Observations (#2838)
* Address the rounds of feedback with some more behavioral refinements and an adjustment to the naming of `Observed` to now name as `Observations` * Correct the next to last example to the latest mechansim's output
1 parent 5d03375 commit 1244848

File tree

1 file changed

+86
-29
lines changed

1 file changed

+86
-29
lines changed

proposals/0475-observed.md

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ that do not use UI as seamlessly as those that do.
5151

5252
## Proposed solution
5353

54-
This proposal adds a straightforward new tool: a closure-initialized `Observed`
54+
This proposal adds a straightforward new tool: a closure-initialized `Observations`
5555
type that acts as a sequence of closure-returned values, emitting new values
5656
when something within that closure changes.
5757

@@ -76,20 +76,20 @@ final class Person {
7676
}
7777
```
7878

79-
Creating an `Observed` asynchronous sequence is straightforward. This example
79+
Creating an `Observations` asynchronous sequence is straightforward. This example
8080
creates an asynchronous sequence that yields a value every time the composed
8181
`name` property is updated:
8282

8383
```swift
84-
let names = Observed { person.name }
84+
let names = Observations { person.name }
8585
```
8686

8787
However if the example was more complex and the `Person` type in the previous
8888
example had a `var pet: Pet?` property which was also `@Observable` then the
8989
closure can be written with a more complex expression.
9090

9191
```swift
92-
let greetings = Observed {
92+
let greetings = Observations {
9393
if let pet = person.pet {
9494
return "Hello \(person.name) and \(pet.name)"
9595
} else {
@@ -158,7 +158,7 @@ is expected to emit the same values in both tasks.
158158

159159
```swift
160160

161-
let names = Observed { person.firstName + " " + person.lastName }
161+
let names = Observations { person.firstName + " " + person.lastName }
162162

163163
Task.detached {
164164
for await name in names {
@@ -185,7 +185,7 @@ properties as a `String`. This has no sense of termination locally within the
185185
construction. Making the return value of that closure be a lifted `Optional` suffers
186186
the potential conflation of a terminal value and a value that just happens to be nil.
187187
This means that there is a need for a second construction mechanism that offers a
188-
way of expressing that the `Observed` sequence iteration will run until finished.
188+
way of expressing that the `Observations` sequence iteration will run until finished.
189189

190190
For the example if `Person` then has a new optional field of `homePage` which
191191
is an optional URL it then means that the construction can disambiguate
@@ -206,7 +206,7 @@ final class Person {
206206
}
207207
}
208208
209-
let hosts = Observed.untilFinished { [weak person] in
209+
let hosts = Observations.untilFinished { [weak person] in
210210
if let person {
211211
.next(person.homePage?.host)
212212
} else {
@@ -218,7 +218,7 @@ let hosts = Observed.untilFinished { [weak person] in
218218
Putting this together grants a signature as such:
219219

220220
```swift
221-
public struct Observed<Element: Sendable, Failure: Error>: AsyncSequence, Sendable {
221+
public struct Observations<Element: Sendable, Failure: Error>: AsyncSequence, Sendable {
222222
public init(
223223
@_inheritActorContext _ emit: @escaping @isolated(any) @Sendable () throws(Failure) -> Element
224224
)
@@ -230,12 +230,12 @@ public struct Observed<Element: Sendable, Failure: Error>: AsyncSequence, Sendab
230230

231231
public static func untilFinished(
232232
@_inheritActorContext _ emit: @escaping @isolated(any) @Sendable () throws(Failure) -> Iteration
233-
) -> Observed<Element, Failure>
233+
) -> Observations<Element, Failure>
234234
}
235235
```
236236

237237
Picking the initializer apart first captures the current isolation of the
238-
creation of the `Observed` instance. Then it captures a `Sendable` closure that
238+
creation of the `Observations` instance. Then it captures a `Sendable` closure that
239239
inherits that current isolation. This means that the closure may only execute on
240240
the captured isolation. That closure is run to determine which properties are
241241
accessed by using Observation's `withObservationTracking`. So any access to a
@@ -253,19 +253,19 @@ The closure has two other features that are important for common usage; firstly
253253
the closure is typed-throws such that any access to that emission closure will
254254
potentially throw an error if the developer specifies. This allows for complex
255255
composition of potentially failable systems. Any thrown error will mean that the
256-
`Observed` sequence is complete and loops that are currently iterating will
256+
`Observations` sequence is complete and loops that are currently iterating will
257257
terminate with that given failure. Subsequent calls then to `next` on those
258258
iterators will return `nil` - indicating that the iteration is complete.
259259

260260
## Behavioral Notes
261261

262-
There are a number of scenarios of iteration that can occur. These can range from production rate to iteration rate differentials to isolation differentials to concurrent iterations. Enumerating all possible combinations is of course not possible but the following explanations should illustrate some key usages. `Observed` does not make unsafe code somehow safe - the concepts of isolation protection or exclusive access are expected to be brought to the table by the types involved. It does however require the enforcements via Swift Concurrency particularly around the marking of the types and closures being required to be `Sendable`. The following examples will only illustrate well behaved types and avoid fully unsafe behavior that would lead to crashes because the types being used are circumventing that language safety.
262+
There are a number of scenarios of iteration that can occur. These can range from production rate to iteration rate differentials to isolation differentials to concurrent iterations. Enumerating all possible combinations is of course not possible but the following explanations should illustrate some key usages. `Observations` does not make unsafe code somehow safe - the concepts of isolation protection or exclusive access are expected to be brought to the table by the types involved. It does however require the enforcements via Swift Concurrency particularly around the marking of the types and closures being required to be `Sendable`. The following examples will only illustrate well behaved types and avoid fully unsafe behavior that would lead to crashes because the types being used are circumventing that language safety.
263263

264264
The most trivial case is where a single produce and single consumer are active. In this case they both are isolated to the same isolation domain. For ease of reading; this example is limited to the `@MainActor` but could just as accurately be represented in some other actor isolation.
265265

266266
```swift
267267
@MainActor
268-
func iterate(_ names: Observed<String, Never>) async {
268+
func iterate(_ names: Observations<String, Never>) async {
269269
for await name in names {
270270
print(name)
271271
}
@@ -276,7 +276,7 @@ func example() async throws {
276276
let person = Person(firstName: "", lastName: "")
277277

278278
// note #2
279-
let names = Observed {
279+
let names = Observations {
280280
person.name
281281
}
282282

@@ -311,7 +311,7 @@ Next is the case where the mutation of the properties out-paces the iteration. A
311311

312312
```swift
313313
@MainActor
314-
func iterate(_ names: Observed<String, Never>) async {
314+
func iterate(_ names: Observations<String, Never>) async {
315315
for await name in names {
316316
print(name)
317317
try? await Task.sleep(for: .seconds(0.095))
@@ -323,7 +323,7 @@ func example() async throws {
323323
let person = Person(firstName: "", lastName: "")
324324

325325
// @MainActor is captured here as the isolation
326-
let names = Observed {
326+
let names = Observations {
327327
person.name
328328
}
329329

@@ -353,7 +353,7 @@ The result of the observation may print the following output, but the primary pr
353353

354354
This case dropped the last value of the iteration because the accumulated differential exceeded the production; however the potentially confusing part here is that the sleep in the iterate competes with the scheduling in the emitter. This becomes clearer of a relationship when the boundaries of isolation are crossed.
355355

356-
Observed can be used across boundaries of concurrency. This is where the iteration is done on a different isolation than the mutations. The types however are accessed always in the isolation that the creation of the Observed closure is executed. This means that if the `Observed` instance is created on the main actor then the subsequent calls to the closure will be done on the main actor.
356+
Observations can be used across boundaries of concurrency. This is where the iteration is done on a different isolation than the mutations. The types however are accessed always in the isolation that the creation of the Observations closure is executed. This means that if the `Observations` instance is created on the main actor then the subsequent calls to the closure will be done on the main actor.
357357

358358
```swift
359359
@globalActor
@@ -362,7 +362,7 @@ actor ExcplicitlyAnotherActor: GlobalActor {
362362
}
363363

364364
@ExcplicitlyAnotherActor
365-
func iterate(_ names: Observed<String, Never>) async {
365+
func iterate(_ names: Observations<String, Never>) async {
366366
for await name in names {
367367
print(name)
368368
}
@@ -373,7 +373,7 @@ func example() async throws {
373373
let person = Person(firstName: "", lastName: "")
374374

375375
// @MainActor is captured here as the isolation
376-
let names = Observed {
376+
let names = Observations {
377377
person.name
378378
}
379379

@@ -402,20 +402,20 @@ The values still will be conjoined as expected for their changes, however just l
402402

403403
If the `iterate` function was altered to have a similar `sleep` call that exceeded the production then it would result in similar behavior of the previous producer/consumer rate case.
404404

405-
The next behavioral illustration is the value distribution behaviors; this is where two or more copies of an `Observed` are iterated concurrently.
405+
The next behavioral illustration is the value distribution behaviors; this is where two or more copies of an `Observations` are iterated concurrently.
406406

407407
```swift
408408

409409
@MainActor
410-
func iterate1(_ names: Observed<String, Never>) async {
410+
func iterate1(_ names: Observations<String, Never>) async {
411411
for await name in names {
412412
print("A", name)
413413
}
414414
}
415415

416416

417417
@MainActor
418-
func iterate2(_ names: Observed<String, Never>) async {
418+
func iterate2(_ names: Observations<String, Never>) async {
419419
for await name in names {
420420
print("B", name)
421421
}
@@ -426,7 +426,7 @@ func example() async throws {
426426
let person = Person(firstName: "", lastName: "")
427427

428428
// @MainActor is captured here as the isolation
429-
let names = Observed {
429+
let names = Observations {
430430
person.name
431431
}
432432

@@ -452,18 +452,64 @@ This situation commonly comes up when the asynchronous sequence is stored as a p
452452

453453
```
454454
A 0 0
455+
B 0 0
455456
B 1 1
456457
A 1 1
457-
B 2 2
458458
A 2 2
459+
B 2 2
459460
A 3 3
460461
B 3 3
461-
A 4 4
462462
B 4 4
463+
A 4 4
463464
```
464465

465466
The same rate commentary applies here as before but an additional wrinkle is that the delivery between the A and B sides is non-determinstic (in some cases it can deliver as A then B and other cases B then A).
466467

468+
There is one additional clarification of expected behaviors - the iterators should have an initial state to determine if that specific iterator is active yet or not. This means that upon the first call to next the value will be obtained by calling into the isolation of the constructing closure to "prime the pump" for observation and obtain a first value. This can be encapsulated into an exaggerated test example as the following:
469+
470+
```swift
471+
472+
@MainActor
473+
func example() async {
474+
let person = Person(firstName: "0", lastName: "0")
475+
476+
// @MainActor is captured here as the isolation
477+
let names = Observations {
478+
person.name
479+
}
480+
Task {
481+
try await Task.sleep(for: .seconds(2))
482+
person.firstName = "1"
483+
person.lastName = "1"
484+
485+
}
486+
Task {
487+
for await name in names {
488+
print("A = \(name)")
489+
}
490+
}
491+
Task {
492+
for await name in names {
493+
print("B = \(name)")
494+
}
495+
}
496+
try? await Task.sleep(for: .seconds(10))
497+
}
498+
499+
await example()
500+
```
501+
502+
Which results in the following output:
503+
504+
```
505+
A = 0 0
506+
B = 0 0
507+
B = 1 1
508+
A = 1 1
509+
```
510+
511+
This ensures the first value is produced such that every sequence will always be primed with a value and will eventually come to a mutual consistency to the values no matter the isolation.
512+
467513
## Effect on ABI stability & API resilience
468514

469515
This provides no alteration to existing APIs and is purely additive. However it
@@ -478,19 +524,19 @@ need to be disambiguated.
478524
This proposal does not change the fact that the spectrum of APIs may range from
479525
favoring `AsyncSequence` properties to purely `@Observable` models. They both
480526
have their place. However the calculus of determining the best exposition may
481-
be slightly more refined now with `Observed`.
527+
be slightly more refined now with `Observations`.
482528

483529
If a type is representative of a model and is either transactional in that
484530
some properties may be linked in their meaning and would be a mistake to read
485531
in a disjoint manner (the tearing example from previous sections), or if the
486532
model interacts with UI systems it now more so than ever makes sense to use
487-
`@Observable` especially with `Observed` now as an option. Some cases may have
533+
`@Observable` especially with `Observations` now as an option. Some cases may have
488534
previously favored exposing those `AsyncSequence` properties and would now
489-
instead favor allowing the users of those APIs compose things by using `Observed`.
535+
instead favor allowing the users of those APIs compose things by using `Observations`.
490536
The other side of the spectrum will still exist but now is more strongly
491537
relegated to types that have independent value streams that are more accurately
492538
described as `AsyncSequence` types being exposed. The suggestion for API authors
493-
is that now with `Observed` favoring `@Observable` perhaps should take more
539+
is that now with `Observations` favoring `@Observable` perhaps should take more
494540
of a consideration than it previously did.
495541

496542
## Alternatives Considered
@@ -527,3 +573,14 @@ parameter of an isolation that was non-nullable this could be achieved for that
527573
however up-coming changes to Swift's Concurrency will make this approach less appealing.
528574
If this route would be taken it would restrict the potential advanced uses cases where
529575
the construction would be in an explicitly non-isolated context.
576+
577+
A name of `Observed` was considered, however that type name led to some objections that
578+
rightfully claimed it was a bit odd as a name since it is bending the "nouning" of names
579+
pretty strongly. This lead to the alternate name `Observations` which strongly leans
580+
into the plurality of the name indicating that it is more than one observation - lending
581+
to the sequence nature.
582+
583+
It was seriously considered during the feedback to remove the initializer methods and only
584+
have construction by two global functions named `observe` and `observeUntilFinished`
585+
that would act as the current initializer methods. Since the types must still be returned
586+
to allow for storing that return into a property it does not offer a distinct advantage.

0 commit comments

Comments
 (0)