diff --git a/CombineExt.xcodeproj/project.pbxproj b/CombineExt.xcodeproj/project.pbxproj index eb4086c..f632aca 100644 --- a/CombineExt.xcodeproj/project.pbxproj +++ b/CombineExt.xcodeproj/project.pbxproj @@ -21,6 +21,9 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 712E87BD2465DEDE00431F5C /* ObjectOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712E87BC2465DEDE00431F5C /* ObjectOwnership.swift */; }; + 71E6F4EC24655F3A00FB4103 /* AssignOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E6F4EB24655F3A00FB4103 /* AssignOwnership.swift */; }; + 71E6F4EE2465616100FB4103 /* AssignOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E6F4ED2465616100FB4103 /* AssignOwnershipTests.swift */; }; 78002BB5241E910C0018AA28 /* Relay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78002BB4241E910C0018AA28 /* Relay.swift */; }; 78002BB7241E915E0018AA28 /* CurrentValueRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78002BB6241E915E0018AA28 /* CurrentValueRelay.swift */; }; 78002BB9241E91D70018AA28 /* PassthroughRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78002BB8241E91D70018AA28 /* PassthroughRelay.swift */; }; @@ -76,6 +79,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 712E87BC2465DEDE00431F5C /* ObjectOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectOwnership.swift; sourceTree = ""; }; + 71E6F4EB24655F3A00FB4103 /* AssignOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignOwnership.swift; sourceTree = ""; }; + 71E6F4ED2465616100FB4103 /* AssignOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignOwnershipTests.swift; sourceTree = ""; }; 78002BB4241E910C0018AA28 /* Relay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Relay.swift; sourceTree = ""; }; 78002BB6241E915E0018AA28 /* CurrentValueRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValueRelay.swift; sourceTree = ""; }; 78002BB8241E91D70018AA28 /* PassthroughRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassthroughRelay.swift; sourceTree = ""; }; @@ -162,6 +168,7 @@ isa = PBXGroup; children = ( 78C193D6241C2E580001B7FD /* Event.swift */, + 712E87BC2465DEDE00431F5C /* ObjectOwnership.swift */, ); path = Models; sourceTree = ""; @@ -188,6 +195,7 @@ children = ( OBJ_12 /* WithLatestFromTests.swift */, 78AA9296241B8532009BD68B /* AssignToManyTests.swift */, + 71E6F4ED2465616100FB4103 /* AssignOwnershipTests.swift */, 78C193D0241C1B450001B7FD /* FlatMapLatestTests.swift */, 78C193D8241CEEA80001B7FD /* CreateTests.swift */, 78C193DF241D4D8D0001B7FD /* MaterializeTests.swift */, @@ -245,6 +253,7 @@ isa = PBXGroup; children = ( OBJ_9 /* AssignToMany.swift */, + 71E6F4EB24655F3A00FB4103 /* AssignOwnership.swift */, OBJ_10 /* WithLatestFrom.swift */, 78C193CE241C16C40001B7FD /* FlatMapLatest.swift */, 78C193D3241C2DE00001B7FD /* Create.swift */, @@ -380,11 +389,13 @@ 78988A23241FFE2400F3A4AF /* Partition.swift in Sources */, BF8121BC241FF42C006A93B8 /* ZipMany.swift in Sources */, BF84B7412426B786001BFA88 /* RemoveAllDuplicates.swift in Sources */, + 71E6F4EC24655F3A00FB4103 /* AssignOwnership.swift in Sources */, 78C193D4241C2DE00001B7FD /* Create.swift in Sources */, OBJ_22 /* AssignToMany.swift in Sources */, BF8EDF4C2453529000B0CC75 /* PrefixDuration.swift in Sources */, BF9D85D32444BB92001783E6 /* ReplaySubject.swift in Sources */, AAEAF0E72436D346007C35E0 /* SetOutputType.swift in Sources */, + 712E87BD2465DEDE00431F5C /* ObjectOwnership.swift in Sources */, 78C193D7241C2E580001B7FD /* Event.swift in Sources */, OBJ_23 /* WithLatestFrom.swift in Sources */, BFB4EA132428256B0096E9E9 /* CombineLatestMany.swift in Sources */, @@ -407,6 +418,7 @@ 78C193D2241C1B750001B7FD /* FlatMapLatestTests.swift in Sources */, 78AA9297241B8532009BD68B /* AssignToManyTests.swift in Sources */, BFB4EA1524283ECF0096E9E9 /* CombineLatestManyTests.swift in Sources */, + 71E6F4EE2465616100FB4103 /* AssignOwnershipTests.swift in Sources */, BF9D85D52444D12F001783E6 /* ReplaySubjectTests.swift in Sources */, AAEAF0E92436D785007C35E0 /* SetOutputTypeTests.swift in Sources */, 78988A25241FFE2E00F3A4AF /* PartitionTests.swift in Sources */, diff --git a/README.md b/README.md index 207b7be..4af4740 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,20 @@ var text: UITextField and: \.text, on: text) ``` +CombineExt provides an additional overload — `assign(to:on​:ownership)` — which lets you specify the kind of ownersip you want for your assign operation: `strong`, `weak` or `unowned`. + +```swift +// Retain `self` strongly +subscription = subject.assign(to: \.value, on: self) +subscription = subject.assign(to: \.value, on: self, ownership: .strong) + +// Use a `weak` reference to `self` +subscription = subject.assign(to: \.value, on: self, ownership: .weak) + +// Use an `unowned` reference to `self` +subscription = subject.assign(to: \.value, on: self, ownership: .unowned) +``` + ------ ### amb diff --git a/Sources/Models/ObjectOwnership.swift b/Sources/Models/ObjectOwnership.swift new file mode 100644 index 0000000..3f3fd4d --- /dev/null +++ b/Sources/Models/ObjectOwnership.swift @@ -0,0 +1,26 @@ +// +// ObjectOwnership.swift +// CombineExt +// +// Created by Dmitry Kuznetsov on 08/05/2020. +// Copyright © 2020 Combine Community. All rights reserved. +// + +import Foundation + +/// The ownership of an object +/// +/// - seealso: https://docs.swift.org/swift-book/LanguageGuide/AutomaticReferenceCounting.html#ID52 +public enum ObjectOwnership { + /// Keep a strong hold of the object, preventing ARC + /// from disposing it until its released or has no references. + case strong + + /// Weakly owned. Does not keep a strong hold of the object, + /// allowing ARC to dispose it even if its referenced. + case weak + + /// Unowned. Similar to weak, but implicitly unwrapped so may + /// crash if the object is released beore being accessed. + case unowned +} diff --git a/Sources/Operators/AssignOwnership.swift b/Sources/Operators/AssignOwnership.swift new file mode 100644 index 0000000..4e00c12 --- /dev/null +++ b/Sources/Operators/AssignOwnership.swift @@ -0,0 +1,113 @@ +// +// AssignOwnership.swift +// CombineExt +// +// Created by Dmitry Kuznetsov on 08/05/2020. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Publisher where Self.Failure == Never { + /// Assigns a publisher’s output to a property of an object. + /// + /// - parameter keyPath: A key path that indicates the property to assign. + /// - parameter object: The object that contains the property. + /// The subscriber assigns the object’s property every time + /// it receives a new value. + /// - parameter ownership: The retainment / ownership strategy for the object, defaults to `strong`. + /// + /// - returns: An AnyCancellable instance. Call cancel() on this instance when you no longer want + /// the publisher to automatically assign the property. Deinitializing this instance + /// will also cancel automatic assignment. + func assign(to keyPath: ReferenceWritableKeyPath, + on object: Root, + ownership: ObjectOwnership = .strong) -> AnyCancellable { + switch ownership { + case .strong: + return assign(to: keyPath, on: object) + case .weak: + return sink { [weak object] value in + object?[keyPath: keyPath] = value + } + case .unowned: + return sink { [unowned object] value in + object[keyPath: keyPath] = value + } + } + } + + /// Assigns each element from a Publisher to properties of the provided objects + /// + /// - Parameters: + /// - keyPath1: The key path of the first property to assign. + /// - object1: The first object on which to assign the value. + /// - keyPath2: The key path of the second property to assign. + /// - object2: The second object on which to assign the value. + /// - ownership: The retainment / ownership strategy for the object, defaults to `strong`. + /// + /// - Returns: A cancellable instance; used when you end assignment of the received value. + /// Deallocation of the result will tear down the subscription stream. + func assign( + to keyPath1: ReferenceWritableKeyPath, on object1: Root1, + and keyPath2: ReferenceWritableKeyPath, on object2: Root2, + ownership: ObjectOwnership = .strong + ) -> AnyCancellable { + switch ownership { + case .strong: + return assign(to: keyPath1, on: object1, and: keyPath2, on: object2) + case .weak: + return sink { [weak object1, weak object2] value in + object1?[keyPath: keyPath1] = value + object2?[keyPath: keyPath2] = value + } + case .unowned: + return sink { [unowned object1, unowned object2] value in + object1[keyPath: keyPath1] = value + object2[keyPath: keyPath2] = value + } + } + } + + /// Assigns each element from a Publisher to properties of the provided objects + /// + /// - Parameters: + /// - keyPath1: The key path of the first property to assign. + /// - object1: The first object on which to assign the value. + /// - keyPath2: The key path of the second property to assign. + /// - object2: The second object on which to assign the value. + /// - keyPath3: The key path of the third property to assign. + /// - object3: The third object on which to assign the value. + /// - ownership: The retainment / ownership strategy for the object, defaults to `strong`. + /// + /// - Returns: A cancellable instance; used when you end assignment of the received value. + /// Deallocation of the result will tear down the subscription stream. + func assign( + to keyPath1: ReferenceWritableKeyPath, on object1: Root1, + and keyPath2: ReferenceWritableKeyPath, on object2: Root2, + and keyPath3: ReferenceWritableKeyPath, on object3: Root3, + ownership: ObjectOwnership = .strong + ) -> AnyCancellable { + switch ownership { + case .strong: + return assign(to: keyPath1, on: object1, + and: keyPath2, on: object2, + and: keyPath3, on: object3) + case .weak: + return sink { [weak object1, weak object2, weak object3] value in + object1?[keyPath: keyPath1] = value + object2?[keyPath: keyPath2] = value + object3?[keyPath: keyPath3] = value + } + case .unowned: + return sink { [unowned object1, unowned object2, unowned object3] value in + object1[keyPath: keyPath1] = value + object2[keyPath: keyPath2] = value + object3[keyPath: keyPath3] = value + } + } + } +} +#endif diff --git a/Sources/Operators/AssignToMany.swift b/Sources/Operators/AssignToMany.swift index 022aa65..e7a2814 100644 --- a/Sources/Operators/AssignToMany.swift +++ b/Sources/Operators/AssignToMany.swift @@ -11,7 +11,7 @@ import Combine @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public extension Publisher where Self.Failure == Never { - /// Assigns each element from a Publisher to properties of the provided object + /// Assigns each element from a Publisher to properties of the provided objects /// /// - Parameters: /// - keyPath1: The key path of the first property to assign. @@ -28,7 +28,7 @@ public extension Publisher where Self.Failure == Never { }) } - /// Assigns each element from a Publisher to properties of the provided object + /// Assigns each element from a Publisher to properties of the provided objects /// /// - Parameters: /// - keyPath1: The key path of the first property to assign. diff --git a/Tests/AssignOwnershipTests.swift b/Tests/AssignOwnershipTests.swift new file mode 100644 index 0000000..d241e63 --- /dev/null +++ b/Tests/AssignOwnershipTests.swift @@ -0,0 +1,98 @@ +// +// AssignOwnershipTests.swift +// CombineExt +// +// Created by Dmitry Kuznetsov on 08/05/2020. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if !os(watchOS) +import XCTest +import Combine +import CombineExt + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +class AssignOwnershipTests: XCTestCase { + var subscription: AnyCancellable! + var value1 = 0 + var value2 = 0 + var value3 = 0 + var subject: PassthroughSubject! + + override func setUp() { + super.setUp() + + subscription = nil + subject = PassthroughSubject() + value1 = 0 + value2 = 0 + value3 = 0 + } + + func testWeakOwnership() { + let initialRetainCount = CFGetRetainCount(self) + + subscription = subject + .assign(to: \.value1, on: self, ownership: .weak) + subject.send(10) + let resultRetainCount1 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount, resultRetainCount1) + + subscription = subject + .assign(to: \.value1, on: self, and: \.value2, on: self, ownership: .weak) + subject.send(15) + let resultRetainCount2 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount, resultRetainCount2) + + subscription = subject + .assign(to: \.value1, on: self, and: \.value2, on: self, and: \.value3, on: self, ownership: .weak) + subject.send(20) + let resultRetainCount3 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount, resultRetainCount3) + } + + func testUnownedOwnership() { + let initialRetainCount = CFGetRetainCount(self) + + subscription = subject + .assign(to: \.value1, on: self, ownership: .unowned) + subject.send(10) + let resultRetainCount1 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount, resultRetainCount1) + + subscription = subject + .assign(to: \.value1, on: self, and: \.value2, on: self, ownership: .unowned) + subject.send(15) + let resultRetainCount2 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount, resultRetainCount2) + + subscription = subject + .assign(to: \.value1, on: self, and: \.value2, on: self, and: \.value3, on: self, ownership: .unowned) + subject.send(20) + let resultRetainCount3 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount, resultRetainCount3) + } + + func testStrongOwnership() { + let initialRetainCount = CFGetRetainCount(self) + + subscription = subject + .assign(to: \.value1, on: self, ownership: .strong) + subject.send(10) + let resultRetainCount1 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount + 1, resultRetainCount1) + + subscription = subject + .assign(to: \.value1, on: self, and: \.value2, on: self, ownership: .strong) + subject.send(15) + let resultRetainCount2 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount + 2, resultRetainCount2) + + subscription = subject + .assign(to: \.value1, on: self, and: \.value2, on: self, and: \.value3, on: self, ownership: .strong) + subject.send(20) + let resultRetainCount3 = CFGetRetainCount(self) + XCTAssertEqual(initialRetainCount + 3, resultRetainCount3) + } +} +#endif diff --git a/Tests/AssignToManyTests.swift b/Tests/AssignToManyTests.swift index b5fcaed..81d5af0 100644 --- a/Tests/AssignToManyTests.swift +++ b/Tests/AssignToManyTests.swift @@ -14,55 +14,86 @@ import CombineExt @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) class AssignToManyTests: XCTestCase { var subscription: AnyCancellable! + + func testAssignToOne() { + let source = PassthroughSubject() + + for ownership in [ObjectOwnership.strong, .weak, .unowned] { + let dest1 = Fake1(prop: 0) + + XCTAssertEqual(dest1.prop, 0) + + subscription = source + .assign(to: \.prop, on: dest1, ownership: ownership) + + source.send(4) + XCTAssertEqual(dest1.prop, 4, "\(ownership) ownership") + + source.send(12) + XCTAssertEqual(dest1.prop, 12, "\(ownership) ownership") + + source.send(-7) + XCTAssertEqual(dest1.prop, -7, "\(ownership) ownership") + } + } + func testAssignToTwo() { let source = PassthroughSubject() - let dest1 = Fake1(prop: 0) - let dest2 = Fake2(ivar: 0) - - XCTAssertEqual(dest1.prop, 0) - XCTAssertEqual(dest2.ivar, 0) - - subscription = source - .assign(to: \.prop, on: dest1, - and: \.ivar, on: dest2) - - source.send(4) - XCTAssertEqual(dest1.prop, 4) - XCTAssertEqual(dest2.ivar, 4) - - source.send(12) - XCTAssertEqual(dest1.prop, 12) - XCTAssertEqual(dest2.ivar, 12) - - source.send(-7) - XCTAssertEqual(dest1.prop, -7) - XCTAssertEqual(dest2.ivar, -7) + + for ownership in [ObjectOwnership.strong, .weak, .unowned] { + let dest1 = Fake1(prop: 0) + let dest2 = Fake2(ivar: 0) + + XCTAssertEqual(dest1.prop, 0) + XCTAssertEqual(dest2.ivar, 0) + + subscription = source + .assign(to: \.prop, on: dest1, + and: \.ivar, on: dest2, + ownership: ownership) + + source.send(4) + XCTAssertEqual(dest1.prop, 4, "\(ownership) ownership") + XCTAssertEqual(dest2.ivar, 4, "\(ownership) ownership") + + source.send(12) + XCTAssertEqual(dest1.prop, 12, "\(ownership) ownership") + XCTAssertEqual(dest2.ivar, 12, "\(ownership) ownership") + + source.send(-7) + XCTAssertEqual(dest1.prop, -7, "\(ownership) ownership") + XCTAssertEqual(dest2.ivar, -7, "\(ownership) ownership") + } } func testAssignToThree() { let source = PassthroughSubject() - let dest1 = Fake1(prop: "") - let dest2 = Fake2(ivar: "") - let dest3 = Fake3(value: "") { String(repeating: $0, count: $0.count) } - - XCTAssertEqual(dest1.prop, "") - XCTAssertEqual(dest2.ivar, "") - XCTAssertEqual(dest3.value, "") - - subscription = source - .assign(to: \.prop, on: dest1, - and: \.ivar, on: dest2, - and: \.value, on: dest3) - - source.send("Hello") - XCTAssertEqual(dest1.prop, "Hello") - XCTAssertEqual(dest2.ivar, "Hello") - XCTAssertEqual(dest3.value, "HelloHelloHelloHelloHello") - - source.send("Meh") - XCTAssertEqual(dest1.prop, "Meh") - XCTAssertEqual(dest2.ivar, "Meh") - XCTAssertEqual(dest3.value, "MehMehMeh") + + for ownership in [ObjectOwnership.strong, .weak, .unowned] { + let dest1 = Fake1(prop: "") + let dest2 = Fake2(ivar: "") + let dest3 = Fake3(value: "") { String(repeating: $0, count: $0.count) } + + XCTAssertEqual(dest1.prop, "") + XCTAssertEqual(dest2.ivar, "") + XCTAssertEqual(dest3.value, "") + + subscription = source + .assign(to: \.prop, on: dest1, + and: \.ivar, on: dest2, + and: \.value, on: dest3, + ownership: ownership) + + source.send("Hello") + XCTAssertEqual(dest1.prop, "Hello", "\(ownership) ownership") + XCTAssertEqual(dest2.ivar, "Hello", "\(ownership) ownership") + XCTAssertEqual(dest3.value, "HelloHelloHelloHelloHello", "\(ownership) ownership") + + source.send("Meh") + XCTAssertEqual(dest1.prop, "Meh", "\(ownership) ownership") + XCTAssertEqual(dest2.ivar, "Meh", "\(ownership) ownership") + XCTAssertEqual(dest3.value, "MehMehMeh", "\(ownership) ownership") + } } }