Почему нельзя написать 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, если считать позиции с нуля.
Используйте индексы строки, а не методы поиска и замены — они пока не разбирались.