8

In our code, we found a bug from not writing the alphabet correctly. Instead of "0123456789abcdefghijklmnopqrstuvwxyz", we had "0123456789abcdefghijklmnoqprstuvwxyz". So we are wondering if it's possible to avoid similar typo by declaring Strings made from ranges of characters?

Using Swift 4.1+, we tried:

attempt 1

let 1: String = "0"..."9" + "a"..."z"

Adjacent operators are in non-associative precedence group 'RangeFormationPrecedence'

attempt 2

let 2: String = ("0"..."9") + ("a"..."z")

Binary operator '+' cannot be applied to two 'ClosedRange<String>' operands

attempt 3

let 3: String = String("0"..."9") + String("a"..."z")

Cannot invoke initializer for type 'String' with an argument list of type '(ClosedRange<String>)'

attempt 4

let 4: String = (Character("0")...Character("9")) + (Character("a")...Character("z"))

Binary operator '+' cannot be applied to two 'ClosedRange<Character>' operands

attempt 5

let 5: String = String(Character("0")...Character("9")) + String(Character("a")...Character("z"))

Cannot invoke initializer for type 'String' with an argument list of type '(ClosedRange<Character>)'

mfaani
  • 33,269
  • 19
  • 164
  • 293
Cœur
  • 37,241
  • 25
  • 195
  • 267
  • 1
    I bet you could use the [String.unicodeScalars](https://developer.apple.com/documentation/swift/string/1539070-unicodescalars), similar to what the user "simons" answered in this question https://stackoverflow.com/questions/31739844/swift-2-separating-an-array-into-a-dictionary-with-keys-from-a-to-z/31771283#31771283 – Jacob Lange Apr 13 '18 at 03:44
  • 1
    Compare https://stackoverflow.com/q/39982335/2976878 – Hamish Apr 13 '18 at 09:20
  • @Hamish Thanks. I guess that your descriptive fatal error is a better approach than my force unwrap. – Cœur Apr 13 '18 at 09:23

4 Answers4

11

"a"..."z" is a ClosedRange, but not a CountableClosedRange. It represents all strings s for which "a" <= s <= "z" according to the Unicode standard. That are not just the 26 lowercase letters from the english alphabet but many more, such as "ä", "è", "ô". (Compare also ClosedInterval<String> to [String] in Swift.)

In particular, "a"..."z" is not a Sequence, and that is why String("a"..."z") does not work.

What you can do is to create ranges of Unicode scalar values which are (UInt32) numbers (using the UInt32(_ v: Unicode.Scalar) initializer):

let letters = UInt32("a") ... UInt32("z")
let digits = UInt32("0") ... UInt32("9")

and then create a string with all Unicode scalar values in those (countable!) ranges:

let string = String(String.UnicodeScalarView(letters.compactMap(UnicodeScalar.init)))
    + String(String.UnicodeScalarView(digits.compactMap(UnicodeScalar.init)))

print(string) // abcdefghijklmnopqrstuvwxyz0123456789

(For Swift before 4.1, replace compactMap by flatMap.)

This works also for non-ASCII characters. Example:

let greekLetters = UInt32("α") ... UInt32("ω")
let greekAlphabet = String(String.UnicodeScalarView(greekLetters.compactMap(UnicodeScalar.init)))
print(greekAlphabet) // αβγδεζηθικλμνξοπρςστυφχψω
Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • 1
    `UInt32` has an Unicode.Scalar initializer `init(_ v: Unicode.Scalar)`. So `let letters = UnicodeScalar("a").value ... UnicodeScalar("z").value` can be shortened to `let letters = UInt32("a")...UInt32("z")` – Leo Dabus Feb 06 '19 at 17:17
  • 2
    @LeoDabus: You are right, will update the code, thanks for the suggestion. – Fun fact: `print(UInt32("a")) // nil` but `print(UInt32("a") as UInt32) // 97` – Martin R Feb 06 '19 at 17:33
7

This isn't necessarily eloquent but it works:

let alphas = UInt8(ascii: "a")...UInt8(ascii: "z")
let digits = UInt8(ascii: "0")...UInt8(ascii: "9")

let 6 =
      digits.reduce("") { $0 + String(Character(UnicodeScalar($1))) }
    + alphas.reduce("") { $0 + String(Character(UnicodeScalar($1))) }

print(6) // "0123456789abcdefghijklmnopqrstuvwxyz"

Big assist from Ole Begemann: https://gist.github.com/ole/d5189f20840c52eb607d5cc531e08874

Mike Taverne
  • 9,156
  • 2
  • 42
  • 58
  • Thank you. You may win the price of readability. I wonder how `reduce` compares to `String(String.UnicodeScalarView(alphas.map { UnicodeScalar($0) }))` in terms of performances. – Cœur Apr 13 '18 at 08:28
  • Why not just map the elements to create an array of characters? `String(digits.map({ Character(UnicodeScalar($0))} ))` – Leo Dabus Feb 07 '19 at 16:25
  • or `digits.map{String(Character(UnicodeScalar($0)))}.joined()` – Leo Dabus Feb 07 '19 at 19:06
4

Unicode ranges will be supported by UInt32. Let's note that UnicodeScalar.init?(_ v: UInt32) will return a non-nil value when:

v is in the range 0...0xD7FF or 0xE000...0x10FFFF

As that's a pretty easy condition to fulfill, because at most we'll have two ranges to concatenate, we'll force unwrap values with ! and avoid undefined behavior.

To support ranges without an extension

We can do:

let alphaRange = ("a" as UnicodeScalar).value...("z" as UnicodeScalar).value
let alpha = String(String.UnicodeScalarView(alphaRange.map { UnicodeScalar($0)! }))

To support ranges with an extension

If we make UnicodeScalar strideable, we can make the above more concise.

extension UnicodeScalar : Strideable {
    public func advanced(by n: Int) -> UnicodeScalar {
        return UnicodeScalar(UInt32(n) + value)!
    }
    public func distance(to other: UnicodeScalar) -> Int {
        return Int(other.value - value)
    }
}

And the solution simply becomes:

let alpha = String(String.UnicodeScalarView(("a" as UnicodeScalar)..."z"))

For ASCII ranges only

We can restrict ourselves to UInt8 and we don't have to force unwrap values anymore, especially with UInt8.init(ascii v: Unicode.Scalar):

let alphaRange = UInt8(ascii: "a")...UInt8(ascii: "z")
let alpha = String(String.UnicodeScalarView(alphaRange.map { UnicodeScalar($0) }))

or:

let alphaRange = UInt8(ascii: "a")...UInt8(ascii: "z")
let alpha = String(data: Data(alphaRange), encoding: .utf8)!

Big thanks to Martin, Mike, jake.lange and Leo Dabus.

Cœur
  • 37,241
  • 25
  • 195
  • 267
  • 1
    You can also use initialize a Data object from your UInt8 range and create a string from it using utf8 string encoding `String(data: Data(UInt8(ascii: "a")...UInt8(ascii: "z")), encoding: .utf8)` – Leo Dabus Feb 06 '19 at 19:32
  • 1
    @LeoDabus oh nice! – Cœur Feb 07 '19 at 04:35
2

Putting the elements together I ended up with the following solution:

extension Unicode.Scalar: Strideable {
    public func advanced(by n: Int) -> Unicode.Scalar {
        let value = self.value.advanced(by: n)
        guard let scalar = Unicode.Scalar(value) else {
            fatalError("Invalid Unicode.Scalar value:" + String(value, radix: 16))
        }
        return scalar
    }
    public func distance(to other: Unicode.Scalar) -> Int {
        return Int(other.value - value)
    }
}

extension Sequence where Element == Unicode.Scalar {
     var string: String { return String(self) }
     var characters: [Character] { return map(Character.init) }
}

extension String {
    init<S: Sequence>(_ sequence: S) where S.Element == Unicode.Scalar {
        self.init(UnicodeScalarView(sequence))
    }
}

("a"..<"z").string  // "abcdefghijklmnopqrstuvwxy"
("a"..."z").string  // "abcdefghijklmnopqrstuvwxyz"

String("a"..<"z") // "abcdefghijklmnopqrstuvwxy"
String("a"..."z") // "abcdefghijklmnopqrstuvwxyz"

("a"..<"z").characters  // ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y"] 
("a"..."z").characters // ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]

Expanding on that if you really want to accomplish that syntax you would need to implement the closed range and half open range operators with a custom precedence group higher than AdditionPrecedence:

precedencegroup RangePrecedence {
    associativity: none
    higherThan: AdditionPrecedence
}

infix operator ..< : RangePrecedence
func ..< (lhs: Unicode.Scalar, rhs: Unicode.Scalar) -> String {
    (lhs..<rhs).string
}

infix operator ... : RangePrecedence
func ... (lhs: Unicode.Scalar, rhs: Unicode.Scalar) -> String {
    (lhs...rhs).string
}

Usage:

let 1 = "0"..."9" + "a"..."z"
print("1", 1)   // 1 0123456789abcdefghijklmnopqrstuvwxyz

A side effect would be that now you would need to explicitly set the resulting type if you want "0"..<"9" to result in a range:

let range: Range<String> = "a" ..< "z"
print("range:", range)   // range: a..<z
Leo Dabus
  • 229,809
  • 59
  • 489
  • 571