-
Notifications
You must be signed in to change notification settings - Fork 2.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Observation] Proposal to add a transactional observation of values #2726
Open
phausler
wants to merge
6
commits into
swiftlang:main
Choose a base branch
from
phausler:pr/observed
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
dac8e99
Proposal to add a transactional observation of values
phausler 5ead8e7
Fix the placeholder SE#
phausler 96bc44c
Update the interfaces to use `@isolated(any)` instead of `isolated` p…
phausler 22da62f
Add a number of example cases to describe in more detail expected beh…
phausler 93a6289
Fix some spellings to the new behavioral section
phausler 521d99c
Transition to a non-optional construction and add a Iteration return …
phausler File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,289 @@ | ||||||
# Transactional Observation of Values | ||||||
|
||||||
* Proposal: [SE-NNNN](NNNN-observed.md) | ||||||
* Authors: [Philippe Hausler](https://github.com/phausler) | ||||||
* Review Manager: None | ||||||
* Status: Draft | ||||||
* Framework: Observation | ||||||
|
||||||
## Introduction | ||||||
|
||||||
Observation was introduced to add the ability to observe changes in graphs of | ||||||
objects. The initial tools for observation afforded seamless integration into | ||||||
SwiftUI, however aiding SwiftUI is not the only intent of the module - it is | ||||||
more general than that. This proposal describes a new safe, ergonomic and | ||||||
composable way to observe changes to models using an AsyncSequence, starting | ||||||
transactions at the first willSet and then emitting a value upon that | ||||||
transaction end at the first point of consistency by interoperating with | ||||||
Swift Concurrency. | ||||||
|
||||||
## Motivation | ||||||
|
||||||
Observation was designed to allow future support for providing an `AsyncSequence` | ||||||
of values, as described in the initial [Observability proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0395-observability.md). | ||||||
This follow-up proposal offers tools for enabling asynchronous sequences of | ||||||
values, allowing non-SwiftUI systems to have the same level of "just-the-right-amount-of-magic" | ||||||
as when using SwiftUI. | ||||||
|
||||||
Numerous frameworks in the Darwin SDKs provide APIs for accessing an | ||||||
`AsyncSequence` of values emitted from changes to a property on a given model | ||||||
type. For example, DockKit provides `trackingStates` and Group Activities | ||||||
provides `localParticipantStates`. These are much like other APIs that provide | ||||||
`AsyncSequence` from a model type; they hand crafted to provide events from when | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
that object changes. These manual implementations are not trivial and require | ||||||
careful book-keeping to get right. In addition, library and application code | ||||||
faces the same burden to use this pattern for observing changes. Each of these | ||||||
uses would benefit from having a centralized and easy mechanism to implement | ||||||
this kind of sequence. | ||||||
|
||||||
Observation was built to let developers avoid the complexity inherent when | ||||||
making sure the UI is updated upon value changes. For developers using SwiftUI | ||||||
and the `@Observable` macro to mark their types, this principle is already | ||||||
realized; directly using values over time should mirror this ease of use, | ||||||
providing the same level of power and flexibility. That model of tracking changes | ||||||
by a graph allows for perhaps the most compelling part of Observation; it | ||||||
can track changes by utilizing naturally written Swift code that is written just | ||||||
like the logic of other plain functions. In practice that means that any solution | ||||||
will also follow that same concept even for disjoint graphs that do not share | ||||||
connections. The solution will allow for iterating changed values for applications | ||||||
that do not use UI as seamlessly as those that do. | ||||||
|
||||||
## Proposed solution | ||||||
|
||||||
This proposal adds a straightforward new tool: a closure-initialized `Observed` | ||||||
type that acts as a sequence of closure-returned values, emitting new values | ||||||
when something within that closure changes. | ||||||
|
||||||
This new type makes it easy to write asynchronous sequences to track changes | ||||||
but also ensures that access is safe with respect to concurrency. | ||||||
|
||||||
The simple `Person` type declared here will be used for examples in the | ||||||
remainder of this proposal: | ||||||
|
||||||
```swift | ||||||
@Observable | ||||||
final class Person { | ||||||
var firstName: String | ||||||
var lastName: String | ||||||
|
||||||
var name: String { firstName + " " + lastName } | ||||||
|
||||||
init(firstName: String, lastName: String) { | ||||||
self.firstName = firstName | ||||||
self.lastName = lastName | ||||||
} | ||||||
} | ||||||
``` | ||||||
|
||||||
Creating an `Observed` asynchronous sequence is straightforward. This example | ||||||
creates an asynchronous sequence that yields a value every time the composed | ||||||
`name` property is updated: | ||||||
|
||||||
```swift | ||||||
let names = Observed { person.name } | ||||||
``` | ||||||
|
||||||
However if the example was more complex and the `Person` type in the previous | ||||||
example had a `var pet: Pet?` property which was also `@Observable` then the | ||||||
closure can be written with a more complex expression. | ||||||
|
||||||
```swift | ||||||
let greetings = Observed { | ||||||
if let pet = person.pet { | ||||||
return "Hello \(person.name) and \(pet.name)" | ||||||
} else { | ||||||
return "Hello \(person.name)" | ||||||
} | ||||||
} | ||||||
``` | ||||||
|
||||||
In that example it would track both the assignment of a new pet and then consequently | ||||||
that pet's name. | ||||||
|
||||||
## Detailed design | ||||||
|
||||||
There a few behaviors that are prerequisites to understanding the requirements | ||||||
of the actual design. These two key behaviors are how the model handles tearing | ||||||
and how the model handles sharing. | ||||||
|
||||||
Tearing is where a value that is expected to be assigned as a singular | ||||||
transactional operation can potentially be observed in an intermediate and | ||||||
inconsistent state. The example `Person` type shows this when a `firstName` is | ||||||
set and then the `lastName` is set. If the observation was triggered just on the | ||||||
trailing edge (the `didSet` operation) then an assignment to both properties | ||||||
would garner an event for both properties and potentially get an inconsistent | ||||||
value emitted from `name`. Swift has a mechanism for expressing the grouping of | ||||||
changes together: isolation. When an actor or an isolated type is modified it is | ||||||
expected (enforced by the language itself) to be in a consistent state at the | ||||||
next suspension point. This means that if we can utilize the isolation that is | ||||||
safe for the type then the suspensions on that isolation should result in safe | ||||||
(and non torn values). This means that the implementation must be transactional | ||||||
upon that suspension; starting the transaction on the first trigger of a leading | ||||||
edge (the `willSet`) and then completing the transaction on the next suspension | ||||||
of that isolation. | ||||||
|
||||||
The simple example of tearing would work as the following: | ||||||
|
||||||
```swift | ||||||
let person = Person(firstName: "", lastName: "") | ||||||
// willSet \.firstName - start a transaction | ||||||
person.firstName = "Jane" | ||||||
// didSet \.firstName | ||||||
// willSet \.lastName - the transaction is still dirty | ||||||
person.lastName = "Appleseed" | ||||||
// didSet \.lastName | ||||||
// the next suspension the `name` property will be valid | ||||||
``` | ||||||
|
||||||
Suspensions are any point where a task can be calling out to something where | ||||||
they `await`. Swift concurrency enforces safety around these by making sure that | ||||||
isolation is respected. Any time a function has a suspension point data | ||||||
associated with the type must be ready to be read by the definitions of actor | ||||||
isolation. In the previous example of the `Person` instance the `firstName` and | ||||||
`lastName` properties are mutated together in the same isolation, that means | ||||||
that no other access in that isolation can read those values when they are torn | ||||||
without the type being `Sendable` (able to be read from multiple isolations). | ||||||
That means that in the case of a non-`Sendable` type the access must be | ||||||
constrained to an isolation, and in the `Sendable` cases the mutation is guarded | ||||||
by some sort of mechanism like a lock, In either case it means that the next | ||||||
time one can read a safe value is on that same isolation of the safe access to | ||||||
start with and that happens on that isolations next suspension. | ||||||
|
||||||
Observing at the next suspension point means that we can also address the second | ||||||
issue too; sharing. The expectation of observing a property from a type as an | ||||||
AsyncSequence is that multiple iterations of the same sequence from multiple | ||||||
tasks will emit the same values at the same iteration points. The following code | ||||||
is expected to emit the same values in both tasks. | ||||||
|
||||||
```swift | ||||||
|
||||||
let names = Observed { person.firstName + " " + person.lastName } | ||||||
|
||||||
Task.detached { | ||||||
for await name in names { | ||||||
print("Task1: \(name)") | ||||||
} | ||||||
} | ||||||
|
||||||
Task.detached { | ||||||
for await name in names { | ||||||
print("Task2: \(name)") | ||||||
} | ||||||
} | ||||||
``` | ||||||
|
||||||
In this case both tasks will get the same values upon the same events. This can | ||||||
be achieved without needing an extra buffer since the suspension of each side of | ||||||
the iteration are continuations resuming all together upon the accessor's | ||||||
execution on the specified isolation. This facilitates subject-like behavior | ||||||
such that the values are sent from the isolation for access to the iteration's | ||||||
continuation. | ||||||
|
||||||
Putting this together grants a signature as such: | ||||||
|
||||||
```swift | ||||||
public struct Observed<Element: Sendable, Failure: Error>: AsyncSequence, Sendable { | ||||||
public init( | ||||||
isolation: isolated (any Actor)? = #isolation, | ||||||
@_inheritActorContext _ emit: @Sendable @escaping () throws(Failure) -> Element? | ||||||
) | ||||||
} | ||||||
``` | ||||||
|
||||||
Picking the initializer apart first captures the current isolation of the | ||||||
creation of the `Observed` instance. Then it captures a `Sendable` closure that | ||||||
inherits that current isolation. This means that the closure may only execute on | ||||||
the captured isolation. That closure is run to determine which properties are | ||||||
accessed by using Observation's `withObservationTracking`. So any access to a | ||||||
tracked property of an `@Observable` type will compose for the determination of | ||||||
which properties to track. | ||||||
|
||||||
The closure is not run immediately it is run asynchronously upon the first call | ||||||
to the iterator's `next` method. This establishes the first tracking state for | ||||||
Observation by invoking the closure inside a `withObservationTracking` on the | ||||||
implicitly specified isolation. Then upon the first `willSet` it will enqueue on | ||||||
to the isolation a new execution of the closure and finishing the transaction to | ||||||
prime for the next call to the iterator's `next` method. | ||||||
|
||||||
The closure has two other features that are important for common usage; firstly | ||||||
the closure is typed-throws such that any access to that emission closure will | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
potentially throw an error if the developer specifies. This allows for complex | ||||||
composition of potentially failable systems. Any thrown error will mean that the | ||||||
`Observed` sequence is complete and loops that are currently iterating will | ||||||
terminate with that given failure. Subsequent calls then to `next` on those | ||||||
iterators will return `nil` - indicating that the iteration is complete. | ||||||
Furthermore the `emit` closure also has a nullable result which indicates the | ||||||
sequence is finished without failure. | ||||||
|
||||||
The nullable result indication can then be easily used with weak references to | ||||||
`@Observable` instances. This likely will be a common pattern of users of the | ||||||
`Observed` async sequence. | ||||||
|
||||||
``` | ||||||
let names = Observed { [weak person] in | ||||||
person?.name | ||||||
} | ||||||
``` | ||||||
|
||||||
This lets the `Observed` async sequence compose a value that represents a | ||||||
lifetime bound emission. That the subject is not strongly referenced and can | ||||||
terminate the sequence when the object is deinitialized. | ||||||
|
||||||
## Effect on ABI stability & API resilience | ||||||
|
||||||
This provides no alteration to existing APIs and is purely additive. However it | ||||||
does have a few points of interest about future source compatibility; namely | ||||||
the initializer does ferry the inherited actor context as a parameter and if | ||||||
in the future Swift develops a mechanism to infer this without a user | ||||||
overridable parameter then there may be a source breaking ambiguity that would | ||||||
need to be disambiguated. | ||||||
|
||||||
## Notes to API authors | ||||||
|
||||||
This proposal does not change the fact that the spectrum of APIs may range from | ||||||
favoring `AsyncSequence` properties to purely `@Observable` models. They both | ||||||
have their place. However the calculus of determining the best exposition may | ||||||
be slightly more refined now with `Observed`. | ||||||
|
||||||
If a type is representative of a model and is either transactional in that | ||||||
some properties may be linked in their meaning and would be a mistake to read | ||||||
in a disjoint manner (the tearing example from previous sections), or if the | ||||||
model interacts with UI systems it now more so than ever makes sense to use | ||||||
`@Observable` especially with `Observed` now as an option. Some cases may have | ||||||
previously favored exposing those `AsyncSequence` properties and would now | ||||||
instead favor allowing the users of those APIs compose things by using `Observed`. | ||||||
The other side of the spectrum will still exist but now is more strongly | ||||||
relegated to types that have independent value streams that are more accurately | ||||||
described as `AsyncSequence` types being exposed. The suggestion for API authors | ||||||
is that now with `Observed` favoring `@Observable` perhaps should take more | ||||||
of a consideration than it previously did. | ||||||
|
||||||
## Alternatives Considered | ||||||
|
||||||
There have been many iterations of this feature so far but these are some of the | ||||||
highlights of alternative mechanisms that were considered. | ||||||
|
||||||
Just expose a closure with `didSet`: This misses the mark with regards to concurrency | ||||||
safety but also faces a large problem with regards to transactionality. This would also | ||||||
be out sync with the expected behavior of existing observation uses like SwiftUI. | ||||||
The one benefit of that approach is that each setter call would have a corresponding | ||||||
callback and would be more simple to implement with the existing infrastructure. It | ||||||
was ultimately rejected because that would fall prey to the issue of tearing and | ||||||
the general form of composition was not as ergonomic as other solutions. | ||||||
|
||||||
Expose an AsyncSequence based on `didSet`: This also falls to the same issues with the | ||||||
closure approach except is perhaps slightly more ergonomic to compose. This was also | ||||||
rejected due to the tearing problem stated in the proposal. | ||||||
|
||||||
Expose an AsyncSequence property extension based on `KeyPath`: This could be adapted | ||||||
to the `willSet` and perhaps transactional models, but faces problems when attempting | ||||||
to use `KeyPath` across concurrency domains (since by default they are not Sendable). | ||||||
The implementation of that approach would require considerable improvement to handling | ||||||
of `KeyPath` and concurrency (which may be an optimization path that could be considered | ||||||
in the future if the API merits it). As it stands however the `KeyPath` approach in | ||||||
comparison to the closure initializer is considerably less easy to compose. | ||||||
|
||||||
One consideration that is an addition is to add an unsafe initializer that lets an | ||||||
isolation be specified manually rather than picking it up from the inferred context. | ||||||
This is something that if usage cases demand it is possible and worth entertaining. | ||||||
But that is completely additive and can be bolted on later-on. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
given that (AFAICT) the cited examples are closed source, it's not self-evident that they have semantics compatible with what's being proposed here. are you asserting that they do – i.e. these API could be re-implemented with the proposed
Observed
type? are there open source examples that could be used here instead?