Unify and expand Range and its likes

1 and 2 seem like arguments not to unify the partial (unbounded) and bounded families. But I'd like to take a weaker position; why not unify ClosedRange and Range? Or, an even weaker position: unifying ClosedRange and Range expressions.

In the spirit of "my beefs with ranges", let me lay out the drawbacks of ClosedRange/Range:

  1. Range is ambiguous. Following other Swift type families like Int32/Int64/Int, I might expect types like OpenRange, ClosedRange and Range (which is preferred). I understand we don't need a machine-dependent semantics like we do for Int, and we want to indicate which type is preferred by giving it a short name, but the inability to clearly indicate off-by-1 bugs in a code review is a real bother.
  2. Conversions are hard. Consider this OS function:
extension MTLComputeCommandEncoder {
    public func setBuffers(_ buffers: [MTLBuffer?], offsets: [Int], range: Range<Int>)
}

I would argue that it is equally sensible for a client to use either syntax:

instance.setBuffers(buffers, offsets: offsets, range: 0..<1)
instance.setBuffers(buffers, offsets: offsets, range: 0...0)

However, only one syntax is allowed here. Someone could write their function <B: RangeExpression> but in practice they didn't, everyone just uses Range. If range expressions were convertible or we had an ExpressibleByRangeLiteral type conformance it would go a long way.


Although this is more an argument in favor of a broader unification, I understand in theory why we want a expressive and therefore safer type system, but in practice I don't think we live in that world. We have a preferred type, everyone uses Range and lives dangerously. Presenting again the OS function:

extension MTLComputeCommandEncoder {
    public func setBuffers(_ buffers: [MTLBuffer?], offsets: [Int], range: Range<Int>)
}

I can call this with

encoder.setBuffers([],offset:[],range:0..<25)

and not only will it compile, it actually UBs with an out-of-bounds read inside Metal. (For those unfamiliar with Metal, the function is supposed to assign the elements of the first parameter to an internal array with the indices in the last parameter, but here the sizes mismatch.)

Arguably this function should take a PartialRangeFrom and infer the final index from the array count rather than having 2 parameters that may disagree. In doing so it would be more obvious when implementing this function that you can't iterate over the range parameter without first converting to a complete/iterable type by supplying an end index from the first parameter. I believe this is the bug @beccadax envisions as being prevented by an expressive typesystem.

I believe there are ObjC bridging reasons to use Range here. It's unfair of me to present some ObjC bug as not being fixed by Swift, but it illustrates how these types are actually used in the wild. When there's better tooling for preferred types, it tilts the playing field in favor of their use, away from what might be the "natural" type we might prefer.

Separately from bridging, there is also a potential callside argument for preferring "the unnatural type" here. That is, if Metal takes an unbounded range and infers the end index from the array's count, it's easier to implement the function correctly, but calling it becomes harder:

encoder.setBuffers(someBuffers,offset:someBuffers.map{0},range:0...)
encoder.setBuffers(moreBuffers,offset:moreBuffers.map{0},range:<#?#>) //what goes here again?

vs by requiring redundant information, we have to remember to check it's the same inside the function, but callers can more easily reason about the arguments:

encoder.setBuffers(someBuffers,offset:someBuffers.map{0},range:0..<5)
encoder.setBuffers(moreBuffers,offset:moreBuffers.map{0},range:5..<10) //copy from line above
encoder.setBuffers(evenMoreBuffers,offset:moreBuffers.map{0},range:10..<17)

I can see arguments for either style. In any case, I think most uses of Range does not truly consider which type really ought to be used. In cases where it is considered, I think the considerations may be at some variance with what choice might produce the best local typesafety.


I do think a lot of this can be fixed without compromising type expressivity or deeply overhauling the stdlib. For example,

  • we could support implicit conversions between (some) ranges (or perhaps range expressions) which would "unify" the syntax callside, without compromising the codegen or safety
  • Provide a more thorough ObjC bridge for the non-preferred ranges
  • typealias OpenRange = Range

I think the Metal example shows a plausible motivation for unifying bounded and unbounded ranges (or expressions) as well, but this is perhaps a bit more specialized, and may be better handled by generics or overloads than new language surface area.

1 Like