Some of the properties power UI, so they're @MainActor
class Doc:Codable {
var foo:String
@MainActor var title:String
}
I have been implementing Codable with
@MainActor
required public init(from decoder: Decoder) throws {
...
}
@MainActor
public func encode(to encoder: Encoder) throws {
...
}
This works, but now gives me the warning
Main actor-isolated initializer 'init(from:)' cannot be used to satisfy nonisolated protocol requirement; this is an error in the Swift 6 language mode
Is there a solution here to have Codable and @MainActor properties?
If not - what is the suggested approach?
Edit to clarify the core of my issue:
Given that I have a class which has some @MainActor properties. Is it really true that Codable "Swift's native serialization mechanism" is no longer available?
I've honestly always found Codable classes to be more trouble than they're worth. I'd recommend a separate Codable struct, along with a dedicated method to apply the changes.
@MainActor
class Doc {
var foo: String?
var title: String?
}
struct DocPayload: Codable {
var foo: String?
var title: String?
}
@MainActor
func apply(_ payload: DocPayload, to doc: Doc) {
doc.foo = payload.foo
doc.title = payload.title
}
To address the title: no, Codable, Concurrency, and Swift 6 are not fundamentally incompatible. But isolated classes likely are incompatible with Codable. The protocols that make up Codable require it to be non-isolated (ie, it can be used from any concurrency context). Isolated classes are fundamentally opposed to that. Codable, in my experience, works best with pure data, with perhaps some convenience methods for performing common mutations are obtaining common data from it. And pure data tends to be best represented in Swift by simple value types. I tend to steer away from trees of subclasses whenever possible, and to keep data constructs separate from controllers of behavior.
It might help to see something a bit closer to what your actual use case is for trees of @MainActor-isolated subclasses that conform to Codable.
That's a valid opinion, but I don't think anyone here can do anything about that. What we can do is potentially provide guidance on how you can move forward within the constraints of the language as it exists.
Without knowing too much more about your specific scenario, I would say:
Deep trees of subclasses is, in my opinion, an anti-pattern. Composition tends to work better than inheritance when it comes to sharing behavior, and Swift has various other tools for accomplishing this (namely protocols, generics, or simply making something a property rather than a subclass**).
Try not to isolate stuff unless you need to. @MainActor annotations should, IMO, be saved for classes that interact directly with the UI, and they should house functionality like how data is manipulated and what happens when buttons are pressed and whatnot (ie the ViewModel pattern).
The data itself should be separated from this behavioral class. Again, Codable conformance should live at the data level.
My doc essentially describes a video editing project.
so, the Doc contains Clips - which contain InterestPoints.
Each of these classes have various other bits of metadata.
Doc, Clip, InterestPoint are all ViewModels for the the parts of the UI which allow them to be edited.
The classes are not themselves @MainActor - but the properties which have direct UI representation are. (where the user can edit by a button / slider / etc)
Each of these classes (Doc, Clip, InterestPoint) is Observable and Codable.
Frankly - this pattern works brilliantly. My document is is easily saved and loaded. Logic is encapsulated well within classes that model 'RealWorld' Objects.
It's a great pattern for an app which essentially edits a project document.
To give you a (massively simplified) idea
@Observable class Doc:Codable {
let path:Url
@MainActor var title:String
@MainActor var clips:[Clip]
}
@Observable class Clip:Codable {
let path:URL
@MainActor var visible:Bool
@MainActor var interestPoints:[InterestPoint]
}
@Observable class InterestPoint:Codable {
@MainActor var location:TimeInterval
}
In reality, the classes are more complex, with both more @MainActor and nonisolated properties (and more classes)
Maintaining a duplicated representation of this data and syncing logic just enable coding/decoding seems like a horrible design.
Given the 'first class' support of Codable in the language - it seems bizarre that I would have to abandon it if I want to make use of modern concurrency...
Unfortunately, actor isolation is the only automatic way to conform classes to Sendable, so we should expect it to be the default for most developers. The fact that most protocols will then be incompatible is a confusing after effect. So while it’s true this particular problem shouldn’t be solved this way, it’s up to the language to give developers other options.
I would suggest that breaking the fundamental built-in method of serialisation/deserialisation that is literally designed into the language is more than 'a confusing after effect'!
And this doesn't seem insurmountable. Codable could be extended to allow @MainActor conformances. (Perhaps this needs to be AsyncCodable - but you get my point.)
Codable isn’t the problem here, the problem is Sendable, or more specifically, the de facto requirement that all types must be Sendable to do well, anything, with them.
of course this is something of an exaggeration, and it’s possible RBI in Swift 6 might steer people away the patterns and paradigms we have collectively learned over the past three years, but i think it is a common experience that people feel like they are constantly adding Sendable conformances to suppress compiler warnings.
of course, RBI being a nightly-only feature, this is not actionable advice, so instead i would just suggest you make liberal use of @unchecked Sendable on your classes.
my mistake, this must be a requirement of @Observable. in that case, no, Sendable won’t help, (and @unchecked would lead to data races) because these properties really must be isolated to @MainActor as it’s being read from another concurrency domain.
FWIW I agree that the language should provide other options here. There are a few future directions in this area that we're exploring to allow you to conform actor-isolated types to nonisolated protocols without deferring actor isolation checking to runtime with @preconcurrency conformances, and instead restricting uses of the conformance to the isolation domain of the conforming type at compile-time.
That sounds like an awful lot of complexity when what I really want is for the language's serialization support not to abandon me...
The ergonomics of codable are great, and it's deeply integrated in the language. It seems crazy that swift 6 would say "sorry, that stuff just doesn't work any more in common cases"
Honestly, I'll just stick with swift 5, accept the warnings and hope future swift thinks serialization support is important again in the future...
The deal with Swift Concurrency is that it can force you to rethink design decisions. Sometimes good ones, but incompatible with it; but some actually will benefit. I'd argue that your example here is the second case:
That's seems to me like huge overload of responsibilities on single object, that mixes different levels of abstraction: UI, persistence, changes observation, etc, – making it too complicated.
From that perspective, separating plain data structure from there is actually beneficial, as you won't have this complex classes as you characterised them, but have separated types that responsible for one task and have certain isolation (or none), and you don't end up mixing it in a single type, struggling with conformances.
Duplication is not necessarily bad, but here I don't see even the need to duplicate, just separate that into DTO:
struct ClipEntity: Codable {
let path: URL
var visible: Bool
var interestPoints: [InterestPointEntity]
}
// Might be wrong here, haven't used Observable macro yet,
// but for ObservableObject that would be it
@Observable
@MainActor
final class Clip {
var entity: ClipEntity
}
That's simpler to reason about, and simpler to maintain, you can see here clearly: that's data I operate on, that's model that relies on the data, and so on.
First of all, that wouldn't solved protocol conformance problem, as the class still can have some isolation, therefore it cannot satisfy non-isolated protocol requirements.
Also, if to treat "not allowing to cross boundaries" in the way "raise an error", then region based isolation helps with that one. But I think you meant having dynamic isolation at the type level, which seems to be reasonable thing to wish for, and I am mostly would like to have this one too, yet lately I've been questioning is it really necessary and does it indeed good fit to the concurrency model at all?