├── .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 | [](https://swiftpackageindex.com/kylehughes/Weakify)
4 | [](https://swiftpackageindex.com/kylehughes/Weakify)
5 | [](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 |
--------------------------------------------------------------------------------