EP 254 · Observation · Oct 23, 2023 ·Members

Video #254: Observation: The Gotchas

smart_display

Loading stream…

Video #254: Observation: The Gotchas

Episode: Video #254 Date: Oct 23, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep254-observation-the-gotchas

Episode thumbnail

Description

While the @Observable macro improves upon nearly every aspect of the @State and @ObservedObject property wrappers, it is not without its pitfalls. We will explore several gotchas that you should be aware of when adopting observation in your applications.

Video

Cloudflare Stream video ID: 180a11f396c0ef471a578821ff2e3ac5 Local file: video_254_observation-the-gotchas.mp4 *(download with --video 254)*

Transcript

0:05

And theoretically it would even be possible to backport all of this code to work on iOS 16 and earlier. However, you wouldn’t be able to backport the SwiftUI support. That unfortunately requires iOS 17. Stephen

0:15

OK, we have no gone deep into the new Observation framework. We understand the problems it’s trying to solve, and see concretely just how magical it can be to use it. But we’ve also explored its primary observation tracking tool, and even dug into the actual source code to see how it the tool roughly works.

0:32

The tool is pretty incredible to use, and solves so many pain points of the original tools, but it does have some gotchas and problems of its own. It is very important to understand these downsides otherwise you can easily find yourself in a situation where you are accidentally observing far too much state.

0:48

Let’s dig in. Gotcha: withObservationTracking

0:51

The first gotchas we want to discuss dovetails with the discussion we just had about withObservationTracking . We already saw that the onChange trailing closure is invoked on the leading edge of the mutation, that is, when the mutation is about to happen but hasn’t yet actually happened.

1:09

This means if you want to observe changes to the count, and hope to do something with the count value like this: func observe() { withObservationTracking { _ = self.count } onChange: { print("Do something with", self.count) self.observe() } } You will be sorely disappointed.

1:17

In order to get the newest value you need to wait for a tick of the runloop so that the mutation can actually happen. One way we’ve seen people approach this, and it was even recommended in the original Swift proposal for observation, is to fire up an unstructured task and then access the state inside the task: func observe() { withObservationTracking { _ = self.count } onChange: { Task { print("Do something with", self.count) } self.observe() } }

1:30

However, this introduces potential race conditions. As we have seen in our series of episodes exploring Swift concurrency and our series on reliably testing async code in Swift, it is basically impossible to predict when the Task closure will be executed. Most of the time it is executed after the Task initializer returns, but sometimes, rarely, it is executed before it returns. And if that ever happens then we run the risk of reading an old value of the count .

1:52

In fact, we can see this right away if we run this preview and increment a few times, we will eventually see that stale data is printed to the console: Do something with 1 Do something with 2 Do something with 3 Do something with 4 Do something with 4

2:26

What you actually need to do is force the task’s async closure to execute on the main thread: Task { @MainActor in print("Do something with", self.count) }

2:34

This does seem to make the console output correct. Every time the tap the “Increment” button the console logs the newest value.

2:41

We are pretty confident that this is guaranteed by the concurrency runtime, but it’s also hard to tell. We’ve even seen older versions of Xcode and Swift not have this guarantee. That is, in the older runtimes it was possible for the line inside this task to execute before the line right after task initialization. But that doesn’t seem to be true anymore, and hopefully it will stay that way.

3:01

However, even though we seem to be printing the correct thing to the console, this is not a great way to be notified of when the value actually changes. We are being notified quite late. A full tick of the runloop has passed and an inestimable number of other parts of our code have been given a chance to execute before we are finally notified that the count did in fact change.

3:19

That will easily lead to race conditions in your code where certain parts of the code base were able to read the newest value of count before we were allowed to react to that change. That means that if we updated some other state when count changes, then other parts of our code base will be able to see the newest value of count but an old version of the derived state. That will certainly cause very strange bugs that are nearly impossible to track down.

3:42

And it’s worth noting that the didSet behavior of observation tracking seems to be important enough that the Observation framework actually exposes some overloads of withObservationTracking where you can tap into didSet separately from willSet : @available(SwiftStdlib 5.9, *) @_spi(SwiftUI) public func withObservationTracking<T>( _ apply: () -> T, willSet: @escaping @Sendable (ObservationTracking) -> Void, didSet: @escaping @Sendable (ObservationTracking) -> Void ) -> T { let (result, accessList) = generateAccessList(apply) ObservationTracking._installTracking( ObservationTracking(accessList), willSet: willSet, didSet: didSet ) return result } @available(SwiftStdlib 5.9, *) @_spi(SwiftUI) public func withObservationTracking<T>( _ apply: () -> T, willSet: @escaping @Sendable (ObservationTracking) -> Void ) -> T { let (result, accessList) = generateAccessList(apply) ObservationTracking._installTracking( ObservationTracking(accessList), willSet: willSet, didSet: nil ) return result } @available(SwiftStdlib 5.9, *) @_spi(SwiftUI) public func withObservationTracking<T>( _ apply: () -> T, didSet: @escaping @Sendable (ObservationTracking) -> Void ) -> T { let (result, accessList) = generateAccessList(apply) ObservationTracking._installTracking( ObservationTracking(accessList), willSet: nil, didSet: didSet ) return result }

