---
title: 'String Operations'
source: 'https://academia.sh/en/courses/swift/string-operations'
course: 'The Swift Language'
language: en
updated: '2026-10-21T05:03:11+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# String Operations

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

`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.

```swift
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.

```swift
// 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.

```swift
// 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.

```swift
// 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.

```swift
// 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.

```swift
// 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.

```swift
// 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.

```swift
// 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?
