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.
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 "
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.
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.
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.
"\(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.