4:01

Those overloads are even public, but they are hidden from us via this @_spi(SwiftUI) . This attribute allows you to hide certain symbols from people who import your framework unless you do a special @_spi import: @_spi(SwiftUI) import Observation

4:25

However, that does not work with the frameworks bundled in the Swift compiler and Xcode: ‘@_spi’ import of ‘Observation’ will not include any SPI symbols; ‘Observation’ was built from the public interface at …/usr/lib/swift/Observation.swiftmodule/arm64-apple-ios-simulator.swiftinterface

4:35

So unfortunately we do not get access to any of these overloads.

4:38

So that’s bad, but also just observing that something changed in the withObservationTracking closure is not very precise. Often we want to know exactly what changed. Ideally, we would like to even be handed the key path of the property that changed, which would be the thing handed to withMutation inside the setter of the model. That could be really useful information for figuring out exactly what changed in a system so that you could perform optimizations based on that logic. But sadly that is not possible with the current state of the tools.

5:11

There is another unfortunate gotcha using the @Observable macro instead of observable objects with @Published properties. The @Published property wrapper has a nice feature that produces a purple runtime warning in Xcode if you ever mutate it off the main thread. However, the @Observable macro does not do this.

6:01

We can drop the @MainActor annotation from the task we spin up: self.timerTask = Task { // @MainActor in … }

6:10

…and this code still runs without any warnings, but is subtle broken. If we start the timer it will eventually miss a timer tick, and the seconds elapsed will suddenly jump by 2 seconds instead of just 1.

6:36

We’re not sure why this functionality was not implemented in SwiftUI when using the @Observable model. It seems like a bug, and it’s probably worth filing a bug with Apple, but either way this is quite a big gotcha. If you accidentally mutate an observable model on a background thread you can have a subtle broken view that will be difficult to debug. Gotcha: Value types Brandon

6:53

The next gotcha that we want to discuss is something that can cause you to observe way too much state in your features, and it can be really surprising at first. And it has to do with using value types in your observable models.

7:07

To demonstrate this we are going to add a new view with corresponding observable model and put it in one of the tabs of the root app view. So let’s add a view called ListView.swift.

7:21

I’m going to start with a very simple observable model that simply holds onto an array of integers: @Observable class ListModel { var numbers: [Int] = [] }

7:37

And then we will have a view that shows those numbers in a form, along with buttons that can increment and decrement each individual row, as well as add new rows to the list: struct ListView: View { let model: ListModel var body: some View { Form { ForEach( self.model.numbers.indices, id: \.self ) { index in HStack { Button("-") { self.model.numbers[index] -= 1 } Text(self.model.numbers[index].description) Button("+") { self.model.numbers[index] += 1 } } .buttonStyle(.plain) } } .toolbar { ToolbarItem { Button("Add") { self.model.numbers.append(0) } } } } }

7:48

And then we will add a preview to the file: #Preview { NavigationStack { ListView(model: ListModel()) } }

7:51

So, nothing special so far. We can run the preview and see that it behaves as we expect. But, how often is the view actually re-computed as we interact with it?

8:18

To uncover that let’s add a Self._printChanges() to the top of the view: var body: some View { let _ = Self._printChanges() … }

8:24

And now when we run the preview we will see that each time we add a row or interact with a counter, the view is re-computed: ListView: @self changed. ListView: @dependencies changed. ListView: @dependencies changed. ListView: @dependencies changed. ListView: @dependencies changed. ListView: @dependencies changed.

8:54

I guess on the one hand that makes sense, after all when the counter is incremented or decremented something did change in the view, and so it should recompute. But on the other hand, why is the entire list view being re-computed? It seems like it would be better if just the row was re-computed? Well, let’s not answer that just yet and instead push forward a bit longer.

9:12

Now that we have a new basic feature built out, let’s install it in the root tab view. We’ll swap out the third tab so that it is powered by the list view: NavigationStack { ListView(model: self.model.tab3) .navigationTitle(Text("List")) } .tabItem { Text("List") } But in order for that to work the tab3 held in the app model must be a ListModel : @Observable class AppModel: ObservableObject { let tab1 = CounterModel() let tab2 = CounterModel() let tab3 = ListModel() }

9:37

With that done we can run the preview, see the list feature in the 3rd tab, switch over to it, and interact with it. And we see that each action we take in that tab causes the ListView to re-compute its body, which is what we saw when we ran that view in isolation too.

9:58

Now let’s do something seemingly innocuous such as badging the 3rd tab based on how many numbers are in the list model: NavigationStack { ListView(model: self.model.tab3) .navigationTitle(Text("List")) } .badge(self.model.tab3.numbers.count) .tabItem { Text("List") }

10:23

Seems innocent. And since we are only accessing just one tiny bit of state from the list model, in particular the count of numbers, then I would hope it does not cause our AppView to needlessly re-compute its body.

10:35

Well, unfortunately that is not the case. If we run the preview, switch over to the 3rd tab, then every little thing we do causes the AppView to re-compute, as well as the ListView . Why is this happening? Since we are only accessing the count of the numbers array: self.model.tab3.numbers.count …I would expect that the AppView should only re-compute if the count in the array changes. But for some reason it’s re-computing when any value inside the array changes, even though the app view doesn’t care about that information at all.

10:55

Is this a bug in the Observation framework?

11:08

Well, no, unfortunately the Observation framework is working exactly as expected. If we expand the macros in our ListModel to see what is added to our numbers property, we will see the following get accessor: get { access(keyPath: \.numbers) return _numbers }

11:25

This means anytime we access the numbers property it gets added to that global dictionary access list. This is true even if we only want to access one small piece of information inside the numbers array, such as the count.

11:52

And further, we will see in the set accessor: set { withMutation(keyPath: \.numbers) { _numbers = newValue } }

11:56

…that any changes to the numbers property does trigger withMutation . But since the numbers array holds onto value types, in particular integers, and mutations to its elements will also trigger the set on the array itself. That means every change inside the array is being triggered as a change to the array itself.

12:31

We can even update the badge to just pluck out the first element of the numbers array: .badge(self.model.tab3.numbers.first ?? 0)

12:36

So now we are focused on an even smaller part of the numbers array. We no longer care about how many numbers there are in the array, we only care about the value in the very first element, if it exists.

12:48

But still when we run this in the preview we will see that every change in the list model causes the AppView to re-compute its body. There just is no way to tell the observation machinery that we only care about a small part of the array. It’s all-or-nothing. The moment you merely touch the numbers array, then as far as Observation is concerned you now care about everything happening inside that array.

13:25

And this goes for other types of data structures too. You will find the same behavior with dictionaries, optionals, and even your own custom, generic types. And the principle guiding this behavior we are seeing is that observability is only as granular as the application of the @Observable macro.

13:43

For example, the ListModel has the @Observable macro applied: @Observable class ListModel { var numbers: [Int] = [] }

13:47

…and hence observability is only as granular as accessing any field inside this class. The Array type in Swift is not marked with the @Observable macro, and that is why we don’t get further granularity inside the array. There is no way to instrument with the access and withMutation tools for each element of the array, and that is why we have no choice but to observe changes to the entire array.

14:10

And even if the Array type wanted to be marked with the @Observable macro it couldn’t. The macro is not allowed to be used on structs, only classes: @Observable struct SomeStruct {} ‘@Observable’ cannot be applied to struct type ’SomeStruct’

14:26

So that’s not even an option.

14:29

The principle of granularity also goes for holding large value types inside an observable model. For example, the code that powers this very site is entirely built in Swift, and in that code base we have a struct that describes the properties of each episode. It looks something like this: public struct Episode: Equatable, Identifiable { public var alternateSlug: String? public var blurb: String public var codeSampleDirectory: String? public var exercises: [Exercise] public var format: Format public private(set) var _fullVideo: Video? public var id: Tagged<Self, Int> public var image: String public var length: Seconds<Int> public var permission: Permission public var publishedAt: Date public var questions: [Question] public var references: [Reference] public var sequence: Sequence public var subtitle: String? public var title: String public var trailerVideo: Video public var _transcriptBlocks: [TranscriptBlock]? }

14:52

This is already quite big, but it also nests lots of other value types that are big themselves.

15:06

If you were to hold onto a value of Episode in your feature’s observable model: var episode: Episode

15:12

And then in the body of your view, if you reach through the model, into the episode, and then further plucked out the image: self.model.episode.image

15:22

…you have now unwittingly started observing all changes to the episode data. We only care about the image, but that doesn’t matter. If the episode data updates for any reason our view will re-compute its body, even if the image did not change. And that’s because we can’t mark the Episode type with the @Observable macro.

15:55

And this is true of holding large data structures inside optionals too. If our observable model held onto an optional Episode : var episode: Episode?

16:04

Then merely checking if the value is nil will observe all of the episode: self.model.episode != nil

16:07

And this is happening because the Optional type is not instrumented with all of the observable machinery we previously witnessed. It’s just a simple value type, an enum at that, and so it does not have further granularity in saying what small sub-parts of its data are accessed and mutated.

16:38

And the same is true of dictionaries, and really any generic type that you define on your own, unless you make the generic type a class and deeply integrate it with the observable machinery that comes with the Observation framework.

16:51

However, this does seems to be in stark contrast with what we have previously witnessed for the AppModel . Why was it that we were allowed to reach through the AppModel , then the first tab’s model, and then into the count field: .badge(self.model.tab1.count)

17:07

…without causing us to observe all state inside the first tab model? Somehow this caused us to only observe the count state, and any changes to the other state did not trigger the app view to re-compute its body.

17:14

Well, that worked out well for that situation because we were only every accessing properties on @Observable models. First we accessed the tab1 field on the AppModel , which is observable, and then we accessed the count field on the CounterModel , which is also @Observable . This shows the principle of granularity in observable models. You can get fine granularity if each layer is marked as @Observable , which also means each layer must be a class.

17:42

So, if our ListModel had held onto an array of reference types instead of plain integers, then we would have gotten more granular observability. Let’s explore this by holding onto an array of counter models in the list model: @Observable class ListModel { var numbers: [Int] = [] var counters: [CounterModel] = [] }

18:12

I’m going to keep around the array of plain integers too so that we can compare and contrast.

18:16

Let’s also add an endpoint to the class that appends a new element to each of the arrays so that we have a single place to consolidate that logic: func addButtonTapped() { self.numbers.append(0) self.counters.append(CounterModel()) }

18:33

Then we’ll update the Form in the view so that it has two sections. One for the plain array of integers and one for the new array of counter models: Form { Section { ForEach( self.model.numbers.indices, id: \.self ) { index in HStack { Button("-") { self.model.numbers[index] -= 1 } Text(self.model.numbers[index].description) Button("+") { self.model.numbers[index] += 1 } } .buttonStyle(.plain) } } header: { Text("Numbers") } Section { ForEach(self.model.counters) { counterModel in HStack { Button("-") { counterModel.decrementButtonTapped() } Text(counterModel.count.description) Button("+") { counterModel.incrementButtonTapped() } } .buttonStyle(.plain) } } header: { Text("Counters") } } … extension CounterModel: Identifiable {}

19:17

And finally we will call out to the new addButtonTapped method in the “Add” button’s action closure: .toolbar { ToolbarItem { Button("Add") { self.model.addButtonTapped() } } }

19:25

Now, how does this feature behave? Well, if we run the list preview we will see that adding new rows causes the ListView to re-render, which is expected. After all, new rows were added to the screen. And of course if we increment or decrement any counter in the first section we also get ListView re-renders. But interestingly if we increment or decrement in the second section we do not ! Only the row view itself re-renders, but the ListView itself does not. So that seems great.

20:17

Even cooler, if we back up to the AppView and update the badge of the third tab to use the count of the counters array rather than numbers array: // .badge(self.model.tab3.numbers.count) .badge(self.model.tab3.counters.count)

20:33

…we will see that adding new rows to the 3rd tab does indeed cause the AppView to re-compute its body, which is to be expected, but incrementing or decrementing any of the counts in the second section does not !

21:12

We can even change the badge to grab its value from just the first counter’s count: .badge(self.model.tab3.counters.first?.count ?? 0)

21:22

Again, adding a new row causes the AppView to re-render, as does incrementing the count in the first row, but any changes made to any other rows does not cause the AppView to re-render.

21:49

This is happening because we have installed more observable models in our hierarchy of features. The AppModel is a reference type with the @Observable macro applied. It holds onto other reference types, each of which has the @Observable macro applied, including the ListModel . And then even the ListModel further holds onto an array of reference types, the CounterModel , which also has the @Observable macro applied. Gotcha: Reference types

21:58

So in the situation where it is basically observable models all the way down, we do get the most granular kind of observability. So it would seem to suggest that Swift and SwiftUI is encouraging us, if not downright forcing us, to use reference types in our feature’s logic rather than value types.

22:16

And I personally find that a little bizarre!

22:20

When Swift was first announced, nearly 10 years ago in 2014, one of its most highly acclaimed features was value types. Structs and enums give us access to simple, behavior-less bags of data in our programs that have well-defined mutation and lifetime semantics, and come with tons of benefits that we may just take for granted these days. Gotcha: Value/reference equatability/hashability

22:39

For one thing, value types have super simple semantics when it comes to equatability and hashability. Most of the time the compiler will synthesize those conformances for us without us having to do any extra work because value types are just data, and you typically want to compare or hash every single field in the type.

22:56

That is not true of classes. The compiler will not synthesize Equatable and Hashable for you on a class because that is actually a pretty serious decision you are making and it takes very careful thought. Because classes are an amalgamation of both data and behavior, it can be quite complex to decide whether or not two classes are equatable. Do you take into account just the data inside, or do you also take into account the behavior?

23:24

For example, we can naively conform our CounterModel to the Hashable protocol like so: @Observable class CounterModel: Hashable { static func == ( lhs: CounterModel, rhs: CounterModel ) -> Bool { lhs.count == rhs.count && lhs.isDisplaySeconds == rhs.isDisplaySeconds && lhs.secondsElapsed == rhs.secondsElapsed && lhs.timerTask == rhs.timerTask } func hash(into hasher: inout Hasher) { hasher.combine(self.count) hasher.combine(self.isDisplaySeconds) hasher.combine(self.secondsElapsed) hasher.combine(self.timerTask) } … }

24:24

You may be a little surprised to learn that Task is equatable and hashable, but it is and it doesn’t even depend on the equatability or hashability of the value computed inside the task. After all, this is a Void task, and Void is neither equatable or hashable, yet somehow the task is both. And this is because the equatability and hashability of task is based on its “identity” rather than its data. Each created task gets its own unique identity, and that is how one can compare two tasks. It essentially means no two tasks are equal unless you are literally comparing a task against itself.

24:59

So, this certainly compiles, but is it correct?

25:04

Well, probably not. As we just mentioned, task equality is based off of its identity, not its data, and hence two different counter models could have the same count , isDisplaySeconds , and secondsElapsed , but they will never have the same timerTask .

25:18

So, we could drop that field from the implementations, but then we aren’t capturing any of the model’s behavior in the equality check. Should two different counters with the same underlying data be equal if one of the counters has a timer on and the other doesn’t? I guess we could just check if the timerTask is nil or not for that. But then if later we also incorporate network requests into this model for fetching data from an external server, or maybe we listen for notifications posted by notification center, how are we going to incorporate all of that behavior into these checks?

25:54

Well, the easy answer is that there just really isn’t anyway to do equality checks on behavior, and so 99 times out of 100 it is more correct to just use the object’s identity as a reference type for its equatable and hashable implementations: @Observable class CounterModel: Hashable { static func == ( lhs: CounterModel, rhs: CounterModel ) -> Bool { lhs === rhs } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } … }

