Pitch: Offsetting Indices and Relative Ranges

Pitch: Offsetting Indices and Relative Ranges

Hello everyone, I’m back with yet another attempt at deriving indices, ranges, and slices through offsets.

This pitch introduces the type RelativeRange, which is capable of representing a range whose bounds are relative to a normal range’s bounds by applying an offset. Applying offsets to indices and using relative ranges brings ease of use improvements to all Collections and is a convenient tools for reducing bugs in Int-indexed Collections. It also makes types like String more approachable in casual contexts, such as for programming puzzles and learning.

Changes in revision 2: Collapses all the relative range types into RelativeRange, internalizes the backing enum of RelativeBound, and minor clarifications.

Approach

Bounds, (e.g. indices) can have offsets applied to them, creating RelativeBounds which can be used for range formation and slicing. We introduce infix ++ and -- to create these, and either side may be omitted to imply startIndex and endIndex, just like the existing range operators.

RelativeRange and corresponding partial variants are introduced, all of which conform to RangeExpression so they can be used in all the normal places, such as slicing and replaceSubrange.

  let str = "abcdefghijklmnopqrstuvwxyz"
  let idx = str.firstIndex { $0 == "n" }!
  print(str[--4]) // w
  print(str[idx++1]) // o
  print(str[idx--2...idx++3]) // lmnopq

Other suggestions for operators are certainly welcome. I accepted ++ and -- after seeing @dabrahams mentioning them here. At first glance, they seemed a little odd (old C++ baggage), but I quickly adjusted and grew to like them. The pair of operators imply advancing forwards and backwards by the value on the right-hand-side.

Example Uses

Reduce Bugs for Int Indices

When a RandomAccessCollection’s index type happens to be Int, it’s a common mistake to assume that such indices start from zero.

For an example from this forum post, the fact that an Int range’s indices are the very Ints themselves means they don’t start at zero. Using relative offsets addresses this:

  let r = 3..<10
  print((absolute: r[5...], relative: r[++5...]))
  // (absolute: Range(5..<10), relative: Range(8..<10))

This can be tricky in generic code. Tests written with e.g. Array will pass, until some future caller supplies a slice type. Most slices share indices with the outer collection and thus do not start at 0. This illustrates the difference:

  func getFifth<C: RandomAccessCollection>(
    _ c: C
  ) -> (absolute: C.Element, relative: C.Element) where C.Index == Int {
    return (c[5], c[++5])
  }

  let array = [0, 1,2,3,4,5,6,7,8,9]
  print(getFifth(array)) // (absolute: 5, relative: 5)
  print(getFifth(array[2...])) // (absolute: 5, relative: 7)

