Skip to content

Commit 0d63fbc

Browse files
authored
[5.9] Observable protocol non-marker, @observable class only (#67196)
* Make the `@Observable` macro class only (#67033) * Make ObservationRegistrar Codable/Hashable These conformances enable automatic Codable synthesis for Observable types, and smooth the runway for structs being supported by the Observable macro in the future. * Limit Observable macro to classes This removes the ability for the Observable macro to apply to structs, and adds diagnostic tests for the three disallowed declaration kinds. * [Observation] Switch `Observable` to be a non-marker protocol (#66993) With support for redundant conformance declarations via macros, the `Observable` protocol can be a non-marker protocol, which provides more flexibility for evolution in the future. rdar://111463883 This change also switches to the new ExtensionMacro protocol, the requirement for which includes information about whether the conformance to the Observable protocol has already been added, either in the declaration or in a superclass to the macro-attributed type. This allows the @observable macro to be applied to subclasses of observable types without redundant-conformance errors.
1 parent a4a3560 commit 0d63fbc

File tree

7 files changed

+93
-75
lines changed

7 files changed

+93
-75
lines changed

lib/Macros/Sources/ObservationMacros/Extensions.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,4 +275,8 @@ extension DeclGroupSyntax {
275275
var isEnum: Bool {
276276
return self.is(EnumDeclSyntax.self)
277277
}
278+
279+
var isStruct: Bool {
280+
return self.is(StructDeclSyntax.self)
281+
}
278282
}

lib/Macros/Sources/ObservationMacros/ObservableMacro.swift

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,12 @@ extension PatternBindingListSyntax {
173173
}
174174

175175
extension VariableDeclSyntax {
176-
func privatePrefixed(_ prefix: String, addingAttribute attribute: AttributeSyntax) -> VariableDeclSyntax {
177-
VariableDeclSyntax(
176+
func privatePrefixed(_ prefix: String, addingAttribute attribute: AttributeSyntax) -> VariableDeclSyntax {
177+
let newAttributes = AttributeListSyntax(
178+
(attributes.map(Array.init) ?? []) + [.attribute(attribute)])
179+
return VariableDeclSyntax(
178180
leadingTrivia: leadingTrivia,
179-
attributes: attributes?.appending(.attribute(attribute)) ?? [.attribute(attribute)],
181+
attributes: newAttributes,
180182
modifiers: modifiers?.privatePrefixed(prefix) ?? ModifierListSyntax(keyword: .private),
181183
bindingKeyword: TokenSyntax(bindingKeyword.tokenKind, leadingTrivia: .space, trailingTrivia: .space, presence: .present),
182184
bindings: bindings.privatePrefixed(prefix),
@@ -206,11 +208,15 @@ extension ObservableMacro: MemberMacro {
206208

207209
if declaration.isEnum {
208210
// enumerations cannot store properties
209-
throw DiagnosticsError(syntax: node, message: "@Observable cannot be applied to enumeration type \(observableType.text)", id: .invalidApplication)
211+
throw DiagnosticsError(syntax: node, message: "'@Observable' cannot be applied to enumeration type '\(observableType.text)'", id: .invalidApplication)
212+
}
213+
if declaration.isStruct {
214+
// structs are not yet supported; copying/mutation semantics tbd
215+
throw DiagnosticsError(syntax: node, message: "'@Observable' cannot be applied to struct type '\(observableType.text)'", id: .invalidApplication)
210216
}
211217
if declaration.isActor {
212218
// actors cannot yet be supported for their isolation
213-
throw DiagnosticsError(syntax: node, message: "@Observable cannot be applied to actor type \(observableType.text)", id: .invalidApplication)
219+
throw DiagnosticsError(syntax: node, message: "'@Observable' cannot be applied to actor type '\(observableType.text)'", id: .invalidApplication)
214220
}
215221

216222
var declarations = [DeclSyntax]()
@@ -261,30 +267,27 @@ extension ObservableMacro: MemberAttributeMacro {
261267
}
262268
}
263269

264-
extension ObservableMacro: ConformanceMacro {
265-
public static func expansion<Declaration: DeclGroupSyntax, Context: MacroExpansionContext>(
270+
extension ObservableMacro: ExtensionMacro {
271+
public static func expansion(
266272
of node: AttributeSyntax,
267-
providingConformancesOf declaration: Declaration,
268-
in context: Context
269-
) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
270-
let inheritanceList: InheritedTypeListSyntax?
271-
if let classDecl = declaration.as(ClassDeclSyntax.self) {
272-
inheritanceList = classDecl.inheritanceClause?.inheritedTypeCollection
273-
} else if let structDecl = declaration.as(StructDeclSyntax.self) {
274-
inheritanceList = structDecl.inheritanceClause?.inheritedTypeCollection
275-
} else {
276-
inheritanceList = nil
277-
}
278-
279-
if let inheritanceList {
280-
for inheritance in inheritanceList {
281-
if inheritance.typeName.identifier == ObservableMacro.conformanceName {
282-
return []
283-
}
284-
}
273+
attachedTo declaration: some DeclGroupSyntax,
274+
providingExtensionsOf type: some TypeSyntaxProtocol,
275+
conformingTo protocols: [TypeSyntax],
276+
in context: some MacroExpansionContext
277+
) throws -> [ExtensionDeclSyntax] {
278+
// This method can be called twice - first with an empty `protocols` when
279+
// no conformance is needed, and second with a `MissingTypeSyntax` instance.
280+
if protocols.isEmpty {
281+
return []
285282
}
286-
287-
return [(ObservableMacro.observableConformanceType, nil)]
283+
284+
let decl: DeclSyntax = """
285+
extension \(raw: type.trimmedDescription): \(raw: qualifiedConformanceName) {}
286+
"""
287+
288+
return [
289+
decl.cast(ExtensionDeclSyntax.self)
290+
]
288291
}
289292
}
290293

stdlib/public/Observation/Sources/Observation/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ add_swift_target_library(swiftObservation ${SWIFT_STDLIB_LIBRARY_BUILD_TYPES} IS
2424
SWIFT_COMPILE_FLAGS
2525
${SWIFT_STANDARD_LIBRARY_SWIFT_FLAGS}
2626
"-enable-experimental-feature" "Macros"
27+
"-enable-experimental-feature" "ExtensionMacros"
2728
-Xfrontend -disable-implicit-string-processing-module-import
2829

2930
C_COMPILE_FLAGS

stdlib/public/Observation/Sources/Observation/Observable.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212

1313
@available(SwiftStdlib 5.9, *)
14-
@_marker public protocol Observable { }
14+
public protocol Observable { }
1515

1616
#if $Macros && hasAttribute(attached)
1717

@@ -22,7 +22,7 @@
2222
@attached(member, names: named(_$observationRegistrar), named(access), named(withMutation), arbitrary)
2323
#endif
2424
@attached(memberAttribute)
25-
@attached(conformance)
25+
@attached(extension, conformances: Observable)
2626
public macro Observable() =
2727
#externalMacro(module: "ObservationMacros", type: "ObservableMacro")
2828

stdlib/public/Observation/Sources/Observation/ObservationRegistrar.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,29 @@ public struct ObservationRegistrar: Sendable {
129129
defer { didSet(subject, keyPath: keyPath) }
130130
return try mutation()
131131
}
132-
}
132+
}
133+
134+
@available(SwiftStdlib 5.9, *)
135+
extension ObservationRegistrar: Codable {
136+
public init(from decoder: any Decoder) throws {
137+
self.init()
138+
}
139+
140+
public func encode(to encoder: any Encoder) {
141+
// Don't encode a registrar's transient state.
142+
}
143+
}
144+
145+
@available(SwiftStdlib 5.9, *)
146+
extension ObservationRegistrar: Hashable {
147+
public static func == (lhs: Self, rhs: Self) -> Bool {
148+
// A registrar should be ignored for the purposes of determining its
149+
// parent type's equality.
150+
return true
151+
}
152+
153+
public func hash(into hasher: inout Hasher) {
154+
// Don't include a registrar's transient state in its parent type's
155+
// hash value.
156+
}
157+
}

test/stdlib/Observation/Observable.swift

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// REQUIRES: swift_swift_parser, executable_test
22

3-
// RUN: %target-run-simple-swift( -Xfrontend -disable-availability-checking -parse-as-library -enable-experimental-feature InitAccessors -enable-experimental-feature Macros -Xfrontend -plugin-path -Xfrontend %swift-host-lib-dir/plugins)
3+
// RUN: %target-run-simple-swift( -Xfrontend -disable-availability-checking -parse-as-library -enable-experimental-feature InitAccessors -enable-experimental-feature Macros -enable-experimental-feature ExtensionMacros -Xfrontend -plugin-path -Xfrontend %swift-host-lib-dir/plugins)
4+
5+
// Run this test via the swift-plugin-server
6+
// RUN: %target-run-simple-swift( -Xfrontend -disable-availability-checking -parse-as-library -enable-experimental-feature InitAccessors -enable-experimental-feature Macros -enable-experimental-feature ExtensionMacros -Xfrontend -external-plugin-path -Xfrontend %swift-host-lib-dir/plugins#%swift-plugin-server)
47

58
// Asserts is required for '-enable-experimental-feature InitAccessors'.
69
// REQUIRES: asserts
@@ -19,29 +22,6 @@ func _blackHole<T>(_ value: T) { }
1922
@Observable
2023
class ContainsNothing { }
2124

22-
@Observable
23-
struct Structure {
24-
var field: Int = 0
25-
}
26-
27-
@Observable
28-
struct MemberwiseInitializers {
29-
var field: Int
30-
}
31-
32-
func validateMemberwiseInitializers() {
33-
_ = MemberwiseInitializers(field: 3)
34-
}
35-
36-
@Observable
37-
struct DefiniteInitialization {
38-
var field: Int
39-
40-
init(field: Int) {
41-
self.field = field
42-
}
43-
}
44-
4525
@Observable
4626
class ContainsWeak {
4727
weak var obj: AnyObject? = nil
@@ -87,6 +67,31 @@ struct NonObservableContainer {
8767
}
8868
}
8969

70+
@Observable
71+
final class SendableClass: Sendable {
72+
var field: Int = 3
73+
}
74+
75+
@Observable
76+
class CodableClass: Codable {
77+
var field: Int = 3
78+
}
79+
80+
@Observable
81+
final class HashableClass {
82+
var field: Int = 3
83+
}
84+
85+
extension HashableClass: Hashable {
86+
static func == (lhs: HashableClass, rhs: HashableClass) -> Bool {
87+
lhs.field == rhs.field
88+
}
89+
90+
func hash(into hasher: inout Hasher) {
91+
hasher.combine(field)
92+
}
93+
}
94+
9095
@Observable
9196
class ImplementsAccessAndMutation {
9297
var field = 3
@@ -153,9 +158,6 @@ class IsolatedInstance {
153158
var test = "hello"
154159
}
155160

156-
@Observable
157-
struct StructHasExistingConformance: Observable { }
158-
159161
@Observable
160162
class ClassHasExistingConformance: Observable { }
161163

@@ -204,23 +206,6 @@ struct Validator {
204206
expectEqual(changed.state, false)
205207
}
206208

207-
suite.test("tracking structure changes") {
208-
let changed = CapturedState(state: false)
209-
210-
var test = Structure()
211-
withObservationTracking {
212-
_blackHole(test.field)
213-
} onChange: {
214-
changed.state = true
215-
}
216-
217-
test.field = 4
218-
expectEqual(changed.state, true)
219-
changed.state = false
220-
test.field = 5
221-
expectEqual(changed.state, false)
222-
}
223-
224209
suite.test("conformance") {
225210
func testConformance<O: Observable>(_ o: O) -> Bool {
226211
return true

test/stdlib/Observation/ObservableDidSetWillSet.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// REQUIRES: swift_swift_parser, executable_test
22

3-
// RUN: %target-run-simple-swift( -Xfrontend -disable-availability-checking -enable-experimental-feature InitAccessors -enable-experimental-feature Macros -Xfrontend -plugin-path -Xfrontend %swift-host-lib-dir/plugins) | %FileCheck %s
3+
// RUN: %target-run-simple-swift( -Xfrontend -disable-availability-checking -enable-experimental-feature InitAccessors -enable-experimental-feature Macros -enable-experimental-feature ExtensionMacros -Xfrontend -plugin-path -Xfrontend %swift-host-lib-dir/plugins) | %FileCheck %s
44

55
// Asserts is required for '-enable-experimental-feature InitAccessors'.
66
// REQUIRES: asserts

0 commit comments

Comments
 (0)