$ sudo teach IT

Почему нельзя написать s[0]

Если бы строка была просто рядом ячеек, взять третий символ было бы так же просто, как взять третий элемент из списка: array[2]. Со String в Swift это не работает, и дело не в капризе языка. Внутри строка хранится как последовательность байт в кодировке UTF-8, а один видимый на экране символ может занимать от одного до нескольких таких байт и даже несколько отдельных юникод-скаляров. Если бы String разрешал писать s[0], ноль означал бы байт номер ноль, а не первый видимый символ, и для многих языков, включая русский, результат обращения по такому «индексу» оказался бы бессмысленным. Поэтому Swift вообще не даёт обращаться к строке по обычному числу — только через специальные индексы, о которых пойдёт речь дальше.

Что такое Character на самом деле

Тип Character — это то, что человек интуитивно назвал бы «одним символом»: буква, цифра, знак препинания. В официальной терминологии Swift такая единица называется расширенным кластером графем — это один зрительно цельный знак, который может состоять из нескольких юникод-скаляров, объединённых вместе.

Пример — буква с диакритическим знаком. Букву можно закодировать одним готовым скаляром, а можно как обычную букву плюс отдельный скаляр-«наклейку», который накладывается сверху при отображении. Для человека в обоих случаях это одна и та же буква, и Swift относится к ним так же: оба варианта — это ровно один Character, хотя внутри может быть записано два юникод-скаляра подряд.

То же самое происходит с эмодзи, которые выглядят одной картинкой, а внутри склеены из нескольких юникод-скаляров невидимым символом-соединителем — например, составные эмодзи с указанием тона кожи или эмодзи-семья из нескольких фигур. Технически внутри несколько скаляров, но выглядит и считается это одним знаком. Правило простое: то, что визуально воспринимается одним символом, Swift и посчитает одним Character, сколько бы юникод-скаляров ни было упаковано внутри.

count и почему он не бесплатный

У массива count — это просто число, уже записанное где-то в памяти: оно возвращается мгновенно независимо от размера массива. У String всё иначе.

let city = "Ташкент"
print(city.count)

Чтобы посчитать count у строки, Swift должен пройти по всем байтам и правильно сгруппировать их в расширенные кластеры графем — то есть решить, где заканчивается один видимый символ и начинается следующий. Это требует прохода по всей строке, поэтому count у String выполняется за время, пропорциональное длине строки, а не мгновенно, как у массива. На короткой строке разницу не заметить, но именно поэтому не стоит вызывать count повторно там, где можно посчитать его один раз и сохранить в переменную.

Индексы вместо чисел

Раз обычные числа для строки не годятся, Swift даёт собственный тип позиции — String.Index. Это не число, а указатель на конкретное место в строке, который знает, где заканчивается один символ и начинается следующий.

Начало строки — это startIndex, а endIndex — позиция сразу после последнего символа: она не указывает ни на какой существующий символ, и обращаться по ней нельзя.

let word = "кот"
let firstIndex = word.startIndex
print(word[firstIndex])

Здесь word[firstIndex] напечатает «к» — первый символ строки. Чтобы сдвинуться на один символ вперёд, есть метод index(after:), который возвращает следующий индекс:

let secondIndex = word.index(after: firstIndex)
print(word[secondIndex])

Он напечатает «о» — второй символ. Если нужно сдвинуться сразу на несколько символов, необязательно вызывать index(after:) много раз подряд — есть index(_:offsetBy:), который сразу даёт индекс со сдвигом на нужное количество символов от указанной позиции:

let thirdIndex = word.index(word.startIndex, offsetBy: 2)
print(word[thirdIndex])

Тут выведется «т». Обратите внимание: этот метод всё равно проходит по символам один за другим внутри себя — он не мгновенный, ровно по той же причине, что и count.

Кроме index(after:) есть симметричный index(before:) — он даёт индекс предыдущего символа. Это работает, потому что String умеет двигаться по себе и вперёд, и назад:

