Repo: Dave861/swift-span-algorithms
SPI Releases: swiftpackageindex.com/Dave861/swift-span-algorithms
Zero dependencies, Apache-2.0, macOS 13 deployment floor for the Span/RawSpan surface, 181 tests.
Why
Span is ~Escapable, so it can't conform to Collection or Sequence and none of the stdlib's generic algorithms come along for free. In practice everyone re-derives split, firstRange(of:) and trimming by hand. Wrote this so you don't have to.
What are the sub-results?
chunks/windows/split classically return lazy collections. Here there's no Collection to return, [Span<T>] isn't expressible at all, and [Range<Int>] would allocate, which defeats the point.
So there are two shapes:
// push — pieces borrow the parent, valid for the closure body
span.forEachSplit(separator: 0) { piece in … }
// pull — same guarantee, but the control flow is yours
var lines = bytes.split(separator: crlf)
while let line = lines.next() { … }
Early exit from the closure form is a typed throws, so it stays allocation-free.
An initial mistak
0.1.0 annotated cursor next() as @_lifetime(&self): each vended piece depended on a mutable borrow of the cursor. That sounds reasonable and is wrong, a cursor mutates its own offset, never the elements. Result:
var c = span.split(separator: 0)
guard let a = c.next(), let b = c.next() else { return }
// error: overlapping accesses to 'c', but modification requires exclusive
// access; consider copying to a local variable
Holding two pieces at once, ordinary look-ahead parsing, was rejected, by a diagnostic that names the caller's variable and suggests a fix that doesn't work.
@_lifetime(copy self) states the actual rule (the piece inherits the base storage's lifetime) and gives up no safety: outliving the storage, returning the piece, and stashing it in a class are all still rejected. Both directions are pinned, the newly-allowed patterns are tests that fail to compile under the old annotation, and the rejections are compile-fail fixtures asserting the diagnostic text.
Multi-source dependence also works, which the pattern-split cursor needs since it borrows both the base and the separator:
@_lifetime(copy base, copy separator)
Performance
memchr/memcmp fast paths for byte spans, as where Element == UInt8 overloads, more specialised than the generic Element: Equatable one, so dispatch is compile-time and free.
M4, ns/op, against a hand-written scalar loop:
| size | hit at 0 | 25% depth | absent |
|---|---|---|---|
| 8 | 0.39× | 0.66× | 2.25× |
| 32 | 0.31× | 1.46× | 5.13× |
| 256 | 0.31× | 6.94× | 11.49× |
| 1024 | 0.35× | 10.41× | 14.51× |
| 65536 | 0.33× | 15.49× | 16.13× |
On a first-byte hit memchr is ~3× slower at every size, a constant ~1.4 ns call overhead. The deciding variable is bytes scanned, not buffer size: scalar ≈0.24 ns/byte vs memchr ≈1.7 ns + 0.015 ns/byte, so break-even is ~7–8 bytes scanned. I've left it unconditional, since the loss is bounded and hit depth isn't knowable in advance, but I'd take arguments.
Reverse search stays scalar: memrchr is a GNU extension and doesn't exist on Darwin, and platform-divergent performance seemed worse than a consistent scalar loop.
Questions I have
split(separator:)returning a cursor, is it the right call, or does borrowing a name that returns anArrayin the stdlib mislead more than the muscle memory helps?- Is
@_lifetime(copy self)what you'd expect on a cursor'snext(), or is there a spelling that expresses "valid while the base storage lives" more directly? - Anything here that's obviously stdlib-destined and shouldn't live in a package at all?
Thank you for reading!