Shorthand for Offsetting startIndex and endIndex

I like your proposal, Letanyan, but shouldn't the mutable subscript be defined on RangeReplaceableCollection instead of MutableCollection? A MutableCollection can only be mutated one element at a time, and you cannot replace arbitrary subranges – that is exactly what RangeReplaceableCollection is for.

Since String conforms to RangeReplaceableCollection, but not MutableCollection, that would allow you to do things like:

var string = "Hello world!"
string[offset: 6..<11] = "universe"

Good point. Thanks!

I really like Xiaodi’s solution. It’s very elegant.

Yes, Xiaodi’s solution is elegant. It also works with one-sided ranges as one would expect.

This looks pretty nice. My only concern is the impact on type checker performance of another integer expressible type. But the compiler performance tests should tell us if that's going to be a problem.

After sleeping on it, I think IndexOffset should be a struct wrapping an Int:

public struct IndexOffset : Equatable {
  public var value: Int
}

extension IndexOffset : ExpressibleByIntegerLiteral {
  public init(integerLiteral value: Int) {
    self.init(value: value)
  }
}

extension IndexOffset : Comparable {
  public static func < (lhs: IndexOffset, rhs: IndexOffset) -> Bool {
    let (a, b) = (lhs.value, rhs.value)
    return (a < b) != ((a < 0) != (b < 0))      // xor
  }
}

extension Collection {
  // subscripts
}

extension RangeReplaceableCollection {
  // mutable subscripts
}

That said, I am not entirely convinced this belongs in the standard library.

1 Like

This is great when using literals! However, would it be acceptable to have to wrap non literal Ints with .start() or IndexOffset inits?

The more I think about it, the more convinced I become that dropFirst() and friends are the right thing to use. They make it natural and convenient to extract the portions of a collection you want, while maintaining O(n) complexity for traversal algorithms.

They certainly are very well-thought-out APIs, and they have their place. But even outside of slicing - just accessing a single element by an index + offset (which I find to not be so uncommon) is extremely verbose.

I'll repeat my example from the other thread:

<collection>[<collection>.index(<collection>.<member>, offsetBy: <distance>)]

or...

results[results.index(results.startIndex, offsetBy: 3)]

Now, it is indeed very explicit about the index traversal operation that will be happening, but it's also a pain in the backside. I'm sure the status quo isn't the best solution we can think of.

I mean, really that's what it comes down to. We have all these operations today, they're just not very pleasant to use or easy to read.

results.dropFirst(3).first isn't great, either.

1 Like

I should really work on strong-typedef more soon.

public typecopy IndexOffset: Int, Comparable, ExpressibleByIntegerLiteral {
    public publish Equatable 
    public publish ExpressibleByIntegerLiteral

    public static func <(lhs: IndexOffset, rhs: IndexOffset) -> Bool {
        let (a, b) = (lhs.rawValue, rhs.rawValue)
        return (a < b) != ((a < 0) != (b < 0)) // xor
    }
}

(Just realized we should have public/internal/etc. for publishing directives.)
(Just realized again: should automatic conformance apply to type-copies that specify Equatable/Hashable/Codable, or should an explicit “publish” be required? I don’t which one would be more surprising.)

Having used many languages that have this feature I am against negative indices counting from the end since it has proven to make 'out-by-one' errors hard to debug.

2 Likes

I think to be able to include the ability to offset from the endIndex we should avoid ranges altogether. Instead, we should use a (Int, Int) to represent the offset amounts.

This would lead to usage looking like such:

var s = "Hello, World!"
s[offset: (7, -1)] = "Swift" // Hello, Swift!

This has the drawbacks of not being able to represent partial ranges, as well as, not having inclusivity options.

Having been writing a lot of tests, involving negative indices, I can certainly find appreciation for this statement. :pensive:

1 Like

Can you explain why negative indexes are any more error-prone (in terms of off-by-one errors) than positive indexes? I’ve found them very useful, and haven’t run into problems like this.

I do think it's less obvious whether you want inclusive or exclusive ranges than when you're thinking forwards. And there's also the question of whether -i>... should be a thing.

I think so. I'm reminded of Dave Abraham's suggestion to use $ and ^ symbols to represent offsets from the start or end, as in $1...^3, which seemed (and seems) a little too cryptic (and Perl-ish) to me. I think being able to explicitly write out the words "start" and "end" is actually more feature than bug.

2 Likes

Off-by-one are normally detected by an out of bounds when you step through all the valid indices. However if -1 is now valid you don't detect out by -1 even if you do test the first index because it wraps. Essentially you have eliminated 50% of your error checking.

Right. The key point here, that I hadn't yet considered, is that negative indexing looks obvious and natural when your range is made from integer literals, but as soon as you start computing the bounds instead it becomes easy to make a mistake that leads to results that are difficult to understand and debug.

I'm not sure you understood what I meant. So here's a more concrete example.

This code won't compile. Because Swift can't lift Int into IndexOffset.

let i = x.index(of: 3)!
let y = x[offset: i...]

One would have to write one of these lines instead.

let y = x[offset: IndexOffset(i)...]
// or
let y = x[offset: .start(i)...]

Letanyan, why are you trying to use an offset there? You already have an index, so you can just write “x[i...]”.