From d2112a801934cd7a2eda83bef735a857caf1ded4 Mon Sep 17 00:00:00 2001 From: Karl Wagner <5254025+karwa@users.noreply.github.com> Date: Sat, 9 Sep 2023 16:34:11 +0200 Subject: [PATCH 1/6] Add a non-mutating lazy replaceSubrange --- Sources/Algorithms/ReplaceSubrange.swift | 169 +++++++++++++++ .../ReplaceSubrangeTests.swift | 198 ++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 Sources/Algorithms/ReplaceSubrange.swift create mode 100644 Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift diff --git a/Sources/Algorithms/ReplaceSubrange.swift b/Sources/Algorithms/ReplaceSubrange.swift new file mode 100644 index 00000000..31d13d1d --- /dev/null +++ b/Sources/Algorithms/ReplaceSubrange.swift @@ -0,0 +1,169 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Algorithms open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +extension LazyCollection { + + @inlinable + public func replacingSubrange( + _ subrange: Range, with newElements: Replacements + ) -> ReplacingSubrangeCollection { + ReplacingSubrangeCollection(base: elements, replacements: newElements, replacedRange: subrange) + } +} + +public struct ReplacingSubrangeCollection +where Base: Collection, Replacements: Collection, Base.Element == Replacements.Element { + + @usableFromInline + internal var base: Base + + @usableFromInline + internal var replacements: Replacements + + @usableFromInline + internal var replacedRange: Range + + @inlinable + internal init(base: Base, replacements: Replacements, replacedRange: Range) { + self.base = base + self.replacements = replacements + self.replacedRange = replacedRange + } +} + +extension ReplacingSubrangeCollection: Collection { + + public typealias Element = Base.Element + + public struct Index: Comparable { + + @usableFromInline + internal enum Wrapped { + case base(Base.Index) + case replacement(Replacements.Index) + } + + /// The underlying base/replacements index. + /// + @usableFromInline + internal var wrapped: Wrapped + + /// The base indices which have been replaced. + /// + @usableFromInline + internal var replacedRange: Range + + @inlinable + internal init(wrapped: Wrapped, replacedRange: Range) { + self.wrapped = wrapped + self.replacedRange = replacedRange + } + + @inlinable + public static func < (lhs: Self, rhs: Self) -> Bool { + switch (lhs.wrapped, rhs.wrapped) { + case (.base(let unwrappedLeft), .base(let unwrappedRight)): + return unwrappedLeft < unwrappedRight + case (.replacement(let unwrappedLeft), .replacement(let unwrappedRight)): + return unwrappedLeft < unwrappedRight + case (.base(let unwrappedLeft), .replacement(_)): + return unwrappedLeft < lhs.replacedRange.lowerBound + case (.replacement(_), .base(let unwrappedRight)): + return !(unwrappedRight < lhs.replacedRange.lowerBound) + } + } + + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + // No need to check 'replacedRange', because it does not differ between indices from the same collection. + switch (lhs.wrapped, rhs.wrapped) { + case (.base(let unwrappedLeft), .base(let unwrappedRight)): + return unwrappedLeft == unwrappedRight + case (.replacement(let unwrappedLeft), .replacement(let unwrappedRight)): + return unwrappedLeft == unwrappedRight + default: + return false + } + } + } +} + +extension ReplacingSubrangeCollection { + + @inlinable + internal func makeIndex(_ position: Base.Index) -> Index { + Index(wrapped: .base(position), replacedRange: replacedRange) + } + + @inlinable + internal func makeIndex(_ position: Replacements.Index) -> Index { + Index(wrapped: .replacement(position), replacedRange: replacedRange) + } + + @inlinable + public var startIndex: Index { + if base.startIndex == replacedRange.lowerBound { + if replacements.isEmpty { + return makeIndex(replacedRange.upperBound) + } + return makeIndex(replacements.startIndex) + } + return makeIndex(base.startIndex) + } + + @inlinable + public var endIndex: Index { + if replacedRange.lowerBound != base.endIndex || replacements.isEmpty { + return makeIndex(base.endIndex) + } + return makeIndex(replacements.endIndex) + } + + @inlinable + public var count: Int { + base.distance(from: base.startIndex, to: replacedRange.lowerBound) + + replacements.count + + base.distance(from: replacedRange.upperBound, to: base.endIndex) + } + + @inlinable + public func index(after i: Index) -> Index { + switch i.wrapped { + case .base(var baseIndex): + base.formIndex(after: &baseIndex) + if baseIndex == replacedRange.lowerBound { + if replacements.isEmpty { + return makeIndex(replacedRange.upperBound) + } + return makeIndex(replacements.startIndex) + } + return makeIndex(baseIndex) + + case .replacement(var replacementIndex): + replacements.formIndex(after: &replacementIndex) + if replacedRange.lowerBound != base.endIndex, replacementIndex == replacements.endIndex { + return makeIndex(replacedRange.upperBound) + } + return makeIndex(replacementIndex) + } + } + + @inlinable + public subscript(position: Index) -> Element { + switch position.wrapped { + case .base(let baseIndex): + return base[baseIndex] + case .replacement(let replacementIndex): + return replacements[replacementIndex] + } + } +} + diff --git a/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift b/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift new file mode 100644 index 00000000..ca6c9934 --- /dev/null +++ b/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift @@ -0,0 +1,198 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Algorithms open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import XCTest +@testable import Algorithms + +final class ReplaceSubrangeTests: XCTestCase { + + func testAppend() { + + // Base: non-empty + // Appending: non-empty + do { + let base = 0..<5 + let result = base.lazy.replacingSubrange(base.endIndex..() + let result = base.lazy.replacingSubrange(base.endIndex..() + let result = base.lazy.replacingSubrange(base.endIndex..() + let result = base.lazy.replacingSubrange(base.startIndex..() + let result = base.lazy.replacingSubrange(base.startIndex.. Date: Sun, 17 Sep 2023 22:16:54 +0200 Subject: [PATCH 2/6] [ReplaceSubrange] Implement BidirectionalCollection --- Sources/Algorithms/ReplaceSubrange.swift | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Sources/Algorithms/ReplaceSubrange.swift b/Sources/Algorithms/ReplaceSubrange.swift index 31d13d1d..46ed3426 100644 --- a/Sources/Algorithms/ReplaceSubrange.swift +++ b/Sources/Algorithms/ReplaceSubrange.swift @@ -167,3 +167,28 @@ extension ReplacingSubrangeCollection { } } +extension ReplacingSubrangeCollection: BidirectionalCollection +where Base: BidirectionalCollection, Replacements: BidirectionalCollection { + + @inlinable + public func index(before i: Index) -> Index { + switch i.wrapped { + case .base(var baseIndex): + if baseIndex == replacedRange.upperBound { + if replacements.isEmpty { + return makeIndex(base.index(before: replacedRange.lowerBound)) + } + return makeIndex(replacements.index(before: replacements.endIndex)) + } + base.formIndex(before: &baseIndex) + return makeIndex(baseIndex) + + case .replacement(var replacementIndex): + if replacementIndex == replacements.startIndex { + return makeIndex(base.index(before: replacedRange.lowerBound)) + } + replacements.formIndex(before: &replacementIndex) + return makeIndex(replacementIndex) + } + } +} From 4770b04be97c94d7fc9601c81186e347800baa13 Mon Sep 17 00:00:00 2001 From: Karl Wagner <5254025+karwa@users.noreply.github.com> Date: Sun, 17 Sep 2023 22:27:42 +0200 Subject: [PATCH 3/6] [ReplaceSubrange] Create .overlay namespace struct, add RRC-like convenience methods --- Sources/Algorithms/ReplaceSubrange.swift | 159 +++++++++++++----- .../ReplaceSubrangeTests.swift | 72 ++++---- 2 files changed, 156 insertions(+), 75 deletions(-) diff --git a/Sources/Algorithms/ReplaceSubrange.swift b/Sources/Algorithms/ReplaceSubrange.swift index 46ed3426..058090ce 100644 --- a/Sources/Algorithms/ReplaceSubrange.swift +++ b/Sources/Algorithms/ReplaceSubrange.swift @@ -9,37 +9,114 @@ // //===----------------------------------------------------------------------===// -extension LazyCollection { +/// A namespace for methods which overlay a collection of elements +/// over a region of a base collection. +/// +/// Access the namespace via the `.overlay` member, available on all collections: +/// +/// ```swift +/// let base = 0..<5 +/// for n in base.overlay.inserting(42, at: 2) { +/// print(n) +/// } +/// // Prints: 0, 1, 42, 2, 3, 4 +/// ``` +/// +public struct OverlayCollectionNamespace { + + @usableFromInline + internal var elements: Elements + + @inlinable + internal init(elements: Elements) { + self.elements = elements + } +} + +extension Collection { + + /// A namespace for methods which overlay another collection of elements + /// over a region of this collection. + /// + @inlinable + public var overlay: OverlayCollectionNamespace { + OverlayCollectionNamespace(elements: self) + } +} + +extension OverlayCollectionNamespace { + + @inlinable + public func replacingSubrange( + _ subrange: Range, with newElements: Overlay + ) -> OverlayCollection { + OverlayCollection(base: elements, overlay: newElements, replacedRange: subrange) + } + + @inlinable + public func appending( + contentsOf newElements: Overlay + ) -> OverlayCollection { + replacingSubrange(elements.endIndex..( + contentsOf newElements: Overlay, at position: Elements.Index + ) -> OverlayCollection { + replacingSubrange(position.. + ) -> OverlayCollection> { + replacingSubrange(subrange, with: EmptyCollection()) + } + + @inlinable + public func appending( + _ element: Elements.Element + ) -> OverlayCollection> { + appending(contentsOf: CollectionOfOne(element)) + } + + @inlinable + public func inserting( + _ element: Elements.Element, at position: Elements.Index + ) -> OverlayCollection> { + inserting(contentsOf: CollectionOfOne(element), at: position) + } @inlinable - public func replacingSubrange( - _ subrange: Range, with newElements: Replacements - ) -> ReplacingSubrangeCollection { - ReplacingSubrangeCollection(base: elements, replacements: newElements, replacedRange: subrange) + public func removing( + at position: Elements.Index + ) -> OverlayCollection> { + removingSubrange(position.. -where Base: Collection, Replacements: Collection, Base.Element == Replacements.Element { +public struct OverlayCollection +where Base: Collection, Overlay: Collection, Base.Element == Overlay.Element { @usableFromInline internal var base: Base @usableFromInline - internal var replacements: Replacements + internal var overlay: Overlay @usableFromInline internal var replacedRange: Range @inlinable - internal init(base: Base, replacements: Replacements, replacedRange: Range) { + internal init(base: Base, overlay: Overlay, replacedRange: Range) { self.base = base - self.replacements = replacements + self.overlay = overlay self.replacedRange = replacedRange } } -extension ReplacingSubrangeCollection: Collection { +extension OverlayCollection: Collection { public typealias Element = Base.Element @@ -48,10 +125,10 @@ extension ReplacingSubrangeCollection: Collection { @usableFromInline internal enum Wrapped { case base(Base.Index) - case replacement(Replacements.Index) + case overlay(Overlay.Index) } - /// The underlying base/replacements index. + /// The underlying base/overlay index. /// @usableFromInline internal var wrapped: Wrapped @@ -72,11 +149,11 @@ extension ReplacingSubrangeCollection: Collection { switch (lhs.wrapped, rhs.wrapped) { case (.base(let unwrappedLeft), .base(let unwrappedRight)): return unwrappedLeft < unwrappedRight - case (.replacement(let unwrappedLeft), .replacement(let unwrappedRight)): + case (.overlay(let unwrappedLeft), .overlay(let unwrappedRight)): return unwrappedLeft < unwrappedRight - case (.base(let unwrappedLeft), .replacement(_)): + case (.base(let unwrappedLeft), .overlay(_)): return unwrappedLeft < lhs.replacedRange.lowerBound - case (.replacement(_), .base(let unwrappedRight)): + case (.overlay(_), .base(let unwrappedRight)): return !(unwrappedRight < lhs.replacedRange.lowerBound) } } @@ -87,7 +164,7 @@ extension ReplacingSubrangeCollection: Collection { switch (lhs.wrapped, rhs.wrapped) { case (.base(let unwrappedLeft), .base(let unwrappedRight)): return unwrappedLeft == unwrappedRight - case (.replacement(let unwrappedLeft), .replacement(let unwrappedRight)): + case (.overlay(let unwrappedLeft), .overlay(let unwrappedRight)): return unwrappedLeft == unwrappedRight default: return false @@ -96,7 +173,7 @@ extension ReplacingSubrangeCollection: Collection { } } -extension ReplacingSubrangeCollection { +extension OverlayCollection { @inlinable internal func makeIndex(_ position: Base.Index) -> Index { @@ -104,33 +181,33 @@ extension ReplacingSubrangeCollection { } @inlinable - internal func makeIndex(_ position: Replacements.Index) -> Index { - Index(wrapped: .replacement(position), replacedRange: replacedRange) + internal func makeIndex(_ position: Overlay.Index) -> Index { + Index(wrapped: .overlay(position), replacedRange: replacedRange) } @inlinable public var startIndex: Index { if base.startIndex == replacedRange.lowerBound { - if replacements.isEmpty { + if overlay.isEmpty { return makeIndex(replacedRange.upperBound) } - return makeIndex(replacements.startIndex) + return makeIndex(overlay.startIndex) } return makeIndex(base.startIndex) } @inlinable public var endIndex: Index { - if replacedRange.lowerBound != base.endIndex || replacements.isEmpty { + if replacedRange.lowerBound != base.endIndex || overlay.isEmpty { return makeIndex(base.endIndex) } - return makeIndex(replacements.endIndex) + return makeIndex(overlay.endIndex) } @inlinable public var count: Int { base.distance(from: base.startIndex, to: replacedRange.lowerBound) - + replacements.count + + overlay.count + base.distance(from: replacedRange.upperBound, to: base.endIndex) } @@ -140,19 +217,19 @@ extension ReplacingSubrangeCollection { case .base(var baseIndex): base.formIndex(after: &baseIndex) if baseIndex == replacedRange.lowerBound { - if replacements.isEmpty { + if overlay.isEmpty { return makeIndex(replacedRange.upperBound) } - return makeIndex(replacements.startIndex) + return makeIndex(overlay.startIndex) } return makeIndex(baseIndex) - case .replacement(var replacementIndex): - replacements.formIndex(after: &replacementIndex) - if replacedRange.lowerBound != base.endIndex, replacementIndex == replacements.endIndex { + case .overlay(var overlayIndex): + overlay.formIndex(after: &overlayIndex) + if replacedRange.lowerBound != base.endIndex, overlayIndex == overlay.endIndex { return makeIndex(replacedRange.upperBound) } - return makeIndex(replacementIndex) + return makeIndex(overlayIndex) } } @@ -161,34 +238,34 @@ extension ReplacingSubrangeCollection { switch position.wrapped { case .base(let baseIndex): return base[baseIndex] - case .replacement(let replacementIndex): - return replacements[replacementIndex] + case .overlay(let overlayIndex): + return overlay[overlayIndex] } } } -extension ReplacingSubrangeCollection: BidirectionalCollection -where Base: BidirectionalCollection, Replacements: BidirectionalCollection { +extension OverlayCollection: BidirectionalCollection +where Base: BidirectionalCollection, Overlay: BidirectionalCollection { @inlinable public func index(before i: Index) -> Index { switch i.wrapped { case .base(var baseIndex): if baseIndex == replacedRange.upperBound { - if replacements.isEmpty { + if overlay.isEmpty { return makeIndex(base.index(before: replacedRange.lowerBound)) } - return makeIndex(replacements.index(before: replacements.endIndex)) + return makeIndex(overlay.index(before: overlay.endIndex)) } base.formIndex(before: &baseIndex) return makeIndex(baseIndex) - case .replacement(var replacementIndex): - if replacementIndex == replacements.startIndex { + case .overlay(var overlayIndex): + if overlayIndex == overlay.startIndex { return makeIndex(base.index(before: replacedRange.lowerBound)) } - replacements.formIndex(before: &replacementIndex) - return makeIndex(replacementIndex) + overlay.formIndex(before: &overlayIndex) + return makeIndex(overlayIndex) } } } diff --git a/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift b/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift index ca6c9934..998b3e69 100644 --- a/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift +++ b/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift @@ -16,38 +16,41 @@ final class ReplaceSubrangeTests: XCTestCase { func testAppend() { + func _performAppendTest( + base: Base, appending newElements: Overlay, + _ checkResult: (OverlayCollection) -> Void + ) { + checkResult(base.overlay.appending(contentsOf: newElements)) + + checkResult(base.overlay.inserting(contentsOf: newElements, at: base.endIndex)) + + checkResult(base.overlay.replacingSubrange(base.endIndex..() - let result = base.lazy.replacingSubrange(base.endIndex..() - let result = base.lazy.replacingSubrange(base.endIndex..(), appending: EmptyCollection()) { result in XCTAssertEqualCollections(result, []) IndexValidator().validate(result, expectedCount: 0) } @@ -55,38 +58,39 @@ final class ReplaceSubrangeTests: XCTestCase { func testPrepend() { + func _performPrependTest( + base: Base, prepending newElements: Overlay, + _ checkResult: (OverlayCollection) -> Void + ) { + checkResult(base.overlay.inserting(contentsOf: newElements, at: base.startIndex)) + + checkResult(base.overlay.replacingSubrange(base.startIndex..() - let result = base.lazy.replacingSubrange(base.startIndex..() - let result = base.lazy.replacingSubrange(base.startIndex..(), prepending: EmptyCollection()) { result in XCTAssertEqualCollections(result, []) IndexValidator().validate(result, expectedCount: 0) } @@ -98,7 +102,7 @@ final class ReplaceSubrangeTests: XCTestCase { do { let base = 0..<10 let i = base.index(base.startIndex, offsetBy: 5) - let result = base.lazy.replacingSubrange(i.. Date: Mon, 18 Sep 2023 03:29:30 +0200 Subject: [PATCH 4/6] [ReplaceSubrange] Conditional replacement --- Sources/Algorithms/ReplaceSubrange.swift | 85 +++++++++++++++---- .../ReplaceSubrangeTests.swift | 19 +++++ 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/Sources/Algorithms/ReplaceSubrange.swift b/Sources/Algorithms/ReplaceSubrange.swift index 058090ce..2825ae9b 100644 --- a/Sources/Algorithms/ReplaceSubrange.swift +++ b/Sources/Algorithms/ReplaceSubrange.swift @@ -9,8 +9,9 @@ // //===----------------------------------------------------------------------===// -/// A namespace for methods which overlay a collection of elements -/// over a region of a base collection. +/// A namespace for methods which return composed collections, +/// formed by replacing a region of a base collection +/// with another collection of elements. /// /// Access the namespace via the `.overlay` member, available on all collections: /// @@ -24,8 +25,7 @@ /// public struct OverlayCollectionNamespace { - @usableFromInline - internal var elements: Elements + public let elements: Elements @inlinable internal init(elements: Elements) { @@ -35,13 +35,44 @@ public struct OverlayCollectionNamespace { extension Collection { - /// A namespace for methods which overlay another collection of elements - /// over a region of this collection. + /// A namespace for methods which return composed collections, + /// formed by replacing a region of this collection + /// with another collection of elements. /// @inlinable public var overlay: OverlayCollectionNamespace { OverlayCollectionNamespace(elements: self) } + + /// If `condition` is true, returns an `OverlayCollection` by applying the given closure. + /// Otherwise, returns an `OverlayCollection` containing the same elements as this collection. + /// + /// The following example takes an array of products, lazily wraps them in a `ListItem` enum, + /// and conditionally inserts a call-to-action element if `showCallToAction` is true. + /// + /// ```swift + /// var listItems: some Collection { + /// let products: [Product] = ... + /// return products + /// .lazy.map { + /// ListItem.product($0) + /// } + /// .overlay(if: showCallToAction) { + /// $0.inserting(.callToAction, at: min(4, $0.elements.count)) + /// } + /// } + /// ``` + /// + @inlinable + public func overlay( + if condition: Bool, _ makeOverlay: (OverlayCollectionNamespace) -> OverlayCollection + ) -> OverlayCollection { + if condition { + return makeOverlay(overlay) + } else { + return OverlayCollection(base: self, overlay: nil, replacedRange: startIndex.. where Base: Collection, Overlay: Collection, Base.Element == Overlay.Element { @@ -103,13 +148,13 @@ where Base: Collection, Overlay: Collection, Base.Element == Overlay.Element { internal var base: Base @usableFromInline - internal var overlay: Overlay + internal var overlay: Optional @usableFromInline internal var replacedRange: Range @inlinable - internal init(base: Base, overlay: Overlay, replacedRange: Range) { + internal init(base: Base, overlay: Overlay?, replacedRange: Range) { self.base = base self.overlay = overlay self.replacedRange = replacedRange @@ -187,7 +232,7 @@ extension OverlayCollection { @inlinable public var startIndex: Index { - if base.startIndex == replacedRange.lowerBound { + if let overlay = overlay, base.startIndex == replacedRange.lowerBound { if overlay.isEmpty { return makeIndex(replacedRange.upperBound) } @@ -198,6 +243,9 @@ extension OverlayCollection { @inlinable public var endIndex: Index { + guard let overlay = overlay else { + return makeIndex(base.endIndex) + } if replacedRange.lowerBound != base.endIndex || overlay.isEmpty { return makeIndex(base.endIndex) } @@ -206,7 +254,10 @@ extension OverlayCollection { @inlinable public var count: Int { - base.distance(from: base.startIndex, to: replacedRange.lowerBound) + guard let overlay = overlay else { + return base.count + } + return base.distance(from: base.startIndex, to: replacedRange.lowerBound) + overlay.count + base.distance(from: replacedRange.upperBound, to: base.endIndex) } @@ -216,7 +267,7 @@ extension OverlayCollection { switch i.wrapped { case .base(var baseIndex): base.formIndex(after: &baseIndex) - if baseIndex == replacedRange.lowerBound { + if let overlay = overlay, baseIndex == replacedRange.lowerBound { if overlay.isEmpty { return makeIndex(replacedRange.upperBound) } @@ -225,8 +276,8 @@ extension OverlayCollection { return makeIndex(baseIndex) case .overlay(var overlayIndex): - overlay.formIndex(after: &overlayIndex) - if replacedRange.lowerBound != base.endIndex, overlayIndex == overlay.endIndex { + overlay!.formIndex(after: &overlayIndex) + if replacedRange.lowerBound != base.endIndex, overlayIndex == overlay!.endIndex { return makeIndex(replacedRange.upperBound) } return makeIndex(overlayIndex) @@ -239,7 +290,7 @@ extension OverlayCollection { case .base(let baseIndex): return base[baseIndex] case .overlay(let overlayIndex): - return overlay[overlayIndex] + return overlay![overlayIndex] } } } @@ -251,7 +302,7 @@ where Base: BidirectionalCollection, Overlay: BidirectionalCollection { public func index(before i: Index) -> Index { switch i.wrapped { case .base(var baseIndex): - if baseIndex == replacedRange.upperBound { + if let overlay = overlay, baseIndex == replacedRange.upperBound { if overlay.isEmpty { return makeIndex(base.index(before: replacedRange.lowerBound)) } @@ -261,10 +312,10 @@ where Base: BidirectionalCollection, Overlay: BidirectionalCollection { return makeIndex(baseIndex) case .overlay(var overlayIndex): - if overlayIndex == overlay.startIndex { + if overlayIndex == overlay!.startIndex { return makeIndex(base.index(before: replacedRange.lowerBound)) } - overlay.formIndex(before: &overlayIndex) + overlay!.formIndex(before: &overlayIndex) return makeIndex(overlayIndex) } } diff --git a/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift b/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift index 998b3e69..a1abe9d5 100644 --- a/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift +++ b/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift @@ -199,4 +199,23 @@ final class ReplaceSubrangeTests: XCTestCase { IndexValidator().validate(result, expectedCount: 0) } } + + func testConditionalReplacement() { + + func getNumbers(shouldInsert: Bool) -> OverlayCollection, CollectionOfOne> { + (0..<5).overlay(if: shouldInsert) { $0.inserting(42, at: 2) } + } + + do { + let result = getNumbers(shouldInsert: true) + XCTAssertEqualCollections(result, [0, 1, 42, 2, 3, 4]) + IndexValidator().validate(result, expectedCount: 6) + } + + do { + let result = getNumbers(shouldInsert: false) + XCTAssertEqualCollections(result, [0, 1, 2, 3, 4]) + IndexValidator().validate(result, expectedCount: 5) + } + } } From 5a192f24cc3a0ebd68fdbe29e508b2b87ef78004 Mon Sep 17 00:00:00 2001 From: Karl Wagner <5254025+karwa@users.noreply.github.com> Date: Mon, 18 Sep 2023 18:37:53 +0200 Subject: [PATCH 5/6] Add guide for Overlay, rename files to Overlay/OverlayTests --- Guides/Overlay.md | 170 ++++++++++++++++++ .../{ReplaceSubrange.swift => Overlay.swift} | 0 ...SubrangeTests.swift => OverlayTests.swift} | 0 3 files changed, 170 insertions(+) create mode 100644 Guides/Overlay.md rename Sources/Algorithms/{ReplaceSubrange.swift => Overlay.swift} (100%) rename Tests/SwiftAlgorithmsTests/{ReplaceSubrangeTests.swift => OverlayTests.swift} (100%) diff --git a/Guides/Overlay.md b/Guides/Overlay.md new file mode 100644 index 00000000..a2934654 --- /dev/null +++ b/Guides/Overlay.md @@ -0,0 +1,170 @@ +# Overlay + +[[Source](https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Overlay.swift) | + [Tests](https://github.com/apple/swift-algorithms/blob/main/Tests/SwiftAlgorithmsTests/OverlayTests.swift)] + +Compose collections by overlaying the elements of one collection +over an arbitrary region of another collection. + +Swift offers many interesting collections, for instance: + +- `Range` allows us to express the numbers in `0..<1000` + in an efficient way that does not allocate storage for each number. + +- `Repeated` allows us to express, say, one thousand copies of the same value, + without allocating space for a thousand values. + +- `LazyMapCollection` allows us to transform the elements of a collection on-demand, + without creating a copy of the source collection and eagerly transforming every element. + +- The collections in this package, such as `.chunked`, `.cycled`, `.joined`, and `.interspersed`, + similarly compute their elements on-demand. + +While these collections can be very efficient, it is difficult to compose them in to arbitrary datasets. +If we have the Range `5..<10`, and want to insert a `0` in the middle of it, we would need to allocate storage +for the entire collection, losing the benefits of `Range`. Similarly, if we have some numbers in storage +(say, in an Array) and wish to insert a contiguous range in the middle of it, we have to allocate storage +in the Array and cannot take advantage of `Range` memory efficiency. + +The `OverlayCollection` allows us to form arbitrary compositions without mutating +or allocating storage for the result. + +```swift +// 'numbers' is a composition of: +// - Range, and +// - CollectionOfOne + +let numbers = (5..<10).overlay.inserting(0, at: 7) + +for n in numbers { + // n: 5, 6, 0, 7, 8, 9 + // ^ +} +``` + +```swift +// 'numbers' is a composition of: +// - Array, and +// - Range + +let rawdata = [3, 6, 1, 4, 6] +let numbers = rawdata.overlay.inserting(contentsOf: 5..<10, at: 3) + +for n in numbers { + // n: 3, 6, 1, 5, 6, 7, 8, 9, 4, 6 + // ^^^^^^^^^^^^^ +} +``` + +We can also insert elements in to a `LazyMapCollection`: + +```swift +enum ListItem { + case product(Product) + case callToAction +} + +let products: [Product] = ... + +var listItems: some Collection { + products + .lazy.map { ListItem.product($0) } + .overlay.inserting(.callToAction, at: min(4, products.count)) +} + +for item in listItems { + // item: .product(A), .product(B), .product(C), .callToAction, .product(D), ... + // ^^^^^^^^^^^^^ +} +``` + +## Detailed Design + +An `.overlay` member is added to all collections: + +```swift +extension Collection { + public var overlay: OverlayCollectionNamespace { get } +} +``` + +This member returns a wrapper structure, `OverlayCollectionNamespace`, +which provides a similar suite of methods to the standard library's `RangeReplaceableCollection` protocol. + +However, while `RangeReplaceableCollection` methods mutate the collection they are applied to, +these methods return a new `OverlayCollection` value which substitutes the specified elements on-demand. + +```swift +extension OverlayCollectionNamespace { + + // Multiple elements: + + public func replacingSubrange( + _ subrange: Range, with newElements: Overlay + ) -> OverlayCollection + + public func appending( + contentsOf newElements: Overlay + ) -> OverlayCollection + + public func inserting( + contentsOf newElements: Overlay, at position: Elements.Index + ) -> OverlayCollection + + public func removingSubrange( + _ subrange: Range + ) -> OverlayCollection> + + // Single elements: + + public func appending( + _ element: Elements.Element + ) -> OverlayCollection> + + public func inserting( + _ element: Elements.Element, at position: Elements.Index + ) -> OverlayCollection> + + public func removing( + at position: Elements.Index + ) -> OverlayCollection> + +} +``` + +`OverlayCollection` conforms to `BidirectionalCollection` when both the base and overlay collections conform. + +### Conditional Overlays + +In order to allow overlays to be applied conditionally, another function is added to all collections: + +```swift +extension Collection { + + public func overlay( + if condition: Bool, + _ makeOverlay: (OverlayCollectionNamespace) -> OverlayCollection + ) -> OverlayCollection + +} +``` + +If the `condition` parameter is `true`, the `makeOverlay` closure is invoked to apply the desired overlay. +If `condition` is `false`, the closure is not invoked, and the function returns a no-op overlay, +containing the same elements as the base collection. + +This allows overlays to be applied conditionally while still being usable as opaque return types: + +```swift +func getNumbers(shouldInsert: Bool) -> some Collection { + (5..<10).overlay(if: shouldInsert) { $0.inserting(0, at: 7) } +} + +for n in getNumbers(shouldInsert: true) { + // n: 5, 6, 0, 7, 8, 9 +} + +for n in getNumbers(shouldInsert: false) { + // n: 5, 6, 7, 8, 9 +} +``` diff --git a/Sources/Algorithms/ReplaceSubrange.swift b/Sources/Algorithms/Overlay.swift similarity index 100% rename from Sources/Algorithms/ReplaceSubrange.swift rename to Sources/Algorithms/Overlay.swift diff --git a/Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift b/Tests/SwiftAlgorithmsTests/OverlayTests.swift similarity index 100% rename from Tests/SwiftAlgorithmsTests/ReplaceSubrangeTests.swift rename to Tests/SwiftAlgorithmsTests/OverlayTests.swift From 598946f0f7b47c0fadfead8b130a17e1cd490f01 Mon Sep 17 00:00:00 2001 From: Karl Wagner <5254025+karwa@users.noreply.github.com> Date: Thu, 26 Oct 2023 12:12:15 +0200 Subject: [PATCH 6/6] - Fixed OverlayCollectionNamespace.removing(at:) - Shrunk OverlayCollection.Index - Implemented Collection.isEmpty - Added tests for single-element append/insert/removal methods - Added separate removeSubrange tests --- Sources/Algorithms/Overlay.swift | 27 ++-- Tests/SwiftAlgorithmsTests/OverlayTests.swift | 130 +++++++++++++++++- 2 files changed, 143 insertions(+), 14 deletions(-) diff --git a/Sources/Algorithms/Overlay.swift b/Sources/Algorithms/Overlay.swift index 2825ae9b..c42be0a7 100644 --- a/Sources/Algorithms/Overlay.swift +++ b/Sources/Algorithms/Overlay.swift @@ -123,7 +123,7 @@ extension OverlayCollectionNamespace { public func removing( at position: Elements.Index ) -> OverlayCollection> { - removingSubrange(position.. + internal var startOfReplacedRange: Base.Index @inlinable - internal init(wrapped: Wrapped, replacedRange: Range) { + internal init(wrapped: Wrapped, startOfReplacedRange: Base.Index) { self.wrapped = wrapped - self.replacedRange = replacedRange + self.startOfReplacedRange = startOfReplacedRange } @inlinable @@ -197,15 +197,15 @@ extension OverlayCollection: Collection { case (.overlay(let unwrappedLeft), .overlay(let unwrappedRight)): return unwrappedLeft < unwrappedRight case (.base(let unwrappedLeft), .overlay(_)): - return unwrappedLeft < lhs.replacedRange.lowerBound + return unwrappedLeft < lhs.startOfReplacedRange case (.overlay(_), .base(let unwrappedRight)): - return !(unwrappedRight < lhs.replacedRange.lowerBound) + return !(unwrappedRight < lhs.startOfReplacedRange) } } @inlinable public static func == (lhs: Self, rhs: Self) -> Bool { - // No need to check 'replacedRange', because it does not differ between indices from the same collection. + // No need to check 'startOfReplacedRange', because it does not differ between indices from the same collection. switch (lhs.wrapped, rhs.wrapped) { case (.base(let unwrappedLeft), .base(let unwrappedRight)): return unwrappedLeft == unwrappedRight @@ -222,12 +222,12 @@ extension OverlayCollection { @inlinable internal func makeIndex(_ position: Base.Index) -> Index { - Index(wrapped: .base(position), replacedRange: replacedRange) + Index(wrapped: .base(position), startOfReplacedRange: replacedRange.lowerBound) } @inlinable internal func makeIndex(_ position: Overlay.Index) -> Index { - Index(wrapped: .overlay(position), replacedRange: replacedRange) + Index(wrapped: .overlay(position), startOfReplacedRange: replacedRange.lowerBound) } @inlinable @@ -262,6 +262,13 @@ extension OverlayCollection { + base.distance(from: replacedRange.upperBound, to: base.endIndex) } + @inlinable + public var isEmpty: Bool { + return replacedRange.lowerBound == base.startIndex + && replacedRange.upperBound == base.endIndex + && (overlay?.isEmpty ?? true) + } + @inlinable public func index(after i: Index) -> Index { switch i.wrapped { diff --git a/Tests/SwiftAlgorithmsTests/OverlayTests.swift b/Tests/SwiftAlgorithmsTests/OverlayTests.swift index a1abe9d5..19c20d17 100644 --- a/Tests/SwiftAlgorithmsTests/OverlayTests.swift +++ b/Tests/SwiftAlgorithmsTests/OverlayTests.swift @@ -56,6 +56,25 @@ final class ReplaceSubrangeTests: XCTestCase { } } + func testAppendSingle() { + + // Base: empty + do { + let base = EmptyCollection() + let result = base.overlay.appending(99) + XCTAssertEqualCollections(result, [99]) + IndexValidator().validate(result, expectedCount: 1) + } + + // Base: non-empty + do { + let base = 2..<8 + let result = base.overlay.appending(99) + XCTAssertEqualCollections(result, [2, 3, 4, 5, 6, 7, 99]) + IndexValidator().validate(result, expectedCount: 7) + } + } + func testPrepend() { func _performPrependTest( @@ -96,6 +115,25 @@ final class ReplaceSubrangeTests: XCTestCase { } } + func testPrependSingle() { + + // Base: empty + do { + let base = EmptyCollection() + let result = base.overlay.inserting(99, at: base.startIndex) + XCTAssertEqualCollections(result, [99]) + IndexValidator().validate(result, expectedCount: 1) + } + + // Base: non-empty + do { + let base = 2..<8 + let result = base.overlay.inserting(99, at: base.startIndex) + XCTAssertEqualCollections(result, [99, 2, 3, 4, 5, 6, 7]) + IndexValidator().validate(result, expectedCount: 7) + } + } + func testInsert() { // Inserting: non-empty @@ -117,9 +155,17 @@ final class ReplaceSubrangeTests: XCTestCase { } } + func testInsertSingle() { + + let base = 2..<8 + let result = base.overlay.inserting(99, at: base.index(base.startIndex, offsetBy: 3)) + XCTAssertEqualCollections(result, [2, 3, 4, 99, 5, 6, 7]) + IndexValidator().validate(result, expectedCount: 7) + } + func testReplace() { - // Location: start + // Location: anchored to start // Replacement: non-empty do { let base = "hello, world!" @@ -129,7 +175,7 @@ final class ReplaceSubrangeTests: XCTestCase { IndexValidator().validate(result, expectedCount: 17) } - // Location: start + // Location: anchored to start // Replacement: empty do { let base = "hello, world!" @@ -161,7 +207,7 @@ final class ReplaceSubrangeTests: XCTestCase { IndexValidator().validate(result, expectedCount: 9) } - // Location: end + // Location: anchored to end // Replacement: non-empty do { let base = "hello, world!" @@ -171,7 +217,7 @@ final class ReplaceSubrangeTests: XCTestCase { IndexValidator().validate(result, expectedCount: 16) } - // Location: end + // Location: anchored to end // Replacement: empty do { let base = "hello, world!" @@ -200,6 +246,82 @@ final class ReplaceSubrangeTests: XCTestCase { } } + func testRemove() { + + // Location: anchored to start + do { + let base = "hello, world!" + let i = base.index(base.startIndex, offsetBy: 3) + let result = base.overlay.removingSubrange(base.startIndex.. OverlayCollection, CollectionOfOne> {