Skip to content
academia.sh

Lesson 04 / 24

String Operations

Measures the character and byte counts of a `String` value, indexing and interpolation: an interpolated expression is rebuilt on every use.

Published Updated

In this lesson

  • Explain the difference between `count` and `utf8.count` with the character model
  • State why a string uses `String.Index` instead of an integer
  • Show that interpolation calls the `description` property again on every evaluation
  • Tell a stored string apart from a string that is rebuilt
Contents

String appeared in the previous lesson by name only. This lesson has three questions: how many characters does a string hold, how do you reach a particular character of a string, and how many times is an expression placed inside a string built? The third one sits on the axis of the course; the first two are the ground for understanding it.

The Character Model

In Swift a string is made of Character values, and count counts characters. The number of bytes the string takes in memory is asked separately with utf8.count. The two do not have to be equal: some Turkish letters are stored with more than one byte.

You cannot reach a string with an integer either. Writing word[1] is rejected by the compiler in Swift 6.4: the snippet below does not compile, which is why it has no output. The reason: characters take different numbers of bytes in memory, so the place of the nth character cannot be found without counting from the start.

let word = "ağaç"
print(word[1])

The right way is to use String.Index: word.startIndex points to the first character, and word.index(after:) to the next one. The measurement covers this too.

String Interpolation

Writing "\(value)" inside a string is string interpolation. A type that conforms to CustomStringConvertible asks for its own description property everywhere it is interpolated. Interpolation is an expression that builds the string; every time it is built, description is called again.

// Probe.swift — records when an expression is evaluated
final class Probe {
    let label: String
    private(set) var evaluations = 0
    private(set) var uses = 0
    private var evaluatedBeforeUse = false

    init(_ label: String) {
        self.label = label
    }

    func eval<T>(_ value: T) -> T {
        evaluations += 1
        if uses == 0 { evaluatedBeforeUse = true }
        return value
    }

    func use<T>(_ access: () -> T) -> T {
        uses += 1
        return access()
    }

    var moment: String {
        if evaluations == 0 { return "never" }
        if evaluations > 1 { return "on-every-use" }
        return evaluatedBeforeUse ? "immediately" : "on-first-use"
    }

    func report() {
        print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
    }
}

func tally(_ probes: [Probe]) {
    for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
        let count = probes.filter { $0.moment == moment }.count
        print("\(moment): \(count)")
    }
}

The measurement builds a small type that carries a Probe inside its description property. Two interpolations use the same value twice; also, the Counter type shows that a stored string does not change afterwards.

// main.swift — character model and interpolation
struct Tag: CustomStringConvertible {
    let probe: Probe
    var description: String { probe.eval("tag") }
}

struct Counter: CustomStringConvertible {
    var n: Int
    var description: String { "n=\(n)" }
}

let word = "ağaç"
print("ağaç: \(word.count) characters, \(word.utf8.count) bytes")
let second = word.index(after: word.startIndex)
print(word[second])

let descriptionProbe = Probe("description-call")
let savedProbe = Probe("stored-string")

let tag = Tag(probe: descriptionProbe)
_ = descriptionProbe.use { "first: \(tag)" }
_ = descriptionProbe.use { "second: \(tag)" }

let saved = Tag(probe: savedProbe)
let text = "\(saved)"
_ = savedProbe.use { text }
_ = savedProbe.use { text }

var counter = Counter(n: 1)
let snapshot = "\(counter)"
counter.n = 2
print("\(snapshot) \(counter)")

let allProbes = [descriptionProbe, savedProbe]
for probe in allProbes { probe.report() }
tally(allProbes)
ağaç: 4 characters, 6 bytes
ğ
n=1 n=2
description-call: 2 evaluations, 2 uses -> on-every-use
stored-string: 1 evaluations, 2 uses -> immediately
immediately: 1
on-first-use: 0
on-every-use: 1
never: 0

The four-letter ağaç is four characters but six bytes: ğ and ç take two bytes each. word[second] gives the second character, ğ; an index is not a number but a value that knows the string.

description-call showed two evaluations for two uses: every interpolation rebuilt the value at the moment it was asked for. The rule reads this as on-every-use. An expression that builds a string is not a lazy expression, but an expression that runs everywhere it is written.

LB24. ağaç was not placed in an aligned column and stays in the string as it is: the subject of the measurement is the character and byte count of this word.

LB25. The count and utf8.count values were measured for this word; the counts of another word or another writing system are not a claim of this lesson.

Concatenation and Multiline Strings

You can join strings with the + operator: "a" + "b" produces a new string. For text that spans several lines there is the triple-quoted multiline string literal: the opening """ stays on its own line, the text starts on the following lines, and the closing """ again ends on its own line. The indentation of the closing triple quote is removed from all lines; this way you write the text to match the indentation of the code and no extra space is left in the output.

