├── .gitignore ├── Tests └── WeakifyTests │ ├── Systems Under Test │ ├── Tap.swift │ ├── Formatter.swift │ ├── SendableFormatter.swift │ ├── SendableTap.swift │ ├── Recorder.swift │ ├── Counter.swift │ ├── SendableRecorder.swift │ └── SendableCounter.swift │ ├── Scaffolding │ └── WeakBox.swift │ ├── DisownParameterPackTests.swift │ ├── WeakifyParameterPackTests.swift │ ├── DisownSendableParameterPackTests.swift │ ├── DisownVoidReturningTests.swift │ ├── WeakifySendableParameterPackTests.swift │ ├── DisownValueReturningTests.swift │ ├── WeakifyValueReturningTests.swift │ ├── WeakifyVoidReturningTests.swift │ ├── DisownSendableVoidReturningTests.swift │ ├── DisownSendableValueReturningTests.swift │ ├── WeakifySendableValueReturningTests.swift │ ├── WeakifyOptionalParameterPackTests.swift │ ├── WeakifySendableVoidReturningTests.swift │ ├── WeakifySendableOptionalParameterPackTests.swift │ ├── WeakifyOptionalValueReturningTests.swift │ └── WeakifySendableOptionalValueReturningTests.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDETemplateMacros.plist ├── Package.swift ├── LICENSE ├── .github └── workflows │ ├── documentation.yml │ └── test.yml ├── Sources └── Weakify │ ├── Disown.swift │ └── Weakify.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/Tap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | 8 | final class Tap { 9 | private let onFire: () -> Void 10 | 11 | init(_ onFire: @escaping () -> Void) { 12 | self.onFire = onFire 13 | } 14 | 15 | func fire() { 16 | onFire() 17 | } 18 | } -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // Copyright © ___YEAR___ Kyle Hughes. All rights reserved. 8 | // SPDX-License-Identifier: MIT 9 | // 10 | 11 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/Formatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | final class Formatter { 7 | // MARK: Internal Initialization 8 | 9 | init() {} 10 | 11 | // MARK: Internal Instance Interface 12 | 13 | func format(_ value: Int, _ label: String) -> String { 14 | "\(label):\(value)" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/Scaffolding/WeakBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | final class WeakBox where Base: AnyObject { 7 | // MARK: Internal Instance Properties 8 | 9 | weak var reference: Base? 10 | 11 | // MARK: Internal Initialization 12 | 13 | init(_ reference: Base? = nil) { 14 | self.reference = reference 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/SendableFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | final class SendableFormatter: Sendable { 7 | // MARK: Internal Initialization 8 | 9 | init() {} 10 | 11 | // MARK: Internal Instance Interface 12 | 13 | func format(_ value: Int, _ label: String) -> String { 14 | "\(label):\(value)" 15 | } 16 | } -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/SendableTap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | 8 | final class SendableTap: Sendable { 9 | private let onFire: @Sendable () -> Void 10 | 11 | init(_ onFire: @escaping @Sendable () -> Void) { 12 | self.onFire = onFire 13 | } 14 | 15 | func fire() { 16 | onFire() 17 | } 18 | } -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/Recorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | final class Recorder { 7 | // MARK: Internal Instance Properties 8 | 9 | private(set) var last: String? 10 | 11 | // MARK: Internal Initialization 12 | 13 | init(last: String? = nil) { 14 | self.last = last 15 | } 16 | 17 | // MARK: Internal Instance Interface 18 | 19 | func record(_ text: String) { 20 | last = text 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Weakify", 7 | products: [ 8 | .library( 9 | name: "Weakify", 10 | targets: [ 11 | "Weakify", 12 | ] 13 | ), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "Weakify" 18 | ), 19 | .testTarget( 20 | name: "WeakifyTests", 21 | dependencies: [ 22 | "Weakify", 23 | ] 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/Counter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | final class Counter { 7 | // MARK: Internal Instance Properties 8 | 9 | private(set) var value: Int 10 | 11 | // MARK: Internal Initialization 12 | 13 | init(value: Int = 0) { 14 | self.value = value 15 | } 16 | 17 | // MARK: Internal Instance Interface 18 | 19 | func add(_ delta: Int) -> Int { 20 | value += delta 21 | 22 | return value 23 | } 24 | 25 | func add(_ a: Int, _ b: Int) -> Int { 26 | value += a + b 27 | 28 | return value 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/SendableRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Synchronization 7 | 8 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 9 | final class SendableRecorder: Sendable { 10 | // MARK: Internal Instance Properties 11 | 12 | var last: String? { 13 | mutex.withLock { $0 } 14 | } 15 | 16 | // MARK: Private Instance Properties 17 | 18 | private let mutex: Mutex 19 | 20 | // MARK: Internal Initialization 21 | 22 | init(last: String? = nil) { 23 | mutex = Mutex(last) 24 | } 25 | 26 | // MARK: Internal Instance Interface 27 | 28 | func record(_ text: String) { 29 | mutex.withLock { 30 | $0 = text 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/DisownParameterPackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Disown w/ Parameter Packs") 11 | struct DisownParameterPackTests { 12 | // MARK: Tests 13 | 14 | @Test("Multiple heterogeneous arguments work") 15 | func heterogeneousArguments() { 16 | let formatter = Formatter() 17 | 18 | let format = disown(Formatter.format(_:_:), on: formatter) 19 | 20 | #expect(format(42, "answer") == "answer:42") 21 | } 22 | 23 | @Test("Multiple homogenous arguments work") 24 | func homogenousArguments() { 25 | let counter = Counter() 26 | 27 | let addPair = disown(Counter.add(_:_:), on: counter) 28 | 29 | #expect(addPair(2, 5) == 7) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifyParameterPackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Parameter Packs") 11 | struct WeakifyParameterPackTests { 12 | // MARK: Tests 13 | 14 | @Test("Multiple heterogeneous arguments work") 15 | func heterogeneousArguments() { 16 | let formatter = Formatter() 17 | 18 | let format = weakify(Formatter.format(_:_:), on: formatter, default: "n/a") 19 | 20 | #expect(format(42, "answer") == "answer:42") 21 | } 22 | 23 | @Test("Multiple homogenous arguments work") 24 | func homogenousArguments() { 25 | let counter = Counter() 26 | 27 | let addPair = weakify(Counter.add(_:_:), on: counter, default: -1) 28 | 29 | #expect(addPair(2, 5) == 7) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Kyle Hughes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Tests/WeakifyTests/DisownSendableParameterPackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Disown w/ Sendable Parameter Packs") 11 | struct DisownSendableParameterPackTests { 12 | // MARK: Tests 13 | 14 | @Test("Multiple heterogeneous arguments work") 15 | func heterogeneousArguments() { 16 | let formatter = SendableFormatter() 17 | 18 | let format: @Sendable (Int, String) -> String = disown(SendableFormatter.format, on: formatter) 19 | 20 | #expect(format(42, "answer") == "answer:42") 21 | } 22 | 23 | @Test("Multiple homogenous arguments work") 24 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 25 | func homogenousArguments() { 26 | let counter = SendableCounter() 27 | 28 | let addPair: @Sendable (Int, Int) -> Int = disown(SendableCounter.add, on: counter) 29 | 30 | #expect(addPair(2, 5) == 7) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/Systems Under Test/SendableCounter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Synchronization 7 | 8 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 9 | final class SendableCounter: Sendable { 10 | // MARK: Internal Instance Properties 11 | 12 | var value: Int { 13 | mutex.withLock { 14 | $0 15 | } 16 | } 17 | 18 | // MARK: Private Instance Properties 19 | 20 | private let mutex: Mutex 21 | 22 | // MARK: Internal Initialization 23 | 24 | init(value: Int = 0) { 25 | mutex = Mutex(value) 26 | } 27 | 28 | // MARK: Internal Instance Interface 29 | 30 | func add(_ delta: Int) -> Int { 31 | mutex.withLock { 32 | $0 += delta 33 | 34 | return $0 35 | } 36 | } 37 | 38 | func add(_ a: Int, _ b: Int) -> Int { 39 | mutex.withLock { 40 | $0 += a + b 41 | 42 | return $0 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/DisownVoidReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Disown w/ Void-Returning Functions") 11 | struct DisownVoidReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Target is deallocated without extending lifetime") 15 | func targetDeallocatedWithoutExtendingLifetime() { 16 | let box = WeakBox() 17 | var fire: (() -> Void)! 18 | 19 | do { 20 | let tap = Tap { } 21 | box.reference = tap 22 | 23 | fire = disown(Tap.fire, on: tap) 24 | fire() 25 | } 26 | 27 | #expect(box.reference == nil) 28 | } 29 | 30 | @Test("Void variant forwards to the method") 31 | func voidVariantWhileAlive() { 32 | let recorder = Recorder() 33 | 34 | let record = disown(Recorder.record(_:), on: recorder) 35 | record("hello") 36 | 37 | #expect(recorder.last == "hello") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifySendableParameterPackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Sendable Parameter Packs") 11 | struct WeakifySendableParameterPackTests { 12 | // MARK: Tests 13 | 14 | @Test("Multiple heterogeneous arguments work") 15 | func heterogeneousArguments() { 16 | let formatter = SendableFormatter() 17 | 18 | let format: @Sendable (Int, String) -> String = weakify(SendableFormatter.format(_:_:), on: formatter, default: "n/a") 19 | 20 | #expect(format(42, "answer") == "answer:42") 21 | } 22 | 23 | @Test("Multiple homogenous arguments work") 24 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 25 | func homogenousArguments() { 26 | let counter = SendableCounter() 27 | 28 | let addPair: @Sendable (Int, Int) -> Int = weakify(SendableCounter.add(_:_:), on: counter, default: -1) 29 | 30 | #expect(addPair(2, 5) == 7) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/DisownValueReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Disown w/ Value-Returning Functions") 11 | struct DisownValueReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Target is deallocated without extending lifetime") 15 | func targetDeallocatedWithoutExtendingLifetime() { 16 | let box = WeakBox() 17 | var add: ((Int) -> Int)! 18 | 19 | do { 20 | let counter = Counter() 21 | box.reference = counter 22 | 23 | add = disown(Counter.add(_:), on: counter) 24 | 25 | #expect(add(3) == 3) 26 | } 27 | 28 | #expect(box.reference == nil) 29 | } 30 | 31 | @Test("Returns the wrapped value while target lives") 32 | func returnsValueWhileTargetAlive() { 33 | let counter = Counter() 34 | 35 | let add = disown(Counter.add(_:), on: counter) 36 | 37 | #expect(add(3) == 3) 38 | #expect(counter.value == 3) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifyValueReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Value-Returning Functions") 11 | struct WeakifyValueReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Falls back to default after target is deallocated") 15 | func returnsDefaultAfterTargetReleased() { 16 | let box = WeakBox() 17 | var add: ((Int) -> Int)! 18 | 19 | do { 20 | let counter = Counter() 21 | box.reference = counter 22 | 23 | add = weakify(Counter.add(_:), on: counter, default: -1) 24 | } 25 | 26 | #expect(box.reference == nil) 27 | #expect(add(42) == -1) 28 | } 29 | 30 | @Test("Returns the wrapped value while target lives") 31 | func returnsValueWhileTargetAlive() { 32 | let counter = Counter() 33 | 34 | let add = weakify(Counter.add(_:), on: counter, default: -1) 35 | 36 | #expect(add(3) == 3) 37 | #expect(counter.value == 3) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifyVoidReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Void-Returning Functions") 11 | struct WeakifyVoidReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Void variant becomes a no-op after release") 15 | func voidVariantAfterRelease() { 16 | var wasCalled = false 17 | 18 | var fire: (() -> Void)! 19 | 20 | do { 21 | let tap = Tap { 22 | wasCalled = true 23 | } 24 | 25 | fire = weakify(Tap.fire, on: tap) 26 | fire() 27 | 28 | #expect(wasCalled) 29 | 30 | wasCalled = false 31 | } 32 | 33 | fire() 34 | 35 | #expect(wasCalled == false) 36 | } 37 | 38 | @Test("Void variant forwards to the method") 39 | func voidVariantWhileAlive() { 40 | let recorder = Recorder() 41 | 42 | let record = weakify(Recorder.record(_:), on: recorder) 43 | record("hello") 44 | 45 | #expect(recorder.last == "hello") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/DisownSendableVoidReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Disown w/ Sendable Void-Returning Functions") 11 | struct DisownSendableVoidReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Target is deallocated without extending lifetime") 15 | func targetDeallocatedWithoutExtendingLifetime() { 16 | let box = WeakBox() 17 | var fire: (@Sendable () -> Void)! 18 | 19 | do { 20 | let tap = SendableTap { } 21 | box.reference = tap 22 | 23 | fire = disown(SendableTap.fire, on: tap) 24 | fire() 25 | } 26 | 27 | #expect(box.reference == nil) 28 | } 29 | 30 | @Test("Void variant forwards to the method") 31 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 32 | func voidVariantWhileAlive() { 33 | let recorder = SendableRecorder() 34 | 35 | let record: @Sendable (String) -> Void = disown(SendableRecorder.record, on: recorder) 36 | record("hello") 37 | 38 | #expect(recorder.last == "hello") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/DisownSendableValueReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Disown w/ Sendable Value-Returning Functions") 11 | struct DisownSendableValueReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Target is deallocated without extending lifetime") 15 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 16 | func targetDeallocatedWithoutExtendingLifetime() { 17 | let box = WeakBox() 18 | var add: (@Sendable (Int) -> Int)! 19 | 20 | do { 21 | let counter = SendableCounter() 22 | box.reference = counter 23 | 24 | add = disown(SendableCounter.add, on: counter) 25 | 26 | #expect(add(3) == 3) 27 | } 28 | 29 | #expect(box.reference == nil) 30 | } 31 | 32 | @Test("Returns the wrapped value while target lives") 33 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 34 | func returnsValueWhileTargetAlive() { 35 | let counter = SendableCounter() 36 | 37 | let add: @Sendable (Int) -> Int = disown(SendableCounter.add, on: counter) 38 | 39 | #expect(add(3) == 3) 40 | #expect(counter.value == 3) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifySendableValueReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Sendable Value-Returning Functions") 11 | struct WeakifySendableValueReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Falls back to default after target is deallocated") 15 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 16 | func returnsDefaultAfterTargetReleased() { 17 | let box = WeakBox() 18 | var add: (@Sendable (Int) -> Int)! 19 | 20 | do { 21 | let counter = SendableCounter() 22 | box.reference = counter 23 | 24 | add = weakify(SendableCounter.add(_:), on: counter, default: -1) 25 | } 26 | 27 | #expect(box.reference == nil) 28 | #expect(add(42) == -1) 29 | } 30 | 31 | @Test("Returns the wrapped value while target lives") 32 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 33 | func returnsValueWhileTargetAlive() { 34 | let counter = SendableCounter() 35 | 36 | let add: @Sendable (Int) -> Int = weakify(SendableCounter.add(_:), on: counter, default: -1) 37 | 38 | #expect(add(3) == 3) 39 | #expect(counter.value == 3) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifyOptionalParameterPackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Parameter Packs & Optional Closures") 11 | struct WeakifyOptionalParameterPackTests { 12 | // MARK: Tests 13 | 14 | @Test("Multiple heterogeneous arguments work") 15 | func heterogeneousArguments() { 16 | let formatter = Formatter() 17 | 18 | let format = weakify(Formatter.format(_:_:), on: formatter, default: nil) 19 | 20 | #expect(format(42, "answer") == "answer:42") 21 | } 22 | 23 | @Test("Multiple homogenous arguments work") 24 | func homogenousArguments() { 25 | let counter = Counter() 26 | 27 | let addPair = weakify(Counter.add(_:_:), on: counter, default: nil) 28 | 29 | #expect(addPair(2, 5) == 7) 30 | } 31 | 32 | @Test("Multiple heterogeneous arguments work with non-nil default") 33 | func heterogeneousArgumentsWithNonNilDefault() { 34 | let formatter = Formatter() 35 | 36 | let format = weakify(Formatter.format(_:_:), on: formatter, default: "n/a") 37 | 38 | #expect(format(42, "answer") == "answer:42") 39 | } 40 | 41 | @Test("Multiple homogenous arguments work with non-nil default") 42 | func homogenousArgumentsWithNonNilDefault() { 43 | let counter = Counter() 44 | 45 | let addPair = weakify(Counter.add(_:_:), on: counter, default: -1) 46 | 47 | #expect(addPair(2, 5) == 7) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifySendableVoidReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Synchronization 8 | import Testing 9 | @testable import Weakify 10 | 11 | @Suite("Weakify w/ Sendable Void-Returning Functions") 12 | struct WeakifySendableVoidReturningTests { 13 | // MARK: Tests 14 | 15 | @Test("Void variant becomes a no-op after release") 16 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 17 | func voidVariantAfterRelease() { 18 | let wasCalled = Mutex(false) 19 | 20 | var fire: (@Sendable () -> Void)! 21 | 22 | do { 23 | let tap = SendableTap { 24 | wasCalled.withLock { 25 | $0 = true 26 | } 27 | } 28 | 29 | fire = weakify(SendableTap.fire, on: tap) 30 | fire() 31 | 32 | #expect( 33 | wasCalled.withLock { 34 | $0 35 | } 36 | ) 37 | 38 | wasCalled.withLock { 39 | $0 = false 40 | } 41 | } 42 | 43 | fire() 44 | 45 | #expect( 46 | wasCalled.withLock { 47 | $0 48 | } == false 49 | ) 50 | } 51 | 52 | @Test("Void variant forwards to the method") 53 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 54 | func voidVariantWhileAlive() { 55 | let recorder = SendableRecorder() 56 | 57 | let record: @Sendable (String) -> Void = weakify(SendableRecorder.record(_:), on: recorder) 58 | record("hello") 59 | 60 | #expect(recorder.last == "hello") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | 9 | # Set permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages. 10 | permissions: 11 | contents: read 12 | id-token: write 13 | pages: write 14 | 15 | # Allow one concurrent deployment. Do not cancel in-flight deployments because we don't want assets to be in a 16 | # a semi-deployed state. 17 | concurrency: 18 | group: "deploy-documentation" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | deploy-documentation: 23 | name: Deploy Documentation 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: macos-15 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | - name: Select Xcode 32 | run: sudo xcode-select -s /Applications/Xcode_16.3.app 33 | - name: Set Up GitHub Pages 34 | uses: actions/configure-pages@v3 35 | - name: Build Documentation 36 | run: | 37 | xcodebuild docbuild \ 38 | -scheme Weakify \ 39 | -derivedDataPath /tmp/DerivedData \ 40 | -destination 'generic/platform=iOS'; 41 | mkdir _site; 42 | $(xcrun --find docc) process-archive \ 43 | transform-for-static-hosting /tmp/DerivedData/Build/Products/Debug-iphoneos/Weakify.doccarchive \ 44 | --hosting-base-path Weakify \ 45 | --output-path _site; 46 | - name: Create index.html 47 | run: | 48 | echo "" > _site/index.html; 49 | - name: Upload Documentation Artifact to GitHub Pages 50 | uses: actions/upload-pages-artifact@v3 51 | with: 52 | path: _site 53 | - name: Deploy to GitHub Pages 54 | id: deployment 55 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifySendableOptionalParameterPackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Sendable Parameter Packs & Optional Closures") 11 | struct WeakifySendableOptionalParameterPackTests { 12 | // MARK: Tests 13 | 14 | @Test("Multiple heterogeneous arguments work") 15 | func heterogeneousArguments() { 16 | let formatter = SendableFormatter() 17 | 18 | let format: @Sendable (Int, String) -> String? = weakify(SendableFormatter.format(_:_:), on: formatter, default: nil) 19 | 20 | #expect(format(42, "answer") == "answer:42") 21 | } 22 | 23 | @Test("Multiple homogenous arguments work") 24 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 25 | func homogenousArguments() { 26 | let counter = SendableCounter() 27 | 28 | let addPair: @Sendable (Int, Int) -> Int? = weakify(SendableCounter.add(_:_:), on: counter, default: nil) 29 | 30 | #expect(addPair(2, 5) == 7) 31 | } 32 | 33 | @Test("Multiple heterogeneous arguments work with non-nil default") 34 | func heterogeneousArgumentsWithNonNilDefault() { 35 | let formatter = SendableFormatter() 36 | 37 | let format: @Sendable (Int, String) -> String? = weakify(SendableFormatter.format(_:_:), on: formatter, default: "n/a") 38 | 39 | #expect(format(42, "answer") == "answer:42") 40 | } 41 | 42 | @Test("Multiple homogenous arguments work with non-nil default") 43 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 44 | func homogenousArgumentsWithNonNilDefault() { 45 | let counter = SendableCounter() 46 | 47 | let addPair: @Sendable (Int, Int) -> Int? = weakify(SendableCounter.add(_:_:), on: counter, default: -1) 48 | 49 | #expect(addPair(2, 5) == 7) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifyOptionalValueReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Value-Returning Functions & Optional Closures") 11 | struct WeakifyOptionalValueReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Falls back to default after target is deallocated") 15 | func returnsDefaultAfterTargetReleased() { 16 | let box = WeakBox() 17 | var add: ((Int) -> Int?)! 18 | 19 | do { 20 | let counter = Counter() 21 | box.reference = counter 22 | 23 | add = weakify(Counter.add(_:), on: counter, default: nil) 24 | } 25 | 26 | #expect(box.reference == nil) 27 | #expect(add(42) == nil) 28 | } 29 | 30 | @Test("Returns the wrapped value while target lives") 31 | func returnsValueWhileTargetAlive() { 32 | let counter = Counter() 33 | 34 | let add = weakify(Counter.add(_:), on: counter, default: nil) 35 | 36 | #expect(add(3) == 3) 37 | #expect(counter.value == 3) 38 | } 39 | 40 | @Test("Falls back to non-nil default after target is deallocated") 41 | func returnsNonNilDefaultAfterTargetReleased() { 42 | let box = WeakBox() 43 | var add: ((Int) -> Int?)! 44 | 45 | do { 46 | let counter = Counter() 47 | box.reference = counter 48 | 49 | add = weakify(Counter.add(_:), on: counter, default: -1) 50 | } 51 | 52 | #expect(box.reference == nil) 53 | #expect(add(42) == -1) 54 | } 55 | 56 | @Test("Returns the wrapped value while target lives with non-nil default") 57 | func returnsValueWhileTargetAliveWithNonNilDefault() { 58 | let counter = Counter() 59 | 60 | let add = weakify(Counter.add(_:), on: counter, default: -1) 61 | 62 | #expect(add(3) == 3) 63 | #expect(counter.value == 3) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.ref_name }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | get-environment-details: 16 | strategy: 17 | matrix: 18 | include: 19 | - os: macos-15 20 | xcode: '16.3' 21 | name: Get Environment Details (Xcode ${{ matrix.xcode }}) 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - name: Select Xcode 25 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 26 | - name: Print OS SDKs 27 | run: xcodebuild -version -sdk 28 | - name: Print simulators 29 | run: | 30 | xcrun simctl delete unavailable 31 | xcrun simctl list 32 | test: 33 | needs: get-environment-details 34 | strategy: 35 | matrix: 36 | include: 37 | - os: macos-15 38 | xcode: '16.3' 39 | platform: iOS 40 | destination: "name=iPhone 16 Pro" 41 | sdk: iphonesimulator 42 | - os: macos-15 43 | xcode: '16.3' 44 | platform: tvOS 45 | destination: "name=Apple TV 4K (3rd generation)" 46 | sdk: appletvsimulator 47 | - os: macos-15 48 | xcode: '16.3' 49 | platform: visionOS 50 | destination: "name=Apple Vision Pro" 51 | sdk: xrsimulator 52 | - os: macos-15 53 | xcode: '16.3' 54 | platform: watchOS 55 | destination: "name=Apple Watch Ultra 2 (49mm)" 56 | sdk: watchsimulator 57 | - os: macos-15 58 | xcode: '16.3' 59 | platform: macOS 60 | name: Test ${{ matrix.platform }} (Xcode ${{ matrix.xcode }}) 61 | runs-on: ${{ matrix.os }} 62 | steps: 63 | - name: Checkout project 64 | uses: actions/checkout@master 65 | - name: Select Xcode 66 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 67 | - name: Run tests (Xcode) 68 | if: matrix.platform != 'macOS' 69 | run: | 70 | set -o pipefail 71 | xcodebuild clean test -scheme Weakify -sdk ${{ matrix.sdk }} -destination "${{ matrix.destination }}" -configuration Debug -enableCodeCoverage YES | xcpretty -c 72 | - name: Run tests (Swift) 73 | if: matrix.platform == 'macOS' 74 | run: swift test -------------------------------------------------------------------------------- /Tests/WeakifyTests/WeakifySendableOptionalValueReturningTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | import Foundation 7 | import Testing 8 | @testable import Weakify 9 | 10 | @Suite("Weakify w/ Sendable Value-Returning Functions & Optional Closures") 11 | struct WeakifySendableOptionalValueReturningTests { 12 | // MARK: Tests 13 | 14 | @Test("Falls back to default after target is deallocated") 15 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 16 | func returnsDefaultAfterTargetReleased() { 17 | let box = WeakBox() 18 | var add: (@Sendable (Int) -> Int?)! 19 | 20 | do { 21 | let counter = SendableCounter() 22 | box.reference = counter 23 | 24 | add = weakify(SendableCounter.add(_:), on: counter, default: nil) 25 | } 26 | 27 | #expect(box.reference == nil) 28 | #expect(add(42) == nil) 29 | } 30 | 31 | @Test("Returns the wrapped value while target lives") 32 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 33 | func returnsValueWhileTargetAlive() { 34 | let counter = SendableCounter() 35 | 36 | let add: @Sendable (Int) -> Int? = weakify(SendableCounter.add(_:), on: counter, default: nil) 37 | 38 | #expect(add(3) == 3) 39 | #expect(counter.value == 3) 40 | } 41 | 42 | @Test("Falls back to non-nil default after target is deallocated") 43 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 44 | func returnsNonNilDefaultAfterTargetReleased() { 45 | let box = WeakBox() 46 | var add: (@Sendable (Int) -> Int?)! 47 | 48 | do { 49 | let counter = SendableCounter() 50 | box.reference = counter 51 | 52 | add = weakify(SendableCounter.add(_:), on: counter, default: -1) 53 | } 54 | 55 | #expect(box.reference == nil) 56 | #expect(add(42) == -1) 57 | } 58 | 59 | @Test("Returns the wrapped value while target lives with non-nil default") 60 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) 61 | func returnsValueWhileTargetAliveWithNonNilDefault() { 62 | let counter = SendableCounter() 63 | 64 | let add: @Sendable (Int) -> Int? = weakify(SendableCounter.add(_:), on: counter, default: -1) 65 | 66 | #expect(add(3) == 3) 67 | #expect(counter.value == 3) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Weakify/Disown.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | // MARK: - Non-Sendable Interface - 7 | 8 | /// Creates a closure that unownedly captures the given `target` and invokes the supplied unapplied method 9 | /// reference. 10 | /// 11 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 12 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 13 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 14 | /// regular `(Args…) -> Result` closure you would normally call. 15 | /// 16 | /// The returned closure can be stored safely without extending the lifetime of `target`. 17 | /// 18 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 19 | /// - Parameter target: The object to be captured unownedly. 20 | /// - Returns: A closure with the same arity and return type as the method referenced by `unappliedMethodReference`, 21 | /// but which is safe to store without extending the lifetime of `target`. 22 | @inlinable 23 | public func disown( 24 | _ unappliedMethodReference: @escaping (Target) -> (repeat each Argument) -> Output, 25 | on target: Target 26 | ) -> (repeat each Argument) -> Output where Target: AnyObject { 27 | { [unowned target] (argument: repeat each Argument) in 28 | unappliedMethodReference(target)(repeat each argument) 29 | } 30 | } 31 | 32 | // MARK: - Sendable Interface - 33 | 34 | /// Creates a `Sendable` closure that unownedly captures the given `target` and invokes the supplied `Sendable` 35 | /// unapplied method reference. 36 | /// 37 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 38 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 39 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 40 | /// regular `(Args…) -> Result` closure you would normally call. 41 | /// 42 | /// The returned `Sendable` closure can be stored safely without extending the lifetime of `target`. 43 | /// 44 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 45 | /// - Parameter target: The `Sendable` object to be captured unownedly. 46 | /// - Returns: A `Sendable` closure with the same arity and return type as the method referenced by 47 | /// `unappliedMethodReference`, but which is safe to store without extending the lifetime of `target`. 48 | @inlinable 49 | public func disown( 50 | _ unappliedMethodReference: @Sendable @escaping (Target) -> (repeat each Argument) -> Output, 51 | on target: Target 52 | ) -> @Sendable (repeat each Argument) -> Output where Target: AnyObject & Sendable { 53 | { [unowned target] (argument: repeat each Argument) in 54 | unappliedMethodReference(target)(repeat each argument) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Weakify 2 | 3 | [![Platform Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fkylehughes%2FWeakify%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/kylehughes/Weakify) 4 | [![Swift Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fkylehughes%2FWeakify%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/kylehughes/Weakify) 5 | [![Test](https://github.com/kylehughes/Weakify/actions/workflows/test.yml/badge.svg)](https://github.com/kylehughes/Weakify/actions/workflows/test.yml) 6 | 7 | *A simple, ergonomic solution for safely capturing method references in Swift.* 8 | 9 | ## About 10 | 11 | Weakify provides convenient and safe mechanisms for capturing method references weakly or unownedly, ensuring closures referencing methods on an object do not inadvertently extend the object's lifetime. It relies on unapplied method references, and uses parameter packs to support heterogeneous argument lists. 12 | 13 | This package encourages: 14 | 15 | * Single-line syntax for capturing method references. 16 | * Avoidance of retain cycles through `weak` or `unowned` captures. 17 | 18 | Weakify is extremely lightweight and has a robust test suite. 19 | 20 | ## Capabilities 21 | 22 | * [x] Weakly capture method references with automatic fallback values. 23 | * [x] Unownedly capture method references when you know the target will outlive the closure. 24 | * [x] Ergonomic handling of heterogenous arguments. 25 | * [x] Swift 6 language mode support. 26 | 27 | ## Supported Platforms 28 | 29 | * iOS 13.0+ 30 | * macOS 10.15+ 31 | * tvOS 13.0+ 32 | * visionOS 1.0+ 33 | * watchOS 6.0+ 34 | 35 | ## Requirements 36 | 37 | * Swift 6.1+ 38 | * Xcode 16.3+ 39 | 40 | > [!NOTE] 41 | > This package ideally would only require Swift 5.9, but compiler versions prior to 6.1 (packaged with Xcode 16.3) 42 | > have a bug that makes the intended usage incompatible with `@MainActor`-isolated closures. We require Swift language 43 | > tools version 6.1 as a proxy for this tribal knowledge. 44 | 45 | ## Documentation 46 | 47 | [Documentation is available on GitHub Pages.](https://kylehughes.github.io/Weakify/) 48 | 49 | ## Installation 50 | 51 | ### Swift Package Manager 52 | 53 | ```swift 54 | dependencies: [ 55 | .package(url: "https://github.com/kylehughes/Weakify.git", .upToNextMajor(from: "1.0.0")), 56 | ] 57 | ``` 58 | 59 | ## Quick Start 60 | 61 | Weakly capture a method reference: 62 | 63 | ```swift 64 | import Weakify 65 | 66 | class MyViewController: UIViewController { 67 | private lazy var button: UIButton = { 68 | let button = UIButton() 69 | button.addAction( 70 | UIAction(handler: weakify(MyViewController.buttonTapped, on: self)), 71 | for: .primaryActionTriggered 72 | ) 73 | return button 74 | }() 75 | 76 | private func buttonTapped(_ action: UIAction) { 77 | print("Button tapped") 78 | } 79 | } 80 | ``` 81 | 82 | Unownedly capture a method reference: 83 | 84 | ```swift 85 | import Weakify 86 | 87 | class MyViewController: UIViewController { 88 | func observe(notificationCenter: NotificationCenter) { 89 | notificationCenter.addObserver( 90 | forName: NSNotification.Name("MyNotification"), 91 | object: nil, 92 | queue: .main, 93 | using: disown(MyViewController.handleNotification, on: self) 94 | ) 95 | } 96 | 97 | private func handleNotification(_ notification: Notification) { 98 | print("Notification received") 99 | } 100 | } 101 | ``` 102 | 103 | ## Usage 104 | 105 | > [!NOTE] 106 | > An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 107 | > instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 108 | > `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the regular 109 | > `(Args…) -> Result` closure you would normally call. 110 | 111 | ### Weak Capture with Fallback 112 | 113 | Use `weakify` to safely capture `self` without retaining it. A default fallback value can be provided for when `self` has been deallocated: 114 | 115 | ```swift 116 | let weakHandler = weakify(MyViewController.formatMessage, on: self, default: "N/A") 117 | ``` 118 | 119 | ### Weak Capture without Fallback 120 | 121 | Use `weakify` to safely capture `self` without retaining it. If the accepting closure does not need a return value, you can omit the fallback. No side effects will occur if the target is deallocated. 122 | 123 | ```swift 124 | let weakHandler = weakify(MyViewController.fireAndForget, on: self) 125 | ``` 126 | 127 | ### Unowned Capture 128 | 129 | Use `disown` to capture `self` when the target object will definitely outlive the closure: 130 | 131 | ```swift 132 | let unownedHandler = disown(MyViewController.updateStatus, on: self) 133 | ``` 134 | 135 | ### Heterogeneous Arguments 136 | 137 | Weakify supports methods with heterogeneous argument lists: 138 | 139 | ```swift 140 | func printMessage(_ prefix: String, count: Int) { 141 | print("\(prefix): \(count)") 142 | } 143 | 144 | let printer = weakify(MyViewController.printMessage, on: self) 145 | printer("Age", 5) 146 | ``` 147 | 148 | ## Important Behavior 149 | 150 | * `weakify` closures evaluate the provided default when the target is deallocated. 151 | * `disown` closures will crash if called after the target is deallocated; ensure the target outlives the closure. 152 | 153 | ## Contributions 154 | 155 | Weakify is not accepting source contributions at this time. Bug reports will be considered. 156 | 157 | ## Author 158 | 159 | [Kyle Hughes](https://kylehugh.es) 160 | 161 | [![Bluesky][bluesky_image]][bluesky_url] 162 | [![LinkedIn][linkedin_image]][linkedin_url] 163 | [![Mastodon][mastodon_image]][mastodon_url] 164 | 165 | [bluesky_image]: https://img.shields.io/badge/Bluesky-0285FF?logo=bluesky&logoColor=fff 166 | [bluesky_url]: https://bsky.app/profile/kylehugh.es 167 | [linkedin_image]: https://img.shields.io/badge/LinkedIn-0A66C2?logo=linkedin&logoColor=fff 168 | [linkedin_url]: https://www.linkedin.com/in/kyle-hughes 169 | [mastodon_image]: https://img.shields.io/mastodon/follow/109356914477272810?domain=https%3A%2F%2Fmister.computer&style=social 170 | [mastodon_url]: https://mister.computer/@kyle 171 | 172 | ## License 173 | 174 | Weakify is available under the MIT license. 175 | 176 | See `LICENSE` for details. 177 | -------------------------------------------------------------------------------- /Sources/Weakify/Weakify.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Kyle Hughes. All rights reserved. 3 | // SPDX-License-Identifier: MIT 4 | // 5 | 6 | // MARK: - Non-Sendable Interface - 7 | 8 | /// Creates a closure that weakly captures the given `target` and invokes the supplied unapplied method 9 | /// reference. 10 | /// 11 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 12 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 13 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 14 | /// regular `(Args…) -> Result` closure you would normally call. 15 | /// 16 | /// This helper evaluates `default()` whenever `target` has been deallocated. 17 | /// 18 | /// The returned closure can be stored safely without extending the lifetime of `target`. 19 | /// 20 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 21 | /// - Parameter target: The object to be captured weakly. 22 | /// - Parameter default: Expression evaluated when `target` is `nil`. 23 | /// - Returns: A closure with the same arity and return type as the method referenced by `unappliedMethodReference`, but 24 | /// which is safe to store without extending the lifetime of `target`. 25 | @inlinable 26 | public func weakify( 27 | _ unappliedMethodReference: @escaping (Target) -> (repeat each Argument) -> Output, 28 | on target: Target, 29 | default: @autoclosure @escaping () -> Output 30 | ) -> (repeat each Argument) -> Output where Target: AnyObject { 31 | { [weak target] (argument: repeat each Argument) in 32 | guard let target else { 33 | return `default`() 34 | } 35 | 36 | return unappliedMethodReference(target)(repeat each argument) 37 | } 38 | } 39 | 40 | /// Creates a closure that weakly captures the given `target` and invokes the supplied unapplied method 41 | /// reference, returning an optional value for compatibility with closures that can accept optional return types. 42 | /// 43 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 44 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 45 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 46 | /// regular `(Args…) -> Result` closure you would normally call. 47 | /// 48 | /// This helper evaluates `default()` whenever `target` has been deallocated. 49 | /// 50 | /// The returned closure can be stored safely without extending the lifetime of `target`. 51 | /// 52 | /// Use this overload when you need to assign the weakified closure to a context that can accept an optional return 53 | /// type, even though the underlying method reference does not return an optional. This is particularly useful if 54 | /// you want the default value to be nil, which is more ergonomic than coming up with a fake return value you don't 55 | /// care about. 56 | /// 57 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 58 | /// - Parameter target: The object to be captured weakly. 59 | /// - Parameter default: Expression evaluated when `target` is `nil`. 60 | /// - Returns: A closure with the same arity and optional return type as compatible with calling contexts that can 61 | /// accept optionals, but which is safe to store without extending the lifetime of `target`. 62 | @inlinable 63 | public func weakify( 64 | _ unappliedMethodReference: @escaping (Target) -> (repeat each Argument) -> Output, 65 | on target: Target, 66 | default: @autoclosure @escaping () -> Output? 67 | ) -> (repeat each Argument) -> Output? where Target: AnyObject { 68 | { [weak target] (argument: repeat each Argument) in 69 | guard let target else { 70 | return `default`() 71 | } 72 | 73 | return unappliedMethodReference(target)(repeat each argument) 74 | } 75 | } 76 | 77 | /// Creates a closure that weakly captures the given `target` and invokes the supplied unapplied method 78 | /// reference. 79 | /// 80 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 81 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 82 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 83 | /// regular `(Args…) -> Result` closure you would normally call. 84 | /// 85 | /// This helper has no side effects whenever `target` has been deallocated. 86 | /// 87 | /// The returned closure can be stored safely without extending the lifetime of `target`. 88 | /// 89 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 90 | /// - Parameter target: The object to be captured weakly. 91 | /// - Returns: A closure with the same arity and return type as the method referenced by `unappliedMethodReference`, 92 | /// but which is safe to store without extending the lifetime of `target`. 93 | @inlinable 94 | public func weakify( 95 | _ unappliedMethodReference: @escaping (Target) -> (repeat each Argument) -> Void, 96 | on target: Target 97 | ) -> (repeat each Argument) -> Void where Target: AnyObject { 98 | weakify(unappliedMethodReference, on: target, default: ()) 99 | } 100 | 101 | // MARK: - Sendable Interface - 102 | 103 | /// Creates a `Sendable` closure that weakly captures the given `target` and invokes the supplied `Sendable` 104 | /// unapplied method reference. 105 | /// 106 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 107 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 108 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 109 | /// regular `(Args…) -> Result` closure you would normally call. 110 | /// 111 | /// This helper evaluates `default()` whenever `target` has been deallocated. 112 | /// 113 | /// The returned `Sendable` closure can be stored safely without extending the lifetime of `target`. 114 | /// 115 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 116 | /// - Parameter target: The object to be captured weakly. 117 | /// - Parameter default: Expression evaluated when `target` is `nil`. 118 | /// - Returns: A `Sendable` closure with the same arity and return type as the method referenced by 119 | /// `unappliedMethodReference`, but which is safe to store without extending the lifetime of `target`. 120 | @inlinable 121 | public func weakify( 122 | _ unappliedMethodReference: @Sendable @escaping (Target) -> (repeat each Argument) -> Output, 123 | on target: Target, 124 | default: @Sendable @autoclosure @escaping () -> Output 125 | ) -> @Sendable (repeat each Argument) -> Output where Target: AnyObject & Sendable { 126 | { [weak target] (argument: repeat each Argument) in 127 | guard let target else { 128 | return `default`() 129 | } 130 | 131 | return unappliedMethodReference(target)(repeat each argument) 132 | } 133 | } 134 | 135 | /// Creates a `Sendable` closure that weakly captures the given `target` and invokes the supplied `Sendable` 136 | /// unapplied method reference, returning an optional value for compatibility with closures that can accept optional return types. 137 | /// 138 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 139 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 140 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 141 | /// regular `(Args…) -> Result` closure you would normally call. 142 | /// 143 | /// This helper evaluates `default()` whenever `target` has been deallocated. 144 | /// 145 | /// The returned `Sendable` closure can be stored safely without extending the lifetime of `target`. 146 | /// 147 | /// Use this overload when you need to assign the weakified closure to a context that can accept an optional return 148 | /// type, even though the underlying method reference does not return an optional. This is particularly useful if 149 | /// you want the default value to be nil, which is more ergonomic than coming up with a fake return value you don't 150 | /// care about. 151 | /// 152 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 153 | /// - Parameter target: The object to be captured weakly. 154 | /// - Parameter default: Expression evaluated when `target` is `nil`. 155 | /// - Returns: A `Sendable` closure with the same arity and optional return type as compatible with calling contexts 156 | /// that can accept optionals, but which is safe to store without extending the lifetime of `target`. 157 | @inlinable 158 | public func weakify( 159 | _ unappliedMethodReference: @Sendable @escaping (Target) -> (repeat each Argument) -> Output, 160 | on target: Target, 161 | default: @Sendable @autoclosure @escaping () -> Output? 162 | ) -> @Sendable (repeat each Argument) -> Output? where Target: AnyObject & Sendable { 163 | { [weak target] (argument: repeat each Argument) in 164 | guard let target else { 165 | return `default`() 166 | } 167 | 168 | return unappliedMethodReference(target)(repeat each argument) 169 | } 170 | } 171 | 172 | /// Creates a `Sendable` closure that weakly captures the given `target` and invokes the supplied `Sendable` 173 | /// unapplied method reference. 174 | /// 175 | /// An unapplied method reference (UMR) is the function value produced by writing an instance method on the type 176 | /// instead of an instance, for example `UIView.layoutIfNeeded` or `Array.append`. Its curried shape is 177 | /// `(Self) -> (Args…) -> Result`, so supplying a specific instance—`unappliedMethodReference(target)`—yields the 178 | /// regular `(Args…) -> Result` closure you would normally call. 179 | /// 180 | /// This helper has no side effects whenever `target` has been deallocated. 181 | /// 182 | /// The returned `Sendable` closure can be stored safely without extending the lifetime of `target`. 183 | /// 184 | /// - Parameter unappliedMethodReference: Unapplied method reference on `Target`. 185 | /// - Parameter target: The object to be captured weakly. 186 | /// - Returns: A `Sendable` closure with the same arity and return type as the method referenced by 187 | /// `unappliedMethodReference`, but which is safe to store without extending the lifetime of `target`. 188 | @inlinable 189 | public func weakify( 190 | _ unappliedMethodReference: @Sendable @escaping (Target) -> (repeat each Argument) -> Void, 191 | on target: Target 192 | ) -> @Sendable (repeat each Argument) -> Void where Target: AnyObject & Sendable { 193 | weakify(unappliedMethodReference, on: target, default: ()) 194 | } 195 | --------------------------------------------------------------------------------