この記事は 株式会社ビットキー Advent Calendar 2023 7日目の記事です。
きみは誰?
あらさんだよ、しばいぬと生活したい。普段はiOSアプリ開発してるよ。
まずは課題の共有をしますね
swift-dependenciesを利用することを前提とするため、まだ知らない方はこちらを見てきてください。素晴らしい依存管理ライブラリです。
理想の書き方とは
まずはこのライブラリを利用するうえで理想的な状態を見ていきましょう。
swift-dependenciesでは構造体とクロージャを組み合わせて依存関係を提供することを推奨しています。これをすると以下のように特定の機能のみを書き換えることができます。
これが素晴らしいところはテストする際にテスト用の確認処理をその場で記述できることです。またプレビューを動かす場合にプレビューで振る舞ってほしい処理も画面毎に書くことすらできます。
struct Runner {
@Dependency(\.structPersistent) var structPersistent
func run() async throws {
do {
let new = withDependencies {
$0.structPersistent.save = { data, url in debugPrint(data, url) }
} operation: {
structPersistent
}
try await new.save(data: "struct".data(using: .utf8)!, url: URL(fileURLWithPath: "/tmp"))
// output: 6 bytes file:///tmp/
}
}
}
@DependencyClient
struct StructPersistent: Sendable {
var load: @Sendable (_ url: URL) throws -> Data
var save: @Sendable (_ data: Data, _ url: URL) async throws -> Void
}
extension StructPersistent: DependencyKey {
static let liveValue = Self(
load: { url in try Data(contentsOf: url) },
save: { data, url in try data.write(to: url) }
)
static let testValue = Self()
}
extension DependencyValues {
var structPersistent: StructPersistent {
get { self[StructPersistent.self] }
set { self[StructPersistent.self] = newValue }
}
}
もしProtocolとClassによる実装であったなら
ではこれをProtocolで実装されているものを考えてみましょう。
構造体とクロージャで宣言を表現している部分を普段の私達が記述しているプロトコルを利用する形に変更して実際の実装とモックの実装を提供します。これを一部書き換えることを考えてみます。
以下の実装ではもちろん失敗します。メソッドとして実装されているものにクロージャとして実装を差し込むことはできません。この場合はProtocolPersistentに準拠した新しい実装を作成するしかありません。
struct Runner {
@Dependency(\.protocolPersistent) var protocolPersistent
func run() async throws {
do {
let new = withDependencies {
// ❌ Cannot assign to value: 'save' is a method
$0.protocolPersistent.save = { data, url in debugPrint(data, url) }
} operation: {
protocolPersistent
}
try await new.save("struct".data(using: .utf8)!, URL(fileURLWithPath: "/tmp"))
}
}
}
enum ProtocolPersistentKey: DependencyKey {
static var liveValue: any ProtocolPersistent = ProtocolPersistentImpl()
static var testValue: any ProtocolPersistent = ProtocolPersistentMock()
}
extension DependencyValues {
var protocolPersistent: any ProtocolPersistent {
get { self[ProtocolPersistentKey.self] }
set { self[ProtocolPersistentKey.self] = newValue }
}
}
protocol ProtocolPersistent: Sendable {
func load(url: URL) throws -> Data
func save(data: Data, url: URL) async throws -> Void
}
final class ProtocolPersistentImpl: @unchecked Sendable, ProtocolPersistent {
func load(url: URL) throws -> Data { try Data(contentsOf: url) }
func save(data: Data, url: URL) async throws -> Void { try data.write(to: url) }
}
final class ProtocolPersistentMock: @unchecked Sendable, ProtocolPersistent {
func load(url: URL) throws -> Data { unimplemented("ProtocolPersistentMock.load") }
func save(data: Data, url: URL) async throws -> Void { }
}
では次のようなProtocolとして書き換えることはどうでしょう?
まぁ一見悪くなさそうですが構造体とクロージャを組み合わせて得られるメリットがいくつか消えてProtocolを使うことが目的となっているように見えます。引数のラベルも消えてしまい、swift-dependenciesが提供しているマクロもこれには付与できません。またXcodeから得られる補完も微妙なものに変化していることは注目すべきものです。
protocol ProtocolPersistent: Sendable {
var load: @Sendable (_ url: URL) throws -> Data { get }
var save: @Sendable (_ data: Data, _ url: URL) async throws -> Void { get }
}
final class ProtocolPersistentImpl: @unchecked Sendable, ProtocolPersistent {
var load: @Sendable (URL) throws -> Data = { @Sendable url in try Data(contentsOf: url) }
var save: @Sendable (Data, URL) async throws -> Void = { @Sendable data, url in try data.write(to: url) }
}
以上の書き換えは、既存のコードベースがある環境で書き換えるメリットはありません。私達のコードベースには既に多くの実装があります。それに大きな修正を加える必要があるのならば構造体とクロージャの形にそのまま持っていくほうが得られるメリットは大きいです。
そのため書き換え自体も時間と手間がかかるためやりたくなく、既存のコードベースに手を加えなくて良い方法を見つけたいです。
課題を解決するために作ったもの
Dependencies Protocol Extras
上記の課題から、プロトコルで作られた実装にマクロを付与するだけで構造体とクロージャで作られたような実装に変換するマクロを作りました。既存の実装をほとんど変えることなくマクロを付与するだけで新時代です。
このライブラリは以下のように使うことができます。
import DependenciesExtrasMacros
import Foundation
struct Runner {
@Dependency(\.protocolPersistent) var protocolPersistent
func run() async throws {
do {
let new = withDependencies {
// プロトコルで実装したはずなのに書き換えられちゃう!!
$0.protocolPersistent.save = { data, url in debugPrint(data, url) }
} operation: {
protocolPersistent
}
try await new.save(data: "struct".data(using: .utf8)!, url: URL(fileURLWithPath: "/tmp"))
// output: 6 bytes file:///tmp/
}
}
}
// ↓ これを付けるだけ!!簡単!!
@DependencyProtocolClient(implemented: ProtocolPersistentImpl.self)
protocol ProtocolPersistent: Sendable {
func load(url: URL) throws -> Data
func save(data: Data, url: URL) async throws -> Void
}
public final class ProtocolPersistentImpl: @unchecked Sendable, ProtocolPersistent {
func load(url: URL) throws -> Data { try Data(contentsOf: url) }
func save(data: Data, url: URL) async throws -> Void { try data.write(to: url) }
}
extension DependencyValues {
#DependencyValueRegister(of: ProtocolPersistent.self, into: "protocolPersistent")
}
このライブラリの最大の特徴は、普段のProtocolで宣言する部分にマクロを付与するだけで全てのコードが内部的に変換されるところです。
+ @DependencyProtocolClient(implemented: ProtocolPersistentImpl.self)
protocol ProtocolPersistent: Sendable {
func load(_ url: URL) throws -> Data
func save(_ data: Data, _ url: URL) async throws -> Void
}
なぜこの形にしたのか
swift-dependenciesは便利でもっと活用したいです。
一つのアイディアとしてマクロを利用するとPoint-Freeがおすすめする構造体とクロージャを組み合わせた記法とProtocolを利用する記法を混ぜ合わせることができます。そうすることで既存のコードベースを大きく変えずに増分差分を作るだけで一部のコードだけを書き換える機能を簡単に得られます。swift-dependencies-extras 0.1.0の段階では最小の機能実験としてこのアイディアを実現しています。
開発するモチベーションは私達が開発するworkhubのiOSアプリです。このアプリでは全てのRepositoryやInteractorなどはProtocolとClassによる実装になっているため、これを手間なく処理するために作成しました。
Swift Macrosを使った設計方針
さて、ココからはswift-dependencies-extrasで提供しているプロトコルと組み合わせて作られた実装を構造体とクロージャに内部で変換する手法を見ていきます。
このマクロライブラリで達成のために必要だった大きな設計について以下に記載します。
前提
Swift Macrosを利用するにはSwift 5.9以上が必要です。これはXcode15以上に相当します。
またデプロイターゲットのバージョンには依存しないことに注意してください。
ObservationフレームワークはiOS17以上を要求していますがMacrosによる制限ではありません。
Swift Macrosでできること、できないこと
Swift Macrosは非常に強力なプリプロセッサ機能です。コードの文字を対象に任意の変更を加えることができ、出力結果はそのままコンパイルされます。
しかしながらすべてが自由ではありません。事前にどのようなコードへの変化がありえるかマクロに教える必要があります。出力された文字列がコンパイル時に事前に教えられた変化の範囲で発生したのか評価され、もし範囲外の操作をしている場合にはそのコードはエラーとしてコンパイルされることがありません。
この仕様によりできること、できないことが発生します。
できることは数多く存在しており、サンプルコードを読む・evolutionのドキュメントを読むことにより例題から学ぶことができます。しかし実際に試してみてわかるできないことの境界を一部見ることで学びを深めることも良いでしょう。
まず見るべきドキュメントです。何を目指してSwift Macrosが作られたのかがわかります。
基本的に構造体やProperty Wrapperのようなものを作成する場合にはこちらを利用します。この中でaccessorに関する操作をする、別の構造体を宣言する、などの特定の操作を宣言します。例として以下のようなコードで利用します。
@attached(peer, names: prefixed(_$))
public macro DependencyProtocolClient<T>(implemented: T.Type) =
#externalMacro(
module: "DependenciesExtrasMacrosPlugin",
type: "DependencyProtocolClientMacro"
)
Equatableに準拠させるマクロなどを書く場合の決まりが書いてあります。例として以下のようなコードで利用します。
@attached(
extension,
conformances: DependencyKey,
names: named(liveValue),
named(from)
)
public macro DependencyLiveDepConformance<T>(of: T.Type) =
#externalMacro(
module: "DependenciesExtrasMacrosPlugin",
type: "DependencyLiveDepConformanceMacro"
)
Macroの実装する際にデフォルトで書かれているstringfyがこれに相当します。
もともとconformanceは独立したマクロとして定義されていましたがextensionなどの中に吸収されました。その変更についてです。
Freestanding Declaration Macrosについて
@freestanding(declaration, names: arbitrary)
public macro DependencyValueRegister<T>(of: T.Type, into property: String) =
#externalMacro(
module: "DependenciesExtrasMacrosPlugin",
type: "DependencyValueRegisterMacro"
)
swift-syntaxの下にマクロの実装例があります。マクロという言語機能は構文に関わる処理に強く関わっているためココにあるようです。実際にドキュメントを読んだ後、書き方に悩む場合に参考にできます。
例えば以下のような既に実装されている構造に対するextensionをマクロで拡張することは現状難しいです。Macroの仕様を読むと既存の実装へextensionで拡張できるコードの変化は策定されていません。
@SomeMyMacro
struct MyStruct {}
extension ExistsStruct {
// custom code here
}
この問題はどのタイミングで発生するか考えてみます。
例えばswift-dependenciesではDependencyKeyに準拠した実装をextensionで拡張したDependencyValuesに登録する方法を採用しています。
この手法では、一つのattached macro(@
から始まるマクロ)を付与しただけではextensionのメンバーに変数を差し込むことはできません。
そのため、このライブラリではDependencyKeyに準拠した実装と実装の登録は別のマクロとして実現しています。
// これはattached macroでは実現できない。
extension DependencyValues {
public var myDependency: MyDependency {
get { self[MyDependency.self] }
set { self[MyDependency.self] = newValue }
}
}
// 新しい実装を増やすためマクロで実装された名前は隠したいのでfreestanding macroを利用する。0.1.0ではprefixに_$を付けるという取り決めをしている。
extension DependencyValues {
#DependencyValueRegister(of: MyDependency.self, into: "myDependency")
}
Macroの設計方針
基本的にはattached macroを付与した場合にはその付与された対象の範囲内にしか影響を与えることは許可されません。
ただしマクロが生成した実装には再度マクロを付与することができます。
このライブラリではDependencyProtocolClientのマクロが内部で生成した実装にマクロを付与して2段階のマクロを処理するようにしています。この手法は非常に使い勝手が良く、できることの幅を広げてくれるものです。
最初のマクロでは実際にProtocolに準拠しているだろうと想定している実装の型をマクロの引数に取ります。これを実際に生成する実装へ再度型として付与します。これにより、実装内容はわからずとも周囲から与えられる情報を組み立てることによって実際の実装を呼び出すコードまで生成することができます。
@DependencyTestDepConformance
@DependencyLiveDepConformance(of: \(raw: implementedType).self)
@DependencyClient
public struct \(raw: generatedStructClientName): Sendable {
\(variables)
}
以下はライブラリが提供するマクロの方針です。
- Protocolに一つマクロを付与することでTestDependencyKeyとDependencyKeyを満たせるようにしましょう。
- 内部的に
_$<Protocol名>
という構造体を宣言して、これのプロパティにはProtocolで表現されている関数をクロージャに変換したものとしましょう。 - 構造体を作った後はswift-dependenciesが提供しているマクロを付与して公式の処理に頼りましょう。
- TestDependencyKeyはswift-dependenciesが提供しているマクロで自動生成されるので、それを使いましょう。
- swift-dependenciesが提供しているマクロでは返り値がある場合にはデフォルトの実装が必要です。これはunimplementedを利用して握り潰しましょう。
- DependencyValuesは自動で生成できないので宣言マクロで利用することにしましょう。
以上のことを満たすためには4つのマクロが必要です。
- 構造体を作成するためのマクロ
- 作成した構造体にDependencyKeyを準拠させるためのマクロ
- 作成した構造体にTestDependencyKeyを準拠させるためのマクロ
- DependencyValuesで宣言するためのマクロ
これからSwift Macrosに飛び込むあなたへ
あらさんが開発する上で得られた知見を以下で紹介します。
SwiftSyntaxを勉強せずにマクロを書く裏技
このライブラリを作っている最中にSwiftSyntaxについて勉強することはほとんどありませんでした。Swift AST Explorerがすべてを導いてくれます。間違いなくこのツールがなければ完成まで辿り着けなかったといえます。
ツールを使ったうえでSwiftSyntaxの機能を最大限に活かすトピックをココに記します。
どんなコードになるのかわかっていて、その形が変わらない場合
SwiftSyntaxには文字列を入力してそのまま解析してもらえる便利な機能があります。決まり切ったコードをすべてコードで組み立てる必要はありません。
適度に文字列をそのまま書き出して手抜きすることができます。
let dependencyExtension = try ExtensionDeclSyntax(
"""
extension \(raw: declRawName): TestDependencyKey {
public static var testValue: \(raw: declRawName) {
\(raw: declRawName)()
}
}
"""
)
変換するコードの形状がわかっているが、静的に表現できない場合
実装として引数のラベルなどを持ってきて関数呼び出しに変換する処理があります。
これらは手動で構造を組み立てています。すべてを手で書くことで細かな制御ができる上、最終的に出力されるコードはASTとしてはあり得る形になるため積極的に活用するべきだと感じました。
しかし気をつけなければいけないことは swift-syntaxとして正しい入力はコンパイルできることではない という意味を持ちます。引数を分かつコンマなどは手動で正しいタイミングで付与する必要があったりします。
とはいえ文字列を一生懸命組み立てる場合より、あるべき場所にあるべきものが存在しているか?という機能が揃っていることからガードレールとして機能して堅牢なマクロを書いている、という感覚にさせてくれます。
extension Converting
where Input == FunctionDeclSyntax, Output == VariableDeclSyntax {
static let convert = Converting { functionSyntax in
var attributes = functionSyntax.attributes
attributes.append(
AttributeListSyntax.Element(
AttributeSyntax(
atSign: .atSignToken(),
attributeName: IdentifierTypeSyntax(
name: .identifier("Sendable")
)
)
)
)
let parameters = functionSyntax.signature.parameterClause.parameters
let effects = functionSyntax.signature.effectSpecifiers
let returnClause = functionSyntax.signature.returnClause
let name = functionSyntax.name
return try VariableDeclSyntax(
modifiers: DeclModifierListSyntax([
DeclModifierSyntax(name: .keyword(.public))
]),
.var,
name: PatternSyntax(IdentifierPatternSyntax(identifier: name)),
type: TypeAnnotationSyntax(
type: AttributedTypeSyntax(
attributes: attributes,
baseType: FunctionTypeSyntax(
leadingTrivia: .space,
parameters: Converting<
FunctionParameterListSyntax,
TupleTypeElementListSyntax
>
.convert(parameters),
effectSpecifiers: TypeEffectSpecifiersSyntax(
asyncSpecifier: effects?.asyncSpecifier,
throwsSpecifier: effects?.throwsSpecifier
),
returnClause: returnClause
?? ReturnClauseSyntax(
type: IdentifierTypeSyntax(
name: .identifier("Void")
)
)
)
)
),
initializer: checkReturnClauseIsVoid(returnClause)
? nil
: InitializerClauseSyntax(
value: ClosureExprSyntax(
signature: ClosureSignatureSyntax(
parameterClause: .parameterClause(
ClosureParameterClauseSyntax(
parameters: ClosureParameterListSyntax(
parameters.map { p in
ClosureParameterSyntax(
firstName: .identifier("_"),
trailingComma: p
!= parameters.last
? .commaToken() : nil
)
}
)
)
)
),
statements: CodeBlockItemListSyntax([
CodeBlockItemSyntax(
item: .expr(
ExprSyntax(
FunctionCallExprSyntax(
calledExpression:
DeclReferenceExprSyntax(
baseName: .identifier(
"unimplemented"
)
),
leftParen: .leftParenToken(),
arguments: LabeledExprListSyntax([
LabeledExprSyntax(
expression:
StringLiteralExprSyntax(
openingQuote:
.stringQuoteToken(),
segments:
StringLiteralSegmentListSyntax(
[
.stringSegment(
StringSegmentSyntax(
content:
.stringSegment(
name
.text
)
)
)
]),
closingQuote:
.stringQuoteToken()
)
)
]),
rightParen: .rightParenToken()
)
)
)
)
])
)
)
)
}
}
開発する上で絶対に使うべきライブラリ
Point-Freeが提供しているMacro Testingは必ず利用するべきです。
このツールはマクロを実行したい文字列を入力するとテストコードの期待値に結果を自動で貼り付けてくれる機能を持ちます。
これがなければ期待するコードの形になっているか判断することは非常に大変な作業になっていたと思います。
以下は実際のテストコードの一部であり、assertMacroの第一引数にコードを入力するだけでexpansionが自動で入力されるinline展開機能が非常に魅力的です。
assertMacro {
"""
extension DependencyValues {
#DependencyValueRegister(of: GreatTool.self, into: "great")
}
@DependencyProtocolClient(implemented: Implements.self)
public protocol GreatTool {
func foo(a: Int) async -> Int
func hoge(_ b: Double) async throws -> Double
func yes(_ what: inout String) async -> Bool
}
public actor Implements: GreatTool {
var x = 1
var y = 2.0
public func yes(_ what: inout String) -> Bool { true }
public func foo(a: Int) -> Int {
x += 1
return x
}
public func hoge(_ b: Double) throws -> Double {
y += 1
return y
}
}
"""
} expansion: {
"""
extension DependencyValues {
public var great: _$GreatTool {
get { self[_$GreatTool.self] }
set { self[_$GreatTool.self] = newValue }
}
}
public protocol GreatTool {
func foo(a: Int) async -> Int
func hoge(_ b: Double) async throws -> Double
func yes(_ what: inout String) async -> Bool
}
@DependencyClient
public struct _$GreatTool: Sendable {
public var foo: @Sendable (_ a: Int) async -> Int = { (_) in
unimplemented("foo")
}
public var hoge: @Sendable (_: Double) async throws -> Double = { (_) in
unimplemented("hoge")
}
public var yes: @Sendable (_: inout String) async -> Bool = { (_) in
unimplemented("yes")
}
}
public actor Implements: GreatTool {
var x = 1
var y = 2.0
public func yes(_ what: inout String) -> Bool { true }
public func foo(a: Int) -> Int {
x += 1
return x
}
public func hoge(_ b: Double) throws -> Double {
y += 1
return y
}
}
extension _$GreatTool: TestDependencyKey {
public static var testValue: _$GreatTool {
_$GreatTool()
}
}
extension _$GreatTool: DependencyKey {
public static var liveValue: _$GreatTool {
let underLive = Implements()
return _$GreatTool.from(underLive)
}
public static func from(_ live: Implements) -> _$GreatTool {
_$GreatTool(foo: {
await live.foo(a: $0)
}, hoge: {
try await live.hoge($0)
}, yes: {
await live.yes(&$0)
})
}
}
"""
}
おまとめですわ〜〜〜!!!!!
Dependencies Protocol Extrasを開発したよ
workhubのiOSアプリをより体験よく開発するために開発しました。非常に使いやすいものとなっております。ぜひ一度お試しください。Issue, PR, 設計方針自体に対するご意見等いろいろお待ちしております。
Swift Macrosはどうだ? 普段の開発に簡単に組み込めるか?
Swift AST ExplorerとpointfreecoのMacro Testingの2つが組み合わさると非常に簡単にマクロを書き始める事ができます。普段の開発で繰り返しが多いものなどには積極的に活用できると確信しました。
Swift Macrosでもっと楽しいSwiftライフ
実際にこのライブラリを開発してみたうえで、Swift Macrosにより様々な要素で快適になれるポテンシャルを感じたことは間違いありません。開発するハードルも非常に低くとりあえずマクロを作ってみるといったこともできると感じました。ぜひ皆さんも良いマクロライフを!!!
8日目の 株式会社ビットキー Advent Calendar 2023 は @ksk-taka が担当します!