Support for all types of ranges

Introduction

Swift currently has 2 objects we can use to express an interval between 2 values: Range and ClosedRange. The first one includes its lowerBound and excludes its upperBounds, the second one includes both. What if we want to create an object from 2 bounds we want excluded ?

Note: I know there are also PartialRangeFrom, PartialRangeUpTo and PartialRangeThrough which deal with ranges with only one bound. While some part of this pitch could also apply to them, I will mostly only discuss of 2 bounded ranges here. See the "About partial ranges" section for more dedicated infos.

Motivation

Imagine we have to check if a variable is between 2 values. Let's say an integer between 0 and 5.
A trivial solution would be to simply have x > 0 && x < 5.
If we want to use ranges to express this, we will have a hard time because there is no support for a range excluding its lower bound. So we would end up creating a different range to manually exclude the 0 value from it.
Example using Int: 0+1..<5 ~= x.
Example using Double: 0.0.nextUp..<5.0 ~= x.
Example using Comparable & Stridable where Stride using an Integer: lowerBound.advanded(by:1)..<upperBound ~= x.

This workaround would work, but then we lose the essence of what we are really trying to do. We should be able to simply express the fact that the range we want to work with excludes its lower bound.

Proposed solution

Range and ClosedRange are very close: they share a lot (and when a say a lot I mean quite all) of their functions. We could unify these objects by simply having 2 new properties in Range: isLowerBoundIncluded and isUpperBoundIncluded. Range could then be any kind of interval:

  • including both bounds - the actual ClosedRange behavior
  • excluding both - a new possibility
  • including the lower bound and excluding the upper bound - the actual Range behavior
  • excluding the lower bound and including the upper bound - a new possibility

For each one of the possibilities listed above, we would need to have a distinct operator to create those kind of ranges. As you all know, there already are 2 operators, one for each existing possibility:

  • ... is the operator to create a ClosedRange, i.e the lower and upper bounds are included.
  • ..< is the operator to create a Range, i.e an included lower bound and excluded upper bound.

If we look at the characters composing those operators, we could see a pattern here: 3 characters, one for each bound, and a . in the middle. If the bound is included the associated character is a ., or a < if the bound is excluded.
Following this pattern, we could create 2 new operators:

  • <.< would create a Range where both bounds are excluded
  • <.. would create a Range where the lower bound is excluded and the upper bound is included.

Bringing back our initial problem, we can now directly express the condition using 0<.<5 ~= x.

Detailed design

A very trivial implemention of this new Range object and its operators could be:

struct Range<Bound> where Bound : Comparable {
  let lowerBound: Bound
  let isLowerBoundIncluded: Bool
  let upperBound: Bound
  let isUpperBoundIncluded: Bool

  static func ...(lhs: Bound, rhs: Bound) -> Range<Bound> {
    guard lhs <= rhs else { /* ? */ }
    return Range(lowerBound: lhs, isLowerBoundIncluded: true, upperBound: rhs, isUpperBoundIncluded: true)
  }

  static func ..<(lhs: Bound, rhs: Bound) -> Range<Bound> {
    guard lhs <= rhs else { /* ? */ }
    return Range(lowerBound: lhs, isLowerBoundIncluded: true, upperBound: rhs, isUpperBoundIncluded: false)
  }

  static func <..(lhs: Bound, rhs: Bound) -> Range<Bound> {
    guard lhs <= rhs else { /* ? */ }
    return Range(lowerBound: lhs, isLowerBoundIncluded: false, upperBound: rhs, isUpperBoundIncluded: true)
  }

  static func <.<(lhs: Bound, rhs: Bound) -> Range<Bound> {
    guard lhs <= rhs else { /* ? */ }
    return Range(lowerBound: lhs, isLowerBoundIncluded: false, upperBound: rhs, isUpperBoundIncluded: false)
  }
}

Source compatibility

Any usage of ClosedRange would have to be replaced by Range. Something like typealias ClosedRange = Range might suffice to avoid those renaming.
As almost every function in Range also exists in ClosedRange and they both seem to use the same supporting types ( though Range has Range.indices) so I don't think it would break anything.

Effect on ABI stability

TBD

Effect on API resilience

TBD

Alternatives considered

We could have a range object specific to any possibility:

  • the actual ClosedRange where both bounds are included.
  • a new OpenedRange where bith bounds are excluded.
  • the actual Range where only the lower bound is included.
  • a new <?> where only the upper bound is included.

With this approach, the behavior of the actual Range is tricky to understand because its name doesn't describe its behavior as clearly as ClosedRange does. It would need to be renamed to better fit in the new schema.

This solution has 2 major inconvenients:

  • Having 4 different objects we can use to work with ranges would be a lot more complex than having just one Range handling all cases.
  • Those 4 objects would look very similar, and as coders we don't like it.

About Partial ranges

Like I said, this pitch is mostly about 2 bounded ranges but we could also apply it to partial ranges.
PartialRangeUpTo and PartialRangeThrough could be unified together in a PartialRangeTo object having an isBoundIncluded property.
PartialRangeFrom could also benefit from an isBoundIncluded property to add support for all values superior to the bound, excluding the bound.

3 Likes

We can't change Range like this because of ABI stability.

We have, however, previously discussed adding something like a PredicateSet (as well as other kinds of Set). Perhaps something like that would be a better way to solve your problem?

What a great pitch! This is exactly how I have always expected ranges to work.

Me too. I know it's been discussed before but I still think all types of ranges should be supported.

Another thing I often reached for is a way to write ranges that end say 5 spots from the end, without having to first find out what the end is. Not sure of the implications of using dropLast for this, it might work.