edu.art
(Eduard)
1
Hi everyone, I'd like to understand if there are any reasons not to support following syntax in Swift:
class A {
@MainActor
var foo: Int
func bar() async {
await foo = 3 // Main actor-isolated property 'foo' can not
// be mutated from a non-isolated context
}
func baz() async {
await MainActor.run {
foo = 3
}
}
}
Seems like MainActor.run(resultType:, body:) is excessive in such contexts.
1 Like
vns
2
Actors model implies that actors communicate with each other via messages – methods in Swift's case. The state is protected by the actor and can be mutated only be the actor. That's like basic building blocks of the whole model. Allowing state modification outside of an actor context breaks this foundation. I would also consider MainActor.run as a last resort in a small amount of cases/during migration, but not as a default tool to solve errors. Additionally, such explicit isolation makes it easy to reason about behaviour and analyse code in terms of isolation, logically structuring the app in that way too.
edu.art
(Eduard)
3
I just don't see much difference between:
- calling actor-isolated method with
async keyword
- getting a value of actor-isolated variable with
async keyword
- setting a value to actor-isolated variable
I started this thread, so maybe someone can point me to the such difference
vns
4
The difference is what that syntax communicates: await x = 1 tells nothing about where this state isolated, how it plays with other parts of the actor state, etc. It also allows to modify state in a much less obvious ways, so
await someActor.x = 1
await someActor.y = 2
Isn’t the same as
await someActor.update(x: 1, y: 2)
And that can lead to unexpected results.
At the same time, message is a clear communication point between isolation domains. As it was stated in one of the threads, thinking about actors as regular classes isn’t a good approach — they look like one, but there is a significant difference in programming paradigms.
3 Likes
nkbelov
(Nikita Belov)
5
To elaborate on that, one of the best mental model shifts you can do when working with actors is to stop designing "cute" and "clean" interface like
actor ImageLoader {
func startLoadingImage(url: URL) async throws
func addTagToImage(_ tag: String)
func saveImageToDisk() throws
}
with the intent to "compose" these calls from the outside:
try await myActor.startLoadingImage(url: url)
await myActor.addTagToImage("cute")
await myActor.addTagToImage("kitty")
await myActor.saveImageToDisk()
— these four calls can be interleaved in many possible combinations; you're more likely to corrupt your data this way than not.
It really helps instead to turn a complete 180 when it comes to API aesthetics: the "unwieldier" a signature looks, the more likely it is that the function exhibits correct transactionality:
func loadImage(at: URL, addingTags: [String], saveToDisk: Bool) async throws
This is a very artificial example, but it aims to show that the interface of an actor should only offer indivisible, complete entry points to the whole "batch" of operations that needs to be performed. If you need variations, you should either parametrize the function or offer a separate function, so that your callers never have to "compose" logic out of smaller steps.
5 Likes
edu.art
(Eduard)
6
Interesting point, but I didn't quite understand how is it related to the topic
nkbelov
(Nikita Belov)
7
What are your compiler settings? The 6.0 language mode doesn't allow the MainActor.run capture either:
func baz() async {
await MainActor.run {
foo = 3 // Capture of 'self' with non-sendable type 'A'
// in a `@Sendable` closure; this is
// an error in the Swift 6 language mode
}
}