26:26

And this brings the CounterModel closer to what we saw with the timer task. Two models are equal only if they are literally the same reference to the underlying model. That is really the only reasonable choice here.

26:38

So, that does get the job done, but it is also in stark contrast with what we get with structs. Structs have a very natural notion of equatability and hashability because they are just simple bags of data, whereas classes have a very complex notion because they are an amalgamation of data and behavior. So we are giving up this simplicity when using classes, and we should be aware of that. Gotcha: Testing

26:58

The other benefit we have with value types over reference types is that they are incredibly easy to test. Because they are simple bags of data, and because the Equatable conformance can usually be automatically synthesized, you typically don’t have to do any work to make simple assertions on the values.

27:12

But even better, value types support copying right out of the box, which means you can make a copy of a value, mutate it, and then compare the before and after of the mutation. This allows you to make some incredible testing tools. For example, our Custom Dump library comes with a replacement for XCTAssertEqual that shows a wonderfully formatted message when the test fails.

27:32

In fact, let’s hop over to the tests in this project and import it real quick so that we can show it off: import CustomDump

27:46

And we’ll add the package to our dependencies in the project.

27:58

Then let’s add a simple struct that is equatable: struct User: Equatable { let id = UUID() var name = "" var bio = "" var age: Int? var friends: [User] = [] }

