Skip to content

Instantly share code, notes, and snippets.

@KaneCheshire
Last active May 7, 2024 08:13
Show Gist options
  • Save KaneCheshire/b13ad47c2f8cdfef7c6225306a3ba919 to your computer and use it in GitHub Desktop.
Save KaneCheshire/b13ad47c2f8cdfef7c6225306a3ba919 to your computer and use it in GitHub Desktop.
Type-erased Swift Task that cancels itself on deinit
/// A type-erased task that you can store in a collection
/// to allow you to cancel at a later date.
///
/// Upon deinit of the task, the task will be cancelled
/// automatically. Similar to Combine's AnyCancellable.
final class AnyTask {
/// Call this cancellation block to cancel the task manually.
let cancel: () -> Void
/// Checks whether the task is cancelled.
var isCancelled: Bool { isCancelledBlock() }
private let isCancelledBlock: () -> Bool
deinit {
// On deinit, if the task is not cancelled then cancel it
if !isCancelled { cancel() }
}
/// Constructs an AnyTask from the provided Task.
/// The provided task is held strongly until AnyTask is
/// deinitted.
/// - Parameter task: The task to construct with.
init<S, E>(_ task: Task<S, E>) {
cancel = task.cancel
isCancelledBlock = { task.isCancelled }
}
}
extension Task {
var eraseToAnyTask: AnyTask { .init(self) }
}
extension Array where Element == Task<Void, Never> {
var erased: [AnyTask] { map(\.eraseToAnyTask) }
}
@KaneCheshire
Copy link
Author

I dunno why the indentation is so large, GitHub keeps reverting is 🤷

@Gurpartap
Copy link

Gurpartap commented Feb 22, 2023

For usage convenience…

Task {
    // ...
}.store(in: &tasks)
extension Task {
    func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == AnyTask {
        collection.append(eraseToAnyTask)
    }
}

@KaneCheshire
Copy link
Author

Nice! Thanks for that!

@KaneCheshire
Copy link
Author

I've turned this into a Swift Package with test coverage: https://github.com/KaneCheshire/AnyTask

@Gurpartap
Copy link

Perfect. I appreciate the effort @KaneCheshire. I’ll be switching to the package.

@richardhenry
Copy link

richardhenry commented Apr 10, 2024

You can also take advantage of the flexibility of AnyCancellable and conform Task to the Cancellable protocol:

extension Task: Cancellable {} 

This offers all of the same functionality:

let task = Task { ... }

// Wrap task in AnyCancellable:
let cancellable = AnyCancellable(task)

// Or use the store(in:) extension method, which we get for free:
var cancellables = Set<AnyCancellable>()
task.store(in: &cancellables)

This works because Task already implements a cancel method, which is the only requirement of the Cancellable protocol.

Here's some Playground code if you want to mess around with this:

import UIKit
import Combine

extension Task: Cancellable {}

let emitter = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

let task = Task {
    for await value in emitter.values {
        print("tick: \(value)")
    }
}

var cancel: AnyCancellable? = AnyCancellable(task)

DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
    // The print statements will stop after 4 seconds when the cancellable is deallocated.
    cancel = nil
}

RunLoop.main.run()  // Playground will run until stopped.

@KaneCheshire
Copy link
Author

That’s nice! But means there’s a dependency on Combine which is a system library and not a language library right?

@richardhenry
Copy link

Yes, that’s correct!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment