Backporting Swift 5.9's observability

Swift 5.9's observability feature is restricted to the upcoming new versions of Apple platforms : macOS 14.0, iOS 17.0, watchOS 10.0 and tvOS 17.0 ...

The feature is promising, but restricted to the latest platforms. That's quite disappointing.
There doesn't seem to be any reason for it, can we please have this backported the same way async/await was ? To be compatible with iOS 13​.​0, macOS 10​.​15, watchOS 6​.​0 and tvOS 13​.​0

51 Likes

Conversation about back deployment has been practically rendered off-topic discussion:

This reason was given for why Observation cannot be back deployed:

As far as I can tell, no technical reason has been revealed. Also, it seems like there could be at least one way it could be made to work with older SwiftUI runtimes. That said, I trust the engineers at Apple have thought of things like this and considered them seriously. They did go to a lot of effort to back deploy Swift Concurrency, after all.

We'll just have to wait for our respective user bases to hit a certain level of adoption and hope the tech debt is not prohibitive.

5 Likes

Thank you @kiel , I had come across these posts but your summary will make it clear for everyone.
Still a shame for a feature that is so in the language that it would be useful outside SwiftUI...

It seems to be getting a lot of attention judging by the likes. But in order to substantively talk about the possibility of a backport, we need to come up with a specific idea for implementing this for SwiftUI. I would like to discuss the practical side of the issue, how it would be possible to add the ability to perform body with tracking to old versions of SwiftUI.
I've been thinking about this for a couple of days but haven't been able to find a solution with the current capabilities of the language. The best I could find is that ViewBuilder is not part of the SwiftUI ABI. It is completely covered by @_alwaysEmitIntoClient attributes. Thus it would be possible to change all of its build* functions to work with closures. And add a buildFinalResult function that would wrap everything in a special View that performs this closure inside a withObservationTracking call, and the onChange event would invalidate this View through a special DynamicProperty.
Unfortunately, this approach does not fully work. In particular, language statements such as if CONDITION { ... }, for ... in SEQUENCE and let ... = EXPRESSION are outside the responsibility of resultBuilder. In other words, CONDITION, SEQUENCE and EXPRESSION will be evaluated outside of withObservationTracking. Which makes this solution incomplete.
However, it seems to me not too laborious to add to the compiler a new kind of transformation to the result builders that takes the body of the function to which it is applied as a closure. Something like

func buildByWrapping(_ body: () -> FinalResult) -> ReturnType

It seems that this will not break the general concept of result builders and at the same time will significantly expand their capabilities. In particular, this will allow the SwiftUI team to extend their ViewBuilder similar to the way I described above.
Thoughts are welcome

@John_McCall @Douglas_Gregor, you were the original authors of the result builders proposal. What are your thoughts on such extension?

The technical reason is the same as for any other feature that depends on introducing new types: Swift does not have a reasonable mechanism for back-deploying new types. Functions can be easily back-deployed, but types are prohibitively difficult.

The scale of the effort to back-deploy Swift Concurrency was huge. It was important for Swift Concurrency because adopting the model fully requires rearchitecting some parts of your app, which cannot be done behind @available or #if available, and the benefits to adopting the concurrency model throughout the ecosystem were large.

Unless we solve the problem that types are hard to back-deploy, e.g., by extending the @backDeploy attribute from SE-0376 to types, back deployment of new types will remain extremely difficult.

I think this is going in the wrong direction. The fundamental issue is about back-deployment of types, and you can't get anywhere without solving that. As for the specific result-builder feature, we'd need to see a number of other use cases before we extend result builder further.

Doug

19 Likes

How were types of Swift Concurrency back-deployed? I know that the back deployment version of the concurrency lib is bundled with apps. But how doesn't this produce conflicts on iOS 15+ with system's version? Does dyld somehow can understand that a dylib can be skipped if a system one is present?

Considering Observability was being discussed before SwiftUI was announced with improved support for it, I think it's fair for many to expect it to be part of the language instead of tied to frameworks. Is there no way Observability can be introduced with no support in old versions of SwiftUI (potentially marked with @available checks when something Observable is used with @State), but available for developers to use in their own business logic?

I am probably wrong, but I would assume the tooling work to get an error to show up when used with older deployment targets on views not marked @available would be a lot easier than back deploying all the functionality to older versions of SwiftUI.

We are no strangers after all to not being able to use new things in SwiftUI when supporting older versions, and as released this is no different, but having a way to use it in other contexts would be very welcome, and would drive adoption of this new API.

2 Likes

As Doug pointed out above, the SwiftUI thing is mostly irrelevant, there's no reasonable way to back deploy types regardless of that.

4 Likes

I was able running "a version" of Observation on old OS (in a very quick and dirty manner, adding Observation sources directly to my app along with cutting a few bottom types that related to thread storage IIRC, as I did this test in a single threaded app). I wasn't able making SwiftUI on old OS to use the new Observation though.

IRT SwiftUI usage of new Observation on new OS and old ObservableObject on old OS - I didn't try this hybrid approach myself but it might work.

