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 aClosedRange
, i.e the lower and upper bounds are included. -
..<
is the operator to create aRange
, 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 aRange
where both bounds are excluded -
<..
would create aRange
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.