-
Notifications
You must be signed in to change notification settings - Fork 440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a non-mutating lazy replaceSubrange #203
base: main
Are you sure you want to change the base?
Conversation
adebb7b
to
799bc7c
Compare
01c73b8
to
d2112a8
Compare
So, when checking this out using Godbolt, I find that the compiler produces shockingly good code. To the extent that, while I initially thought it might be a bit niche, I now wonder if it could be an interesting alternative to mutating operations for a lot of other applications - particularly SwiftUI applications. Imagine I have some data, perhaps retrieved from the network or stored in some global datasource object. Let's say they're some kind of product, perhaps shoes, and I'm displaying them in a list/grid. I also have a boolean, telling me whether I should display a special call-to-action item in the list. This is a fairly standard thing to want. The actual data that I want to use to drive my list/grid is composed from those two elements: the list of products and the boolean. class ProductsListViewModel {
var rawProductsList: [Product] = ...
var showCallToAction: Bool = ...
var viewItems: ??? // built by combining rawProductsList with showCallToAction
} SwiftUI tends to emphasise computed properties for these kinds of things -- while I could make Computed properties, on the other hand, are always up-to-date, taking account of the latest list of products and the latest state of the boolean. With However, within the implementation of the computed property, we must make a copy of the products list in order to inject elements in to it. The lack of lazy insertions also means we have to perform eager transformations such as class ProductsListViewModel {
var rawProductsList: [Product] = ...
var showCallToAction: Bool = ...
var viewItems: some Collection<ListItem> {
var result = rawProductsList.map { .product($0) }
if showCallToAction {
result.insert(.callToAction, at: min(result.count, 4))
}
return result
}
} This is unfortunate, since With lazy insertions, we can avoid that: class ProductsListViewModel {
var rawProductsList: [Product] = ...
var showCallToAction: Bool = ...
var viewItems: some Collection<ListItem> {
let result = rawProductsList.lazy.map { .product($0) }
let ctaPosition = result.index(result.startIndex, offsetBy: 4, limitedBy: result.endIndex) ?? result.endIndex
if showCallToAction {
return result.replacingSubrange(ctaPosition..<ctaPosition, with: CollectionOfOne(.callToAction))
} else {
return result.replacingSubrange(result.startIndex..<result.startIndex, with: EmptyCollection()) // no-op.
}
}
}
Iterating such a collection is more expensive than iterating an array, but because the compiler does such a good job with the generated code, there's a very good chance that it's still a net positive. Unfortunately, it is also more awkward to use. Which is why another idea that I have WRT naming is to create a result.overlay.insert(.callToAction, at: ctaPosition)
// instead of
result.replacingSubrange(ctaPosition..<ctaPosition, with: CollectionOfOne(.callToAction)) (Array obviously has integer indexing, which is supremely convenient. We can't quite match that, but we could perhaps add some convenience APIs such as |
One issue is that, if you're only conditionally applying a replacement while returning an opaque type, you need to perform a no-op replacement which ensures both branches have the same type. e.g: if showCallToAction {
return result.overlay.inserting(.callToAction, at: min(4, result.count))
} else {
// Must return an OverlayCollection<Base, CollectionOfOne<ListItem>> which only contains the base elements.
// That's not trivial.
} What we can do instead is to (internally) make the overlay elements optional (i.e. the stored property To expose that feature, we could add a conditional overlay function: extension Collection {
@inlinable
public func overlay<Overlay>(
if condition: Bool,
_ makeOverlay: (OverlayCollectionNamespace<Self>) -> OverlayCollection<Self, Overlay>
) -> OverlayCollection<Self, Overlay> {
if condition {
return makeOverlay(self.overlay)
} else {
return OverlayCollection(base: self, overlay: nil, replacedRange: startIndex..<startIndex)
}
}
} Allowing usage like: var viewItems: some Collection<ListItem> {
let result = rawProductsList.lazy.map { .product($0) }
return result.overlay(if: showCallToAction) {
$0.inserting(.callToAction, at: min(4, $0.count))
}
} |
32af96d
to
5a192f2
Compare
replaceSubrange - Nice! |
For the single-element removal method, isn't "removingSubrange(position..<position)" wrong? The range above specifies zero elements. You'll need "position ..< NEXT(position)" instead, where NEXT is replaced with the appropriate code. |
@CTMacUser Yes, good catch! |
|
You could add an optimized @inlinable
public var isEmpty: Bool {
base[..<replacedRange.lowerBound].isEmpty && base[replacedRange.upperBound...].isEmpty && (overlay?.isEmpty ?? true)
} |
Yes it can, because whether we're constructing a base or overlay index is determined based on unbound generics. Specialisation happens later, and does not affect overload resolution. This is one of the ways in which Swift generics are not like C++ templates. For example, the very first test appends an |
- Shrunk OverlayCollection.Index - Implemented Collection.isEmpty - Added tests for single-element append/insert/removal methods - Added separate removeSubrange tests
@natecook1000 Thoughts? |
In my run through of func getNoNumbers(shouldInsert: Bool) -> OverlayCollection<Range<Int>, CollectionOfOne<Int>> {
(0..<0).overlay(if: shouldInsert) { $0.inserting(42, at: 0) }
}
do {
let result = getNoNumbers(shouldInsert: true)
XCTAssertEqualCollections(result, [42])
IndexValidator().validate(result, expectedCount: 1)
}
do {
let result = getNoNumbers(shouldInsert: false)
XCTAssertEqualCollections(result, [])
IndexValidator().validate(result, expectedCount: 0)
} |
Sometimes, you want to insert/remove elements from a collection without creating a copy and mutating it.
It's kind of a generalisation of
chain
. And maybejoined
. I still need to do the guide document and implementBidirectionalCollection
, but I'm creating the PR now for discussion purposes. In particular:Is this a good candidate for inclusion in the project?
What should it be named?
x.lazy.replacingSubrange(i..<j, with: y)
isn't amazing, but draws strong parallels withreplaceSubrange
.I could also see "overlaying", as in
x.overlaying(i..<j, with: y)
. Any other ideas?Does anybody know of similar features in other languages?
For the guide doc. I tried searching but couldn't find anything.
Checklist