#
Глава 9. Управление памятью
Боитесь, что указатели завершат вашу карьеру программиста фейерверком ошибок времени выполнения? Расслабьтесь. К тому моменту, как вы закончите эту главу, вы увидите указатели такими, какие они есть на самом деле: просто ещё один инструмент для управления памятью. Вы узнаете, почему подход Zig к указателям, выделению памяти и владению — это не злой лабиринт неопределённого поведения, а ясная и понятная сделка: вам нужна память — вы её выделяете, вы ею владеете и вы её освобождаете. Никаких догадок не требуется.
В этой главе вы узнаете, как обращаться с указателями, не теряя рассудка, безопасно управлять стеком и кучей, а также погрузитесь в продвинутые приёмы, такие как типы нулевого размера и приведение типов (кастинг). Каждая подтема покажет вам, как дизайн Zig помогает сохранять контроль над вашей памятью, при этом наслаждаясь скоростью и эффективностью.
В этой главе мы рассмотрим следующие темы:
- Стек и куча: двуглавый зверь памяти.
- Указатели: хорошие, плохие и злые (но в основном хорошие).
- Выделение памяти: искусство просить больше памяти (и получать её).
- Соображения о времени жизни.
- Типы нулевого размера: фантомная угроза управления памятью (но у них есть своё применение).
- Приведение типов (кастинг): когда вашим данным нужна маскировка.
К концу этой главы вы не только научитесь правильно работать с указателями и выделением памяти, но и убирать за собой мусор так, чтобы ваш компьютер вас не возненавидел — или не упал. Вы уйдёте с уверенностью в решении реальных проблем, таких как утечки памяти, ошибки использования после освобождения и огромные выделения в куче.
В повседневном программировании и системных задачах правильное управление памятью не подлежит обсуждению. Независимо от того, создаёте ли вы низкоуровневое приложение, которое никогда не должно падать, или просто хотите выйти за рамки языков «просто работает», Zig даёт вам силу и ясность для явного управления памятью. Изучите эти концепции здесь, и вы будете готовы ко всему: от отладки странных проблем с указателями в ваших собственных проектах до создания надёжного программного обеспечения, выдерживающего производственную нагрузку.
#
Технические требования
Весь код, показанный в этой главе, можно найти в каталоге Chapter09 нашего репозитория Git: https://github.com/PacktPublishing/Learning-Zig/tree/main/Chapter09.
#
Стек и куча: двуглавый зверь памяти
Если вы когда-либо пытались выяснить, где живут ваши данные во время выполнения программы, вы, вероятно, слышали о стеке и куче. Вот краткая версия: это не лекция по информатике, а лишь основы, чтобы вы понимали, что происходит. Готовы к скоростному прохождению?
#
Память и процессы (вкратце)
Во-первых, память на самом деле принадлежит компьютеру, а ядро — это ворчливый арендодатель, который выделяет её процессам. Процесс — это, по сути, дескриптор, который ОС использует для управления запущенными программами, отслеживания открытых файлов и отображения того, какие области памяти вам разрешено использовать. Вы получаете кусок доступной пользователю памяти, который включает в себя стек, кучу и несколько других областей, таких как глобальные данные, константы только для чтения и неинициализированные (BSS) сегменты.
Представьте себе два разных складских помещения в одном здании: одно маленькое, безопасное и лёгкое для входа и выхода, другое — огромное и более гибкое, но требующее больше усилий для управления. В этом суть того, как стек и куча работают внутри адресного пространства вашей программы.
#
Стек
Стек — это высокоорганизованная область памяти для локальных переменных функции. Представьте это как временное рабочее пространство: когда вызывается функция, она получает новую чистую область для работы.
Когда функция завершается, всё это рабочее пространство мгновенно и автоматически очищается. Этот строгий упорядоченный процесс делает выделение стека таким быстрым, поскольку не требует ручной очистки или сложного поиска в памяти.
Однако стек ограничен по размеру. Если вы попытаетесь запихнуть туда огромный массив или вложить слишком много вызовов функций, вы рискуете попасть на сторожевую страницу (guard page) и вызвать сбой. Linux часто устанавливает размер основного потока стека по умолчанию около 8 МБ, который может быстро исчезнуть, если вы будете неосторожны.
Вот почему данные в стеке обычно должны быть известного скромного размера на этапе компиляции.
Это можно резюмировать следующим образом:
- Он ограничен. Вы не можете просто вечно складывать туда вещи. Взорвите его огромным локальным массивом или сумасшедшей рекурсией — и вы получите сбой (знаменитая ошибка переполнения стека).
- Вы должны знать размеры своих переменных на этапе компиляции. Никаких массивов «на всякий случай» по 50 МБ (если только вам не нравятся сегфолты).
#
Куча
Кучу можно представить как большой складской комплекс, который вы арендуете по мере необходимости. Вы можете запросить столько площади пола, сколько вам нужно, при условии, что у ядра есть ресурсы. В Zig аллокатор выступает посредником в этих запросах, забирая память у ядра большими кусками, а затем распределяя её для вашего кода.
Эта гибкость фантастична для непредсказуемых или больших выделений данных, но как только вы закончите с этим складским пространством, вы должны освободить его самостоятельно. В противном случае вы будете продолжать платить аренду за пустые полки и в конечном итоге у вас закончится место. Выделение и освобождение в куче также сложнее выделения стека, поэтому это может быть медленнее и подвержено фрагментации, когда приходит и уходит множество запросов разного размера.
Нужен огромный массив, но вы не уверены в его размере или ожидаете динамического роста? Вы просите аллокатор (в Zig это может быть что-то вроде std.heap.page_allocator или кастомный аллокатор) выделить больше места, и аллокатор вежливо запрашивает кусок у ядра. Если операционная система говорит: «Конечно», вы получаете свою память. Если нет — не повезло. Кроме того, вы должны освободить её, когда закончите (не будьте тем человеком, который забывает это сделать и устраивает утечки памяти повсюду).
ДТП в 32-битной системе
В 32-битной системе ваша программа живёт в небольшом 4-гигабайтном виртуальном адресном пространстве. Куча растёт вверх (upward), а стек — вниз (downward), поэтому места не так много до того, как они столкнутся. Это столкновение может привести к сбою вашей программы (или ядро может отказать в дальнейших выделениях). В отличие от этого, 64-битные системы имеют гораздо больше виртуального пространства, поэтому столкновения кучи и стека встречаются реже, и ваша программа может дышать свободнее. «Насколько больше?» — спросите вы. Как насчёт 16 экзабайт? Лично я думаю, этого достаточно.
#
Другие разделы (мы клянёмся, они существуют)
Помимо стека и кучи, у вашего процесса также есть области для глобальных данных, кода, BSS (неинициализированные данные) и других. Эти разделы в основном обрабатываются операционной системой или загрузчиком, и вы редко возитесь с ними напрямую. Например, если вы объявляете глобальные переменные в Zig, некоторые из них попадают в сегмент данных, а обнулённые могут оказаться в BSS. Полезно знать об их существовании, но это не то, с чем вы обычно возитесь на ежедневной основе, если только не отлаживаете сложную ошибку или не работаете над кастомным загрузчиком.
Теперь, когда мы подготовили почву, поговорив о стеке и куче, пришло время посмотреть, как мы на самом деле получаем ссылки на данные в этих областях: без копирования всего подряд или взрыва нашей программы. Встречайте указатели — врата к мощным (и потенциально опасным) манипуляциям с памятью.
#
Указатели: хорошие, плохие и злые (но в основном хорошие)
Ах, указатели. Упомяните их в комнате, полной программистов, и вы увидите целый спектр реакций: от ветеранов с закатанными глазами до новобранцев, беззвучно шепчущих: «О нет, только не снова». Но в Zig указатели не стремятся вас достать (в большинстве случаев). Это просто ещё один хорошо определённый инструмент, который поможет вам управлять памятью. Думайте о них как о ссылках с чуть большей честностью и меньшим количеством страховочных сеток. Никаких иллюзий: только вы, ваши данные и ваше благоразумие.
Давайте посмотрим правде в глаза: в системном программировании далеко не уйти без указателей. Они как секретный ингредиент, который делает всё возможным, и одновременно главный подозреваемый, когда что-то идёт ужасно не так. В Zig указатели бывают нескольких форм и размеров, позволяя вам ссылаться на память структурированным способом, не таская за собой всю полезную нагрузку данных.
Позвольте мне представить вам причину существования указателей, их различные формы и то, как они помогают вам приручать память. По пути мы увидим фрагменты кода, которые иллюстрируют мощь указателей и предупреждают нас быть начеку. Никому ведь не хочется из-за случайного нулевого указателя устроить крах в 3 часа ночи, верно?
#
Зачем вообще возиться с указателями?
Помните, когда мы говорили о стеке и куче? Стек быстрый и опрятный, но имеет ограниченное пространство, и вам лучше заранее знать, сколько вам нужно. Куча позволяет хранить большие или непредсказуемые по размеру данные, но требует от вас управлять памятью (выделять, а затем освобождать), как будто вы арендуете складскую ячейку. Указатели — это ключ к этому складу. Вместо того чтобы таскать огромные массивы в стеке, вы можете просто передать указатель на данные — это как передать кому-то ключ от вашей ячейки хранения вместо того, чтобы катить всё содержимое по коридору.
Грубо говоря, указатели решают две большие проблемы:
- Вы не всегда можете знать размер объекта на этапе компиляции.
- Даже если вы знаете, вы можете не хотеть копировать эти данные.
Конечно, у указателей есть свои болевые точки: висячие ссылки (dangling references), утечки памяти (memory leaks), проблемы с выравниванием (alignment issues) и так далее. Но при ответственном использовании они — ваш лучший выбор для эффективной работы с памятью, особенно в таком языке, как Zig, который стремится дать вам производительность C с меньшим количеством потенциальных проблем.
Говоря об этом, модель указателей в Zig одновременно проще и более явная, чем та, к которой вы могли привыкнуть в таких языках, как C. У вас есть указатели на один элемент и указатели на несколько элементов (many-item pointers), каждый со своими возможностями и ограничениями. Ваша миссия, если вы решитесь её принять — научиться использовать эти сырые ссылки на память, не отстрелив себе ногу.
#
Получение указателя: & (адрес от)
Начнём с классического оператора «адрес от» — &:
const std = @import("std");
const expect = std.testing.expect;
test "managing player health using pointers" {
const npc_health: i32 = 100;
const npc_health_ptr = &npc_health;
try expect(npc_health_ptr.* == 100);
try expect(@TypeOf(npc_health_ptr) == *const i32);
var player_health: i32 = 150;
const player_health_ptr = &player_health;
try expect(@TypeOf(player_health_ptr) == *i32);
player_health_ptr.* -= 20;
try expect(player_health_ptr.* == 130);
try expect(player_health == 130)
}
Запуск примеров
В наших исследованиях мы пройдём через несколько тестов, демонстрирующих основы работы с указателями. Вы можете запустить их с помощью команды zig test <filename>.zig.
Не торопитесь, исследуйте и попытайтесь понять, что происходит в этих тестах. Как только почувствуете себя готовым — переходите к следующему объяснению.
В этом тесте здоровье NPC представлено константой (npc_health) с начальным значением 100. Мы берём его адрес (&npc_health), получая указатель типа *const i32 (указатель на константное целое число). Поскольку указатель указывает на неизменяемое значение, его можно только читать, но не изменять.
Далее здоровье игрока представлено изменяемой переменной (player_health) с начальным значением 150. Взятие адреса этой переменной даёт *i32 (указатель на изменяемое целое число). Используя этот указатель, мы симулируем получение игроком урона путём прямого изменения значения здоровья (player_health_ptr.* -= 20).
Этот небольшой фрагмент на удивление подчёркивает функции безопасности памяти Zig: явная изменяемость (mutability), семантика указателей и безопасность на этапе компиляции.
Примечание о преимуществах указателей
Вы можете подумать, что использование указателя для одного i32 не даёт большого прироста производительности — и будете правы. Настоящая мощь становится очевидной при работе с большими структурами. Представьте структуру с десятками полей, копировать её каждый раз при передаче в функцию было бы медленно и затратно по памяти. Передавая вместо этого указатель, вы копируете только адрес памяти (несколько байт), что даёт эффективный доступ к исходным данным без накладных расходов.
#
Явная изменяемость
Вы можете изменять данные только в том случае, если тип указателя и изменяемость исходной переменной это позволяют. Это предотвращает случайные побочные эффекты в коде.
Когда мы берём адрес неизменяемой переменной (const), результирующий тип указателя — *const T (указатель на константное значение). Это гарантирует, что вы не сможете изменить значение через этот указатель. С другой стороны, когда мы берём адрес изменяемой переменной (var), результирующий тип указателя — *T (указатель на изменяемое значение). Это позволяет вам изменять значение, на которое ссылается указатель.
#
Семантика указателей
Zig обеспечивает строгую типизацию для указателей. Программа проверяет во время выполнения (и неявно на этапе компиляции), что указатель на константную переменную имеет тип *const T, в то время как указатель на изменяемую переменную имеет тип *T. Это принудительное применение типов гарантирует, что вы не сможете случайно изменить неизменяемые переменные или неправильно использовать изменяемые.
Делая изменяемость указателей явной в их типах, Zig предоставляет чёткую и предсказуемую модель для работы с памятью.
#
Безопасность на этапе компиляции
После взятия адреса изменяемой переменной (var) программа изменяет её значение через указатель (y_ptr.*). Эта операция выполняется успешно, поскольку тип указателя позволяет выполнять изменения. Обновлённое значение проверяется путём повторного разыменования указателя.
Система типов Zig отлавливает многие потенциальные ошибки, связанные с изменяемостью и неправильным использованием указателей, на этапе компиляции.
Что, если взять адрес только одного элемента в массиве?
#
Доступ к элементам массива через указатель
В Zig массивы не могут содержать константные значения, попытка объявить массив, такой как [_]const u8, приведёт к ошибке компиляции. Из-за этого правила, когда вы берёте адрес элемента внутри массива, вы получаете указатель на тип элемента, а не указатель на константное значение внутри массива. Для изменяемого массива (var) вы получаете указатель *T. Для неизменяемого массива (const) вы получаете указатель *const T. Если вы хотите изменять данные через указатель, исходный массив должен быть изменяемым с самого начала.
Таким образом, с массивами вы получаете указатель на один элемент (*T), поэтому арифметика указателей не разрешена.
Для изменяемых данных вы получаете *T. Для неизменяемых данных вы получаете *const T. Zig не позволяет вам незаметно отбрасывать const. Если вы хотите выполнять мутации через указатель, он должен быть изменяемым с самого начала.
Но вы всё равно можете разыменовать его для чтения или записи этого элемента:
test "upgrading player inventory slots using pointers" {
var inventory_slots = [_]u8 { 1, 2, 3, 4, 5 };
const slot_ptr = &inventory_slots[2];
try expect(@TypeOf(slot_ptr) == *u8); // Указатель на изменяемый u8
try expect(slot_ptr.* == 3);
slot_ptr.* += 1;
try expect(inventory_slots[2] == 4); // 3-й слот теперь на уровне 4
}
Давайте разберём этот пример:
- Массив inventory_slots представляет уровни различных ячеек инвентаря игрока, инициализированный значениями {1, 2, 3, 4, 5}.
- Указатель slot_ptr создаётся с помощью оператора адреса (&) и указывает на третью ячейку (inventory_slots[2]), которая изначально имеет значение 3.
- Указатель slot_ptr используется для увеличения значения третьей ячейки (
slot_ptr.* += 1). - Поскольку inventory_slots является изменяемым, эта мутация напрямую влияет на массив.
Тест гарантирует следующее:
- Тип указателя —
*u8, что подтверждает его изменяемость. - Значение в третьей ячейке правильно обновляется, чтобы отразить его (4).
Синтаксис ptr.* явно разыменовывает указатель, делая ясным, что вы обращаетесь к значению по адресу памяти указателя. Другими словами, ptr.* — это просто причудливый способ сказать: «вещь по адресу ptr».
#
Преобразование между указателями и целыми числами
Иногда вам нужно рассматривать указатели как числовые адреса, возможно, для низкоуровневых манипуляций или взаимодействия с оборудованием. Например, в игровом движке вы можете захотеть напрямую проинспектировать память (скажем, выясняя, почему ваши NPC появляются в странных позициях). Zig предоставляет инструменты для представления указателей как «сырых» числовых адресов:
- @intFromPtr(address) преобразует адрес указателя в целое число.
- @ptrFromInt(ptr) преобразует целое число в указатель.
test "debugging spawn points with pointer math" {
const spawn_point_ptr: *usize = @ptrFromInt(0xdeadbee0); // Точка появления в памяти
const addr = @intFromPtr(spawn_point_ptr);
try expect(@TypeOf(addr) == usize);
try expect(addr == 0xdeadbee0); // Точка появления совпадает с ожидаемым адресом
}
#
volatile
При работе с ячейками памяти, которые имеют реальные последствия в физическом мире, volatile — ваш основной инструмент. Он сообщает компилятору, что не следует оптимизировать операции чтения или записи, которые могут взаимодействовать с внешними системами. Подробнее про volatile смотрите по ссылке.
Это особенно важно в следующих сценариях:
Взаимодействие с аппаратным обеспечением
Конкретные адреса памяти, такие как ввод-вывод с отображением на память (Memory-Mapped I/O, MMIO), соответствуют регистрам аппаратного обеспечения, а не обычной ОЗУ. Запись в эти адреса может запускать аппаратные действия, такие как запуск двигателя, переключение светодиода или отправка данных на периферийное устройство. Также чтение аппаратных счётчиков или обмен данными через общую память с устройствами требует гарантии, что каждая операция чтения или записи происходит именно так, как задумано.Обработка прерываний
Когда процедура обработки прерывания (Interrupt Service Routine, ISR) изменяет общие переменные,volatileгарантирует, что основная программа всегда считывает самое актуальное значение.Предотвращение оптимизаций компилятора
По умолчанию компиляторы предполагают, что операции загрузки и сохранения в память являются простыми операциями без побочных эффектов. Они могут:- Кэшировать значения, что приводит к меньшему количеству фактических чтений из памяти.
- Переупорядочивать инструкции для повышения производительности.
Однако в программировании аппаратного обеспечения это может привести к некорректному поведению. Рассмотрим следующий пример:
- При двойном чтении регистра аппаратного обеспечения компилятор может оптимизировать второе чтение, предполагая, что значение не изменилось.
- Запись в управляющий регистр может быть переупорядочена, что нарушит тайминги ожидания аппаратного обеспечения.
volatile явно инструктирует компилятор: «Эти операции загрузки и сохранения должны происходить ровно так, как они написаны в исходном коде».
#
volatile против конкурентности
Важно отметить, что volatile не является инструментом для конкурентности или многопоточности. Хотя он и гарантирует, что обращения к памяти происходят так, как написано, он не делает следующего:
- Не предотвращает состояния гонки (data races).
- Не обеспечивает атомарность.
Для многопоточного программирования используйте атомарные операции или примитивы синхронизации. Использование volatile для потокобезопасных флагов или в многопоточной среде часто указывает на ошибку и может привести к неопределённому поведению.
#
Приведение указателей: лутбоксы и загадочные байты
Представьте, что вы парсите сетевой пакет с наградами из лутбокса, и данные приходят в виде сырых байтов. Чтобы извлечь осмысленную информацию, вам нужно интерпретировать эти байты как структурированные данные.
Здесь на помощь приходят @ptrCast и @alignCast:
test "open the loot box (pointer casting)" {
const loot_bytes = [_]u8 { 0x12, 0x34, 0x56, 0x78 };
const loot_value_ptr: *const u32 = @alignCast(@ptrCast(&loot_bytes[0]));
try std.testing.expect(loot_value_ptr.* == 0x78563412);
}
Этот тест показывает, как интерпретировать сырые байты (loot_bytes) как u32 с помощью @ptrCast и @alignCast. Он обеспечивает правильное выравнивание указателя и проверяет интерпретированное значение (0x78563412), которое зависит от порядка байтов системы (эндианности). Этот метод полезен для работы с сырой памятью, но требует осторожности с выравниванием и порядком байтов.
Более безопасные варианты
В стандартной библиотеке есть более безопасная альтернатива: функция bytesAsSlice может достичь того же результата. Однако мы приберегаем стандартную библиотеку для Главы 10. Давайте двигаться шаг за шагом, хорошо?
Распространённый сценарий из реального мира — во встроенном программировании, где конкретный аппаратный регистр (например, порт управления GPIO) расположен по фиксированному адресу в памяти, указанному в техническом описании оборудования. Для взаимодействия с ним необходимо преобразовать этот целочисленный адрес в указатель.
#
Зачем использовать @alignCast вместе с @ptrCast?
При интерпретации сырых байтов как определённого типа важно выравнивание. В то время как @ptrCast говорит компилятору переинтерпретировать память по заданному адресу как указатель другого типа (в данном случае, преобразуя указатель на u8 в указатель на u32), @alignCast гарантирует, что указатель соответствует требованиям выравнивания целевого типа. Например, для u32 обычно требуется, чтобы память была выровнена по 4-байтовой границе. Несоблюдение правильного выравнивания может привести к сбоям или неопределённому поведению, особенно на платформах со строгими правилами выравнивания.
#
Переинтерпретация битов
Иногда вам вообще не нужны манипуляции с указателями. Например, представьте декодирование специального предмета в инвентаре игры, где его идентификатор упакован как u32, но вам нужно просмотреть его как четыре отдельных байта u8. На помощь приходит @bitCast:
test "bitCast for decoding item ID" {
const item_id: u32 = 0x12345678;
const item_id_bytes = @bitCast([4]u8, item_id);
std.testing.expect(item_id_bytes[0] == 0x78);
std.testing.expect(item_id_bytes[1] == 0x56);
std.testing.expect(item_id_bytes[2] == 0x34);
std.testing.expect(item_id_bytes[3] == 0x12);
}
Этот тест переинтерпретирует u32 (item_id) как массив из четырёх байтов с помощью @bitCast. Он безопасно преобразует биты без манипуляций с памятью или указателями и проверяет порядок байтов. Это более простой и безопасный подход для переинтерпретации на уровне битов.
Зачем использовать @bitCast?
- Безопасность: Нет необходимости в манипуляциях с указателями или проверках выравнивания.
- Простота: напрямую переинтерпретирует биты, уменьшая количество шаблонного кода и потенциальных ошибок.
- Учёт эндианности: как и приведение указателей, результат зависит от порядка байтов системы, поэтому убедитесь в ожидаемом порядке на вашей целевой платформе.
Когда следует использовать @bitCast?
- Декодирование пакетов: интерпретация сырых сетевых данных как структурированных полей.
- Программирование графики: преобразование между форматами, такими как упакованные цвета (например,
u32RGBA) и массивы каналовu8. - Сериализация/десериализация: быстрая переинтерпретация значения без копирования или дополнительных накладных расходов.
Если ваша цель — переинтерпретировать данные, @bitCast позволяет избежать сложности манипуляций с указателями и обеспечивает ясность вашего кода.
Хорошо! Вы можете задаться вопросом: когда следует использовать один подход вместо другого? Вот рекомендации:
- Используйте
@ptrCastвместе с@alignCast, когда вам нужно переинтерпретировать конкретное место в памяти (например, сырые байты из аппаратного обеспечения или буферов). - Используйте
@bitCast, когда вам нужно переинтерпретировать биты значения без прямого обращения к памяти, что делает этот способ проще и безопаснее для таких задач, как сериализация или преобразование форматов.
#
Указатели на один элемент и на несколько элементов
Zig различает указатели по тому, на что они указывают. *T — это указатель на один элемент типа T. Это как сказать: «Я точно знаю, что здесь находится ровно один элемент». Тем временем, [*]T — это указатель на несколько элементов, признающий, что в памяти существует несколько последовательных элементов, даже если их количество неизвестно на этапе компиляции. Считайте это более гибким представлением: «Это указывает куда-то в последовательность элементов».
*T (указатель на один элемент) можно описать следующим образом:
- Вы можете разыменовать его с помощью
ptr.*. - Вы не можете выполнять операции индексирования, такие как
ptr[i], напрямую. - Не допускается арифметика указателей (нет
ptr + 1), поскольку Zig не предполагает, что после первого элемента есть ещё один.
[*]T (указатель на несколько элементов) можно описать следующим образом:
- Поддерживает индексацию:
ptr[i]. - Позволяет выполнять арифметику указателей:
ptr + 1даёт следующий элемент. - Позволяет рассматривать память как непрерывный буфер элементов, предполагая, что они действительно там есть.
Для данных фиксированного размера, известного на этапе компиляции, *[N]T — это указатель на массив из N элементов. Это как иметь твёрдую гарантию: «Прямо здесь находится ровно N элементов».
#
Срезы с помощью указателей
Вы можете создавать указатели на массивы с помощью срезов: x_ptr[0..1] создаёт *[1]i32 — указатель на массив из одного элемента. Принудительное приведение этого типа к [*]i32 даёт вам указатель на несколько элементов, ссылающийся ровно на этот один элемент:
test "slice syntax on pointers" {
var y: i32 = 5678;
const y_ptr = &y;
const y_array_ptr = y_ptr[0..1]; // *[1]i32
const y_many_ptr: [*]i32 = y_array_ptr;
try std.testing.expect(@TypeOf(y_many_ptr) == [*]i32);
try std.testing.expect(y_many_ptr[0] == 5678);
}
Такая цепочка может показаться странной, но как только вы к ней привыкнете, она становится удивительно ясной. Вы начинаете с одной ссылки, затем «притворяетесь», что это массив длины 1, и, наконец, обобщаете это до типа указателя на несколько элементов. Я почти уверен, что срезы теперь стали для вас более понятными, верно?
#
Арифметика указателей: заряженное ружьё
Арифметика указателей разрешена для указателей на несколько элементов. Вы можете выполнить ptr += 1, чтобы перейти к следующему элементу. Просто помните, что Zig вам доверяет. Если вы укажете в никуда — это ваша проблема.
Для срезов ([]T) не стоит возиться, напрямую изменяя slice.ptr без корректировки slice.len, вы создадите «лживый» срез, который утверждает, что содержит больше элементов, чем может подтвердить. Срезы Zig безопаснее, поэтому, если вам нужна гибкость, рассмотрите возможность работы со срезами, а не с сырыми указателями.
test "pointer arithmetic" {
const arr = [_]i32{ 1, 2, 3, 4 };
var ptr: [*]const i32 = &arr;
try std.testing.expect(ptr[0] == 1);
ptr += 1; // перемещаемся вперёд на один элемент
try std.testing.expect(ptr[0] == 2);
}
Срезы против указателей
Срезы ([]T) — это как указатель плюс длина: «толстый указатель» (это название придумал не я, вините Уолтера Брайта). Они безопаснее, потому что знают свои границы. Выход за пределы диапазона при индексации перехватывается во время выполнения. С сырым указателем вы предоставлены сами себе. Выбирайте с умом.
Указатели в Zig обеспечивают прямой и гибкий доступ к (виртуальной) памяти, давая вам возможность манипулировать данными на низком уровне. Независимо от того, ссылаетесь ли вы на один элемент (*T), работаете с указателем на несколько элементов ([*]T) или безопасно индексируете срез ([]T), Zig предлагает тип указателя для каждой задачи. От системного колдовства до повседневных манипуляций с массивами — указатели являются незаменимыми инструментами, но они сопряжены с неотъемлемыми рисками. Ошибки, такие как висячие указатели (dangling pointers), проблемы с выравниванием или доступ к чужой памяти, могут быстро привести к ошибкам или сбоям.
Zig находит отличный баланс между мощностью и безопасностью. Он применяет правила, которые помогают вам избежать очевидных ошибок (например, смешивания указателей на один и несколько элементов), но оставляет окончательную ответственность в ваших руках. Освоение указателей означает тщательное обдумывание своих действий, использование возможности прямого управления памятью при уважении к опасностям, которые оно несёт.
Вооружившись этими знаниями, вы готовы погрузиться в мир выделения памяти. Похлопайте себя по спине: вы укротили зверя указателей и готовы к тому, что ждёт впереди.
#
Выделение памяти: искусство просить больше памяти (и получать её)
Если указатели — это «ключи» к памяти, то аллокаторы — это арендодатели. Философия Zig похожа на философию предельно честного арендодателя, который не верит в «магические» сервисы. Здесь нет сборщика мусора, который убирал бы за вами.
Вместо этого Zig вручает вам ключи и договор аренды, чётко давая понять, что контракт заключается между вами и системой памяти. Ответственность за возврат ключей (освобождение памяти) лежит исключительно на вас. Если вы этого не сделаете — это будет только ваша вина.
#
Zig против мейнстримных языков: няни и взрослые
Большинство мейнстримных языков обращаются с вами как с великовозрастным малышом, когда дело касается памяти. Java и Go радостно выдают вам столько памяти, сколько вы захотите, будучи уверенными, что их сборщик мусора всё за вами уберёт, как заботливая няня без границ. Python идёт ещё дальше, убеждая вас, что разбрасывать вещи повсюду — это нормально, потому что кто-то другой всё уберёт. Даже Rust, со своим самодовольным проверяющим заимствований (borrow checker), настаивает на том, чтобы держать вас за руку, хотя и бормочет при этом снисходительно о времени жизни и владении.
А потом появляется Zig. Zig не держит вас за руку. Он её отталкивает. Управление памятью здесь похоже на аренду квартиры в безжалостном городе: вы сами решаете, где будете жить, как долго будете платить и когда съезжать. И если вы забудете съехать — угадайте что? Вам придётся жить с этим бардаком.
В отличие от C, который тихонько подсовывает вам malloc и надеется, что вы не заметите плохих привычек, которые он поощряет, Zig не делает никаких предположений. Здесь нет скрытого аллокатора по умолчанию.
Вам нужна память? Отлично. Но вам придётся решить, откуда она берётся, как ею управляют и когда её убирать. Ручное управление памятью даёт вам детерминированную производительность, устраняя непредсказуемые паузы, вызванные сборщиком мусора. Это также позволяет проводить тонкую оптимизацию использования памяти, что критически важно в системном программировании, разработке игр и других областях, чувствительных к производительности.
Подход Zig прост: вы это сломали — вы это и чините.
Жёсткая проверка реальностью OOM
В Java вы бы беззаботно писали код, игнорируя возможность нехватки памяти. Когда это случается — бум. Ошибка «Недостаточно памяти» (Out-of-Memory, OOM), игра окончена. Go пытается быть умнее с помощью overcommit, но это просто приводит к тому, что убийца OOM (OOM killer) бесшумно «убивает» ваш процесс.
Если память недоступна, Zig возвращает ошибку error.OutOfMemory. Никаких отговорок, никаких сказок — только суровая, честная ошибка. Ваша задача — изящно обработать её, повторить попытку или обрезать функциональность.
#
Интерфейс аллокаторов
Zig — это язык, который осмелился показать миру, что системное программирование может быть больше про владение клинком, чем про его полировку. Но прежде чем вы слишком обрадуетесь, позвольте мне на мгновение умерить ваш энтузиазм: в Zig нет «интерфейсов» в том виде, в котором вы их знаете и любите (или терпите) в других языках.
Нет, вы не будете писать публичный интерфейс MemoryAllocator со всей помпезностью архитектора ПО, посмотревшего слишком много корпоративных туториалов. Zig оставляет вещи «сырыми» — как и должно быть в системном программировании — и при этом каким-то образом умудряется предоставить вам всё, что вам действительно нужно.
Не стоит сейчас потеть над мелкими деталями. Главный вывод в том, что в Zig нет встроенных «интерфейсов». Вместо этого он полагается на указатели на функции и структуры, чтобы вы могли формировать поведение своего кода. Думайте об этом как о создании собственных магических заклинаний вместо того, чтобы полагаться на готовую волшебную палочку — вы по-прежнему управляете памятью, просто с более прямым контролем. Всё остальное может подождать.
Давайте копнём глубже в аллокаторы — а точнее, в тип std.mem.Allocator — и посмотрим, как они функционируют как интерфейс, но делают это стильно. Мы исследуем бездну управления памятью через несколько вдохновлённых играми аналогий.
#
Аллокаторы в Zig
Представьте, что вы — мастер игры (Game Master), который обустраивает подземелье для своих искателей приключений. Аллокаторы — это как волшебные рюкзаки. У каждого свои правила для хранения, извлечения и выбрасывания предметов (или блоков памяти). В Zig это не обеспечивается интерфейсом, а скорее свободным протоколом: у аллокатора должны быть методы alloc, resize и free. Если вы не будете его придерживаться, маг вашей партии наколдует исключение NullPointerException быстрее, чем вы успеете отладить код.
Вот жестокая правда: Zig не будет танцевать с памятью за вас. Вместо этого вы — хореограф собственного системного балета. Здесь есть три главных движения: alloc, free и resize. Давайте разберём их.
#
alloc: Ручной захват памяти
Забудьте о уютных страховочных сетках. alloc — это ваша базовая команда для запроса памяти. Нужен массив из 10 целых чисел? Вы вызываете alloc, указываете тип и количество. Он либо выдаёт вам новый блестящий срез (slice), либо возвращает ошибку, если ваша система слишком стеснена в байтах. Просто и понятно, без лишних сложностей.
#
free: Верни или страдай
Когда вы закончили с этой памятью — освободите её. Да, если вы забудете это сделать, ваша программа будет тихо съедать всё больше и больше памяти, пока ваша машина не закричит. Zig не прощает. Если вы никогда не освобождаете память, ваша программа будет разрастаться как космический ужас, пока не поглотит всю ОЗУ. Удачи с объяснением этого своему боссу. Это всё на вас. Так что будьте ответственным и взрослым — освобождайте свои ресурсы.
#
resize: Вежливая просьба о большем
Иногда вы понимаете, что вам нужен срез большего размера, чем тот, который вы изначально выделили. Или, может быть, вы выделили слишком много и хотите уменьшить его. Для этого существует resize. Он запрашивает новый размер для вашего существующего выделения без его перемещения в памяти — если только ваш аллокатор не может с этим справиться, в таком случае resize пожимает плечами и возвращает false. Если это происходит, вам придётся выполнить ручную процедуру: выделить новый кусок памяти, скопировать туда свои данные и освободить старый кусок.
Это требует немного больше усилий, но это та цена, которую вы платите за полный контроль. При работе с аллокатором мы получаем две дополнительные функции удобства: create и destroy.
#
create: Выделение одного элемента
create — это ваш основной инструмент для создания одного элемента заданного типа. Вместо выделения всего массива вы просто получаете один указатель. Под капотом это просто alloc для одного элемента плюс щепотка удобства. По-прежнему именно вы несёте ответственность за управлением этой памятью, так как create — это стандартная функция удобства, а не форма автоматического управления памятью.
#
destroy: Освободи этот единственный элемент
И наоборот, у нас есть destroy. Она работает в паре с create для одноразовых элементов, так же как free работает в паре с alloc для срезов. Вам не нужно передавать длину, потому что она отслеживается внутри. Но посыл тот же: хватит занимать память, которую вы не используете. Вызывайте destroy, когда закончите.
Вот и всё. Прямо по делу? Никаких красивых иллюзий или сборщиков мусора, которые спасли бы вас от последствий неряшливого кода. Zig не будет держать вас здесь за руку — и именно поэтому вы его полюбите... или полюбите со временем. Сравните это с другими языками, где вы обычно используете ключевое слово new для создания экземпляров объектов или выделения памяти. Это не просто стилистический выбор, речь идёт о том, чтобы дать вам полный контроль. new в других языках часто скрывает детали управления памятью, делая его простым в использовании, но более сложным для оптимизации или отладки. create в Zig заставляет вас столкнуться с этими деталями лицом к лицу.
#
Соображения о времени жизни
Управление памятью в Zig похоже на управление зельями здоровья вашего героя в огромном подземелье RPG. Это не просто вопрос о том, чтобы взять то, что вам нужно, но и о том, чтобы не оставить после себя беспорядок из забытых зелий (или висячих указателей), которые могут вернуться и преследовать вас. Если вы привыкли к тёплым объятиям сборщиков мусора, приготовьтесь к ледяному уколу ответственности. В Zig вы управляете временем жизни объектов явно, и нет волшебного целителя, который уберёт ваши ошибки.
Каждый байт в вашей программе имеет своё место, как и разные слоты инвентаря в вашей любимой RPG. Давайте посмотрим, где эти байты хранятся:
const std = @import("std");
// Хранится в глобальной константной секции памяти.
const manaPotion: f64 = 1.337;
const battleCry = "For Honor!";
// Хранится в глобальной секции данных.
var monstersDefeated: usize = 0;
fn calculateDamage() u8 {
// Эти локальные переменные исчезают, как только функция завершается.
const swordDamage: u8 = 10;
const shieldBonus: u8 = 5;
const totalDamage: u8 = swordDamage + shieldBonus;
// Возвращается копия `totalDamage`, в целости и сохранности.
return totalDamage;
}
В этом примере глобальные константы, такие как manaPotion и battleCry, хранятся в постоянном разделе памяти — подобно предметам инвентаря, которые никогда не заканчиваются. Тем временем локальные переменные, такие как swordDamage, живут только в области видимости функции. Как только функция заканчивается, эти переменные исчезают, как временные баффы после битвы.
Эта схема работает нормально, пока вы не попытаетесь срезать путь и не вернёте память, которая не переживёт свой контекст. Давайте поговорим об этих опасных ловушках.
Иногда искатели приключений идут на опасный риск, например, заходят в наполненное ловушками подземелье. В Zig ловушки часто принимают форму висячих указателей. Рассмотрим эту неразумную стратегию:
fn cursedSword() *u8 {
var attackPower: u8 = 42;
// Сила атаки живёт в стеке и исчезнет после выхода из функции.
return &attackPower;
}
Эта функция возвращает указатель на стековую переменную. Когда функция заканчивает свое выполнение, фрейм стека уничтожается, и указатель становится недействительным. Попытка использовать его похожа на экипировку проклятого меча — он может работать мгновение, но рано или поздно обернётся против вас.
Теперь познакомьтесь с его столь же безрассудным кузеном:
fn cursedScroll() []u8 {
var spell: [5]u8 = .{ 'F', 'i', 'r', 'e', '!' };
const incantation = spell[1..]; // Срез массива.
// Массив исчезает после завершения функции, оставляя `incantation` висячим.
return incantation;
}
Этот срез ссылается на память, которая уничтожается при выходе из функции. Возврат его подобен вручению вашим искателям приключений карты подземелья, которое обрушивается в тот момент, когда они входят.
Чтобы избежать этих ловушек, убедитесь, что память, выделенная в ваших функциях, переживает саму функцию. Выделение в куче — это святилище вашего героя:
fn enchantedSword(allocator: std.mem.Allocator) std.mem.Allocator.Error![]u8 {
var swordStats: [5]u8 = .{ 'S', 'l', 'a', 's', 'h' };
const statsCopy = try allocator.alloc(u8, swordStats.len);
@memcpy(statsCopy, &swordStats);
return statsCopy;
}
Здесь statsCopy безопасно выделяется в куче, что означает, что она переживёт функцию. Теперь ответственность за освобождение памяти ложится на вызывающего — подобно тому, как ваши искатели приключений должны возвращать взятые напрокат предметы в гильдию.
Когда квесты становятся длиннее, а добыча — выше, ручное управление временем жизни становится утомительным и подверженным ошибкам. Чтобы справиться с этим, программисты на Zig часто принимают общепринятый паттерн с использованием методов init и deinit. Важно отметить, что это не специальные ключевые слова или возможности языка (как конструкторы и деструкторы в других языках), а просто общепринятый паттерн для функций, которые вы пишете сами. Это соглашение позволяет инкапсулировать управление памятью внутри структур, гарантируя, что ресурсы выделяются и освобождаются предсказуемым образом — думайте об этом как о найме оруженосца для переноски вашего снаряжения и уборки после битв.
const Sword = struct {
stats: []u8,
pub fn init(allocator: std.mem.Allocator, stats: []const u8) !*Sword {
// Выделяем память для структуры.
const sword_ptr = try allocator.create(Sword);
errdefer allocator.destroy(sword_ptr);
// Выделяем память для характеристик меча.
sword_ptr.stats = try allocator.alloc(u8, stats.len);
@memcpy(sword_ptr.stats, stats);
return sword_ptr;
}
pub fn deinit(self: *Sword, allocator: std.mem.Allocator) void {
// Освобождаем память для характеристик.
allocator.free(self.stats);
// Уничтожаем саму структуру.
allocator.destroy(self);
}
};
Эта структура гарантирует, что память для Sword и её характеристик управляется должным образом. Инкапсулируя логику выделения и очистки памяти, вы снижаете риск утечек памяти и висячих указателей.
Совет профессионала
Всегда используйте errdefer при выделении памяти, чтобы гарантировать немедленную очистку любых частичных выделений в случае сбоя. Это гарантирует, что ваша программа не оставит после себя бесхозных выделений памяти даже в случае ошибки.
#
Тестовый аллокатор: std.testing.allocator
Zig серьёзно относится к тестированию, и управление памятью не является исключением. std.testing.allocator — это мощный инструмент, предназначенный для помощи в тестировании использования аллокаторов. Это отладочный аллокатор, который обнаруживает распространённые проблемы с выделением памяти, такие как утечки памяти, двойное освобождение или недопустимый доступ.
Включив этот аллокатор в свои тесты, вы можете проверить, что ваш код безопасно и корректно взаимодействует с выделяемой им памятью.
В Zig лучшей практикой считается не навязывать аллокатор пользователям вашей функции или библиотеки. Вместо этого вы предоставляете им гибкость в выборе аллокатора, который лучше всего соответствует их потребностям. Этот подход уважает философию Zig, которая расширяет возможности разработчиков за счёт контроля и прозрачности. Принимая аллокатор в качестве параметра, вы позволяете вызывающему коду решить, использовать ли универсальный аллокатор, кастомный аллокатор, настроенный под конкретные требования к производительности, или даже временный стековый аллокатор для короткоживущих данных. Такой дизайн способствует адаптивности, делая ваш код более универсальным и пригодным для повторного использования в широком диапазоне контекстов.
#
Зачем использовать std.testing.allocator?
Представьте, что вы запускаете свою игру или приложение и сталкиваетесь с тихой утечкой памяти, которая проявляется только после нескольких часов работы. Отладка таких проблем может быть пугающей. std.testing.allocator решает эту проблему следующим образом:
- Отслеживает все выделения и освобождения памяти.
- Сообщает о расхождениях, таких как утечка или неправильное освобождение памяти.
- Применяет более строгие проверки для раннего обнаружения проблем в процессе разработки.
#
Тестирование вашего снаряжения
Как знает любой опытный искатель приключений, проверка снаряжения перед битвой необходима. Точно так же вы должны протестировать управление памятью, чтобы убедиться, что оно работает должным образом:
const battleCry = "Victory!";
test "Sword initialization and cleanup" {
const allocator = std.testing.allocator;
var sword_ptr = try Sword.init(allocator, battleCry);
// Убедимся, что характеристики были скопированы правильно.
try std.testing.expectEqualStrings(battleCry, sword_ptr.stats);
}
Код кажется нормальным — он добавляет элементы и проверяет длину списка. Однако при запуске теста терминал рассказывает другую историю:
> [gpa] (err): memory address 0x102bc0000 leaked
Теперь не паникуйте! Это не ваш средний балл из колледжа преследует вас. В Zig GPA не означает «grade point average» (средний балл), но с таким же успехом это может быть «great panic alarm» (великий сигнал тревоги) для управления памятью. Забыли вызвать deinit? БАМ! Zig настучит на вас, как профессор, который только что узнал, что вы прогуляли занятия ради видеоигр.
Тестовый аллокатор усердно ловит такие ошибки во время тестирования, но в продакшене эти утечки могут накапливаться незаметно, приводя к значительным проблемам.
Чтобы решить эту проблему, всегда используйте deinit вместе с init. Использование defer гарантирует, что очистка произойдёт автоматически, даже если возникнет ошибка:
const battleCry = "Victory!";
test "Sword initialization and cleanup" {
const allocator = std.testing.allocator;
var sword_ptr = try Sword.init(allocator, battleCry);
defer sword_ptr.deinit(allocator);
// Убедимся, что характеристики были скопированы правильно.
try std.testing.expectEqualStrings(battleCry, sword_ptr.stats);
}
Этот тест инициализирует Sword, проверяет правильность копирования его характеристик и очищает выделенную память. Это как заточить клинок и проверить его баланс перед тем, как броситься в бой.
Думайте об этом как о своевременной сдаче домашнего задания — ваш профессор по аллокаторам будет доволен, и никакие утечки памяти не испортят ваш идеальный послужной список. Забудете об этом — и Zig оставит вам записку на столе: «Утёк адрес памяти, повезёт в следующем семестре!»
#
Обработка сбоев выделения: std.testing.failing_allocator
Выделение памяти — это игра в Тетрис, где ставки высоки, а детали невидимы. Но что, если детали иногда просто... исчезают в воздухе? Именно здесь на сцену выходит FailingAllocator, накинув свой злодейский плащ, чтобы нарушить ваше аккуратное управление памятью. И честно говоря, вы должны поблагодарить его за эту боль — он здесь, чтобы сделать ваш код обработки ошибок менее жалким.
В чём суть?
FailingAllocator — это не ваш аллокатор для повседневного применения. Нет, этот — сертифицированный возмутитель спокойствия. Он здесь не для того, чтобы помочь вашему коду работать, он здесь, чтобы доказать, что ваш код не развалится, когда всё пойдёт наперекосяк.
Думайте о нём как о «хаос-обезьяне» для вашего управления памятью. Он притворяется нормальным аллокатором, но в глубине души он считает ваши выделения, точит свой метафорический кинжал и ждёт момента, чтобы закричать: «Недостаточно памяти!»
Он детерминированно симулирует сбои выделения. Это позволяет вам воспроизводить и тестировать пограничные случаи, когда выделение памяти завершается неудачей, гарантируя, что ваш код реагирует на это изящно. Вот примеры:
- Проверка того, что утечки памяти не происходят даже при сбоях выделения.
- Обеспечение правильной логики обработки ошибок и восстановления в вашем коде.
Вот как вы вызываете этого восхитительного мучителя:
const battleCry = "Victory!";
test "Sword initialization and cleanup" {
const allocator = std.testing.failing_allocator;
const sword = Sword.init(allocator, battleCry);
try std.testing.expectError(error.OutOfMemory, sword);
}
Этот тест гарантирует, что функция Sword.init корректно обрабатывает сбои выделения памяти. Достаточно просто, правда? Но подождите...
#
Знакомьтесь с fail_index: Ассасин выделений
fail_index — это то место, где происходит магия. Он определяет, сколько выделений ваш код может успешно выполнить, прежде чем вечеринка закончится. Установите его на 2, и третье выделение провалится с треском, как ваш проект на Java на первом курсе.
var failing_allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 2 });
var allocator = failing_allocator.allocator();
// Два успешных выделения. Ура!
var a = try allocator.create(i32);
var b = try allocator.create(i32);
// О нет! Третье выделение спотыкается о *fail_index*.
try std.testing.expectError(error.OutOfMemory, allocator.create(i32));
Тест пытается выполнить третье выделение, но на этот раз аллокатор даёт сбой. Этот сбой ожидается, потому что failing_allocator был настроен на отказ после определённого количества успешных выделений (известного как fail_index). Функция std.testing.expectError гарантирует, что возвращённая ошибка — это именно error.OutOfMemory.
#
Работа со сбоями: Трассировка стека
Теперь вы можете подумать: «Какой толк от сбоя, если я не знаю, откуда он взялся?» Не волнуйтесь, FailingAllocator прикроет вашу спину — или, скорее, ваш стек. Когда происходит сбой выделения, он захватывает стековый след (stack trace), ведущий к катастрофе. Потому что зачем отлаживать код, если можно получить стековый след, который вас же и отчитает?
if (failing_alloc.has_induced_failure) {
const trace = failing_alloc.getStackTrace();
std.debug.print("Stack trace of failure: {any}\n", .{trace});
}
Это идеально подходит для тех моментов, когда вы хотите точно определить, какая строка кода вас предала.
#
Изменение размера? Знакомьтесь с resize_fail_index
Если выделения памяти недостаточно, чтобы испортить ваш день, есть также родственная функция — resize_fail_index. Та же идея, другая цель. Эта функция определяет, сколько раз вам разрешено изменять размер памяти, прежде чем она захлопнет дверь перед вашим носом.
Сначала мы инициализируем FailingAllocator и устанавливаем resize_fail_index в 1. Это означает, что первая попытка изменения размера (с индексом 0) будет успешной, но вторая (с индексом 1) завершится неудачей:
var failing_alloc = std.testing.FailingAllocator.init(
std.testing.allocator, .{ .resize_fail_index = 1 }
);
const allocator = failing_alloc.allocator();
Теперь давайте выделим буфер и попробуем изменить его размер дважды:
const buffer = try allocator.alloc(u8, 16);
defer allocator.free(buffer);
try std.testing.expect(allocator.resize(buffer, 32) == true);
try std.testing.expect(allocator.resize(buffer, 64) == false);
Как и было настроено, первый вызов resize завершается успешно. Второй вызов возвращает false, как мы и планировали. Это позволяет нам детерминированно протестировать в нашем коде путь обработки сбоев. Это как тот надоедливый друг, который помогает тебе перенести только одну коробку, а потом заявляет, что он «слишком устал».
#
Метрики, потому что мы такие модные
Мало того, что FailingAllocator приносит хаос, он ещё и отслеживает разрушения с навязчивой точностью:
- alloc_index: Сколько успешных выделений вы сделали. Думайте об этом как о таймере предательства аллокатора.
- allocated_bytes / freed_bytes: Учет полученной и потерянной памяти. Как ваша банковская выписка, но с указателями вместо плохих решений.
- allocations / deallocations: Табло для каждого раза, когда вы вызывали alloc или free.
Эти метрики позволяют вам доказать, что ваш код протекает под нагрузкой — или, если вам повезёт, подтвердить, что это не так.
Почему вас это должно волновать?
Дело вот в чём: написать код, который работает — это не самое сложное. Написать код, который не рассыплется под давлением — вот где рождаются легенды. FailingAllocator — это не просто инструмент, это ваш личный мастер подземелий, расставляющий ловушки и загадки для вашей логики обработки ошибок. Выживите в его испытаниях, и вы выйдете оттуда с кодом, закалённым в боях и готовым к продакшену — или, по крайней мере, готовым к более изящному падению.
Так что вперёд, примите хаос. Только не вините FailingAllocator, когда он выставит напоказ ваши ошибки. Он просто делает свою работу.
Итак, подход Zig к аллокаторам и интерфейсам нетрадиционен? Абсолютно. Но именно поэтому он так захватывает — вы не просто пишете код, вы приручаете зверя. Аллокаторы Zig дают вам власть определять свои собственные правила, строить свои системы так, как вы хотите, и делать это с точностью, недоступной большинству «высокоуровневых» языков. Конечно, на ваших плечах лежит больше ответственности, но разве не в этом суть системного программирования?
Далее мы погрузимся в «настоящие аллокаторы» и поговорим о том, как работают разные их типы, с ещё более вдохновлёнными играми аналогиями. А пока идите и напишите немного кода. Или, как мог бы сказать Zig: «Хватит теоретизировать и начинай всё ломать».
#
FixedBufferAllocator: ваш первый настоящий аллокатор
Хорошо, мы поиграли с модными абстракциями, окунули пальцы ног в воду с тестовым аллокатором и FailingAllocator, и отладили наши выделения как профи. Но вот в чём фишка: ничего из этого не было «настоящим» аллокатором. Это всё было симуляцией, как тренировочные колёса на вашем первом велосипеде. Теперь пора ехать в одиночку. Встречайте FixedBufferAllocator, первый «настоящий» аллокатор из стандартной библиотеки Zig.
Это не просто ещё один аллокатор, это надёжная рабочая лошадка с предвыделенной памятью, о которой вы даже не подозревали. Он предназначен для ситуаций, когда требования к памяти предсказуемы и предопределены. Подумайте о встраиваемых системах (embedded systems), разработке игр или о том моменте, когда вы решаете жить опасно и говорите: «Нет динамическим выделениям для меня, спасибо».
FixedBufferAllocator работает на буфере фиксированного размера, что означает следующее:
- Вы определяете объём памяти заранее.
- Он управляет этой памятью в строгих границах.
- Как только буфер заполнен, он вежливо говорит: «Извини, тебе не повезло».
Вот как вы его инициализируете:
const std = @import("std");
pub fn main() !void {
var buffer: [1024]u8 = undefined; // Фиксированный буфер размером 1 КБ.
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
const slice = try allocator.alloc(u8, 512); // Выделить 512 байт.
defer allocator.free(slice); // Освободить их по завершении.
std.debug.print("Allocated {d} bytes from FixedBufferAllocator\n", .{slice.len});
}
Вы только что выделили память как профи — предсказуемо, контролируемо и абсолютно круто. Давайте погрузимся в несколько примеров, чтобы понять, почему этот аллокатор заслуживает места в вашем наборе инструментов.
Пример 1. Конкатенация строк
Представьте, что вы работаете со строками (срезами u8) и вам нужно объединить две из них. Вот как это делается с помощью FixedBufferAllocator:
fn catAlloc(allocator: std.mem.Allocator, a: []const u8, b: []const u8) ![]u8 {
const result = try allocator.alloc(u8, a.len + b.len);
@memcpy(result[0..a.len], a);
@memcpy(result[a.len..], b);
return result;
}
test "Concatenating Strings with FBA" {
var buffer: [64]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
const hello = "Hello, ";
const world = "world!";
const result = try catAlloc(allocator, hello, world);
defer allocator.free(result);
try std.testing.expectEqualStrings(result, "Hello, world!");
}
Здесь мы заранее выделяем 64 байта и используем их для конкатенации строк. Никакой фрагментации кучи, никаких сюрпризов. Просто старый добрый контроль.
Пример 2. Выделение структур
FixedBufferAllocator работает не только со срезами. Вы можете выделять и структуры. Допустим, вы создаёте игру и вам нужно управлять небольшим пулом объектов Entity:
const Entity = struct {
id: u32,
name: []const u8,
};
fn createEntities(allocator: std.mem.Allocator, count: usize) ![]Entity {
return try allocator.alloc(Entity, count);
}
test "Allocating Structs with FBA" {
var buffer: [128]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
const entities = try createEntities(allocator, 3);
defer allocator.free(entities);
entities[0] = .{ .id = 1, .name = "Hero" };
entities[1] = .{ .id = 2, .name = "Villain" };
entities[2] = .{ .id = 3, .name = "Sidekick" };
try std.testing.expectEqual(entities[0].id, 1);
try std.testing.expectEqualStrings(entities[1].name, "Villain");
}
На этот раз мы заранее выделяем 128 байт и используем их для хранения нескольких структур Entity. Аллокатор эффективно управляет памятью, так что вы можете сосредоточиться на интересных вещах, например, на спасении мира (или его разрушении, как вариант).
#
Ограничения (или почему вы не можете использовать это везде)
Прежде чем вы броситесь переписывать всю свою кодовую базу с помощью FixedBufferAllocator, давайте поговорим о его ахиллесовой пяте:
- Фиксированный размер: как только буфер заполнен, вы закончили. Никакого резервного варианта, никакого изменения размера.
- Отсутствие совместного использования: он привязан к конкретному буферу, поэтому вы не можете просто так делиться им между потоками без дополнительной работы.
- Ручное управление: вам нужно думать о размерах выделений и выравнивании, что означает больше работы для вас.
Вооружившись FixedBufferAllocator, вы вступаете в высшую лигу. Речь идёт не просто об управлении памятью. Речь о том, чтобы владеть ею. Так что седлайте коня и начинайте использовать эту мощную штуку, чтобы сделать ваши программы быстрее, стройнее и намного круче.
Жизнь не всегда так предсказуема, не так ли? Иногда вам просто нужен универсальный, на все руки мастер аллокатор, чтобы справиться с хаосом.
Встречайте GeneralPurposeAllocator, универсальную рабочую лошадку в линейке аллокаторов Zig. Независимо от того, жонглируете ли вы динамическими рабочими нагрузками, справляетесь с непредсказуемыми требованиями к памяти или просто хотите что-то надёжное для поддержки вашего кода — этот аллокатор вас не подведёт. Думайте о нём как о швейцарском армейском ноже для управления памятью — готовый адаптироваться, эффективный и всегда надёжный.
Примечание переводчика
В версиях Zig, старше 0.15, GeneralPurposeAllocator является псевдонимом для DebugAllocator.
#
Обобщённые аллокаторы (GeneralPurposeAllocator): во множественном числе? Да.
Ладно, я соврал. Помните, как я сказал, что GeneralPurposeAllocator — это аллокатор, который правит всеми? Оказывается, в стандартной библиотеке Zig их на самом деле два. Не волнуйтесь, вы не попали внезапно в мультивселенную управления памятью. Один из них — это практичная, лишённая излишеств реализация, которую вы, скорее всего, будете использовать в 90% случаев. Другой? Он больше похож на запасной меч в вашем инвентаре — полезный, но ситуативный.
Давайте проясним это, прежде чем ваше замешательство вызовет переполнение стека. Когда мы говорим о GeneralPurposeAllocator, мы обычно имеем в виду главного игрока — конфигурируемый, динамический аллокатор, предназначенный для обработки всего: от маленьких объектов до гигантских выделений, при этом сохраняя управление памятью безопасным и эффективным. Но в тени скрывается его сырая, урезанная версия, используемая для крайних случаев и специальных настроек.
Итак, восстановив свою репутацию (отчасти), давайте погрузимся в настоящего GeneralPurposeAllocator — рабочую лошадку, на которую вы будете полагаться в продакшене.
#
Знакомьтесь: GeneralPurposeAllocator
Этот аллокатор — не просто ваш средний набор инструментов. Это высоко настраиваемый центр управления памятью. Разработанный для баланса между безопасностью, производительностью и гибкостью, GeneralPurposeAllocator может адаптироваться к вашим потребностям с помощью различных режимов оптимизации и параметров конфигурации.
GeneralPurposeAllocator настраивается как конструктор, с тремя режимами оптимизации на выбор:
- Режим отладки (Debug mode): Приоритет отдается безопасности и диагностике. Идеально подходит для отлова багов и обеспечения корректности операций с памятью.
- Быстрый релиз (Release fast): Фокусируется на производительности во время выполнения и низкой фрагментации, принося в жертву некоторые проверки безопасности.
- Малый релиз (Release small): Оптимизирован для малых размеров бинарных файлов, что делает его идеальным для встраиваемых систем или сред, где пространство — это роскошь.
Вы можете дополнительно настроить его поведение с помощью множества параметров конфигурации, от включения стековых следов до управления лимитами памяти.
Давайте посмотрим на него в действии с практическим примером:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{
.safety = true,
.enable_memory_limit = true,
.retain_metadata = true,
}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const memory = try allocator.alloc(u8, 512);
defer allocator.free(memory);
std.debug.print("Allocated {} bytes\n", .{memory.len});
}
В этом фрагменте мы делаем следующее:
- Настраиваем аллокатор для включения проверок безопасности, лимитов памяти и сохранения метаданных.
- Выделяем 512 байт с помощью аллокатора.
- Освобождаем память после этого, потому что мы ответственные разработчики (иногда).
Вы можете настроить его поведение с помощью структуры конфигурации (Config), адаптируя её под свои конкретные требования. Вот разбивка некоторых ключевых параметров конфигурации и вариантов их использования:
Проверки безопасности:
safety: Включает проверки во время выполнения для отлова двойного освобождения, недопустимого доступа и других ошибок, связанных с памятью.- По умолчанию:
std.debug.runtime_safety(включено в режиме отладки, отключено в противном случае).
var gpa = std.heap.GeneralPurposeAllocator(.{ .safety = true, // Явное включение проверок безопасности }){};- Лимиты памяти:
enable_memory_limit: Позволяет установить лимит памяти. Когда общий объем выделенной памяти превышает лимит, выделения возвращают ошибкуerror.OutOfMemory.requested_memory_limit: Фактический лимит памяти (когдаenable_memory_limitистинно).
- Сохранение метаданных:
retain_metadata: Сохраняет метаданные об выделениях даже после их освобождения. Полезно для отладки двойного освобождения и отслеживания использования памяти.never_unmap: Предотвращает выгрузку памяти из системы. В сочетании сretain_metadataпомогает отлаживать ошибочные записи, сохраняя память в определённом состоянии.var gpa = std.heap.GeneralPurposeAllocator(.{ .retain_metadata = true, .never_unmap = true, }){};
- Потокобезопасность:
thread_safe: Гарантирует, что аллокатор может безопасно использоваться в разных потоках.MutexType: Настраивает тип мьютекса, используемого для потокобезопасности.var gpa = std.heap.GeneralPurposeAllocator(.{ .thread_safe = true, }){};
#
Аллокатор страниц: Невоспетый герой
В то время как GPA обрабатывает малые выделения с помощью своей системы корзин, более крупные выделения полагаются на аллокатор страниц. Он напрямую взаимодействует с операционной системой для управления памятью на уровне страниц, что делает его одновременно универсальным и эффективным.
Ключевые особенности следующие:
- Оптимизация для конкретной платформы: В Linux он использует mmap, в Windows — VirtualAlloc. Это гарантирует, что аллокатор страниц бесшовно работает на разных платформах.
- Эффективные крупные выделения: Выделяет память кратно размеру страницы системы (обычно 4 КБ) и возвращает выровненные блоки памяти.
- Освобождение памяти: Возвращает память операционной системе, как только она больше не нужна, предотвращая ненужное поглощение ресурсов.
#
Прямое выделение страниц
Если вы хотите использовать аллокатор страниц напрямую (без GPA), вот как это делается:
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
const memory = try allocator.alloc(u8, 8192); // Выделить 2 страницы по 4 КБ в системе
defer allocator.free(memory);
std.debug.print("Allocated {} bytes\n", .{memory.len});
}
В этом примере у нас есть следующее:
- Мы запрашиваем 8192 байта (две страницы в типичной системе с размером страницы в 4 КБ)
- Аллокатор страниц обрабатывает выделение на уровне системы и возвращает выровненный блок памяти
#
Как аллокатор страниц питает GPA
Аллокатор страниц выступает в качестве резервного аллокатора для GPA. Вот как эти два взаимодействуют:
- Малые выделения: GPA использует корзины (buckets) для эффективного управления малыми объектами.
- Крупные выделения: Для объектов, которые слишком велики, чтобы поместиться в корзину, GPA обращается к аллокатору страниц.
#
Режимы конфигурации: Отладка, релиз и малый размер
В зависимости от режима оптимизации, GPA корректирует свое поведение:
- Режим отладки (Debug mode):
- Фокусируется на безопасности памяти, отладке и обнаружении утечек.
- Идеально подходит для разработки.
- Быстрый релиз (Release fast):
- Приоритет отдается производительности с уменьшенными проверками безопасности.
- Подходит для продакшн-сред, где важна скорость.
- Малый релиз (Release small):
- Оптимизирует размер бинарных файлов.
- Идеально подходит для встраиваемых систем и сред с ограниченными ресурсами.
GeneralPurposeAllocator — это мощная машина, способная справляться с разнообразными сценариями выделения с помощью своего надежного помощника, аллокатора страниц. Вместе они обеспечивают надежную основу для управления памятью в Zig, предлагая гибкость, безопасность и эффективность.
В следующем разделе мы углубимся в ArenaAllocator, специализированный аллокатор, предназначенный для сценариев, где освобождение памяти происходит массово. Давайте сделаем ваше управление памятью еще более эффективным!
#
Аллокатор арены: игровая площадка для управления памятью
Аллокатор арены — это как большой ребёнок на игровой площадке, который заботится обо всех игрушках. Он не утруждает себя моделью «возврат отправителю» для освобождения отдельных выделений. Вместо этого он управляет большим блоком памяти, позволяет вам захватывать куски по мере необходимости, а затем очищает всё одним махом, когда вы закончите.
Это делает аллокатор арены отличным выбором для определённых сценариев использования, но не для всех. Давайте углубимся в то, как он работает, когда он проявляет себя с лучшей стороны и как его эффективно настраивать.
#
Что такое аллокатор арены?
По своей сути, аллокатор арены — это стратегия управления памятью, при которой вы делаете следующее:
- Выделяете большой блок памяти заранее (или по мере необходимости).
- Разбиваете блок на более мелкие по мере выделения без их индивидуального освобождения.
- Освобождаете весь блок памяти сразу, когда он больше не нужен.
Думайте об этом как об обустройстве мастерской: вы покупаете большой стол (блок памяти), выполняете на нём всю свою работу (выделения), а затем убираете со стола, когда проект готов (освобождаете блок).
#
Когда использовать аллокатор арены
Аллокатор арены отлично подходит для следующих сценариев:
- Преобладают временные выделения: вы знаете, что все выделения недолговечны и могут быть освобождены вместе, например, в процедуре парсинга или при пакетной обработке данных.
- Важна высокая производительность: избегание накладных расходов на индивидуальное освобождение выделений может значительно повысить производительность.
- Предсказуемое использование памяти: у вас есть хорошее представление о том, сколько памяти вам понадобится, что минимизирует потери.
Он не идеален в следующих обстоятельствах:
- Индивидуальные выделения необходимо освобождать независимо.
- Использование памяти должно быть очень динамичным и долговременным.
#
Особенности аллокатора арены
- Список буферов: выделяет память кусками и связывает их вместе с помощью связного списка. Новые куски добавляются только тогда, когда существующие заполнены.
- Возможность сброса: очищает все выделения без деинициализации аллокатора. Необязательный предварительный «прогрев» сохраняет ранее выделенную ёмкость для повторного использования, повышая производительность в итеративных сценариях.
- Настраиваемые режимы сброса:
free_all: освобождает всю память, начиная с нуля.retain_capacity: сохраняет наибольший размер буфера, используемый ранее, избегая будущих выделений для аналогичных рабочих нагрузок.retain_with_limit: сохраняет ёмкость до указанного предела, при необходимости уменьшая более крупные буферы.
Вот как использовать аллокатор арены для временных выделений:
const std = @import("std");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // Освобождает всю память одним махом
const allocator = arena.allocator();
// Выделить память
const memory1 = try allocator.alloc(u8, 100);
const memory2 = try allocator.alloc(u8, 200);
std.debug.print("Allocated {} and {} bytes\n", .{memory1.len, memory2.len});
// Нет необходимости освобождать индивидуально; `defer arena.deinit()` позаботится обо всём
}
При работе в итеративных сценариях вы можете сбросить аллокатор арены для повторного использования ранее выделенной ёмкости:
const std = @import("std");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
for ([_]usize{256, 512, 1024}) |alloc_size| {
const memory = try allocator.alloc(u8, alloc_size);
std.debug.print("Allocated {} bytes\n", .{memory.len});
// Сбросить арену и повторно использовать память
_ = arena.reset(.retain_capacity);
}
}
Если вы хотите повторно использовать память, но также хотите установить ограничение по размеру, используйте режим retain_with_limit:
const std = @import("std");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
// Выделить и использовать память
_ = try allocator.alloc(u8, 1024);
// Сбросить, сохранив только 512 байт ёмкости
if (!arena.reset(.{ .retain_with_limit = 512 })) {
std.debug.print("Failed to reset with limit\n", .{});
}
}
Следующие факты являются преимуществами аллокатора арены:
- Производительность: выделение и сброс происходят быстро, так как нет необходимости освобождать память по отдельности.
- Простой жизненный цикл: легко управлять памятью, когда вы знаете, что всё можно освободить вместе.
- Возможность повторного использования: сброс с сохранением ёмкости оптимизирует использование памяти для повторяющихся рабочих нагрузок.
Следующие факты являются недостатками аллокатора арены:
- Нет индивидуального освобождения: память освобождается только вся сразу, поэтому она не подходит для динамических или долговременных выделений.
- Потенциальная потеря памяти: если ваша рабочая нагрузка непредсказуема, неиспользуемые части больших буферов могут быть потрачены впустую.
Аллокатор арены — это элегантное решение для конкретных сценариев, где выделения можно группировать и освобождать вместе. Используя режимы сброса и предварительный нагрев, он обеспечивает высокую производительность для повторяющихся задач. Однако важно оценить, соответствует ли его ограничение «нет индивидуального освобождения» вашему варианту использования.
Пришло время пойти ещё глубже и изучить, как расширять или изменять существующие аллокаторы для создания пользовательского поведения.
При работе с аллокаторами в Zig ключ к успеху заключается в выборе правильного инструмента для работы и следовании лучшим практикам для обеспечения надёжного и эффективного управления памятью:
- Всегда освобождайте выделенную память, в идеале используя defer, чтобы гарантировать очистку даже в случае ошибок.
- Передавайте аллокаторы в качестве параметров для обеспечения гибкости и тестируемости вашей кодовой базы.
- Для приложений с циклическими паттернами выделения, таких как игровые циклы или веб-серверы, std.heap.ArenaAllocator является естественным выбором, позволяющим выполнять массовое освобождение в конце каждого цикла.
- Когда потребности в ограниченной памяти известны на этапе компиляции, std.heap.FixedBufferAllocator предлагает лёгкое и эффективное решение.
- Для отладки или тестирования std.testing.allocator или std.testing.FailingAllocator могут помочь выявить проблемы с памятью на ранней стадии.
- Если ни один из этих сценариев не применим, стандартный аллокатор std.heap.GeneralPurposeAllocator Zig предоставляет надёжную резервную копию, часто служащую основным аллокатором в разнообразных приложениях.
Согласовав выбор аллокатора с паттернами выделения памяти вашей программы, вы сможете с лёгкостью достичь как производительности, так и удобства сопровождения.
#
Слон в комнате: Безопасность, часть 2
Большинство ошибок безопасности памяти происходят от двух обычных подозреваемых: разыменования нулевого указателя и выхода за границы массива. Кстати, сырые типы указателей в Rust по умолчанию допускают значение null и даже не утруждают себя проверкой разыменования нулевого указателя. Конечно, есть тип NonNull
В Zig, однако, указатели по умолчанию не допускают null, и вам нужно явно подключать возможность нулевого значения с помощью ? (например, ?*Value). Вы также получаете встроенные проверки разыменования нулевого указателя с самого начала. Лично я нахожу это гораздо более разумным. Делать безопасный путь дефолтным — это просто правильно.
Владение в Zig простое: вы владеете тем, что выделили. Если ваша функция возвращает указатель на выделенную память, вы решаете, кто её очищает. Никаких дефолтов, никакой магии. Сделайте правила явными:
- Если вы выделили это — задокументируйте, кто это освобождает.
- Если вы заимствуете память — убедитесь, что владелец продолжает существовать.
Сделайте это неправильно, и Zig вас не защитит. Неправильно используйте память — и вы ныряете с головой в неопределённое поведение, потому что Zig предполагает, что вы знаете, что делаете.
На первый взгляд может показаться, что модель ручного выделения и освобождения памяти в Zig оставляет его уязвимым для тех видов ошибок безопасности памяти, которые модель владения Rust призвана предотвратить. Проверяющий заимствований Rust похож на строгого, но любящего родителя, который обеспечивает соблюдение правил во время компиляции ради вашей безопасности. Zig, с другой стороны, похоже, вручает вам острые инструменты и говорит: «Не порежьтесь, удачи». Но значит ли это, что Zig менее безопасен? Абсолютно нет. Zig просто подходит к безопасности памяти с другой философией.
Давайте разберёмся, почему Zig не менее безопасен, чем Rust, несмотря на отсутствие проверяющего заимствований.
#
Философия Zig: Честно, явно и контролируемо
Zig полагается на явный контроль программиста и принципы проектирования по контракту для достижения безопасности:
- Нет скрытого поведения: Zig не пытается угадать ваши намерения. Выделение памяти, освобождение и владение находятся полностью под вашим контролем. Это предотвращает сюрпризы, такие как неявное продление времени жизни или скрытые выделения.
- Обработка ошибок по умолчанию: Каждое выделение в Zig возвращает результат (
!T), который должен быть обработан явно. Если память заканчивается, вы получаетеerror.OutOfMemory. Никакого тихого оверкоммита, никаких падений без объяснения причин. - Строгие контракты: Функции, которые выделяют или заимствуют память, явно указывают, что они ожидают и что возвращают. Эта ясность обеспечивает безопасное взаимодействие между различными частями вашей программы.
- Валидация на этапе компиляции: Система типов Zig обеспечивает корректное использование указателей, срезов и других конструкций памяти, помогая вам избежать распространённых ловушек, таких как смешивание стека и кучи.
Компилятор Rust гарантирует соблюдение этих правил, в то время как Zig полагается на чёткие контракты и явное время жизни. Рассмотрим следующий пример:
fn allocateString(allocator: *std.mem.Allocator, len: usize) ![]u8 {
return try allocator.alloc(u8, len);
}
Здесь ясно, что вызывающий код владеет результатом и должен освободить его по завершении. Эта явность устраняет двусмысленность и поощряет дисциплинированное программирование, точно так же, как модель владения Rust.
Zig использует принципы проектирования по контракту вместо сеток безопасности во время выполнения:
- Аллокаторы в Zig передаются явно, что делает ясным, кто управляет памятью.
- Заимствование памяти безопасно до тех пор, пока вы придерживаетесь контрактов функций. Если функция возвращает заимствованный срез, вы знаете, что он действителен только до тех пор, пока базовая память остаётся стабильной.
Например, std.ArrayList в Zig раскрывает свой внутренний буфер, но предупреждает вас о его времени жизни. Проверяющий заимствований Rust обеспечивает соблюдение аналогичных правил, но подход Zig гарантирует, что вы всегда осознаёте эти условия.
Zig не менее безопасен, чем Rust. Он просто менее патерналистичен. Там, где Rust предполагает, что вы совершите ошибки, и останавливает вас во время компиляции, Zig доверяет вам мыслить критически и ответственно обращаться с памятью. Он не навязывает проверяющего заимствований, но даёт вам мощные инструменты: обработку ошибок, проверки выравнивания и контракты — чтобы оставаться в безопасности.
Если вы хотите углубиться в реальные случаи, есть прекрасный пост о «небезопасности» и о том, насколько быстрее может быть Zig в «небезопасных» сценариях: https://zackoverflow.dev/writing/unsafe-rust-vs-zig/.
Подход Zig к управлению памятью освежающе честен и прямолинеен. Вы явно выбираете аллокатор, явно управляете памятью и явно обрабатываете ошибки. Принимая эту прозрачность, вы получаете контроль и предсказуемость. Это не так пугающе, как кажется. Как только вы освоитесь с этим, вы удивитесь, как вы вообще могли доверять языку угадывать ваши желания.
Запомните следующее:
- Аллокатор для вас выбирает не Zig. Это делаете вы.
- Всегда знайте, откуда берутся ваши байты и куда они уходят.
- Документируйте правила владения и времени жизни.
- Примите явность, именно она делает Zig мощным и адаптируемым.
Теперь, когда у вас есть понимание указателей и выделения памяти, вы готовы к решению ещё более тонких аспектов управления памятью. Сохраняйте ясность ума, знайте свои аллокаторы и никогда не переставайте задавать вопрос: «Где находятся байты?»
#
Типы нулевого размера: фантомная угроза управления памятью (но у них есть свои применения)
Представьте себе тип, который вообще не занимает места. Ни одного байта для хранения, никакого лишнего багажа, просто концептуальная заглушка, которая существует только в уме компилятора. В Zig существуют типы, размер которых равен нулю бит.
Они похожи на призраков системы типов: они ничего не обещают (и ничего не предоставляют) с точки зрения хранимых данных, но могут быть решающими для формирования структуры и поведения вашего кода.
Тип нулевого размера — это тип, для которого @sizeOf(type) == 0. Другими словами, вы можете иметь переменную этого типа, но она всегда будет иметь одно и то же единственное возможное значение. Сколько бы вы ни присваивали или копировали её, вы просто перетасовываете концептуальный дескриптор, который не потребляет никакой памяти.
К общим типам нулевого размера относятся следующие:
void- 0-битные целые числа:
u0иi0 - Массивы и векторы длины 0, или чьи элементы сами являются типами нулевого размера
- Перечисления (enums) только с одним возможным тегом
- Структуры, все поля которых являются типами нулевого размера
- Объединения (unions) с единственным полем нулевого размера
С точки зрения компилятора, эти типы исчезают во время выполнения. Отсутствие представления во время выполнения означает отсутствие накладных расходов. Это изящный трюк для кодирования информации, известной только на этапе компиляции, или создания универсальных структур данных, которые могут оптимизировать ненужное хранилище.
#
Тип, который ничего не хранит
Рассмотрим void. Он может быть полезен для создания экземпляров универсальных типов. Например, если у вас есть HashMap(Key, Value), использование void в качестве типа значения превращает его в Set(Key), поскольку значению не нужно ничего хранить. Это не просто стилистический выбор, устраняя фактическое хранимое значение, компилятор может генерировать более компактный и эффективный код, свободный от ненужных операций загрузки и сохранения в память:
test "using void as a value type in a HashMap" {
var map = std.AutoHashMap(i32, void).init(std.testing.allocator);
defer map.deinit();
// Вставляем ключи без каких-либо связанных данных
try map.put(42, {});
try std.testing.expect(map.contains(42));
_ = map.remove(42);
try std.testing.expect(!map.contains(42));
}
В этом сценарии map.put(42, {}) не хранит никаких данных, кроме ключа. Запись фактически является просто ключом, поля значения не существует. Это не только снижает использование памяти, но и устраняет код для обработки значений.
#
void против anyopaque
Не путайте void с anyopaque. void — это известный тип нулевого размера. anyopaque — это заглушка для неизвестных типов и, следовательно, имеет ненулевой размер во время выполнения. void действительно означает «нет данных», в то время как anyopaque означает «какие-то данные, но я не скажу вам какие».
#
Игнорирование выражений
В Zig игнорирование выражения, не являющегося void, обычно является ошибкой компиляции. Язык хочет гарантировать, что каждое созданное вами значение либо используется, либо явно отбрасывается. Это предотвращает случайные логические ошибки и поощряет написание более чистого кода.
Но как насчёт выражений типа void? Поскольку они не представляют никакого фактического значения, их игнорирование абсолютно безопасно. Например, вызов функции fn returnsVoid() void без использования её возвращаемого значения — это нормально. Буквально нечего использовать.
Если вы действительно хотите явно отбросить выражение, не являющееся void, присвойте его переменной _. Это говорит: «Да, я знаю, что это значение существует, и я намеренно его игнорирую».
test "ignoring values" {
// foo() возвращает i32, поэтому игнорирование вызовет ошибку:
// foo(); // ошибка компиляции
// Вместо этого явно игнорируем:
_ = foo();
// returnsVoid() возвращает void, поэтому игнорирование допустимо:
returnsVoid();
}
fn returnsVoid() void {}
fn foo() i32 { return 1234; }
#
Уменьшение объёма кода с помощью типов нулевого размера
Способность Zig компилировать неиспользуемый код и данные является одной из его сильных сторон. Типы нулевого размера помогают компилятору представлять концепции, не требующие места в рантайме. Они идеально подходят для следующего:
- Превращения структур данных в «бестелесные» варианты (например, множества из карт).
- Создания универсального кода, который оптимизирует ненужные поля или операции.
- Представления состояний или тегов, которые не несут данных — важен лишь сам факт их существования.
В результате типы нулевого размера могут повысить эффективность использования памяти и уменьшить объём генерируемого кода, что приводит к созданию бинарных файлов меньшего размера и более быстрому выполнению.
Типы нулевого размера — это неосязаемые инструменты в вашем наборе инструментов Zig. Они не несут данных в рантайме, служат невидимыми заглушками и позволяют писать более общий и экономичный с точки зрения памяти код. Хотя на первый взгляд они могут показаться тривиальным любопытством, освоение их использования может сделать ваш код более гибким и оптимизированным.
Это квинтэссенция принципа «меньше значит больше»: иногда лучшие данные — это их полное отсутствие.
#
Приведение типов (кастинг): когда вашим данным нужна маскировка
В мире, где данные не всегда имеют ту форму, которая вам нужна, приведение типов — это искусство вежливо (а иногда и принудительно) преобразовывать один тип в другой. Zig проводит чёткое различие между безопасными, автоматическими преобразованиями (коэрцитивными) и теми, которые требуют преднамеренного приведения (явные приведения). Это уменьшает количество сюрпризов, затрудняя проникновение случайных преобразований под радар и вызову тонких ошибок.
#
Преобразование типов: Безопасно и автоматически
Преобразование типов происходит автоматически, когда Zig может гарантировать безопасное и однозначное автоматическое приведение одного типа в другой.
Например, присвоение значения u8 переменной типа u16 безопасно — никакая информация не теряется, так как u16 может хранить каждое значение, которое может хранить u8:
const a: u8 = 42;
const b: u16 = a; // Автоматически расширяет от u8 до u16
Это не «приведение» в традиционном смысле. Специальный синтаксис не требуется. Компилятор знает, что вы хотите, и гарантирует, что это безвредно. Коэрцитивность может происходить в нескольких сценариях:
- Расширение целых чисел и чисел с плавающей запятой: Меньшие числовые типы могут помещаться в большие.
- Квалификаторы и выравнивание: Вы можете добавить константность или уменьшить выравнивание без драмы.
- Срезы, массивы и указатели: Легко преобразовать из фиксированных массивов в срезы или указатели в массивы.
- Опциональные типы и объединения ошибок: Превратить полезную нагрузку или
nullв опциональный тип или обернуть значение/ошибку в объединение ошибок. - Кортежи в массивы: Однородные кортежи (все элементы одного типа) могут стать массивами.
Коэрцитивность всегда безопасна, поэтому Zig просто выполняет её автоматически, когда это ясно и однозначно.
#
Когда преобразование типов не срабатывает
Если что-то неоднозначно или рискованно, Zig отказывается от автоматического приведения типа. Например, сужение от большего целочисленного типа к меньшему работает во время компиляции только в том случае, если известно, что значение помещается. Если нет, вы должны обработать это явно, иначе Zig выдаст ошибку компиляции. Другой пример: попытка преобразовать число с плавающей запятой в целое число без гарантированного безопасного пути заставит Zig «запротестовать». Вам придётся прибегнуть к явному приведению.
Для всего остального Zig требует явного приведения с помощью встроенных функций, таких как @intCast, @floatCast или @ptrCast. Это скальпель, который вы берёте в руки, когда должны сказать: «Я знаю, что делаю, правда!»
Явные приведения различаются по безопасности и могут вызывать проверки во время выполнения, усечения или другие преобразования. Некоторые из них могут завершиться ошибкой во время выполнения, если преобразование на самом деле небезопасно.
Рассмотрим эти примеры:
@intCast(u8, some_u32_value)попытается преобразовать 32-битное целое число в 8-битное. Если оно не помещается, вы получите ошибку времени выполнения.@floatCast(f32, some_f64_value)уменьшает размер числа с плавающей запятой, возможно, теряя точность, но это преднамеренное действие.@bitCastизменяет тип, не меняя биты. Это полезно для переинтерпретации сырых данных как другого типа, но вы должны быть уверены, что целевой тип имеет тот же размер и совместимый битовый шаблон.
Если автоматическое приведение типов — это более мягкие и щадящие преобразования, то явное приведение типа — это то место, где вы засучиваете рукава и говорите: «Это может быть некрасиво, но я знаю, что делаю».
Существуют распространённые явные приведения:
- Целое в число с плавающей запятой:
@floatFromInt(f64, x)берёт целое число x и преобразует его вf64. - Число с плавающей запятой в целое:
@intFromFloat(u32, y)берёт число с плавающей запятой y и усекает его доu32. - Сужение целых чисел:
@intCastили@truncateдля уменьшения разрядности. - Преобразование указателей:
@ptrCastдля переинтерпретации указателей между разными типами. - Переинтерпретация битов:
@bitCastдля трактовки значения данных как другого типа без изменения битов.
Каждая функция приведения задокументирована в стандартной библиотеке Zig с указанием того, когда она безопасна или опасна.
#
Особый случай: указатели C
Указатели C могут быть более снисходительными. При взаимодействии с кодом на C Zig позволяет определённые коэрцитивные преобразования, которые в противном случае не были бы разрешены. Философия такова: если вы создаёте мост между Zig и C, вам может потребоваться выполнять менее строгие приведения. Но всё равно действуйте с осторожностью. Код на C имеет свои собственные ловушки.
#
Сохраняя ясность
Zig спроектирован так, чтобы вы случайно не отбросили ценную информацию. Коэрцитивность безопасна и автоматична, в то время как явные приведения преднамеренны и заметны. Это делает код более понятным:
- Если вы видите
@intCast, вы знаете, что есть риск усечения или переполнения. - Если вы видите
@bitCast, вы знаете, что кто-то напрямую переинтерпретирует биты. - Если вы видите простое присваивание от u8 к u16, вы знаете, что это безвредно и гарантируется языком.
Такая прозрачность снижает вероятность появления тонких, труднонаходимых ошибок.
Когда ваши данные не подходят к имеющемуся типу, задайте себе вопрос: может ли Zig безопасно выполнить коэрцитивное преобразование? Если нет — выбирайте явное приведение осторожно. Эта честность языка поощряет создание более безопасного и удобного в сопровождении кода — достойная цель для набора инструментов любого программиста.
#
Разрешение одноранговых типов (Peer Type Resolution)
Представьте, что вы пишете фрагмент кода, в котором есть несколько условных ветвей, каждая из которых возвращает немного отличающийся тип, но в итоге вы хотите получить один унифицированный тип. Или рассмотрим выражение switch, в котором каждая ветвь возвращает что-то своё, но каким-то образом совместимое. В таких случаях Zig вступает в тонкий танец, называемый разрешением одноранговых типов.
Разрешение одноранговых типов происходит в нескольких местах, включая следующие:
- Выражения
if - Выражения
while - Выражения
for - Выражения
switch - Блоки с несколькими операторами
break, возвращающими разные типы - Определённые бинарные операции
Всякий раз, когда Zig сталкивается с несколькими «одноранговыми» типами — типами из разных ветвей, которые должны быть объединены в один результирующий тип, — он пытается найти тип, в который все одноранговые элементы могут быть безопасно преобразованы. Это гарантирует, что всё выражение будет иметь согласованный тип без необходимости явно указывать его каждый раз.
#
Как это работает
Zig смотрит на все возможные возвращаемые значения в этих конструкциях и пытается найти общий «супертип», который может представлять их все с помощью безопасных преобразований. Если такой тип существует, Zig использует его. Если нет — вы получите ошибку компиляции.
Например, рассмотрим выражение if, возвращающее либо массив, либо срез. Массивы и срезы строк часто приводятся к []const u8 или другим связанным типам. Если одна ветвь возвращает фиксированный массив, такой как &[_]u8{'h', 'i'}, а другая возвращает срез, полученный из данных времени выполнения, Zig находит подходящий общий тип, такой как []const u8, к которому могут быть приведены оба. Результирующий тип выражения — []const u8.
Вот распространённые сценарии:
Расширение числовых типов
Если одна ветвь возвращаетi8, а другая —i16, Zig выбираетi16, потому что оба могут быть объединены в тип, который безопасно содержит все возможные значения. Меньший тип может быть расширен без потери данных.Массивы и срезы
Ветви, которые возвращают массивы разных размеров или смесь массивов и срезов, часто преобразуются в[]const u8или аналогичный тип среза. Zig находит такой тип среза, к которому все массивы или указатели могут быть безопасно приведены.Опциональные и не-опциональные значения
Если одна ветвь возвращает?T, а другая —T, общим супертипом является?T. ОбычныйTможет быть приведён к?Tпросто путём представления ненулевого значения.Пустые массивы и непустые срезы
Если один путь даёт пустой массив (&[_]u8{}), а другой — непустой срез (slice[0..1]), разрешение одноранговых типов может выбрать[]const u8. И пустой массив, и срез могут быть приведены к этому типу среза.Объединения ошибок
Если одна ветвь возвращает значение, а другая — объединение ошибок, Zig может объединить их в единый тип объединения ошибок или опциональный тип, в зависимости от шаблона. Это позволяет изящно обрабатывать как условия успеха, так и ошибки в одном выражении.Указатели и опциональные указатели
Если одна ветвь возвращает*const T, а другая —?*T, результатом может быть унификация в?*const T. Zig находит тип, который может вместить как указатель, так и нулевое состояние, если это необходимо.Перечисления и размеченные объединения
Если у вас есть перечисление в одной ветви и размеченное объединение в другой, и размеченное объединение может представлять тот же набор значений, Zig находит способ выбрать тип, которому могут соответствовать все значения.
#
Почему это полезно?
Разрешение одноранговых типов избавляет вас от необходимости писать громоздкие приведения типов каждый раз, когда вы возвращаете значение из разных ветвей. Это упрощает ваш код и позволяет компилятору справиться со сложностью поиска совместимого типа. Это приводит к более чистому и удобному в сопровождении коду, особенно в сложной логике ветвления или полиморфных функциях.
#
Что если Zig не сможет разрешить?
Если Zig не может найти тип, в который могут быть приведены все ветви, он завершится ошибкой компиляции. Это ваш сигнал вмешаться и предоставить явное приведение или пересмотреть свою логику, чтобы возвращаемые типы были совместимы.
- Разрешение одноранговых типов происходит там, где несколько ветвей или одноранговых элементов возвращают разные, но потенциально совместимые типы.
- Zig пытается найти общий супертип, который может представлять все исходы через безопасные приведения.
- Этот механизм уменьшает беспорядок в коде, так как вам не нужно вручную приводить всё к одному типу.
- Если подходящий общий тип не найден, Zig сообщает вам об этом с помощью ошибки компиляции, побуждая к более явному подходу.
Понимая разрешение одноранговых типов, вы можете писать более гибкий и лаконичный код. Позвольте Zig вести тонкие переговоры между типами и наслаждайтесь более простой логикой в ваших выражениях ветвления.
Эти концепции, от высокоуровневого контроля над стеком и кучей до мельчайших деталей преобразования типов, вносят свой вклад в мощный и явный подход Zig к программированию. Имея в руках эти инструменты, давайте сделаем шаг назад и обобщим ключевые принципы управления памятью, которые вы изучили.
#
Итоги
Теперь, когда вы прошли через джунгли указателей, аллокаторов и совершенно безумных секций памяти, поздравляю! Вы пережили самую суровую сторону Zig. Вы не только узнали разницу между стеком и кучей, но и научились жонглировать ими, не уронив весь свой процесс в пустоту. Вы столкнулись с арифметикой указателей, эфемерными фреймами стека и душераздирающими проверками на null, и всё же вы здесь: всё ещё живы, с широко открытыми глазами и (надеюсь) с меньшим количеством утечек памяти.
Давайте будем честны: это одно дело — читать об управлении памятью в учебнике, и совсем другое — видеть, как ваша программа умирает в огне ошибки «Недопустимое чтение размера 4». Вся фишка Zig в том, чтобы держать вас в честности. Хотите сырой мощи? Отлично! Вот вам сырой адрес. Хотите гарантию, что вы не выйдете за границы индекса? Используйте срез или готовьтесь отстрелить себе ногу. Явный подход Zig может показаться суровым, но он освежающе разумен, как только вы осознаёте, сколько ловушек другие языки прячут под ковром.
Так что да, указатели в Zig одновременно являются вашим лучшим другом и потенциальной мигренью в понедельник утром. Но продолжайте практиковаться, и вы увидите, что это ни чёрная магия, ни ядерные взрывы, а просто набор хорошо определённых инструментов, которые ожидают, что вы будете делать свою работу. Добавьте к этому типы нулевого размера (потому что кто не любит тип, который не занимает места?) и несколько умно спроектированных аллокаторов, и вы получите язык, который переопределяет «ручное управление памятью» во что-то опасно близкое к «на самом деле управляемому».
Теперь, если вы думаете: «Отлично, я понял. Zig заставляет меня делать тяжёлую работу. Но как мне перестать переписывать одни и те же старые рутины управления памятью снова и снова?» — вы не одиноки. Хорошая новость в том, что стандартная библиотека Zig ждёт своего часа. В следующей главе мы погрузимся во встроенные структуры данных, высокоуровневые помощники и лучшие практики, которые связывают все эти концепции памяти в аккуратный, целостный пакет. Если вы думали, что укрощение указателей было поучительным, просто подождите, пока не увидите, как стандартная библиотека строит на этом фундаменте, не добавляя ни единой унции лишнего. Готовьтесь: ваше приключение по укрощению памяти вот-вот станет намного более цивилизованным.