diff --git a/Evolution/0012-produce-empty-chunks.md b/Evolution/0012-produce-empty-chunks.md new file mode 100644 index 00000000..fe65104f --- /dev/null +++ b/Evolution/0012-produce-empty-chunks.md @@ -0,0 +1,96 @@ +# Produce Empty Chunks + +* Proposal: [0012](0012-produce-empty-chunks.md) +* Author: [Rick Newton-Rogers](https://github.com/rnro) +* Review Manager: TBD +* Status: **Implemented** + +* Implementation: + [Source](https://github.com/rnewtonrogers/swift-async-algorithms/blob/allow_empty_chunks/Sources/AsyncAlgorithms/AsyncChunksOfCountOrSignalSequence.swift) | + [Tests](https://github.com/rnewtonrogers/swift-async-algorithms/blob/allow_empty_chunks/Tests/AsyncAlgorithmsTests/TestChunk.swift) + +## Introduction + +At the moment it is possible to use a signal `AsyncSequence` to provide marks at which elements of a primary +`AsyncSequence` should be 'chunked' into collections. However if one or more signals arrive when there are no elements +from the primary sequence to vend as output then they will be ignored. + +## Motivation + +As noted in [a GitHub Issue](https://github.com/apple/swift-async-algorithms/issues/247) it could be useful to output empty +chunks in the outlined case to provide information of a lack of activity on the primary sequence. This would likely be +particularly useful when combined with a timer as a signaling source. + +## Proposed solution + +Modify the API of the `AsyncSequence` `chunks` and `chunked` extensions to allow specifying of a new parameter +(`produceEmptyChunks`) which determines if the output sequence produces empty chunks. The new parameter will retain the +previous behavior by default. + +## Detailed design + +The modified API will look as follows: +```swift +extension AsyncSequence { + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type of a given count or when a signal `AsyncSequence` produces an element. + public func chunks(ofCount count: Int, or signal: Signal, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: count, signal: signal, produceEmptyChunks: produceEmptyChunks) + } + + /// Creates an asynchronous sequence that creates chunks of a given count or when a signal `AsyncSequence` produces an element. + public func chunks(ofCount count: Int, or signal: Signal, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence { + chunks(ofCount: count, or: signal, into: [Element].self, produceEmptyChunks: produceEmptyChunks) + } + + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type when a signal `AsyncSequence` produces an element. + public func chunked(by signal: Signal, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: signal, produceEmptyChunks: produceEmptyChunks) + } + + /// Creates an asynchronous sequence that creates chunks when a signal `AsyncSequence` produces an element. + public func chunked(by signal: Signal, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence { + chunked(by: signal, into: [Element].self, produceEmptyChunks: produceEmptyChunks) + } + + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type of a given count or when an `AsyncTimerSequence` fires. + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + public func chunks(ofCount count: Int, or timer: AsyncTimerSequence, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: count, signal: timer, produceEmptyChunks: produceEmptyChunks) + } + + /// Creates an asynchronous sequence that creates chunks of a given count or when an `AsyncTimerSequence` fires. + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + public func chunks(ofCount count: Int, or timer: AsyncTimerSequence, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> { + chunks(ofCount: count, or: timer, into: [Element].self, produceEmptyChunks: produceEmptyChunks) + } + + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type when an `AsyncTimerSequence` fires. + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + public func chunked(by timer: AsyncTimerSequence, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: timer, produceEmptyChunks: produceEmptyChunks) + } + + /// Creates an asynchronous sequence that creates chunks when an `AsyncTimerSequence` fires. + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + public func chunked(by timer: AsyncTimerSequence, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> { + chunked(by: timer, into: [Element].self, produceEmptyChunks: produceEmptyChunks) + } +} +``` +The previous API will be marked as deprecated and `@_disfavoredOverload` to avoid ambiguity with the new versions. + + +## Effect on API resilience + +This change is API-safe due to the default value but ABI-unsafe. + +## Alternatives considered + +- Providing a config struct to future-proof the API against further changes in the future was rejected because it +seems to add overhead defending against an unlikely event. +- Providing entirely new API without deprecating the old one was ruled out to avoid an explosion in API complexity +which increases maintenance burden and reduces readability of code. + +## Acknowledgments + +- [@tachyonics](https://github.com/tachyonics) for the initial GitHub issue describing the requirement. diff --git a/Sources/AsyncAlgorithms/AsyncChunksOfCountOrSignalSequence.swift b/Sources/AsyncAlgorithms/AsyncChunksOfCountOrSignalSequence.swift index e322faea..bba12316 100644 --- a/Sources/AsyncAlgorithms/AsyncChunksOfCountOrSignalSequence.swift +++ b/Sources/AsyncAlgorithms/AsyncChunksOfCountOrSignalSequence.swift @@ -11,47 +11,47 @@ extension AsyncSequence { /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type of a given count or when a signal `AsyncSequence` produces an element. - public func chunks(ofCount count: Int, or signal: Signal, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { - AsyncChunksOfCountOrSignalSequence(self, count: count, signal: signal) + public func chunks(ofCount count: Int, or signal: Signal, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: count, signal: signal, produceEmptyChunks: produceEmptyChunks) } /// Creates an asynchronous sequence that creates chunks of a given count or when a signal `AsyncSequence` produces an element. - public func chunks(ofCount count: Int, or signal: Signal) -> AsyncChunksOfCountOrSignalSequence { - chunks(ofCount: count, or: signal, into: [Element].self) + public func chunks(ofCount count: Int, or signal: Signal, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence { + chunks(ofCount: count, or: signal, into: [Element].self, produceEmptyChunks: produceEmptyChunks) } /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type when a signal `AsyncSequence` produces an element. - public func chunked(by signal: Signal, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { - AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: signal) + public func chunked(by signal: Signal, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: signal, produceEmptyChunks: produceEmptyChunks) } /// Creates an asynchronous sequence that creates chunks when a signal `AsyncSequence` produces an element. - public func chunked(by signal: Signal) -> AsyncChunksOfCountOrSignalSequence { - chunked(by: signal, into: [Element].self) + public func chunked(by signal: Signal, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence { + chunked(by: signal, into: [Element].self, produceEmptyChunks: produceEmptyChunks) } /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type of a given count or when an `AsyncTimerSequence` fires. @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) - public func chunks(ofCount count: Int, or timer: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { - AsyncChunksOfCountOrSignalSequence(self, count: count, signal: timer) + public func chunks(ofCount count: Int, or timer: AsyncTimerSequence, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: count, signal: timer, produceEmptyChunks: produceEmptyChunks) } /// Creates an asynchronous sequence that creates chunks of a given count or when an `AsyncTimerSequence` fires. @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) - public func chunks(ofCount count: Int, or timer: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence> { - chunks(ofCount: count, or: timer, into: [Element].self) + public func chunks(ofCount count: Int, or timer: AsyncTimerSequence, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> { + chunks(ofCount: count, or: timer, into: [Element].self, produceEmptyChunks: produceEmptyChunks) } /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type when an `AsyncTimerSequence` fires. @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) - public func chunked(by timer: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { - AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: timer) + public func chunked(by timer: AsyncTimerSequence, into: Collected.Type, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: timer, produceEmptyChunks: produceEmptyChunks) } /// Creates an asynchronous sequence that creates chunks when an `AsyncTimerSequence` fires. @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) - public func chunked(by timer: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence> { - chunked(by: timer, into: [Element].self) + public func chunked(by timer: AsyncTimerSequence, produceEmptyChunks: Bool = false) -> AsyncChunksOfCountOrSignalSequence> { + chunked(by: timer, into: [Element].self, produceEmptyChunks: produceEmptyChunks) } } @@ -74,12 +74,14 @@ public struct AsyncChunksOfCountOrSignalSequence let count: Int? + let produceEmptyChunks: Bool var iterator: Merged.AsyncIterator var terminated = false - init(iterator: Merged.AsyncIterator, count: Int?) { + init(iterator: Merged.AsyncIterator, count: Int?, produceEmptyChunks: Bool) { self.count = count self.iterator = iterator + self.produceEmptyChunks = produceEmptyChunks } public mutating func next() async rethrows -> Collected? { @@ -104,6 +106,9 @@ public struct AsyncChunksOfCountOrSignalSequence 0) } @@ -121,11 +127,12 @@ public struct AsyncChunksOfCountOrSignalSequence Iterator { - return Iterator(iterator: merge(chain(base.map { Either.element($0) }, [.terminal].async), signal.map { _ in Either.signal }).makeAsyncIterator(), count: count) + return Iterator(iterator: merge(chain(base.map { Either.element($0) }, [.terminal].async), signal.map { _ in Either.signal }).makeAsyncIterator(), count: count, produceEmptyChunks: produceEmptyChunks) } } diff --git a/Sources/AsyncAlgorithms/Deprecated.swift b/Sources/AsyncAlgorithms/Deprecated.swift new file mode 100644 index 00000000..ec68422d --- /dev/null +++ b/Sources/AsyncAlgorithms/Deprecated.swift @@ -0,0 +1,63 @@ + + +extension AsyncSequence { + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type of a given count or when a signal `AsyncSequence` produces an element. + @_disfavoredOverload + @available(*, deprecated, renamed: "chunks(ofCount:or:into:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunks(ofCount count: Int, or signal: Signal, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: count, signal: signal, produceEmptyChunks: false) + } + + /// Creates an asynchronous sequence that creates chunks of a given count or when a signal `AsyncSequence` produces an element. + @_disfavoredOverload + @available(*, deprecated, renamed: "chunks(ofCount:or:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunks(ofCount count: Int, or signal: Signal) -> AsyncChunksOfCountOrSignalSequence { + chunks(ofCount: count, or: signal, into: [Element].self) + } + + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type when a signal `AsyncSequence` produces an element. + @_disfavoredOverload + @available(*, deprecated, renamed: "chunked(by:into:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunked(by signal: Signal, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: signal, produceEmptyChunks: false) + } + + /// Creates an asynchronous sequence that creates chunks when a signal `AsyncSequence` produces an element. + @_disfavoredOverload + @available(*, deprecated, renamed: "chunked(by:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunked(by signal: Signal) -> AsyncChunksOfCountOrSignalSequence { + chunked(by: signal, into: [Element].self) + } + + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type of a given count or when an `AsyncTimerSequence` fires. + @_disfavoredOverload + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + @available(*, deprecated, renamed: "chunks(ofCount:or:into:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunks(ofCount count: Int, or timer: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: count, signal: timer, produceEmptyChunks: false) + } + + /// Creates an asynchronous sequence that creates chunks of a given count or when an `AsyncTimerSequence` fires. + @_disfavoredOverload + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + @available(*, deprecated, renamed: "chunks(ofCount:or:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunks(ofCount count: Int, or timer: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence> { + chunks(ofCount: count, or: timer, into: [Element].self) + } + + /// Creates an asynchronous sequence that creates chunks of a given `RangeReplaceableCollection` type when an `AsyncTimerSequence` fires. + @_disfavoredOverload + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + @available(*, deprecated, renamed: "chunked(by:into:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunked(by timer: AsyncTimerSequence, into: Collected.Type) -> AsyncChunksOfCountOrSignalSequence> where Collected.Element == Element { + AsyncChunksOfCountOrSignalSequence(self, count: nil, signal: timer, produceEmptyChunks: false) + } + + /// Creates an asynchronous sequence that creates chunks when an `AsyncTimerSequence` fires. + @_disfavoredOverload + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + @available(*, deprecated, renamed: "chunked(by:produceEmptyChunks:)", message: "This method has been deprecated to allow the option for sequences to produce empty chunks.") + public func chunked(by timer: AsyncTimerSequence) -> AsyncChunksOfCountOrSignalSequence> { + chunked(by: timer, into: [Element].self) + } +} diff --git a/Tests/AsyncAlgorithmsTests/TestChunk.swift b/Tests/AsyncAlgorithmsTests/TestChunk.swift index 7845b1a0..c88ec9fe 100644 --- a/Tests/AsyncAlgorithmsTests/TestChunk.swift +++ b/Tests/AsyncAlgorithmsTests/TestChunk.swift @@ -59,6 +59,15 @@ final class TestChunk: XCTestCase { } } + func test_signal_emptyChunks_produceEmptyChunks() { + validate { + "--1--|" + "XX-XX|" + $0.inputs[0].chunked(by: $0.inputs[1], produceEmptyChunks: true).map(concatCharacters) + "''''-1''|" + } + } + func test_signal_error() { validate { "AB^" @@ -95,6 +104,15 @@ final class TestChunk: XCTestCase { } } + func test_signalAndCount_countAlwaysPrevails_produceEmptyChunks() { + validate { + "AB --A-B -|" + "-- X---- X|" + $0.inputs[0].chunks(ofCount: 2, or: $0.inputs[1], produceEmptyChunks: true).map(concatCharacters) + "-'AB'''---'AB'''|" + } + } + func test_signalAndCount_countResetsAfterCount() { validate { "ABCDE -ABCDE |" @@ -149,6 +167,15 @@ final class TestChunk: XCTestCase { } } + func test_time_emptyChunks_produceEmptyChunks() throws { + guard #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) else { throw XCTSkip("Skipped due to Clock/Instant/Duration availability") } + validate { + "-- 1- --|" + $0.inputs[0].chunked(by: .repeating(every: .steps(2), clock: $0.clock), produceEmptyChunks: true).map(concatCharacters) + "-'' -1 -''|" + } + } + func test_time_error() throws { guard #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) else { throw XCTSkip("Skipped due to Clock/Instant/Duration availability") } validate { @@ -185,6 +212,15 @@ final class TestChunk: XCTestCase { } } + func test_timeAndCount_countAlwaysPrevails_produceEmptyChunks() throws { + guard #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) else { throw XCTSkip("Skipped due to Clock/Instant/Duration availability") } + validate { + "AB --A-B -|" + $0.inputs[0].chunks(ofCount: 2, or: .repeating(every: .steps(8), clock: $0.clock), produceEmptyChunks: true).map(concatCharacters) + "-'AB'----'AB'''|" + } + } + func test_timeAndCount_countResetsAfterCount() throws { guard #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) else { throw XCTSkip("Skipped due to Clock/Instant/Duration availability") } validate { @@ -194,6 +230,15 @@ final class TestChunk: XCTestCase { } } + func test_timeAndCount_countResetsAfterCount_produceEmptyChunks() throws { + guard #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) else { throw XCTSkip("Skipped due to Clock/Instant/Duration availability") } + validate { + "ABCDE --- ABCDE |" + $0.inputs[0].chunks(ofCount: 5, or: .repeating(every: .steps(8), clock: $0.clock), produceEmptyChunks: true).map(concatCharacters) + "----'ABCDE'--'' ----'ABCDE'|" + } + } + func test_timeAndCount_countResetsAfterSignal() throws { guard #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) else { throw XCTSkip("Skipped due to Clock/Instant/Duration availability") } validate {