Skip to content

Commit 6165e32

Browse files
feat(minor): [sc-22780] equatable macro (#1)
1 parent 1035abb commit 6165e32

12 files changed

Lines changed: 1481 additions & 3 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ playground.xcworkspace
5757
.swiftpm
5858
.DS_Store
5959
.build/
60+
.vscode/
6061

6162
# CocoaPods
6263
#

.swift-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
5.10
1+
6.1.1

Package.resolved

Lines changed: 51 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// swift-tools-version: 6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import CompilerPluginSupport
5+
import PackageDescription
6+
7+
let package = Package(
8+
name: "Equatable",
9+
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17), .watchOS(.v10), .macCatalyst(.v17)],
10+
products: [
11+
// Products define the executables and libraries a package produces, making them visible to other packages.
12+
.library(
13+
name: "Equatable",
14+
targets: ["Equatable"]
15+
)
16+
],
17+
dependencies: [
18+
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.1"),
19+
.package(url: "https://github.com/pointfreeco/swift-macro-testing.git", from: "0.6.3")
20+
],
21+
targets: [
22+
// Targets are the basic building blocks of a package, defining a module or a test suite.
23+
// Targets can depend on other targets in this package and products from dependencies.
24+
// Macro implementation that performs the source transformation of a macro.
25+
.macro(
26+
name: "EquatableMacros",
27+
dependencies: [
28+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
29+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
30+
]
31+
),
32+
33+
// Library that exposes a macro as part of its API, which is used in client programs.
34+
.target(name: "Equatable", dependencies: ["EquatableMacros"]),
35+
36+
// A client of the library, which is able to use the macro in its own code.
37+
.executableTarget(name: "EquatableClient", dependencies: ["Equatable"]),
38+
39+
.testTarget(
40+
name: "EquatableTests",
41+
dependencies: [
42+
"EquatableMacros",
43+
.product(name: "MacroTesting", package: "swift-macro-testing")
44+
]
45+
)
46+
]
47+
)