During code review, the generic signature and constraint on C.Index might trigger more careful inspection that might catch the bug. But, this is a big foot-gun for Collections that are their own slice type, such as Data. For Data, there’s nothing in the type system that hints it might not start at zero. A library may just happen to work for multiple releases until one day a client passes in a Data that happened to be sliced. This illustrates the difference:

  func getFifth(_ data: Data) -> (absolute: UInt8, relative: UInt8) {
    return (data[5], data[++5])
  }

  var data = Data([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  print(getFifth(data)) // (absolute: 5, relative: 5)

  data = data.dropFirst()
  print(getFifth(data)) // (absolute: 5, relative: 6)

Expressive Relative Indexing

For an example from a simplification of this forum post, we can conveniently apply a relative offset to an index we derived from another API:

  func splitAndTruncate<T: BinaryFloatingPoint>(
    _ value: T, precision: Int = 3
  ) -> (whole: Substring, fraction: Substring) {
    let str = String(describing: value)
    guard let dotIdx = str.firstIndex(of: ".") else { return (str[...], "") }
    let fracIdx = dotIdx++1
    return (str[..<dotIdx], str[fracIdx..<fracIdx++precision])
  }

  print(splitAndTruncate(1.0)) // (whole: "1", fraction: "0")
  print(splitAndTruncate(1.25)) // (whole: "1", fraction: "25")
  print(splitAndTruncate(1.1000000000000001)) // (whole: "1", fraction: "1")
  print(splitAndTruncate(1.3333333)) // (whole: "1", fraction: "333")
  print(splitAndTruncate(200)) // (whole: "200", fraction: "0")

An operation such as fracIdx++precision has runtime complexity of O(precision).

Relative indexing is also useful for fixed-width textual formats. A very simple example from Advent of Code 2018 Day 7:

  func parseRequirement(
    _ str: Substring
  ) -> (predecessor: Unicode.Scalar, successor: Unicode.Scalar) {
    return (str.unicodeScalars[++5], str.unicodeScalars[++36])
  }

  """
  Step C must be finished before step A can begin.
  Step C must be finished before step F can begin.
  Step A must be finished before step B can begin.
  Step A must be finished before step D can begin.
  Step B must be finished before step E can begin.
  Step D must be finished before step E can begin.
  Step F must be finished before step E can begin.
  """.split(separator: "\n").forEach { print(parseRequirement($0)) }
  // (predecessor: "C", successor: "A")
  // (predecessor: "C", successor: "F")
  // (predecessor: "A", successor: "B")
  // (predecessor: "A", successor: "D")
  // (predecessor: "B", successor: "E")
  // (predecessor: "D", successor: "E")
  // (predecessor: "F", successor: "E")

These being available on all Collections means that all of String’s views, such as the UnicodeScalarView and UTF8View, benefit as well. These views give more technical precision, which is useful for processing textual formats that are not defined over grapheme clusters.

The below is a visual demonstration of all the nifty new additions:

  let str = "abcdefghijklmnopqrstuvwxyz"
  let idx = str.firstIndex { $0 == "n" }!
  // -- single element subscript --
  print(str[--14]) // m
  print(str[idx--100]) // a
  print(str[idx++1]) // o
  print(str[(idx++1)--10]) // e
  // -- relative range --
  print(str[++1 ..< --2]) // bcdefghijklmnopqrstuvwx
  print(str[idx--2 ..< --2]) // lmnopqrstuvwx
  print(str[idx ..< --2]) // nopqrstuvwx
  print(str[idx--2..<idx]) // lm
  print(str[idx--2..<idx++3]) // lmnop
  print(str[--4 ..< --2]) // wx
  // -- relative range through --
  print(str[idx--2 ... --2]) // lmnopqrstuvwxy
  print(str[idx ... --2]) // nopqrstuvwxy
  print(str[idx--2...idx]) // lmn
  print(str[idx--2...idx++3]) // lmnopq
  print(str[--4 ... --2]) // wxy
  // -- partial relative range up to --
  print(str[..<idx++2]) // abcdefghijklmno
  print(str[..<idx--2]) // abcdefghijk
  print(str[..<(++20)]) // abcdefghijklmnopqrst
  print(str[..<(--20)]) // abcdef
  // -- partial relative range through --
  print(str[...idx++2]) // abcdefghijklmnop
  print(str[...idx--2]) // abcdefghijkl
  print(str[...(++20)]) // abcdefghijklmnopqrstu
  print(str[...(--20)]) // abcdefg
  // -- partial relative range from --
  print(str[idx++2...]) // pqrstuvwxyz
  print(str[idx--2...]) // lmnopqrstuvwxyz
  print(str[++20...]) // uvwxyz
  print(str[--20...]) // ghijklmnopqrstuvwxyz

Detailed Design

This introduces RelativeBound, which is capable of representing a bound (e.g. index) with some offset applied to it. The bound itself may be omitted, in which case it is a pure offset from either the start of end.

public struct RelativeBound<Bound: Comparable> {
  // implementation is internal enum of the form:
  //  case from(Bound, offset: Int)
  //  case fromStart(offset: Int)
  //  case fromEnd(offset: Int)
}

Negative offsets, or positive fromEnd offsets, are only valid on BidirectionalCollection, following the same rules as index(_:offsetBy:).

This also adds relative (partial) range structs corresponding to existing (partial) ranges. They conform to RangeExpression so they can be used in slicing, replaceSubrange, removeSubrange, etc. Conforming to RangeExpression means that they supply a partial-order (for containment) and can produce a Range<Index> when given a Collection. These new structs are necessary due to ABI-stability, since we cannot change the existing ranges nor RangeExpression.

public struct RelativeRange<Bound: Comparable>: RangeExpression {
  public func relative<C: Collection>(
    to col: C
  ) -> Range<Bound> where C.Index == Bound
  public func contains(_ element: Bound) -> Bool
}
public struct RelativeClosedRange<Bound: Comparable>: RangeExpression {
  public func relative<C: Collection>(
    to col: C
  ) -> Range<Bound> where C.Index == Bound
  public func contains(_ element: Bound) -> Bool
}
public struct RelativePartialRangeUpTo<Bound: Comparable>: RangeExpression {
  public func relative<C: Collection>(
    to col: C
  ) -> Range<Bound> where C.Index == Bound
  public func contains(_ element: Bound) -> Bool
}
public struct RelativePartialRangeThrough<Bound: Comparable>: RangeExpression {
  public func relative<C: Collection>(
    to col: C
  ) -> Range<Bound> where C.Index == Bound
  public func contains(_ element: Bound) -> Bool
}
public struct RelativePartialRangeFrom<Bound: Comparable>: RangeExpression {
  public func relative<C: Collection>(
    to col: C
  ) -> Range<Bound> where C.Index == Bound
  public func contains(_ element: Bound) -> Bool
}

The partial order is derived by considering the relative ranges as having a “hole” in the middle, similarly to how partial ranges have holes at the beginning or end. Order is derived by first comparing the bounds, followed by offsets, accounting for the implicit start and end bound:

  internal static func < (lhs: RelativeBound, rhs: RelativeBound) -> Bool {
    switch (lhs, rhs) {
    case (.from(let lhsBound, let lhsOffset),
          .from(let rhsBound, let rhsOffset)):
      if lhsBound == rhsBound { return lhsOffset < rhsOffset }
      return lhsBound < rhsBound
    case (.from(_, _), .fromStart(_)): return false
    case (.from(_, _), .fromEnd(_)): return true

    case (.fromStart(_), .from(_, _)): return true
    case (.fromStart(let lhs), .fromStart(let rhs)): return lhs < rhs
    case (.fromStart(_), .fromEnd(_)): return true

    case (.fromEnd(_), .from(_, _)): return false
    case (.fromEnd(_), .fromStart(_)): return true
    case (.fromEnd(let lhs), .fromEnd(let rhs)): return lhs > rhs
    }
  }

RelativeBound does not conform to Comparable, which would make them candidates for the type checker to consider as bounds for the existing range structs. Such range structs would be rejected anyways when used due to the same-type constraint between Bound and Index.

What a relative range really represents is determined when applied to a collection. These ranges don’t supply additional conditional conformances, such as Range’s random access conformance if Bound is Strideable, as these are ephemeral structures meant to soon be applied, producing a regular Range. Unlike Range, these are not likely to be heavily used as currency types.

We provide infix ++ and --, which may have the left hand side side omitted to imply an implicit start/end index. We do this through more overloads of prefix and postfix ++, --, and disambiguating overloads over PartialRangeFrom:

precedencegroup RelativeOffsetPrecedence {
  higherThan: RangeFormationPrecedence
}

infix operator ++ : RelativeOffsetPrecedence
infix operator -- : RelativeOffsetPrecedence

extension Int {
  public static prefix func ++<Bound: Comparable>(
    rhs: Int
  ) -> RelativeBound<Bound>

    public static prefix func --<Bound: Comparable>(
    rhs: Int
  ) -> RelativeBound<Bound>
}
extension Comparable {
  public static func ++(
    lhs: Self, rhs: Int
  ) -> RelativeBound<Self>

  public static func --(
    lhs: Self, rhs: Int
  ) -> RelativeBound<Self>
}

extension RelativeBound {
  public static func ++(
    lhs: RelativeBound, rhs: Int
  ) -> RelativeBound {
    return lhs.addingOffset(rhs)
  }
  public static func --(
    lhs: RelativeBound, rhs: Int
  ) -> RelativeBound {
    return lhs.addingOffset(-rhs)
  }
}

// ... and some more convenience overloads

We provide overloads for the range operators:

extension RelativeBound {
  public static func ..< (
    lhs: RelativeBound<Bound>, rhs: RelativeBound<Bound>
  ) -> RelativeRange<Bound>

  public static func ..< (
    lhs: Bound, rhs: RelativeBound<Bound>
  ) -> RelativeRange<Bound>

  public static func ..< (
    lhs: RelativeBound<Bound>, rhs: Bound
  ) -> RelativeRange<Bound>

  public static func ... (
    lhs: RelativeBound<Bound>, rhs: RelativeBound<Bound>
  ) -> RelativeClosedRange<Bound>

  public static func ... (
    lhs: Bound, rhs: RelativeBound<Bound>
  ) -> RelativeClosedRange<Bound>

  public static func ... (
    lhs: RelativeBound<Bound>, rhs: Bound
  ) -> RelativeClosedRange<Bound>

  public static prefix func ..< (
    maximum: RelativeBound<Bound>
  ) -> PartialRelativeRangeUpTo<Bound>

  public static prefix func ... (
    maximum: RelativeBound<Bound>
  ) -> PartialRelativeRangeThrough<Bound>

  public static postfix func ... (
    maximum: RelativeBound<Bound>
  ) -> PartialRelativeRangeFrom<Bound>
}

Try it Out

Copy-paste this gist into your code, and you can try it today! It has examples at the bottom. No need to download/install toolchains.

The implementation is here.

Acknowledgements

Many people have contributed to this design space in the prior threads. Huge thanks to @Letan’s original thread and proposals which spawned a ton of great feedback and explored so much of the design space. My earlier thread acknowledges the insights of @Letan, @dabrahams, @QuinceyMorris, @xwu, @Karl, @nnnnnnnn, @SDGGiesbrecht, and @itaiferber. It was meant to be a smaller less controversial short-term approach, but this way provides much more functionality and obviates some of the design details such as the optional-ness in APIs.

This approach is a combination of @xwu’s ordering semantics with @beccadax’s RelativeBound and @dabrahams ’s syntax.

If any of you want to be a co-author or acknowledged in the formal proposal, please let me know.

18 Likes

Personally, I don't like the idea that relative indexes can be saved into variables. I feel like that's just asking for accidental O(n2) things on non-random-access collections. If you want to save a relative index for later, go and actually advance it on your collection so that every future usage of it doesn't have to do that.

Since there's not much we can do to prevent this, I'm still positive on the pitch as a whole, but would like it if you didn't have examples promoting that use (like let fracIdx = dotIdx++1). Instead, people should probably use (dotIdx++1).index(for: str) or str.index(after: dotIdx) though the latter doesn't stop at the end of the collection.

Demo:

func fast() {
	let str = String(repeating: "ab", count: 50_000)
	var index = str.startIndex
	for _ in 0..<100_000 {
		print(str[index])
		index = (index++1).index(for: str)
	}
}

func slow() {
	let str = String(repeating: "ab", count: 50_000)
	var index = str.startIndex++0
	for _ in 0..<100_000 {
		print(str[index])
		index = index++1
	}
}

This seems 100% good to me. The only thing that kinda bugs me is the ++ and -- operators, because obv they will be confusing with people who’ve used other languages. But I don’t have any better suggestions, so, as they (I) say at my company (which is me), “If you don’t have a helpful suggestion you can STFU.”

3 Likes

I love it!

Exciting! I think this looks really promising — I'm going to play with some different uses that I've had in the past and come up with some examples to show.

A couple notes/questions:

  1. Could we use only RelativeRange and RelativeClosedRange instead of five separate relative range types? The other three could be defined in terms of those two -- e.g. PartialRelativeRangeUpTo is the same as ++0 ..< bound.

  2. It's fairly easy to devise relative ranges that "fail" contains(_:) checks. I'm putting fail in scare quotes since containment isn't explicitly defined for range expressions, though any reasonable definition would probably expect success in places where relative ranges simply can't succeed, given that they don't know about the actual indices of the collection they're checking. String indices are impossible to test without the string:

    let s = "abcdefg"
    let i_e = s.firstIndex(of: "f")!
    let i_3 = (s.startIndex ++ 3)
    let i_7 = (s.startIndex ++ 7)
    
    (i_3...).contains(i_e) // true, correct
    (i_7...).contains(i_e) // true, incorrect
    

    And cases like this don't look great:

    let offsetByTwo = 0++2
    let twoToEight = offsetByTwo ..< ++8
    
    twoToEight.contains(2)    // false
    twoToEight.contains(4)    // false
    twoToEight.contains(10)   // false
    

    I think my question is — is that a problem? RangeExpression probably shouldn't have included that requirement, but I'm not sure if a stdlib type can just sort of shrug at it.

I do like the general approach, love the notation (++ and -- for offsets), and I'm flattered to be thought of as having contributed anything to the discussion.

Admitting that I haven't played with this myself in its current incarnation, I do feel uncomfortable with the full set of features proposed. Specifically, with a bit of imprecision in terminology that I hope can be forgiven, I have the following thought:

The major convenience furnished by this syntax--the main motivation, at least for me--is that we can offset from the start and end of a collection without first retrieving a specific index, which in the general case is only valid for a specific instance of a collection. Suppose I wanted to take off the first and last letter of several strings. I could write:

let range = ++1 ... --1
let x = " Hello, World! "
let y = "abcdefg"
let z = "  "

print(x[range])
// "Hello, World!"
print(y[range])
// "bcdef"
print(z[range])
// ""

This reusability is a killer feature, and as far as I can tell is explicitly supported in this pitch:

What a relative range really represents is determined when applied to a collection.


Yet, here the same shorthand (i.e., the same operators in their infix form) is proposed for offsetting relative to a specific index. Indeed it is even proposed to support the mixing and matching of bounds where one is not relative to a specific index but the other is:

  // Verbatim from the pitch text:
  let str = "abcdefghijklmnopqrstuvwxyz"
  let idx = str.firstIndex { $0 == "n" }!
  print(str[idx ..< --2]) // nopqrstuvwx
  print(str[idx--2 ..< --2]) // lmnopqrstuvwx

Of course, the ranges idx ..< --2 and idx--2 ..< --2 are valid only with respect to str and not any other string.

There is no question that there are use cases where this might come in handy. But, given empiric evidence on this forum of how misunderstood Swift indices can be, I have no doubt that users will try to assign idx--2 ..< --2 to a variable just as they do --4 ..< --2 and try to slice more than one collection. Absent additional guarantees of the specific collection type, however, the former can only correctly be used to slice one specific instance while the latter can be used to slice any collection.

Therefore, I question whether it is wise to expand this feature set to support such use cases, given the difficulty of learning, teaching, and correctly using a feature where "what a relative range really represents is determined when applied to a collection," but where sometimes both or even just one of the bounds is actually already predetermined to be valid when applied only to a specific collection instance.

This is a longwinded way of saying that I'm wondering if it may be wiser to limit the feature to the prefix operators only. I would surmise it would represent the vast majority of uses of this feature, and if constrained in that way, I think it would be much less prone to misuse and misunderstanding.

7 Likes

I like the idea but I hate the operators. The double plus or minus operator is confusing. It's very beginner unfriendly. And, yes… it's odd too.

For example, why not print(str[idx+1]) instead of print(str[idx++1])? The ++ is known as increment operator, while -- is known as decrement operator, so using it as range operator isn't really obvious. When I see idx++1, I see it as increment instead of range bound.

How about using » and « instead? It's just ⇧⌥| and ⌥| on Mac keyboard anyway. It's easy to type.

For example, print(str[idx » 1]). I think it looks better too. Or, FWIW, we could override >> and << operators for string index as range bound operator. Like print(str[idx>>1]) that could be read as move from idx up to 1 point. Or print(str[idx<<1]) that could be read as move from idx down to 1 point. It could be a problem if we want to compute range bound in binary. Hence I prefer the « and ».

What do you think?

1 Like

Not everyone uses a mac, and even if they did, you would have to check if that's true for all keyboard layouts

Imho string processing (for the sake of simplicity, I ignore a vast amount of other useful collections ;-) definitely needs some polishing, so I really hope this will turn into an accepted proposal.

I agree with those who have reservations against ++/-- and would prefer something different (<< / >>?).
Most likely, there are some good reasons not to use +/- (although concatenation already stretches the meaning of those operators quite a lot already ;-) - but afaics, they aren't that obvious (in the examples that I tried, there was no issue with neither precedence nor ambiguity).

I played with an alternative design that doesn't use an enum, but relies on closures (Collection) -> Collection.Index.
It's not as sophisticated as the pitch, but I'm confident that the performance can be improved - and it adds some additional capabilities:

let str = "12345"
print(str[.start + 3]!)							// 4 - single element
print(str[.start + 2 ..< .end - 1])				// 34 - start/end offset
print(str[.findFirst("2") ..< .end - 1])		// 234 - start index depends on content
print(str[.findFirst("2") <| 3])				// 234 - take n entries
print(str[.findFirst("9") + 1 ..< .end - 1])	// Empty string
4 Likes

It's not really about relative ranges, but since @Tino's post is suggesting shorthands for finding things inside of a subscript, I'll suggest that instead of inventing a new syntax for this we just make it easier to reuse our current tools.

My idea is that inside the brackets of a subscript you could use $ as a shorthand to refer to the object you are subscripting. For instance, you wouldn't have to write str twice here as you can use $ as a substitute inside the subscript:

str[$.firstIndex(of: "9")++1 ..< --1]

This is a toy demonstration example that doesn't show much. If however str was a more complex expression it could save a lot of typing. Like in this one:

// currently:
company.employee[1].name[company.employee[1].name.indexAfter(company.employee[1].name.firstIndex(of: " ")!) ..< company.employee[1].name.endIndex]
// with `$` as a shorthand:
company.employee[1].name[$.indexAfter($.firstIndex(of: " ")!) ..< $.endIndex]
// with `$` and unary `++` & `--`
company.employee[1].name[$.indexAfter($.firstIndex(of: " ")!) ..< --0]
// with `$` and binary `++` & `--`
company.employee[1].name[$.firstIndex(of: " ")!++1 ..< $.endIndex]
// with `$`, unary and binary `++` & `--`
company.employee[1].name[$.firstIndex(of: " ")!++1 ..< --0]
1 Like

I don't understand that: Using $ as in your example isn't possible without changing the language itself (thus a new tool, and no reuse), is it?
On the other hand, the examples I gave can be compiled with Xcode as it is...

I like the proposal idea but I feel strongly against the ++ and -- operators, they are not clear at all and seem really confusing.

I propose two offset operators: a prefix one -> and suffix one <-.

let string = "ABCD"
string[->0] // "A"
string[1<-] // "C"
string[->1...1<-] // "BC"

How would you offset from an index that isn't startIndex or endIndex?

Hmmm… I suppose we could also define inflix ones:

let indexA = string.firstIndex(of: "A")
let indexD = string.firstIndex(of: "D")
string[indexA->1 ... 1<-indexD] // "BC"

I'm not sure what you mean by "thus a new tool, and no reuse"... but yeah, I was proposing a new language feature.

What I meant is that your implementation of RelativeIndex needs the index-returning functions of the collection to be wrapped in another function that creates the closure. Instead of adding all weight in the library for all of them, it might be better to add some syntax to access more easily the already existing functions.

I'll also point out that your implementation makes choices that aren't necessarily what expectations would dictate. For instance, RelativeIndex.findFirst returns endIndex when it finds nothing, unlike Collection.firstIndex(of:) which returns nil. While this allows you to return a non-optional index from the closure, I doubt this inconsistency in behavior is a good thing. Accessing functions directly from the collection provides assurances of equivalent behavior and better predictability.

I’m delighted! Everything about this is excellent.

I share the concern about the glut of RelativeRange types, but I’m not overly bothered by it. In addition to the suggestion about representing RelativeRange as a single enum, could we represent these as opaque types?

It’s possible both suggestions are a distraction; explicit spellings help with documenting what the operators do.

2 Likes

First of all, a nitpick about the proposal:

"Omitting" either side isn't allowed for an infix operator, so it's really talking about prefix operators without acknowledging the fact.

Second, the proposal starts talking about "relative" ranges without saying what that means. If the relative ranges are the ones created using prefix operators, then the proposal should say so, explicitly. I spent quite a few minutes scanning up and down the proposal, before I knew what the syntax for "relative" was supposed to be.

I appreciate being called out as a participant in earlier discussions, but I have to admit my first thought was, "Oh, no, here we go again! :worried:"

  • I don't hate the ++ and -- infix operators. They're a logical choice, and PTCD ("post traumatic C-syntax disorder") can be treated.

  • The ergonomics of the prefix operators, in this context where they're mixed with ranged operators (..< and ...), is horrendous, and at this stage this would be enough for me to give a thumbs down to the proposal.

  • I continue to believe the only acceptable solution to the problem of getting non-relative and relative ranges via the same syntax is a specific sigil meaning "relative to the start" or "relative to the end", preferably something with a keyword in it so that it's actually readable. (For example, #start++3 or #end--1, to illustrate the concept, not to propose a syntax. I've given up trying to propose a syntax for this.)

  • Or, introduce separate proposals for non-relative and relative scenarios, with different syntax for each.

  • Please don't forget that negative offsets relative to collection indices are a behavior of BidirectionalCollection, not Collection.

1 Like

I’m...fine with this. I don’t love it, I don’t hate it, I’m just fine with it.

At an intellectual level I think this proposal satisfies the use-case it wishes to address. I still have concerns about this syntax hiding performance problems, but those performance problems are not necessarily apparent to calling code in all cases anyway even when using the lengthier current syntax. The trade off proposed here of increasing the weight of the syntax compared to “just index by integer” seems good to me.

However, I kinda loathe the syntax. I don’t have a clear reason why, my mind just rebels against it. Until I can articulate a reason I think my gut reaction there should just be ignored, but I still wanted to mention it.

2 Likes

Overall, I like it. However it seems counter-intuitive to me that the following yields different results:


let string = "12345"

print(string[++3]) // 4
print(string[--3]) // 3
1 Like