Formatting a string according to an expression means the expression is evaluated again on every call. The block below writes a function; the function wraps the incoming text in square brackets and passes the wrapping through a Probe. The function is called twice. On the last line a multiline literal is printed.

// Join.swift — concatenation and multiline string
func decorate(_ text: String, probe: Probe) -> String {
    probe.eval("[" + text + "]")
}
let joinProbe = Probe("concatenation")
print(joinProbe.use { decorate("a", probe: joinProbe) })
print(joinProbe.use { decorate("b", probe: joinProbe) })
let note = """
    first line
      second line
    """
print(note)
joinProbe.report()
[a]
[b]
first line
  second line
concatenation: 2 evaluations, 2 uses -> on-every-use

The first two lines were [a] and [b]. The next two lines are the output of the multiline string: the first line line was printed without indentation, the second line line with two spaces of indentation. The closing """ was indented by four spaces, and this indentation was removed from all lines; the extra two spaces of the second line itself were kept. The measurement line shows two evaluations: the decorate call recomputes the concatenation in its body every time, so the moment is on-every-use.

A multiline string literal, on the other hand, is read once as an expression; the written text is a constant and is not joined at run time. When you do the joining with +, the joining can happen at run time. Which one you choose depends on whether the text is constant or computed.

LB26. Concatenation and the multiline literal were measured with ASCII text; the platform differences in line-ending characters are outside this lesson.

A Substring Is a Slice, Not a String

When you take a part of a string, for example with prefix(7) or by slicing with a range, the result is of type Substring, not String. A Substring is a slice view over the source string and carries many of the string’s methods. When you want to store a Substring for good or hand it to a place that expects a String, you convert it to a string with String(...). This explicit conversion prevents a short-lived slice from keeping the source string alive for no reason.

The block below produces a sentence with a Probe, takes a slice of it, prints the type of the slice and turns the slice into a String. The evaluation count of the source string is followed through the slicing and the conversion.

// Slice.swift — slice and conversion to string
let sourceProbe = Probe("source-sentence")
let sentence = sourceProbe.eval("welcome world")
let head = sentence.prefix(7)
let owned = String(head)
_ = sourceProbe.use { head.count }
_ = sourceProbe.use { owned.count }
print(head, type(of: head))
print(owned, type(of: owned))
sourceProbe.report()
welcome Substring
welcome String
source-sentence: 1 evaluations, 2 uses -> immediately

head printed welcome and its type came out as Substring; String(head) carried the same seven-character text as a String. The Probe showed a single evaluation: the slicing and the conversion did not produce the source string again, they worked on the existing value. The slice looks at a part of the source; the conversion gives an independent copy of that part as a string. Because the evaluation was done once before use, the moment is immediately.

This distinction shows itself in APIs: if a function expects a String, you cannot give it the slice directly, you have to convert it first. The compiler enforcing this makes you manage the lifetime of the slice deliberately.

LB27. The slice was measured with prefix; that slicing with a range operator and other operations on a slice give the same type is outside this lesson.

Character, Unicode Scalar and Byte

There are three separate units in string counts. A character (Character) is the unit the reader sees as a single symbol. A Unicode scalar is a single code point in the Unicode table. A byte is the unit in which the string is stored in the UTF-8 encoding. For most ASCII characters all three are one; but they part ways in combined characters. For example, the letter e and the accent mark attached to it are written as two scalars, yet they appear as a single character.

The block below writes the letter e and the combining accent mark that follows it with the \u{301} escape sequence. The three counts of the string are measured inside a function over a Probe. The function is called with two different texts; the second text is a plain ASCII word.

// Units.swift — character, scalar and byte counts
func measure(_ text: String, probe: Probe) -> (Int, Int, Int) {
    probe.eval((text.count, text.unicodeScalars.count, text.utf8.count))
}
let unitProbe = Probe("string-units")
let combined = "e\u{301}"
print(unitProbe.use { measure(combined, probe: unitProbe) })
print(unitProbe.use { measure("bird", probe: unitProbe) })
unitProbe.report()
(1, 2, 3)
(4, 4, 4)
string-units: 2 evaluations, 2 uses -> on-every-use

The combined text gave (1, 2, 3): one character, two Unicode scalars, three bytes. The letter e takes one byte in UTF-8 and the combining mark takes two. In the plain word bird, on the other hand, the three numbers came out as four, four and four. The two calls brought two evaluations, so the moment reads as on-every-use.

You can relate the character count to the symbols the reader sees, but the byte count concerns storage size. Mixing the two leads to silent errors in bounds checks. When you ask for the length of a string, choose which unit you are asking about.

LB28. The three counts were measured with two texts; the counts of other combined characters and emoji sequences are outside this lesson.

Comparison, Prefix and Suffix

== compares two strings character by character, but the idea of “the same character” follows Unicode: two strings written with different scalar sequences that look the same to the reader are counted equal by canonical equivalence. This makes a single é and a string made of e and a combining accent mark the same. hasPrefix and hasSuffix check the start and the end of a string; they also use the same idea of a character.

The block below compares two spellings. The single-scalar spelling goes through a Probe, the second one has two scalars. Then the results of hasPrefix and hasSuffix are printed.

// Compare.swift — comparison, prefix and suffix
func same(_ left: String, _ right: String, probe: Probe) -> Bool {
    probe.eval(left == right)
}
let compareProbe = Probe("string-equality")
let single = "\u{E9}"
let composed = "e\u{301}"
print(compareProbe.use { same(single, composed, probe: compareProbe) })
print(single.unicodeScalars.count, composed.unicodeScalars.count)
let word = "swift-guide"
print(word.hasPrefix("swift"), word.hasSuffix("guid"))
compareProbe.report()
true
1 2
true false
string-equality: 1 evaluations, 1 uses -> on-first-use

Comparing the single-scalar é with the two-scalar e\u{301} gave true, yet the scalar counts parted ways as 1 and 2. So == looks not at scalars but at canonical equivalence: even if two strings are not the same byte by byte, they are counted equal if they stand for the same character. hasPrefix("swift") came out true and hasSuffix("guid") false, because the word ends with guide, not with guid. The Probe showed a single evaluation and the moment is on-first-use.

This matters in code that compares user input: text coming from a keyboard or a file may look the same but be written with different scalars. == solves this for you; a raw byte comparison would not.

LB29. The comparison was measured with a single combined character; case-insensitive comparison is outside this lesson.

Access by Index

You cannot reach a Swift string with an integer like text[3]. Because the byte length of characters varies, where the third character starts cannot be known without counting from the start. String.Index values are used instead: text.startIndex points to the first character, and text.index(text.startIndex, offsetBy: 3) to the fourth. The index computation is done by walking inside the string.

The block below takes the third character of a word by index. The word comes from a Probe; the function is called with two different words.

// Index.swift — access by index
func third(_ text: String, probe: Probe) -> Character {
    let source = probe.eval(text)
    let position = source.index(source.startIndex, offsetBy: 2)
    return source[position]
}
let indexProbe = Probe("index-access")
print(indexProbe.use { third("table", probe: indexProbe) })
print(indexProbe.use { third("chair", probe: indexProbe) })
indexProbe.report()
b
a
index-access: 2 evaluations, 2 uses -> on-every-use

The third character came out as b in the word table and a in the word chair. The index was found in each word by walking from the start: index(_:offsetBy:) passed over the characters one by one, because how many bytes a character takes is not known in advance. This is why there is no integer subscript; if there were one, a slow search would hide silently in a long text. The Probe showed two evaluations for two calls and the moment reads as on-every-use.

If access by index is a frequent job, the string can be turned into an array of characters with an Array(text) conversion and read with integers; but this means copying the whole string once.

The index type being a separate type also gives some safety: using an index taken from one string in another string may give a wrong result. Keeping the line that computes the index and the line that uses it tied to the same string is a good habit for this reason. Using a single local copy named source in the same function guarantees that the index and the string look at the same value.

The same mechanism applies to reaching the last character of a string: text.index(before: text.endIndex) gives the index of the last character. Because index computations walk from the start or from the end, in long strings it is cheaper to compute them once and store them than to repeat them inside a loop. The measurement shows that the word was taken again on every call.

LB30. Index access was measured with ASCII words; the cases where an out-of-range index stops the program are not run in this lesson.

The Limiting Measurement

If a string is built once and stored in a let, the table changes. stored-string showed one evaluation for two uses: "\(saved)" ran only on the line where it was stored, and the following two reads only read the string. The rule says immediately.

This also has a cost. The snapshot string was built before counter changed, and a value that changes after the string is built does not show in it: snapshot is still n=1, and the rebuilt string is n=2. A stored string is a photograph; interpolation is a photograph taken anew every time.

LB31. Counter is a structure and snapshot carries its value at the moment it was built; the behavior of class types was not measured in this lesson.

Summary

  • count counts characters and utf8.count counts bytes; ağaç is 4 characters and 6 bytes.
  • A string is not indexed with an integer; String.Index is used.
  • Interpolation calls the description property again on every evaluation.
  • A stored string is a single evaluation and does not reflect a value that changes later.
  • The moment of this lesson: on every use (two description calls in two interpolations).

Next Step

Strings also join and compare through operators such as + and ==. The next lesson looks at operators: is the right-hand side of an operator always evaluated?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close