28:08

We can then write a test that compares two values. If we were to use the XCTAssertEqual function provided by XCTest, we get a failure that’s a bit difficult to parse: func testBasics() { var before = User() var after = before after.name = "Blob" XCTEqual(before, after) } testBasics(): XCTAssertEqual failed: (“User(id: C15772BE-318D-4653-96A6-AEC8F63B94DD)”, name: “”, age: nil, friends: []”) is not equal to (“User(id: C15772BE-318D-4653-96A6-AEC8F63B94DD, name: “Blob”, age: nil, friends: [])”)

28:44

We can use Custom Dump’s XCTAssertNoDifference function instead: // XCTAssertEqual(before, after) XCTAssertNoDifference(before, after) And get a much more nicely formatted failure message: testBasics(): XCTAssertNoDifference failed: … User( id: UUID(3AA1D94B-6729-455E-80A4-04011B85B81E), − name: "", + name: "Blob", bio: "", age: nil, friends: [] ) (First: −, Second: +)

29:09

This is only possible because we are comparing simple structs for equality instead of references. We get to compare the data inside the values rather than simply asserting that two references point to the same thing.

29:24

Of course you would never use this tool exactly like this. Instead, you would invoke some code in your library that executes your feature’s logic and produces some value, and then you would want to assert against that value to make sure you understand everything happening in your feature.