Let's focus first on the types of the Observation lib. ObservationTracking, _AccessList, ObservationRegistrar.*, _ManagedCriticalState, DeinitializingLockedBuffer, _Deinitializable, _ThreadLocal.

_ThreadLocal can be dropped as it doesn't do anything useful besides of being a namespace for a wrapper around the tls getter and setter.

Through a combination of tricks involving special compiler support, weak linking, special RPATHs, building shared libraries from the concurrency sources that gracefully degrade to what's available in OSes, delivering those shared libraries via app bundles, and leveraging existing "hooks" we engineering into the Swift runtime back in the Swift 5.0 days to allow for something like this.

I don't think that's a reasonable expectation. The Observability proposal has no changes to the language---it adds some types and some macros to the standard library. The library ships with an OS, and new types added to the library aren't back-deployable. Concurrency back-deployment was a unique exception---literally the only types we've back-deployed since Swift moved into the OS back in Spring 2019. Expecting that a library feature like Observable will be back-deployable goes against demonstrated facts.

As I have noted before, there are strong technical reasons why types are not back-deployable.

Doug

13 Likes

Wow, this sounds very cool and intriguing. I'd love to read an article with details.

But why isn't it an option to make the Observation a standalone library? It's runtime is pretty small, so I don't think the size is a concern.

2 Likes

Alas, the likelihood of me having time to write that article is vanishingly small.

You can have it be a standalone library, or you can use it in in system APIs, but you can't have both.

Doug

1 Like

Is it technically impossible to resolve the OS bundled module with a module from an external package by their version? The OS bundled one will provide the resolver a logical version upper bound.

So hypothetically if the user wants to use v5 from an external package, but is targeting a minimal OS which has v2 of the same module, it will use up to v2.

On OSs where this feature wasn't even available yet, we'll have the external package with up to v2, but on any OS that has the module baked in, we'll prefer and use that one.

It sounds too good to be true though.

1 Like

When I had a need to "back port" diffable data source machinery to older OS I used something like this:

// analogue of UITableViewDiffableDataSource for older OS versions
class MyTableViewDiffableDataSource<Section: Hashable, Item: Hashable> {
    private var realDS: AnyObject!
    
    @available(iOS 13, *)
    var ds: UITableViewDiffableDataSource<Section, Item> {
        realDS as! UITableViewDiffableDataSource<Section, Item>
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard #available(iOS 13, *) else {
            return tableView.cellForRow(at: indexPath)!
        }
        return ds.tableView(tableView, cellForRowAt: indexPath)
    }
    
    // other methods
}

Was a bit painful but possible. In principle such a wrapper (which is very lightweight) could be within the library to allow this new type on pretty much any OS version, and the wrapper's overhead (runtime / space) is tiny to be noticeable.


Alternatively could we do this like security patches are done? So the new feature is not available on, say, 14.8 or 15.7, or 16.6, but user can upgrade to 14.9 or 15.8, or 16.7 to get a new feature. Version checks with such fragmentation could be challenging though :crazy_face:

Thanks for taking the time and effort to explain this, Doug. I am less ignorant now.

1 Like

I did a little inspection of the code of the Observation lib, and I think I've found a solution.
Pretty much all the types can be replaced with tuples, typealiases and free functions that work on them.

  • _ThreadLocal shouldn't be a struct at all, it's a enum in the sense of "namespace" because it doesn't have any fields or methods on self. But anyway it can be safely dropped.
  • struct ObservationTracking, it's a enum as well.
  • ObservationTracking.Entry -> typealias for (registerTracking: @Sendable (Set<AnyKeyPath>, @Sendable @escaping () -> Void) -> Int, cancel: @Sendable (Int) -> Void, properties = Set<AnyKeyPath>())
  • ObservationTracking._AccessList -> typealias for [ObjectIdentifier : Entry]
  • ObservationRegistrar -> Context
  • ObservationRegistrar.Context -> _ManagedCriticalState
  • ObservationRegistrar.State -> (id: Int, observations: [Int : Observation], lookups: [AnyKeyPath : Set<Int>])
  • ObservationRegistrar.State.Observation -> (properties: Set<AnyKeyPath>, observer: @Sendable () -> Void)
    _ManagedCriticalState -> typealias _ManagedCriticalState<T> = (ManagedBuffer<T, UnsafeRawPointer>)

The only issue is _ManagedCriticalState.DeinitializingLockedBuffer because it has a deinit. But I didn't find any use of it or use of _Deinitializable protocol. If they are unused, the Observation library could be expressed without introducing new types.

cc @nnnnnnnn @Philippe_Hausler as the authors of the Observability proposal

3 Likes

Replacing a type (particularly a non-frozen type) with a tuple of its members is not a change that you get 'for free'. Doing these substitutions would be a tradeoff against the usability and evolvability of the library going forward.

1 Like

But having both back-deployable functions and back-deployable types provides a way to introduce as many versions of the API as we want in the future.