Home

Strings and Unicode

Graphemes, views, and why count is O(n)

String is the type where Swift most visibly refuses to lie to you. In most languages a string is an array of code units and length is cheap, correct-looking and wrong for a large part of the world. Swift makes the correctness explicit and charges you for it up front.

A String is a collection of graphemes

let flag = "🇻🇳"
flag.count                    // 1
flag.unicodeScalars.count     // 2 — two regional indicators
flag.utf16.count              // 4
flag.utf8.count               // 8

All four numbers are right; they answer different questions. String iterates grapheme clusters — what a reader would call a character — which is why count is O(n): the string has to be walked, because a character can be any number of bytes.

This is also why String has no integer subscript. str[5] cannot be O(1), and Swift will not give you an operation whose cost hides in plain sight:

let text = "Xin chào"
let index = text.index(text.startIndex, offsetBy: 4)
text[index]                    // "h"
text[text.startIndex..<index]  // "Xin "

Canonical equivalence

Two strings can hold different bytes and still be equal:

let composed = "é"            // U+00E9
let decomposed = "e\u{0301}"  // U+0065 U+0301
composed == decomposed        // true
composed.utf8.count           // 2
decomposed.utf8.count         // 3

Swift compares by canonical equivalence, so text that renders identically compares as equal. That is almost always what a person means. It also means == on strings is not a memcmp, and that Dictionary lookups on string keys do real Unicode work.

Warning

Never send a Swift String comparison result across a boundary that expects byte identity — a hash, a signature, a database primary key. Compare Array(string.utf8) when you need bytes to be bytes.

The views

Every String exposes four views over the same storage, and picking the right one is most of what “string performance” means in Swift:

  • characters (the string itself) — grapheme clusters. For anything shown to a person.
  • unicodeScalars — Unicode code points. For classifying characters, checking properties.
  • utf16 — for interop with NSString, NSRange, and most C APIs on Apple platforms.
  • utf8 — for wire formats, hashing, and file I/O. Since Swift 5, this is the native storage, so the utf8 view is free.
func isASCIIDigitsOnly(_ text: String) -> Bool {
    !text.utf8.isEmpty && text.utf8.allSatisfy { $0 >= 0x30 && $0 <= 0x39 }
}

Working in the utf8 view here skips grapheme breaking entirely: the check is a byte scan.

Substring is not String

Slicing gives you a Substring, which — like ArraySlice — shares the parent’s storage:

func lastComponent(of path: String) -> String {
    guard let slash = path.lastIndex(of: "/") else { return path }
    return String(path[path.index(after: slash)...])   // the String(...) matters
}

Without the conversion you return a Substring that pins the entire path in memory. The type system nudges you correctly here: APIs that store text take String, so the conversion is usually forced at the boundary. Inside a parsing loop, stay on Substring and convert once at the end.

Interpolation is a protocol

"\(value)" is not magic, and it is not description. It goes through ExpressibleByStringInterpolation, which you can extend:

extension String.StringInterpolation {
    mutating func appendInterpolation(_ value: Double, decimals: Int) {
        appendLiteral(String(format: "%.\(decimals)f", value))
    }
}

"π ≈ \(Double.pi, decimals: 3)"   // "π ≈ 3.142"

The compiler turns each segment into an appendLiteral or appendInterpolation call, so this is resolved statically — a custom interpolation costs no more than the code you would have written by hand, and it keeps formatting logic out of the call site.