I've been toying around with an idea to reverse the polarity of nesting an actor
into a @MainActor
protected class with the ability to hide the class object.
There are some additional goals here:
- the actor could be initialized from anywhere, so its
init
isnonisolated
by default - there should be no reference cycle between the parent and child objects
- the
init
should not fallback to becomeasync
- we should be able to expose the access to the nested
@MainActor
protected object through non-async parent actor members when those are also marked as@MainActor
Let's walk the experiment process together:
The first solution that I came up with involved using lazy
.
actor A {
actor _B {
nonisolated unowned let a: A
init(a: A) {
self.a = a
}
}
lazy var _b = _B(a: self)
}
If on the other hand we would try to construct the A
actor through its init
, we will have to delay the initialization and then actor isolation would kick in. That won't work and require an async
init
.
Let's convert B
into a class and wrap it with @MainActor
.
actor A {
@MainActor
class _B {
nonisolated unowned let a: A
nonisolated init(a: A) {
self.a = a
}
}
lazy var _b = _B(a: self)
}
Additionally _B
's init
had to become nonisolated
in order to avoid the hop to the main thread to receive MainActor
protection during the initialization.
So far so good. Now we want to extend _B
with some properties which also would be protected by the MainActor
. After that is done, A
should expose access to those properties via MainActor
s protected computed properties.
actor A {
@MainActor
class _B {
nonisolated unowned let a: A
var string: String {
"swift"
}
nonisolated init(a: A) {
self.a = a
}
}
lazy var _b = _B(a: self)
@MainActor
var string: String {
_b.string // error: Actor-isolated property '_b' can not be referenced from the main actor
}
}
This results into the first of our issues. _b
cannot be accessed safely as it's protected by A
. The solution for this would be almost trivial. We could make it nonisolated
. However that is theoretically illegal for mutable stored properties on an actor and we will run into a new error:
actor X {
actor Y {}
nonisolated var y = Y() // error: 'nonisolated' can not be applied to stored properties
}
Interestingly enough, the compiler permits two other options and compiles the program without an error.
Option A)
actor A {
...
@MainActor
class _B {
...
var string: String {
"swift"
}
var number: Int = 42
}
// surprisingly no error! 👀
nonisolated lazy var _b = _B(a: self)
@MainActor
var string: String {
_b.string
}
@MainActor
var number: Int {
get {
_b.number
}
set {
_b.number = newValue
}
}
}
Option B)
actor A {
...
// My assumption: this is theoretically illegal, or will become illegal in Swift 6
@MainActor
lazy var _b = _B(a: self)
}
Option B is interesting, but as I already noted in the comment, it's likely to become illegal, unless I misunderstood the direction we're heading.
Option A smells like an actual bug. I could file a bug report if needed.
So all in all, there are currently two possible solutions to the problem which likely will become illegal in the future. However I would like to keep this ability to construct a cross referencing base actor
with a hidden @MainActor
protected class object, without the need to force an async
init
.
The only solution that comes to my mind would be: nonisolated lazy let
.
- It will make
_b
an immutable reference. -
_b
is implicitlySendable
as it's protected byMainActor
. -
_b
can remain hidden from the outside world. -
_b
can capture a reference toself
during its delayednonisolated
initialization. - Since
_b
would benonisolated
we can exposeMainActor
protected onA
without an issue.
By reversing the polarities, we can do interesting things with A
. We could conform it to ObservableObject
and pipe that through the hidden _B
class. The type becomes much more flexible as it can be constructed from anywhere, it can be safely used in UI and it's not restricted to always be awaited, unless we would access members that it actually protects. That way we can feed that type easily from the background which may not even need to update the UI object.
The "usual" approach however requires that we construct a UI object first, that would then contain our actor object and we would need to expose it if we wanted to pipe something through it. That isn't always ideal. One could decouple those two objects, but that would force the delayed initialization of the UI object to be async
as it would need to read and sync states with the actor object.
The other way around is a bit more straightforward:
@MainActor
class AA {
actor BB {
nonisolated unowned let aa: AA
@MainActor
var string: String {
aa.string
}
@MainActor
var number: Int {
get {
aa.number
}
set {
aa.number = newValue
}
}
init(aa: AA) {
self.aa = aa
}
}
// we cannot make this non-isolated
// it's semi-okay this way around though
var bb: BB! = nil
var string: String {
"swift"
}
var number: Int = 42 {
didSet {
print(number)
}
}
init() {
// delayed init
self.bb = BB(aa: self)
}
}