29:41

We even have a tool in Custom Dump that makes this quite nice. It’s called XCTAssertDifference . It allows you to provide a value type representing the initial value, then you provide a trailing closure where you execute your feature’s logic which will ultimately mutate the value, and then you provide a second trailing closure that is handed a mutable version of the original value and it is your jump to mutate it into its final shape: func doSomething(_ user: inout User) { // Complex feature logic here user.name = "Blob" } XCTAssertDifference(before) { doSomething(&before) } changes: { $0.name = "Blob" }

31:34

This tool mimics the tool we provide to the Composable Architecture where you can send an action into a TestStore and exhaustively assert how the state of your feature changed: store.send(.incrementButtonTapped) { $0.count = 1 }

31:56

This is all very powerful stuff, but it really only works for value types because they are copyable and because their equality is based on their data, not their identity. For example, the following passes: func testReference() { let before = CounterModel() var after = before before.count = 1 XCTAssertNoDifference(before, after) }

32:38

…even though we would hope it fails. And that’s because technically before and after both point to the exact same underlying reference. And so when we mutate one we are secretly mutating the other.

32:55

And so we can’t use tools like XCTAssertDifference and XCTAssertNoDifference to assert how reference types change. We must instead assert on the individual pieces of data inside: let before = CounterModel() before.incrementButtonTapped() XCTAssertEqual(before.count, 1)

33:23

And we must be vigilant to assert on as much of the data as possible or else bugs could be lurking in the shadows. But of course it’s not possible to always be fully exhaustive, and so all we can do is try our best. Overall, testing reference types can be quite tricky, and there’s always the possibility of getting something wrong. Gotcha: Spooky action at a distance

33:50

The last benefit of value types over reference types that we want to discuss is that they are behavior-less. This means that they cannot execute side effects that would change their value over time. We saw a bit of this earlier when we tried showing why we can’t make CounterModel a struct, but let’s see it again very clearly. If you have a struct with a method that uses a dispatch queue to execute some async work, then you are not allowed to mutate self in that closure: struct SomeValue { var count = 0 mutating func apply() { DispatchQueue.global().asyncAfter( deadline: .now() + 1 ) { self.count += 1 } } }

34:22

The Swift compiler just does not allow this: Mutable capture of ‘inout’ parameter ‘self’ is not allowed in concurrently-executing code

34:24

And that is because it allows the mutation to escape into some other execution context, and if it were allowed then it would mean later parts of our code using this value could see the mutation suddenly without ever explicitly performing a mutation.

34:39

For example, it would be very strange if you could create a value, invoke apply , immediately observe that the count is still 0, yet after executing a few more lines of code we may suddenly see that the count magically went up to 1: var value = SomeValue() value.apply() value.count // 0 … value.count // 1

35:09

This is not how we expect value types to behave. We expect them to remain completely inert until we decide to mutate them. We do not expect outside systems to ever mutate our values unless we specifically hand control over to them via inout. doSomething(&value)

35:31

This ampersand symbol is an explicit way to give doSomething the ability to mutate the value. Without providing that symbol, the function would not be allowed to mutate it.

35:42

There is one small hole in Swift right now where Swift seemingly does allow us to escape a mutation: var value = SomeValue() DispatchQueue.global().asyncAfter(deadline: .now() + 1) { value.count += 1 }

35:58

This is allowed, and it seems really dangerous. However, Swift had no choice but to allow this in the early days because there were no first class concurrency tools built directly into the language.

36:07

But, now that those tools have landed in Swift, this kind of code will eventually not be allowed. In fact, we can crank the project’s concurrency settings to the max.

36:23

And now we will see this is a warning: Reference to captured var ‘value’ in concurrently-executing code; this is an error in Swift 6

36:27

…and it will be an error in Swift 6.

36:29

And this is an error because it not safe code whatsoever. There is a potential race condition here where if you mutate the value outside of the closure, then the mutation inside the closure may interleave in strange ways, causing one of the mutations to be missed.

36:44

With the modern async tools at our disposal, a better way to write this kind of code is the following: mutating func apply() async throws { try await Task.sleep(for: .seconds(1)) self.count += 1 }

37:07

This compiles with no problems and it’s kind of amazing. We are mutating a value type over time, and it’s completely safe because there is no escaping happening whatsoever. Thanks to structured programming we can guarantee that the value will remain alive for the duration of the async work, and whoever is calling this method will just have to wait for it to finish its job.

37:27

What we are seeing here is that Swift has some incredibly powerful guard rails to make sure we use value types in the right way, such as not escaping data out of a lexical scope and trying to mutate it. Those guard rails have been there since the inception of Swift, and they are only getting stronger in the future, especially with things like ownership on the horizon.

37:44

However, these guard rails are significantly weakened, and sometimes just completely absent, when it comes to reference types. With reference types it is completely ok to escape the reference out of a lexical scope, hold onto it many parts of an application for a long period of time, and then make mutations that will instantly be visible to all parts of the app at once, but no one will know where the heck the mutation came from.

38:06

For example, let’s change the entry point of our app so that after launch we will wait 5 seconds, and then make a mutation to our model: @main struct ObservationExplorationsApp: App { let model = AppModel() var body: some Scene { WindowGroup { AppView(model: self.model) .onAppear { DispatchQueue.main.asyncAfter( deadline: .now() + 5 ) { model.tab3.numbers = [] model.tab3.counters = [] } } } } }

38:50

The only reason this is possible, and why Swift isn’t trying to warn us or fail to compile, is because AppModel is a reference type. As we saw a moment ago, this would not fly with value types.

39:06

But now we can run our application, switch to the 3rd tab, add some counters and do some incrementing and decrementing, and then all of the sudden we will see all of our work clear out. This is what is known as “spooky action at a distance”.

39:21

There is absolutely no reason for us to know that a strange mutation like that was possible. Everything inside the list feature and app feature seems copacetic, but unbeknownst to us someone escaped a model, held onto it for a very long time, and then mutated it. It’s great that the view automatically updated after the mutation. That’s thanks to the power of the new Observation framework and would not have worked so nicely in pre-iOS 17.

39:42

But bugs that result from the view getting out of sync with the model are only one flavor of bug that can occur with reference types. The other kinds of bugs are where multiple parts of your app hold onto references to the same object and are all competing to make changes to the model or execute side effects. It is incredibly difficult, if not impossible, to fit in your head all at once how the many different parts of the app are interacting with the one reference, and so it’s best to just not deal with such situations. It is a good thing to have guard rails that prevent you from doing things that are going to make your code more complex to understand. Gotcha: A new era of reference types

40:15

So, we’ve seen that value types are incredibly important for building complex applications without the complexity leaking into our code. They give us a simple way to distinguish between values, they are easy to test, and the Swift compiler prevents certain kinds of “spooky actions at a distance” from happening. Brandon

40:31

And honestly, proper value types are quite rare in modern programming languages, even today, 9 years after Swift was announced. Many languages are trying to catch up by bolting value types on after the fact, but Swift had it from the very beginning.

40:45

And because value types were a pretty big mental shift from what we were used to in Objective-C and most other languages, a lot of the WWDC talks in 2014 and the years after sought to push people towards value types and away from reference types. This includes the protocol-oriented programming presentations featuring the beloved “Crusty”, and even a session entitled “ Building Better Apps with Value Types in Swift ”. Stephen

41:12

So, why does it seem like we are retreating a bit back into the cold, uncertain times of reference types when we’ve been told for 9 years that value types are the future? In fact, SwiftUI did something quite amazing by providing us the tools to build view hierarchies using value types even though historically the view layer was reference type heavy. In the UIKit days we had, UIViewController , UIView , UIButton , UILabel , and more, all of which were classes, even meant to be subclassed.

41:39

Then SwiftUI completely turned all of this upside down by allowing us to create view hierarchies using simple, descriptive structs holding data. Then somewhere deep in the bowels of the SwiftUI framework the hard work would be done to interpret that data and put something on the screen. Brandon

41:54

So that seemed like a huge step forward, but now we are being asked to take the model and logic layer of our application, which historically was the thing we could actually use value types for, and we are now being asked to make everything into reference types.

42:08

In fact, let’s check out of one Apple’s demo applications from WWDC 2023 to see how they suggest to build a large, multi-feature application.

42:21

I have Apple’s “ Backyard Birds ” demo application open right now, which is what they used for WWDC 2023 to show off all of the fancy new features of iOS 17, and let’s search for “class “ in the project.

42:36

We will find 10 classes in the project, most of which are quite surprising. A lot of these types are simple bags of data, and so would be things that we would typically want to model with structs.

42:51

For example, the foundational type of a Bird is a class: @Model public class Bird { @Attribute(.unique) public var id: String public var creationDate: Date public var species: BirdSpecies? public var favoriteFood: BirdFood? public var dislikedFoods: [BirdFood] = [] public var colors: BirdPalette public var tag: String? public var lastKnownVisit: Date? /// The preferred time of day, when shown in the UI. public var backgroundTimeInterval: Double … }

42:57

Now there is also this @Model macro and some other things going on. That is all related to SwiftData, which is its own thing to discuss and we are not really concerned with it right now.

43:09

But if we look at the data being held in this Bird type it’s all simple data data and seems like a prototypical example of a struct. And the same goes for most of all the other classes in this project, such as BirdFood : @Model public class BirdFood { @Attribute(.unique) public var id: String public var name: String public var summary: String public var priority: Int … }

43:38

For some reason holding onto just the name, summary and priority of bird food demands us to hold onto this data as a class rather than a struct.

43:43

The only reason we are contorting ourselves to cram this data into classes is because of how observability works in Swift. It requires us to store data in classes if we want the most granular observation of state, but then we open ourselves up to a whole plethora of unknowability and complexity.

44:02

Let’s compare this to a large, real world application built almost entirely in SwiftUI. It’s our open source word game called “ isowords ”. It’s a word search game that is wrapped around a 3D cube. It’s quite complex, and I have the project open right here:

44:18

And I am going to search for “class “ again.

44:22

We will find that there are indeed some classes in this project, but they are all classically what we would expect to be a class. For example, we see classes in some our dependencies, such as our Game Center, Store Kit and Notification Center dependencies. That makes sense because they encapsulate behavior that we want to interact with Apple’s APIs, and a lot of the time dependencies are classes for this very reason.

44:55

Then we have some uses for classes where we need to subclass view-related classes for the times we cannot work purely in SwiftUI. This includes interacting with SceneKit for drawing the 3D cube, as well as some other basic UIKit views. And then finally there are a few classes for some basic helpers, such as a Box type, and then a whole bunch of classes for our tests.

45:22

But nowhere in here do we use a class for a foundational domain type. All of those are structs or enums. For example, we can expand the SharedModels directory to see a bunch of our domain types. There’s a Cube type that encapsulates the 3 visible faces on a cube: public struct Cube: Codable, Equatable { public var left: CubeFace public var right: CubeFace public var top: CubeFace public var wasRemoved: Bool … }

46:04

And a CubeFace encapsulates the data available in a face, such as the letter, side and number of times it has been used so far: public struct CubeFace: Codable, Equatable { public var letter: String public var side: Side public var useCount: Int … }

46:16

There’s a type for a move that a player can make, such as finding a word in the cube or removing a cube: public struct Move: Codable, Equatable, Sendable { public var playedAt: Date public var playerIndex: PlayerIndex? public var reactions: [PlayerIndex: Reaction]? public var score: Int public var type: MoveType … }

46:24

And there’s a type for all the data collected upon completing a game: public struct CompletedGame: Codable, Equatable, Sendable { public var cubes: ArchivablePuzzle public var gameContext: GameContext public var gameMode: GameMode public var gameStartTime: Date var _language: Language? public var localPlayerIndex: Move.PlayerIndex? public var moves: Moves public var secondsPlayed: Int … }

46:34

All of these types are structs because they are simple data with no behavior. All of the logic and behavior that mutates and evolves this data lives elsewhere, in particular a reducer since this app is built with the Composable Architecture .

46:52

This means when we have a value of a CompletedGame we know that no other external system out there can come in and mutate our value without us knowing. That gives us a huge amount of confidence that we know what our code is doing and how it will behave at runtime. Next time: The future

47:13

But looking at Apple’s most modern sample code leads us to believe that structs are no longer appropriate for such foundational, currency domain types. If we want granular observation for these types and their nestings, then we should convert them to classes and apply the @Observable macro. Stephen

47:49

So, this is a pretty big shift in how we are to build SwiftUI applications, and it seems to have kind of flown under the radar. I think we are all so blown away by the power of the @Observable macro that we didn’t pay attention to the fact that we are now spreading reference types all over our applications.

48:05

And it turns out that the @Observable macro simply does not work on structs. If you try to apply it to a struct you will instantly be greeted with a compiler error letting you know that the macro currently only works with classes. Brandon

48:17

But it wasn’t always this way. In the first few betas of Xcode 15 and Swift 5.9 the macro was allowed to be used on structs. However, there were some quirks with it. The most obvious is that at that time in the early betas the ObservationRegistrar type was not Equatable or Hashable , which meant that you would lose automatic synthesis of those conformances on your structs if you use the macro, and that is something that we should expect to keep working.

48:43

This motivated us to open up a discussion on the Swift forums on how the @Observable macro is supposed to work with structs, and boy did that open a can of worms. It turns out that observable structs are a lot more subtle than first meets the eye, and ultimately the core Swift team decided to restrict the macro only to classes for the time being, while confessing that eventually they would like it to work with structs. Stephen

49:09

So, we want to spend some time investigating what observable structs would mean if they were possible, why they can be tricky, and what one could do to remedy the situation. This discussion will be a little theoretical since the core team made the decision to not support observable structs, but we will use these ideas when we bring the tools from the Observation framework to the Composable Architecture, since one of the main perks of that library is that you get to build your applications with value types instead of reference types…next time! Downloads Sample code 0254-observation-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .