Привет, Хабр! Сегодня поговорим о теме, которая вроде бы знакома каждому разработчику, но при этом часто остаётся в тени. Речь пойдёт о строках в Swift.
Каждый, кто писал или пишет приложения на этом языке, так или иначе работает со строками. Но задумывались ли вы когда-нибудь, как они устроены внутри? В этой статье я постараюсь приоткрыть завесу и рассказать, какие тайны скрывают строки в Swift.
Не будем затягивать вступление - поехали!

Что такое строки?
Начнём с самого простого и знакомого:
let str = "Hello, world!"
Строка в Swift - это контейнер для символов, более того строки - это value типы, что необычно для современных языков. По строке можно итерироваться, искать подстроки, работать с индексами и выполнять множество привычных операций. Казалось бы, всё просто. Попробуем?
let str = "Hello, world!"
// 'subscript(_:)' is unavailable: cannot subscript String with an Int, use a String.Index instead.
print(str[0])
Стоп! Ошибка? Но ведь индекс вроде корректный?!
Оказывается, в Swift строки устроены немного иначе. Для них есть свой особый тип индекса - String.Index
. Чтобы обратиться к символу, нужно использовать его:
let str = "Hello, world!"
print(str[str.startIndex]) // H
print(str[str.index(str.startIndex, offsetBy: 4)]) // o
print(str[str.index(after: str.startIndex)]) // e
// Thread 1: Fatal error: String index is out of bounds
print(str[str.endIndex])
Сразу натыкаемся на мину. Мы словили крэш. Дело в том, что endIndex
- это не индекс последнего символа, а позиция сразу после него. Это похоже на count
у массива: он возвращает количество элементов, но не индекс последнего. Чтобы получить последний символ без ошибки, нужно использовать index(before:)
:
let str = "Hello, world!"
print(str[str.startIndex]) // H
print(str[str.index(str.startIndex, offsetBy: 3)]) // l
print(str[str.index(after: str.startIndex)]) // e
print(str[str.index(before: str.endIndex)]) // !
Вопрос напрашивается сам собой: зачем Apple сделали такой «неудобный» интерфейс? Почему нельзя было просто использовать Int
, как, например, в Go?
Разберёмся.
Представления строки
Попробуем простой пример:
let letter = "A"
print(letter.count) // 1
Здесь всё очевидно: один символ, один результат.
А если так?
let cafe = "Café ??"
print(cafe.count) // 6
На первый взгляд кажется, что count
действительно отражает длину строки. Возникает вопрос: если мы можем посчитать символы через count
, зачем нужен этот «лишний» String.Index
? Почему нельзя использовать обычный Int
, как в массивах?
А вот тут есть подвох. На самом деле count
возвращает количество графемных кластеров - видимых символов, которые мы видим в консоли. Но символы вроде é
или ?? состоят из нескольких кодовых точек. Давайте проверим:
let cafe = "Café ??"
print(cafe.unicodeScalars.count) // 7
print(cafe.utf16.count) // 9
print(cafe.utf8.count) // 14
? Что произошло?
Каждое представление строки разбивает её по-своему. Посмотрим, как это выглядит:
let cafe = "Café ??"
print(Array(cafe.unicodeScalars))
// ["C", "a", "f", "\u{00E9}", " ", "\u{0001F1EB}", "\u{0001F1F7}"]
print(Array(cafe.utf16))
// [67, 97, 102, 233, 32, 55356, 56811, 55356, 56823]
print(Array(cafe.utf8))
// [67, 97, 102, 195, 169, 32, 240, 159, 135, 171, 240, 159, 135, 183]
unicodeScalars
- представление строки как последовательности Unicode скаляров (UTF-32).utf16
иutf8
- представление строк в одноименных кодировках соответствующих кодировках.Каждое число или графема - это кодовая единица, из которых в итоге складываются символы, которые мы видим.
Теперь возвращаемся к индексам. Почему нельзя просто использовать Int
?
Возьмём, например, индекс 3
:
В
unicodeScalars
это\u{00E9}
В
utf16
это233
В
utf8
это195
Совсем разные значения. В зависимости от представления строка разбивается на разные кусочки. Именно поэтому в Swift ввели новый тип индекс - String.Index
, который учитывает эту специфику.
Если нужно проверить, совпадают ли индексы между представлениями, можно воспользоваться функцией samePosition(in:)
:
let cafe = "Café ??"
let first = cafe.startIndex
let second = cafe.utf8.index(after: first)
print(cafe.utf8[first]) // 67
print(cafe.utf8[second]) // 97
print(cafe[first]) // C
print(cafe[second]) // a
if let exactIndex = second.samePosition(in: cafe) {
print(cafe[exactIndex]) // a
} else {
print("Не соответствует")
}
Примечание: если нужно проверить, пустая ли строка, используйте свойство
isEmpty
. Оно работает за O(1), в отличие от.count
, который имеет сложность O(n). Причина в том, что count под капотом использует методdistance(from:to:)
, а он работает за O(n) для коллекций, не подписанных наRandomAccessCollection
.String
, в свою очередь, кRandomAccessCollection
не относится.
Мы разобрались, как строка устроена логически и как она разбивается на символы. Но возникает естественный вопрос: а как всё это хранится в памяти?
Как строки хранятся в памяти
Строка в Swift (как и массив) - это абстракция над реальным хранилищем данных. В исходниках можно встретить структуры: _StringGuts
, которая управляет внутренними деталями, и _StringObject
, которая работает с данными напрямую.
Концептуально строки делятся на маленькие и большие. Маленькие (до 15 байт на 64-битных машинах) хранятся прямо внутри _StringObject
, что избавляет от выделения памяти в куче и ускоряет доступ. Если строка не помещается во внутреннее хранилище, она уходит в кучу, а _StringObject
хранит указатель на неё. Такой подход позволяет строкам быть практически любого размера.
Как и коллекции, строки используют оптимизацию Copy-on-Write: при копировании они делят общий буфер и создают его копию только в момент изменения.
Когда буфера не хватает, данные переносятся в кучу, а размер хранилища увеличивается экспоненциально. Благодаря этому добавление новых символов работает за амортизированное константное время.
Бриджинг String в NSString
Напоследок затронем тему, которая сегодня встречается не так часто, но знать её полезно. Речь идёт о работе со строками между Swift и Objective-C.
Сейчас большинство проектов уже полностью на Swift или хотя бы в основном на нём, поэтому в дебри Objective-C многие разработчики не заглядывают. Но для полноты картины этот аспект стоит упомянуть.
Строки в Swift могут свободно конвертироваться в NSString
:
let s: String = "Swift String"
let ns: NSString = s as NSString
Аналогично и в обратную сторону:
let ns2: NSString = "Objective-C NSString"
let s2: String = ns2 as String
Копирование строки из Objective-C в Swift стоит O(n). Поэтому пока строка только читается, Swift под капотом использует
NSString
. Но как только строка изменяется, происходит копирование данных во внутреннее хранилищеString
.
Выводы
Строки в Swift на первый взгляд кажутся простым и привычным типом, но на самом деле под капотом у них много интересных деталей. Индексы, разные представления, хитрые оптимизации и даже бриджинг с Objective-C - всё это сделано ради удобства и скорости. В обычной разработке редко приходится об этом задумываться, но знать, как устроены строки изнутри, полезно хотя бы для расширения кругозора(вдруг спросят на собеседовании ?).
aamonster
Что, серьёзно в swift не хранят длину строк? Я думал, от этого ушли много лет назад, только легаси в C осталось, "паскалевские" строки победили