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/Overlay.swift b/Sources/Algorithms/Overlay.swift new file mode 100644 index 00000000..c42be0a7 --- /dev/null +++ b/Sources/Algorithms/Overlay.swift @@ -0,0 +1,329 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// 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: +/// +/// ```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 { + + public let elements: Elements + + @inlinable + internal init(elements: Elements) { + self.elements = elements + } +} + +extension 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..( + _ 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 removing( + at position: Elements.Index + ) -> OverlayCollection> { + removingSubrange(position.. +where Base: Collection, Overlay: Collection, Base.Element == Overlay.Element { + + @usableFromInline + internal var base: Base + + @usableFromInline + internal var overlay: Optional + + @usableFromInline + internal var replacedRange: Range + + @inlinable + internal init(base: Base, overlay: Overlay?, replacedRange: Range) { + self.base = base + self.overlay = overlay + self.replacedRange = replacedRange + } +} + +extension OverlayCollection: Collection { + + public typealias Element = Base.Element + + public struct Index: Comparable { + + @usableFromInline + internal enum Wrapped { + case base(Base.Index) + case overlay(Overlay.Index) + } + + /// The underlying base/overlay index. + /// + @usableFromInline + internal var wrapped: Wrapped + + /// The base index at which the overlay starts -- i.e. `replacedRange.lowerBound` + /// + @usableFromInline + internal var startOfReplacedRange: Base.Index + + @inlinable + internal init(wrapped: Wrapped, startOfReplacedRange: Base.Index) { + self.wrapped = wrapped + self.startOfReplacedRange = startOfReplacedRange + } + + @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 (.overlay(let unwrappedLeft), .overlay(let unwrappedRight)): + return unwrappedLeft < unwrappedRight + case (.base(let unwrappedLeft), .overlay(_)): + return unwrappedLeft < lhs.startOfReplacedRange + case (.overlay(_), .base(let unwrappedRight)): + return !(unwrappedRight < lhs.startOfReplacedRange) + } + } + + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + // 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 + case (.overlay(let unwrappedLeft), .overlay(let unwrappedRight)): + return unwrappedLeft == unwrappedRight + default: + return false + } + } + } +} + +extension OverlayCollection { + + @inlinable + internal func makeIndex(_ position: Base.Index) -> Index { + Index(wrapped: .base(position), startOfReplacedRange: replacedRange.lowerBound) + } + + @inlinable + internal func makeIndex(_ position: Overlay.Index) -> Index { + Index(wrapped: .overlay(position), startOfReplacedRange: replacedRange.lowerBound) + } + + @inlinable + public var startIndex: Index { + if let overlay = overlay, base.startIndex == replacedRange.lowerBound { + if overlay.isEmpty { + return makeIndex(replacedRange.upperBound) + } + return makeIndex(overlay.startIndex) + } + return makeIndex(base.startIndex) + } + + @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) + } + return makeIndex(overlay.endIndex) + } + + @inlinable + public var count: Int { + 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) + } + + @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 { + case .base(var baseIndex): + base.formIndex(after: &baseIndex) + if let overlay = overlay, baseIndex == replacedRange.lowerBound { + if overlay.isEmpty { + return makeIndex(replacedRange.upperBound) + } + return makeIndex(overlay.startIndex) + } + return makeIndex(baseIndex) + + case .overlay(var overlayIndex): + overlay!.formIndex(after: &overlayIndex) + if replacedRange.lowerBound != base.endIndex, overlayIndex == overlay!.endIndex { + return makeIndex(replacedRange.upperBound) + } + return makeIndex(overlayIndex) + } + } + + @inlinable + public subscript(position: Index) -> Element { + switch position.wrapped { + case .base(let baseIndex): + return base[baseIndex] + case .overlay(let overlayIndex): + return overlay![overlayIndex] + } + } +} + +extension OverlayCollection: BidirectionalCollection +where Base: BidirectionalCollection, Overlay: BidirectionalCollection { + + @inlinable + public func index(before i: Index) -> Index { + switch i.wrapped { + case .base(var baseIndex): + if let overlay = overlay, baseIndex == replacedRange.upperBound { + if overlay.isEmpty { + return makeIndex(base.index(before: replacedRange.lowerBound)) + } + return makeIndex(overlay.index(before: overlay.endIndex)) + } + base.formIndex(before: &baseIndex) + return makeIndex(baseIndex) + + case .overlay(var overlayIndex): + if overlayIndex == overlay!.startIndex { + return makeIndex(base.index(before: replacedRange.lowerBound)) + } + overlay!.formIndex(before: &overlayIndex) + return makeIndex(overlayIndex) + } + } +} diff --git a/Tests/SwiftAlgorithmsTests/OverlayTests.swift b/Tests/SwiftAlgorithmsTests/OverlayTests.swift new file mode 100644 index 00000000..19c20d17 --- /dev/null +++ b/Tests/SwiftAlgorithmsTests/OverlayTests.swift @@ -0,0 +1,343 @@ +//===----------------------------------------------------------------------===// +// +// 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() { + + 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..(), appending: EmptyCollection()) { result in + XCTAssertEqualCollections(result, []) + IndexValidator().validate(result, expectedCount: 0) + } + } + + 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( + base: Base, prepending newElements: Overlay, + _ checkResult: (OverlayCollection) -> Void + ) { + checkResult(base.overlay.inserting(contentsOf: newElements, at: base.startIndex)) + + checkResult(base.overlay.replacingSubrange(base.startIndex..(), prepending: EmptyCollection()) { result in + XCTAssertEqualCollections(result, []) + IndexValidator().validate(result, expectedCount: 0) + } + } + + 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 + do { + let base = 0..<10 + let i = base.index(base.startIndex, offsetBy: 5) + let result = base.overlay.inserting(contentsOf: 20..<25, at: i) + XCTAssertEqualCollections(result, [0, 1, 2, 3, 4, 20, 21, 22, 23, 24, 5, 6, 7, 8, 9]) + IndexValidator().validate(result, expectedCount: 15) + } + + // Inserting: empty + do { + let base = 0..<10 + let i = base.index(base.startIndex, offsetBy: 5) + let result = base.overlay.inserting(contentsOf: EmptyCollection(), at: i) + XCTAssertEqualCollections(result, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + IndexValidator().validate(result, expectedCount: 10) + } + } + + 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: anchored to start + // Replacement: non-empty + do { + let base = "hello, world!" + let i = base.index(base.startIndex, offsetBy: 3) + let result = base.overlay.replacingSubrange(base.startIndex.. 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) + } + } +}