let animal = "кот"
let lastIndex = animal.index(before: animal.endIndex)
print(animal[lastIndex])

Так можно получить последний символ строки без специального свойства — здесь напечатается «т». Если запросить индекс за пределами строки, например слишком большой offsetBy, программа аварийно завершится прямо во время выполнения: индексы не проверяют границы сами, следить за этим нужно вам.

Подстрока через диапазон индексов

Чтобы получить часть строки, диапазон задают из двух индексов, а не из двух чисел:

let title = "Программирование"
let endOfPiece = title.index(title.startIndex, offsetBy: 5)
let piece = title[title.startIndex..

Здесь напечатается «Прогр» — первые пять символов. Но у результата необычный тип: это не String, а Substring. Substring — отдельный тип, который делит память с исходной строкой и не копирует символы заново, поэтому такая операция дешёвая. На практике Substring ведёт себя почти как String и поддерживает те же операции, но если кусок нужно сохранить надолго отдельно от оригинала, стоит явно превратить его обратно в строку: String(piece).

Проход по символам

Иногда нужно посмотреть на каждый символ строки по очереди. Полноценно циклы разберём отдельно, но базовая форма прохода по строке выглядит так:

let word = "банан"
for letter in word {
    print(letter)
}

На каждом шаге letter получает ровно один Character — целый расширенный кластер графем, а не один байт. Это ещё раз показывает разницу со строкой как «массивом байт»: Swift сам берёт на себя правильную разбивку на видимые символы.

isEmpty, first и last

isEmpty — самый дешёвый способ проверить, есть ли в строке хоть один символ: он возвращает обычный Bool и не требует прохода по всей строке, в отличие от сравнения count == 0.

let empty = ""
print(empty.isEmpty)

first и last дают первый и последний символ строки. Но у пустой строки нет ни первого, ни последнего символа, поэтому оба свойства возвращают не сам Character, а опциональное значение — Character?. Что такое опционалы и как с ними работать, подробно разберём чуть дальше в этом же модуле; пока достаточно знать, что first и last — тот случай, когда значения может не быть, и Swift честно отражает это в типе результата.

Частые ошибки

  • Попытка написать s[0] или s[i] с обычным числом: у String нет подписки по Int, нужен String.Index.
  • Ожидание, что count у строки работает так же быстро, как у массива: для строки это проход по всем символам.
  • Обращение к word[word.endIndex]: endIndex указывает на позицию сразу после последнего символа, а не на сам символ, и такое обращение упадёт.
  • Смешивание индексов от разных строк: String.Index, полученный из одной строки, не подходит для другой, даже если строки выглядят одинаково.
  • Слишком большой сдвиг в index(_:offsetBy:): если уйти дальше конца строки, программа завершится с ошибкой во время выполнения, а не тихо обрежет результат.

Резюме

  • String нельзя индексировать числом: символы занимают разное количество байт, поэтому доступ идёт только через String.Index.
  • Character — расширенный кластер графем: буква с диакритикой считается одним Character, даже если внутри записано несколько юникод-скаляров.
  • count у строки не мгновенный: Swift проходит по всей строке, чтобы правильно посчитать символы.
  • startIndex, endIndex, index(after:), index(before:) и index(_:offsetBy:) — способы получить и сдвинуть позицию в строке.
  • Диапазон из двух индексов даёт подстроку типа Substring, а не String; для долгого хранения превращайте её в String(...).
  • isEmpty возвращает обычный Bool, а вот first и last — опциональный Character?, потому что у пустой строки их не существует.

Проверьте себя

4 вопроса

Средний символ

Напишите функцию middleCharacter(of text: String) -> Character, которая возвращает средний символ переданной строки.

Строка всегда непустая. Если количество символов нечётное — возвращайте символ ровно посередине. Если чётное — возвращайте тот, что стоит сразу после середины, то есть символ с индексом count / 2, если считать позиции с нуля.

Используйте индексы строки, а не методы поиска и замены — они пока не разбирались.