README.md

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,137 @@
1-
# equatable
1+
[![codecov](https://codecov.io/github/ordo-one/equatable/graph/badge.svg?token=pqcp4akVCV)](https://codecov.io/github/ordo-one/equatable)
2+
[![Swift address sanitizer](https://github.com/ordo-one/equatable/actions/workflows/swift-sanitizer-address.yml/badge.svg)](https://github.com/ordo-one/equatable/actions/workflows/swift-sanitizer-address.yml)
3+
[![Swift thread sanitizer](https://github.com/ordo-one/equatable/actions/workflows/swift-sanitizer-thread.yml/badge.svg)](https://github.com/ordo-one/equatable/actions/workflows/swift-sanitizer-thread.yml)
4+
[![Swift Linux build](https://github.com/ordo-one/equatable/actions/workflows/swift-linux-build.yml/badge.svg)](https://github.com/ordo-one/equatable/actions/workflows/swift-linux-build.yml)
5+
[![Swift macOS build](https://github.com/ordo-one/equatable/actions/workflows/swift-macos-build.yml/badge.svg)](https://github.com/ordo-one/equatable/actions/workflows/swift-macos-build.yml)
26

3-
test
7+
# Equatable Macros
8+
9+
A Swift package that provides macros for generating `Equatable` conformances for structs.
10+
11+
## Overview
12+
13+
The @Equatable macro generates an `Equatable` implementation that compares all of the struct's stored instance properties, excluding properties with SwiftUI property wrappers like @State and @Environment that trigger view updates through other mechanisms. Properties that aren't `Equatable` and don't affect the output of the view body can be marked with `@EquatableIgnored` to exclude them from the generated implementation. Closures are not permitted by default but can be marked with `@EquatableIgnoredUnsafeClosure` to indicate that they are safe to exclude from equality checks.
14+
15+
## Installation
16+
17+
Add this package to your project using Swift Package Manager:
18+
19+
```swift
20+
dependencies: [
21+
.package(url: "https://github.com/ordo-one/equatable.git", from: "1.0.0")
22+
]
23+
```
24+
25+
Then add the dependency to your target:
26+
27+
```swift
28+
.target(
29+
name: "YourTarget",
30+
dependencies: [
31+
.product(name: "Equatable", package: "equatable")
32+
]
33+
)
34+
```
35+
36+
## Usage
37+
38+
Apply the `@Equatable` macro to structs to automatically generate `Equatable` conformance:
39+
40+
```swift
41+
import Equatable
42+
import SwiftUI
43+
44+
@Equatable
45+
struct ProfileView: View {
46+
var username: String // Will be compared
47+
@State private var isLoading = false // Automatically skipped
48+
@ObservedObject var viewModel: ProfileViewModel // Automatically skipped
49+
@EquatableIgnored var cachedValue: String? // This property will be excluded
50+
@EquatableIgnoredUnsafeClosure var onTap: () -> Void // This closure is safe and will be ignored in comparison (in order for it to be safe we must be sure that this closure does not capture value types on call site)
51+
let id: UUID // Will be compared first for short-circuiting equality checks
52+
53+
var body: some View {
54+
VStack {
55+
Text(username)
56+
if isLoading {
57+
ProgressView()
58+
}
59+
}
60+
}
61+
}
62+
```
63+
64+
The generated extension will implement the `==` operator with property comparisons ordered for optimal performance (e.g., IDs and simple types first):
65+
66+
```swift
67+
extension ProfileView: Equatable {
68+
nonisolated public static func == (lhs: ProfileView, rhs: ProfileView) -> Bool {
69+
lhs.id == rhs.id && lhs.username == rhs.username
70+
}
71+
}
72+
```
73+
74+
## Safety Considerations
75+
76+
Closures marked with `@EquatableIgnoredUnsafeClosure` should not affect the logical identity of the type. For example:
77+
78+
### Exammple - Safe Usage of `@EquatableIgnoredUnsafeClosure`
79+
```swift
80+
struct UserActions: Equatable {
81+
let id: UUID
82+
let name: String
83+
@EquatableIgnoredUnsafeClosure
84+
var onTap: () -> Void
85+
}
86+
struct ContentView: View {
87+
var body: some View {
88+
UserActions(id: UUID(), name: "Example") {
89+
print("User tapped") // This closure does not capture value types on call site
90+
// and does not influence rendering of `UserActions` view's body.
91+
}
92+
}
93+
}
94+
```
95+
In this example, `onTap` will be excluded from equality comparisons, allowing the `UserActions` instances to be properly compared based only on `id` and `name`.
96+
97+
### Example - Unsafe Usage of `@EquatableIgnoredUnsafeClosure`
98+
```swift
99+
struct DemoView: View {
100+
@State var enabled = false
101+
var body: some View {
102+
Text("Enabled? \(enabled)")
103+
.onTapGesture(perform: {
104+
enabled.toggle()
105+
})
106+
Content(enabled: enabled)
107+
}
108+
}
109+
struct Content: View {
110+
var enabled: Bool
111+
var body: some View {
112+
ViewTakesClosure(
113+
label: "This view takes a closure",
114+
onTapGesture: {
115+
// This will always print "enabled? False", because this `ViewTakesClosure`
116+
// is never re-rendered (its Equatable inputs never change).
117+
// The closure captures the initial value of `enabled=false`.
118+
print("enabled? \(enabled)")
119+
})
120+
}
121+
}
122+
@Equabable
123+
struct ViewTakesClosure: View {
124+
let label: String
125+
@EquatableIgnoredUnsafeClosure let onTapGesture: () -> Void
126+
var body: some View {
127+
Text(label)
128+
.onTapGesture(perform: onTapGesture)
129+
}
130+
}
131+
```
132+
In this example `ViewTakesClosure`'s closure captures the `enabled` value on callsite and since it's marked with `@EquatableIgnoredUnsafeClosure`
133+
it will not cause a re-render when the value of `enabled` changes. The closure will always print the initial value of `enabled` which is an incorrect behavior.
134+
135+
## References
136+
137+
This package is inspired by Cal Stephens' blog post [Understanding and Improving SwiftUI Performance](https://medium.com/airbnb-engineering/understanding-and-improving-swiftui-performance-37b77ac61896).

0 commit comments

Comments
 (0)