1. Зачем знать про unsafe‑память
Слово Unsafe в Swift — это честное предупреждение от языка: «Я больше не гарантирую безопасность памяти, дальше вы едете без ремня». Ирония в том, что в обычных приложениях (и особенно в нашем LibraryCLI) вы можете никогда не писать Unsafe* руками и всё равно быть отличным разработчиком: стандартная библиотека и Foundation закрывают 99% задач.
Но знать основы всё равно полезно. Во‑первых, вы будете читать чужой код и документацию: withUnsafeBytes, UnsafeBufferPointer и MemoryLayout встречаются в примерах, в оптимизациях, в обвязках вокруг системных API. Во‑вторых, unsafe — это зона, где ошибка может не проявиться сразу: код может «работать» на вашем ноутбуке и ломаться на другой машине или только в релизной сборке.
Поэтому наша цель — не «научиться писать unsafe», а научиться узнавать unsafe, понимать его правила и держать его в маленьком «карантине».
Карта unsafe‑типов в Swift
Когда новичок видит UnsafePointer, он часто думает: «О, это как ссылка». И вот тут начинается комедия. Указатель — это не «ссылка на объект с гарантией», а просто адрес. Адрес не знает, живы ли данные, какого они типа, и сколько их там лежит.
Поэтому Swift разделяет unsafe‑мир на несколько семейств: типизированные указатели (на T) и «сырые» указатели (на байты).
Ниже — таблица‑шпаргалка. Её не нужно учить наизусть, достаточно узнавать названия и понимать идею.
| Семейство | Тип | Интуитивно | Типичный сценарий |
|---|---|---|---|
| Typed pointer | |
«читать T по адресу» | чтение данных, которые уже точно инициализированы как T |
| Typed pointer | |
«читать/писать T по адресу» | редкие низкоуровневые API, ручные буферы |
| Raw pointer | |
«читать байты по адресу» | интерпретация байтов, переходы между типами через load |
| Raw pointer | |
«читать/писать байты по адресу» | выделили память, а тип ещё не “назначили” |
| Buffer pointer | |
«указатель + длина» | безопаснее, чем голый pointer, потому что есть count |
| Raw buffer | |
«байты + длина» | withUnsafeBytes, Data.withUnsafeBytes, парсинг бинарных форматов |
Важно, что в Swift есть чёткая идея: «сырая память» и «память, связанная с типом». В предложениях Swift Evolution это обычно объясняется на примерах с UnsafeMutableRawPointer.allocate(...), и там же видно, что при работе с raw‑памятью вам приходится явно учитывать MemoryLayout<T>.stride и alignment.
2. Undefined behavior и цена ошибки
В safe‑Swift вы привыкли: если что-то плохо, то либо компилятор ругается, либо вы получаете понятное падение (например, выход за границы массива). В unsafe‑мире бывает иначе: вы можете получить undefined behavior — ситуацию, когда язык и компилятор уже ничего не обещают.
Undefined behavior — это не обязательно мгновенный fatal error. Это может быть:
- «иногда правильно, иногда мусор»,
- «работает в debug, ломается в release»,
- «на одном устройстве нормально, на другом падает»,
- «тихо портит данные, а вы замечаете через минуту в другом месте».
И это как раз причина, почему новичкам unsafe почти не нужен: стоимость ошибки слишком высокая.
Если вам нужна аналогия: safe‑Swift — это кухня, где ножи острые, но у вас есть правила, перчатки и нормальный свет. Unsafe‑Swift — это та же кухня, но свет выключили, пол мокрый, а ножи летают по комнате. Можно приготовить ужин? Теоретически да. Практически — лучше включить свет и вернуться к Array, String, Data и нормальным API.
4. Практика: держим unsafe в «карантине»
Время жизни и withUnsafeBytes
Один из лучших способов держать unsafe под контролем — не давать указателю «жить долго». В Swift для этого есть стиль: указатель выдаётся только внутрь замыкания, а затем становится недействительным.
Именно так устроены многие стандартные функции, например withUnsafeBytes(of:_:) : они возвращают UnsafeRawBufferPointer на время выполнения closure. Указатель валиден только внутри тела closure.
Простейший пример (и он компилируется):
import Foundation
var x = 42
withUnsafePointer(to: &x) { ptr in
print(ptr.pointee) // 42
}
Что здесь важно: ptr нельзя «запомнить» и использовать позже. Это не «плохая привычка», а прямой путь к use-after-free (когда вы читаете память, которая уже не ваша).
Если вам нужно увидеть байтовое представление значения (это полезно для диагностики), используйте withUnsafeBytes:
import Foundation
let value: UInt32 = 0x12345678
withUnsafeBytes(of: value) { bytes in
print(bytes.count) // 4
print(bytes[0]) // порядок байтов зависит от endianness
}
Смысл этого подхода в том, что Swift сам держит «рамку времени жизни»: пока выполняется closure — доступ легален, после — нет.
UnsafeRawBufferPointer: байты как «окно» в память
Когда речь идёт о байтах, очень удобно думать не «у меня есть адрес», а «у меня есть срез байтов длиной N». Именно поэтому в Swift существует UnsafeRawBufferPointer: это пара «start + count», то есть уже не совсем «голый адрес».
В Swift Evolution отдельно подчёркивается, что UnsafeRawBufferPointer помогает переписать код, который раньше делал опасные «перекасты указателей», в более корректный и читабельный стиль: вы двигаетесь по буферу по смещениям и загружаете значения через load(fromByteOffset:as:).
Давайте сделаем мини‑пример в духе нашего LibraryCLI: допустим, мы хотим вывести «магическое число» (первые 4 байта) файла базы, чтобы понимать, что это вообще наш формат, а не случайный текст.
Сначала — безопасный вариант через Data (почти всегда достаточно):
import Foundation
func fileMagic(_ data: Data) -> String {
let prefix = data.prefix(4)
return prefix.map { String(format: "%02X", $0) }.joined()
}
А теперь вариант, где мы используем Data.withUnsafeBytes, но всё ещё держим unsafe в маленьком замыкании и никуда не выносим указатель:
import Foundation
func fileMagicFast(_ data: Data) -> String {
data.withUnsafeBytes { raw in
let bytes = raw.prefix(4)
return bytes.map { String(format: "%02X", $0) }.joined()
}
}
Технически здесь есть unsafe‑вкус, но он локализован: у нас есть буфер, у него есть длина, мы не делаем «типовые фокусы», не делаем rebinding памяти и не занимаемся ручными аллокациями. Это хороший компромисс, если вам нужно «чуть ближе к металлу», но без выхода на территорию «давайте сами управлять памятью».
MemoryLayout<T>: size, stride, alignment
Когда вы слышите size, хочется думать: «Размер структуры в байтах». И да, но есть нюанс — выравнивание. Поэтому у MemoryLayout есть три ключевых параметра: size, stride, alignment.
Чтобы было понятнее, представьте книжную полку. size — это ширина книги. stride — это сколько места нужно на полке, если книги должны начинаться только на «целых делениях линейки» (выравнивание). Иногда между книгами остаётся пустой зазор, потому что полка размечена крупными делениями. Вот этот зазор и делает stride больше size.
Мини‑пример:
import Foundation
struct Pair {
let a: UInt8
let b: UInt32
}
print(MemoryLayout<Pair>.size)
print(MemoryLayout<Pair>.stride)
print(MemoryLayout<Pair>.alignment)
Если stride окажется больше size, это не «ошибка Swift», а нормальный эффект выравнивания. В unsafe‑коде игнорирование stride и alignment — частый путь к проблемам, потому что вы можете «съехать» на неверный адрес при шаге по памяти или сделать некорректно выровненный доступ.
И именно поэтому в примерах Swift Evolution, когда выделяют raw‑память, используют allocate(bytes:alignedTo:) на базе MemoryLayout<T>.stride и alignment.
Ручная аллокация: allocate → initialize → use → deinitialize → deallocate
Это тот момент, где я обязан сказать: «Не повторять дома без необходимости». В нашем курсе (и в LibraryCLI) почти наверняка не будет ситуации, когда вам нужно вручную выделять память. Но как обзор полезно знать, как выглядит «канонический жизненный цикл».
Swift Evolution показывает пример нормального lifetime для значения в выделенной памяти: выделили, инициализировали, использовали, деинициализировали, освободили.
Упрощённая версия (пример учебный, не «рекомендуемая практика для бизнеса»):
import Foundation
struct Box { var value: Int }
func demoManualAllocation() {
let p = UnsafeMutablePointer<Box>.allocate(capacity: 1)
p.initialize(to: Box(value: 10))
print(p.pointee.value) // 10
p.deinitialize(count: 1)
p.deallocate()
}
Здесь важно два правила, которые можно запомнить как «симметрия»:
- если вы сделали initialize, вы обязаны сделать deinitialize;
- если вы сделали allocate, вы обязаны сделать deallocate.
Если вы нарушили симметрию, вы можете получить утечку, двойное освобождение или чтение неинициализированной памяти. А дальше — undefined behavior и «приключения».
5. Где unsafe встречается в LibraryCLI
В реальном LibraryCLI unsafe обычно появляется не потому, что вы сами захотели, а потому что кто-то ниже по стеку (системный API, оптимизированная библиотека, низкоуровневый код) просит «дай мне байты».
Типичный сценарий «на уровне нашего проекта» — диагностика. Например, у вас есть JSON‑файл базы, и вы хотите сделать команду уровня diagnose, которая показывает: размер файла, первые байты, возможно, проверяет, что файл похож на UTF‑8 текст, а не на бинарный мусор.
Сделаем маленькую функцию для «первых N байтов в hex» (это больше про диагностику, чем про бизнес‑логику). Сначала — полностью safe‑вариант:
import Foundation
func hexDumpPrefix(_ data: Data, limit: Int) -> String {
let n = min(limit, data.count)
return data.prefix(n).map { String(format: "%02X", $0) }.joined(separator: " ")
}
Если вы видите, что это работает достаточно быстро — на этом можно и остановиться. Если вдруг вы упёрлись в скорость (редко, но бывает), тогда уже имеет смысл думать про более низкий уровень, но всё равно в стиле «короткое замыкание и не выносить указатели наружу», как в примерах выше.
6. fatalError только для внутренних инвариантов
Очень легко перепутать две ситуации.
Первая ситуация: пользователь ввёл ерунду, файл не найден, сеть не ответила, JSON битый. Это неприятно, но ожидаемо. Значит, мы возвращаем ошибку (throws/Result), печатаем понятное сообщение, выставляем корректный exit code (по вашему каноническому ExitCode, который вы вводили раньше), и программа завершается нормально.
Вторая ситуация: ваш код сам себя загнал в состояние, которое не должно быть достижимо, если все предыдущие шаги были сделаны правильно. Это и есть внутренний инвариант. Вот здесь fatalError уместен: он честно говорит разработчику «мы нарушили свой контракт».
Мини‑пример «внутреннего инварианта» (не пользовательского ввода). Представим, что у нас есть функция, которая вызывается только после валидации и гарантирует, что массив не пустой:
import Foundation
func requireFirstID(_ ids: [String]) -> String {
guard let first = ids.first else {
fatalError("Internal invariant violated: ids must be non-empty here")
}
return first
}
Если вы попытаетесь использовать fatalError как реакцию на ввод пользователя — это будет архитектурная ошибка. Например, так делать не надо:
import Foundation
func parseYearBad(_ text: String) -> Int {
guard let year = Int(text) else {
fatalError("User entered invalid year") // так нельзя
}
return year
}
Правильный стиль — throws, потому что плохой ввод нормален:
import Foundation
enum ParseError: Error {
case invalidYear(String)
}
func parseYear(_ text: String) throws -> Int {
guard let year = Int(text) else {
throw ParseError.invalidYear(text)
}
return year
}
Идея простая: fatalError — это не «мне лень обрабатывать». Это «я как разработчик утверждаю, что сюда невозможно попасть, и если попали — значит, баг в коде».
7. Типичные ошибки при работе с unsafe‑памятью
Ошибка №1: «Утащить указатель наружу из withUnsafe… и пользоваться потом».
Такое часто делают по наивности: кажется, что ptr — это «ссылка», которую можно сохранить в свойство. Но указатели, полученные из withUnsafePointer, withUnsafeBytes, Data.withUnsafeBytes, валидны только пока выполняется closure. Вынесли наружу — вы уже в зоне use‑after‑free, то есть чтения чужой или освобождённой памяти.
Ошибка №2: Путать size и stride, а потом удивляться, что «прыжок по памяти» ломает данные.
size — это «сколько байтов занимает значение», stride — «сколько байтов занимает слот в массиве таких значений с учётом выравнивания». Если вы вручную адресной арифметикой шагаете по памяти, ориентируйтесь на stride, иначе вы можете попадать между элементами и читать мусор.
Ошибка №3: Делать ручной allocate без симметрии deinitialize/deallocate.
В safe‑коде вы привыкли, что память «сама». В unsafe‑коде за неё отвечаете вы. Забыли deinitialize или deallocate — утечка. Сделали deallocate не туда или два раза — undefined behavior. Это тот случай, когда один лишний или недостающий вызов превращает программу в лотерею.
Ошибка №4: Использовать fatalError для ошибок пользователя, файловой системы или сети.
Это логическая ошибка уровня архитектуры: пользовательский ввод и внешнее окружение должны приводить к нормальной ошибке (через throws/Result) и предсказуемому завершению CLI. fatalError оставляем для ситуаций «внутри программы сломали своё правило», когда продолжать выполнение опасно именно потому, что мы потеряли корректность состояния.
Ошибка №5: Пытаться «ускорить» код unsafe‑переходами без измерений и без необходимости.
Почти всегда настоящие тормоза в CLI — это I/O (диск/сеть/консоль) или алгоритмика (лишние проходы по данным), а не «слишком безопасные массивы». Если вы полезли в unsafe без базовой линии измерений, высок шанс получить код сложнее, опаснее и при этом не быстрее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