Привет, Хабр! Сегодня поговорим о теме, которая вроде бы знакома каждому разработчику, но при этом часто остаётся в тени. Речь пойдёт о строках в 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 - всё это сделано ради удобства и скорости. В обычной разработке редко приходится об этом задумываться, но знать, как устроены строки изнутри, полезно хотя бы для расширения кругозора(вдруг спросят на собеседовании ?).

Ссылки

Комментарии (0)


  1. aamonster
    20.09.2025 12:08

    Что, серьёзно в swift не хранят длину строк? Я думал, от этого ушли много лет назад, только легаси в C осталось, "паскалевские" строки победили