Appearance
Swift Macros
Summary outcome
We should wait for Xcode 15 to become available to install on SKYRIN machines and then try and implement some basic macros and use them. From there we can then see what's the worth overall. As a whole our advice would be that we could and should use them where they provide a tangible benefit. However right now we have some blockers, mainly the fact that Swift Macros can only be implemented in a Swift Macro Package and distributed via Swift Packages, which we are not adopting in SKYRIN yet.
How Swift Macros work
Macros are used to generate at compile time what would otherwise be boilerplate code. Macros are always additive: they can only add code, never move, edit or delete existing code. Macros are written in Swift. There are two types of macros: "freestanding" and "attached". Freestanding macros add code anywhere they are called. Attached macros expand based on the context to which they are attached (expression, declaration, peer, accessor, memberAttribute, member, conformance). So we can use them to generate freestanding chunks of code, or to add characteristics to existing code, e.g. init methods, protocol conformance, variables, methods implementations.
Important note: When macros “expand”, they add the relevant code to the existing source code at compile time - that code does not exist outside of this scope. This is done via SyntaxTree objects (how Swift sees written code). This does not update or generate new Swift source files or code that can actually be committed or manually edited by the consumers of the macro. Which means it’s completely different from generating code from other sources; i.e. generate UI code from a json, which can then be used “as if it had been written manually”
Advantages
- We can help people adopting PH and a Redux architecture to reduce the amount of boilerplate code they have to write.
- Gives us some “control” over the implementation of the architecture. (The standard boilerplate code doesn’t get manually implemented by all different teams, so it stays the same across all plugins).
- It might help us with reducing boilerplate when declaring things like
MessageNamesin plugins. This bit needs a bit more investigation though. - We can implement macros to add initializers, conformance and members to any
structorclassin projects as a whole.
Drawbacks, blockers, considerations
- Xcode 15 is the minimum requirement and it’s still in beta and SKYRIN developers don’t have access to it at present.
- As things stand right now macro declarations happen in the module in which they are used (plugin, host app etc), whereas macro implementations need to be in a separate module. This is due to the way the compiler invokes a compiler plugin when it sees a macro in the source code and it runs that plugin in a sandbox.
- This means that macros can only be implemented by creating a
Swift Macropackage, which is actually a Swift Package, which means that we might not be able to currently distribute it, as SKYRIN hasn’t moved to Swift Packages and relies on the internal Nexus.
Examples
Now let's look at some examples of how adding macros to our code might make it less verbose and save us from writing down repetitive code when adopting a Redux stack.
Our current implementation for a Store looks something like this:
Swift
class Store<S, A, R: ReducerProtocol, FX: SideEffectProtocol>: ObservableObject
where R.ReducerState == S, R.ReducerAction == A, FX.SideEffectState == S, FX.SideEffectAction == A {
@Published private(set) var state: S
private let reducer: R
private let sideEffect: FX
private var subscriptions = Set<AnyCancellable>()
private let queue = DispatchQueue(label: "store", qos: .userInitiated)
init(reducer: R, state: S, sideEffect: FX) {
self.reducer = reducer
self.state = state
self.sideEffect = sideEffect
}
func dispatch(_ action: A) {
queue.sync {
self.performRecursiveTask(currentState: self.state, action: action)
}
}
private func performRecursiveTask(currentState: S, action: A) {
state = reducer.reduce(currentState, action)
do {
try sideEffect.handle(state, action: action)
} catch {
assertionFailure("Error with side effects: \(error)")
}
}
}Note how a redux Store will always have an init method taking the reducer, state and sideEffect as parameters. So we could have a @AddInit macro, which reads the stored variables (with no initial value) in the struct and generates an init method; the code would reduce to this:
Swift
@AddInit
class Store<S, A, R: ReducerProtocol, FX: SideEffectProtocol>: ObservableObject
where R.ReducerState == S, R.ReducerAction == A, FX.SideEffectState == S, FX.SideEffectAction == A {
@Published private(set) var state: S
private let reducer: R
private let sideEffect: FX
private var subscriptions = Set<AnyCancellable>()
private let queue = DispatchQueue(label: "store", qos: .userInitiated)
func dispatch(_ action: A) {
queue.sync {
self.performRecursiveTask(currentState: self.state, action: action)
}
}
private func performRecursiveTask(currentState: S, action: A) {
state = reducer.reduce(currentState, action)
do {
try sideEffect.handle(state, action: action)
} catch {
assertionFailure("Error with side effects: \(error)")
}
}
}Similarly all Store structs in any of our Redux stacks will have the same basic variables and dispatch and performRecursiveTask method. Therefore we can implement @AddStoreVars and @AddStoreMethods macros and the default code for all Store implementations would look like this:
Swift
@AddInit
@AddStoreMethods
@AddStoreVars
class Store<S, A, R: ReducerProtocol, FX: SideEffectProtocol>: ObservableObject
where R.ReducerState == S, R.ReducerAction == A, FX.SideEffectState == S, FX.SideEffectAction == A {
}Pretty neat.
Ideally we could also have a @DeclareStore macro that does all that.
In the same fashion we can remove boilerplate code from the Redux.State basic implementation and generate init methods for Redux.SideEffect.
For a more detailed example on how to build a @AddInit macro and what's involved in declaring and implementing macros, including creating a Swift Macro package follow this guide:
Also note that many common macros might already be available in the standard Swift library which we can use directly and others exist as third-party resources from which we can take inspiration. We won't necessarily have to write all our macros from scratch, but this is something to evaluate on a case by case basis when and if we do adopt macros.