#
Глава IV. Указатели
В Zig нет сборки мусора. Бремя управления памятью лежит на разработчике. Это большая ответственность, поскольку это имеет прямое влияние на производительность, стабильность и безопасность ваших программ.
Мы начнём изучать указатели, это важная тема как сама по себе, но также и для того, чтобы начать приучать себя смотреть на программы с точки зрения использования памяти компьютера. Если вы свободно обращаетесь с указателями, динамическим выделением/освобождением памяти, понимаете, что такое "висячий" указатель (dangling pointer), тогда вы можете пропустить пару глав и перейти к Главе VI, там есть некоторые особенности, специфичные для Zig.
В следующем примере создаётся пользователь с идентификатором 1 и "силой" 100, а затем вызывается функция, которая увеличивает силу пользователя на единицу. Можете угадать, что выведет эта программа?
const std = @import("std");
pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
// добавленная строчка
levelUp(user);
std.debug.print("Пользователь {d} обладает силой {d}\n", .{user.id, user.power});
}
fn levelUp(user: User) void {
user.power += 1;
}
pub const User = struct {
id: u64,
power: i32,
};
Ладно, это была грубая уловка... Эта программа ничего не выведет, потому что она просто не откомпилируется:
src/ex-ch04-01.zig:16:16: error: cannot assign to constant
user.power += 1;
~~~~~~~~~~~^~~~
Мы уже знаем из Главы I, что параметры функций являются константами
и поэтому инструкция user.power += 1;
некорректна. Чтобы исправить
эту ошибку компиляции, мы могли бы написать что-то вроде
fn levelUp(user: User) void {
var u = user;
u.power += 1;
}
Теперь программа успешно откомпилируется, но она напечатает вот это:
$ /opt/zig-0.11/zig run src/ex-ch04-02.zig
Пользователь 1 обладает силой 100
Мы вроде как хотели увеличить силу пользователя на единицу, но она
осталась равной изначальной, то есть 100. Почему так происходит? Чтобы
понять, полезно думать о данных с точки зрения их размещения в памяти
или, выражаясь иначе, думать о переменных как о ярлычках, которые
связывают значение переменной с каким-то конкретным местом в памяти.
Например, в функции main
мы создаём экземпляр структуры User
. Простой
визуализацией этих данных в памяти может служить такая картинка:
user -> ------------ (id)
| 1 |
------------ (power)
| 100 |
------------
Здесь нужно отметить две важные вещи. Первая - переменная user
показывает на начало структуры. Вторая - поля располагаются
последовательно друг за другом. У переменной user
также есть тип,
который говорит нам о том, что поле id
это 64-битное целое, а поле
power
это тоже целое размером 32 бита. Вооруженный ссылкой на начало
структуры и типом, компилятор может перевести user.power
примерно так -
"получить доступ к 32-х битному целому, расположенному по смещению 64
бита от начала структуры". В этом мощь переменных, они всегда отсылают к
какому-то месту в памяти и включают в себя информацию о типе, для того,
чтобы можно было манипулировать содержимым памяти осмысленным образом.
По умолчанию Zig не гарантирует какое-то конкретное расположение полей
структуры в памяти - он может расположить их в алфавитном порядке, в
порядке увеличения размера, при этом, возможно, с пустыми промежутками.
Он может делать всё что угодно, при условии, что он сгенерирует
правильный машинный код. Такая свобода позволяет осуществлять некоторые
оптимизации. Мы можем получить строгие гарантии расположения только если
объявим так называемую упакованную структуру (packed struct
). Тем не
менее, наша визуализация переменной user
вполне разумна и полезна.
Вот несколько иная визуализация, тут добавлены адреса ячеек памяти:
user -> ------------ (id: 1043368d0)
| 1 |
------------ (power: 1043368d8)
| 100 |
------------
Тут адрес начала это просто какое-то условное число. Имея его и зная
размеры полей, мы знаем адреса всех полей. В данном случае, поскольку
размер поля id
равен 8-ми байтам, поле power
имеет адрес, равный
адресу начала структуры плюс 8.
Чтобы вы могли сами в этом убедиться, мы сейчас введём оператор взятия
адреса, он обозначается символом &
. Как подразумевает название этого
оператора, он возвращает адрес переменной (а также может возвращать адрес
функции, то есть адрес, где начинается код функции). Не меняя
существующее определение User
, попробуйте вот такую main
:
pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
std.debug.print("{*}\n{*}\n{*}\n", .{&user, &user.id, &user.power});
}
Этот код печатает адреса user
, user.id
и user.power
:
$ /opt/zig-0.11/zig run src/ex-ch04-03.zig
ex-ch04-03.User@7ffdb6cad4d8
u64@7ffdb6cad4d8
i32@7ffdb6cad4e0
В зависимости от вашей аппаратной платформы, а также от ряда других
факторов, вы можете увидеть не конкретно такие числа, но это не важно.
Важно то, что адреса user
и его первого поля user.id
одинаковы, а
адрес второго поля ровно на 8 байт больше.
Оператор взятия адреса возвращает указатель на значение. Это другой
тип, не такой же, как у самого значения. Указатель на значение типа T
пишется как *T
. Следовательно, когда мы берём адрес переменной user
,
он будет иметь тип *User
, то есть указатель на User
:
pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
const user_ptr = &user;
std.debug.print("{any}\n", .{@TypeOf(user_ptr)});
}
Нашей изначальной целью было увеличение силы пользователя на единицу,
посредством вызова функции levelUp
. Мы добились успешной компиляции, но
когда мы печатали значение user.power
, мы видели, что оно у нас после
вызова levelUp
не изменилось. А давайте напечатаем адреса обеих наших
переменных user
, той, что в main
и той, что в levelUp
:
pub fn main() void {
const user = User{
.id = 1,
.power = 100,
};
// добавили это
std.debug.print("main: {*}\n", .{&user});
levelUp(user);
std.debug.print("Пользователь {d} обладает силой {d}\n", .{user.id, user.power});
}
fn levelUp(user: User) void {
// добавили это
std.debug.print("levelUp: {*}\n", .{&user});
var u = user;
u.power += 1;
}
Если вы это запустите, то увидите совершенно разные адреса:
$ /opt/zig-0.11/zig run src/ex-ch04-05.zig
main: ex-ch04-05.User@20cfb0
levelUp: ex-ch04-05.User@7ffc8e852798
Это означает, что user
, который подвергается модификации внутри
levelUp
это совсем не тот user
, который фигурирует в main
. Так
получается потому, что в функцию levelUp
передаётся копия значения.
Это может показаться странным умолчанием, но одно из преимуществ такого
поведения состоит в том, что на вызывающей стороне мы абсолютно уверены,
что вызываемая функция ничего не поменяет, потому что попросту не может
этого сделать. Во многих случаях весьма полезно иметь такую гарантию, но
иногда (как в нашем примере с levelUp
) мы-то как раз хотим изменить
силу пользователя и чтобы этого добиться, мы должны сделать так, чтобы
эта функция работала именно с той областью памяти, где располагается
user
, который в main
, а не с его копией. Мы это можем сделать, если
передадим в levelUp
адрес переменной user
:
const std = @import("std");
pub fn main() void {
var user = User{
.id = 1,
.power = 100,
};
// user -> &user
levelUp(&user);
std.debug.print("Пользователь {d} обладает силой {d}\n", .{user.id, user.power});
}
// User -> *User
fn levelUp(user: *User) void {
user.power += 1;
}
pub const User = struct {
id: u64,
power: i32,
};
Нам пришлось сделать два изменения:
- при вызове
levelUp
мы передаём адрес переменнойuser
, то есть&user
- при определении
levelUp
мы написалиlevelUp(user: *User)
, то есть теперь эта функция принимает указатель на структуруUser
Теперь наш код работает в точности так, как мы задумывали:
$ /opt/zig-0.11/zig run src/ex-ch04-06.zig
Пользователь 1 обладает силой 101
Есть ещё много тонкостей при передаче параметров в функции и с нашей моделью памяти в целом, но тем не менее, мы уже значительно продвинулись. Тут хороший момент отметить, что всё это не является какой-то специфической особенностью именно Zig. Модель, которую мы исследуем, является наиболее общей, просто многие языки могут скрывать детали, тем самым лишая разработчика некоторой гибкости.
#
Методы
До этого мы всё время оформляли нашу levelUp
как "саму по себе". Но
более чем вероятно, что в реальном коде эта функция будет оформлена как
метод структуры, то есть;
pub const User = struct {
id: u64,
power: i32,
fn levelUp(user: *User) void {
user.power += 1;
}
};
Это вызывает вопрос - как нам вызывать метод, у которого параметр это
указатель (*User
в нашем примере)? Может, надо писать что-то вроде
&user.levelUp()
? На самом деле, можно вызывать такой метод обычным
образом, то есть user.levelUp()
. Компилятор понимает, что методу нужен
указатель и передаёт значение по ссылке, то есть так, как нам и надо.
Отдельно стоящая функция использовалась только потому, что это более явно и легче понять.
#
Немутабельные параметры функций
Уже не раз явно говорилось, что по умолчанию Zig передаёт в функцию копию значения. Но вскоре мы увидим, что реальность немного более изощрённа (подсказка - как насчёт более сложных значений с вложенными объектами?)
Даже если мы будем придерживаться относительно простых типов, правда в
том, что компилятор Zig может передавать параметры так, как ему покажется
более разумным, при условии, что будут соблюдены намерения программиста.
В нашей изначальной levelUp
, где параметр имел тип User
, Zig мог
передать как копию значения, так и ссылку на оригинальную переменную
main.user
, лишь бы было гарантировано, что эта функция ни при каких
обстоятельствах не сможет её изменить. Да, мы хотели её изменить, но то,
что тип параметра User
(а не *User
), говорит компилятору о том, что
менять её нельзя.
Такая свобода позволяет компилятору использовать наиболее оптимальную
стратегию, основываясь на типе параметра. Значения небольших по размеру
типов, как наш User
, можно без особых затрат передать в виде копии.
Более "крупные" значения, возможно, дешевле передать в виде ссылки. Zig
может использовать любой подход, лишь бы, как уже было сказано,
соблюдались бы намерения разработчика. В некоторой степени это возможно
как раз благодаря тому, что параметры функций являются константами.
Возможно, вы задались вопросом - а каким образом передача по ссылке может
быть медленнее, чем по значению, даже для маленьких структур? Более ясно
мы поймём это далее, но суть в том, что обращение к полям (user.power
,
например) через указатель добавляет некоторые накладные расходы и поэтому
компилятору приходится оценивать временные затраты на копирование и на
косвенный (то есть через указатель) доступ к полям.
#
Указатель на указатель
Ранее мы видели, как располагается переменная user
в памяти.
Если мы передаём ссылку, то картина будет примерно такая:
main:
user -> ------------ (id: 1043368d0) <---
| 1 | |
------------ (power: 1043368d8) |
| 100 | |
------------ |
|
............. empty space |
............. or other data |
|
levelUp: |
user -> ------------- (*User) |
| 1043368d0 |----------------------
-------------
Внутри функции levelUp
user
это указатель на структуру User
.
Значение этого параметра это адрес переменной main.user
. Но это не
просто адрес, это ещё и тип (*User
). Не имеет значения, говорим ли об
указателях или нет - переменные связывают информацию о типе с адресом.
Единственная особенность с указателями состоит в том, что если мы пишем
user.power
, Zig, зная, что user
это указатель, автоматически
проследует по этому адресу. Отметим также, что в других языках (C/C++,
например) для доступа к значениям через указатель используется особый
синтаксис.
Важно понимать, что переменная user
в функции levelUp
сама по себе
тоже располагается в памяти по некоторому адресу. Давайте напечатаем вот
так:
fn levelUp(user: *User) void {
std.debug.print("{*}\n{*}\n", .{&user, user});
user.power += 1;
}
Этот фрагмент напечатает адрес, по которому расположен параметр user
, в
котором, в свою очередь, содержится адрес переменной user
, которая
определена в функции main
. Иными словами, если user
это *User
, то
&user
это **User
, то есть указатель на указатель на User
.
У многоуровнего косвенного доступа (то есть через несколько указателей) есть свои применения, но прямо сейчас нам это не понадобится. Цель этого раздела состояла в том, чтобы показать, что указатели не есть что-то особенное, они, как и другие переменные, имеют значение (адрес каких-то других переменных) и тип.
#
Указатели в структурах
До сих пор тип User
был у нас вполне простой, он содержал в себе
два целых числа. Визуализировать его представление в памяти достаточно
просто и, когда мы говорим о копировании, не возникает никаких
неоднозначностей. Но что будет происходить, если тип User
станет
более сложным, например, будет содержать какой-то указатель?
pub const User = struct {
id: u64,
power: i32,
name: []const u8,
};
Здесь мы добавили поле name
, которое является срезом. Как мы помним,
срез это пара указатель + длина (количество элементов). Если мы проинициализируем
поле name
значением Goku
, то как будет выглядеть экземпляр
такой структуры в памяти?
user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
------| 1182145c0 |
| -------------
|
| ............. empty space
| ............. or other data
|
---> ------------- (1182145c0)
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
Новое поле (name
) это срез, который состоит из указателя (ptr
) и
длины (len
). Они располагаются в памяти по порядку, наряду с другими
полями. На 64-х битной платформе как указатель, так и длина имеют размер
8 байт. Значение поля name.ptr
это адрес какого-то другого места в
памяти.
Типы могут гораздо более сложными, чем в нашем примере по мере того, как
мы будем увеличивать количество уровней вложенности. Однако, вне
зависимости от степени сложности, ведут они себя одинаково. Например,
если мы вернёмся к нашему изначальному коду, где функция levelUp
принимает аргумент User
, то есть работает с копией, то как будет
выглядеть картинка размещения в памяти при условии, что мы добавили в
структуру срез (поле name
)?
Ответ такой - функция получит так называемую поверхностную копию (shallow copy).
Или, выражаясь иначе, будут скопированы только непосредственно (а не косвенно,
то есть через указатели) адресуемые ячейки памяти. Может показаться, что
levelUp
получит какую-то "недоделанную" копию переменной user
, но помните,
что указатель (как user.name.ptr
) имеет значение (равное адресу) и копия
этого адреса, очевидно есть тот же самый адрес:
main: user -> ------------- (id: 1043368d0)
| 1 |
------------- (power: 1043368d8)
| 100 |
------------- (name.len: 1043368dc)
| 4 |
------------- (name.ptr: 1043368e4)
| 1182145c0 |-------------------------
levelUp: user -> ------------- (id: 1043368ec) |
| 1 | |
------------- (power: 1043368f4) |
| 100 | |
------------- (name.len: 1043368f8) |
| 4 | |
------------- (name.ptr: 104336900) |
| 1182145c0 |-------------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- (1182145c0) <---
| 'G' |
-------------
| 'o' |
-------------
| 'k' |
-------------
| 'u' |
-------------
Из этой картинки понятно, что поверхностная копия будет работать как
надо. Поскольку значением указателя является адрес, копирование этого
значения даёт тот же самый адрес. Это имеет серьёзные последствия в плане
возможности поменять значения переменных. Наша функция не может поменять
поля, которые в main.user
доступны непосредственно, потому что работает
с их копиями, но у неё есть доступ к тому же name
(через указатель),
поэтому вопрос - может ли она его изменить? В конкретно этом случае -
нет, поскольку name
это []const u8
(константа). Плюс к этому, наше
значение (Goku
) это буквальное значение, которое всегда иммутабельно.
Однако, не очень сильно изменив код, мы может добиться того, чтобы можно
было изменить поле name
в функции levelUp
:
const std = @import("std");
pub fn main() void {
var name = [4]u8{'G', 'o', 'k', 'u'};
var user = User{
.id = 1,
.power = 100,
// slice it, [4]u8 -> []u8
.name = name[0..],
};
levelUp(user);
std.debug.print("{s}\n", .{user.name});
}
fn levelUp(user: User) void {
user.name[2] = '!';
}
pub const User = struct {
id: u64,
power: i32,
// []const u8 -> []u8
name: []u8
};
Этот код напечатает "Go!u". Нам тут пришлось изменить тип поля name
с
[]const u8
на []u8
и вместо строкового литерала (который в принципе
нельзя модифицировать) использовать массив и срез от него. Некоторые из
вас, возможно, увидят здесь некоторую нестыковочку. Передача по значению
не даёт функции возможности изменять поля, доступные непосредственно, но
не запрещает изменять поля, доступные по ссылке. Если мы действительно
хотели, чтобы функция не могла менять имя, нам бы тогда следовало описать
как и раньше, то есть как []const u8
. а не как []u8
.
В некоторых языках всё это реализуется по другому, но многие языки работают именно так (ну, или близко к этому). Хотя всё это может показаться понятным лишь посвящённым, тем не менее, это является основой для повседневного программирования. Хорошие новости в том, что вы можете освоить эту "эзотерику", используя простые примеры и отрывки кода - по мере того, как сложность остальных частей системы растёт, основы остаются неизменными и не становятся более сложными.
#
Рекурсивные структуры
Иногда вам может потребоваться, чтобы структура была рекурсивной.
Давайте добавим в структуру User
необязательное поле manager
.
Мы также создадим двух пользователей и назначим одного из них
менеджером другого:
const std = @import("std");
pub fn main() void {
const leto = User{
.id = 1,
.power = 9001,
.manager = null,
};
const duncan = User{
.id = 1,
.power = 9001,
.manager = leto,
};
std.debug.print("{any}\n{any}", .{leto, duncan});
}
pub const User = struct {
id: u64,
power: i32,
manager: ?User,
};
Увы, этот код не пройдёт компиляцию, компилятор пожалуется на то, что
структура User
зависит сама от себя. Так происходит потому, что размеры
всех типов должны быть известны во время компиляции.
У нас не было такой проблемы, когда мы добавили поле name
, несмотря на
то, что имена, вообще говоря, могут иметь разную длину. Тут вопрос не в
размерах значений, а в размерах самого типа. Компилятору необходимо такое
знание для того, чтобы делать всё то, о чём мы говорили выше, например,
генерировать код для доступа к полям структуры, исходя из его смещения
относительно начала структуры. Поле name
было у нас срезом, то есть
имело вполне определённый размер, по 8 байт на указатель и на длину,
всего 16 байт.
Возможно, вы подумаете, что это будет проблемой с любым необязательным
полем или объединением. Однако, как необязательные значения, так и
объединения имеют заранее известный максимальный размер и Zig может его
использовать для своих дел. Тут дело в том, что рекурсивная структура не
имеет верхнего предела для своего размера, поскольку уровень рекурсии
потенциально может быть какой угодно, хоть два, хоть миллион. Это число
варьировалось бы от пользователя к пользователю, поэтому размер структуры
User
был бы неизвестен во время компиляции.
Собственно, мы уже видели, как решить нашу проблему (на примере name
):
используйте указатель. Указатели всегда имеют размер, равный размеру
usize
. На 32-х битной платформе это 4 байта, на 64-х битной - 8 байт.
Точно так же, как само имя "Goku" не хранилось внутри структуры (хранился
только срез размером строго 16 байт), так же и тут, вместо вкладывания
структуры User
в неё саму будет использоваться указатель, который так же
имеет известный размер:
const std = @import("std");
pub fn main() void {
const leto = User{
.id = 1,
.power = 9001,
.manager = null,
};
const duncan = User{
.id = 1,
.power = 9001,
// changed from leto -> &leto
.manager = &leto,
};
std.debug.print("{any}\n{any}", .{leto, duncan});
}
pub const User = struct {
id: u64,
power: i32,
// changed from ?const User -> ?*const User
manager: ?*const User,
};
Может быть, вам в вашей практике рекурсивные структуры никогда и не понадобятся, но мы тут говорили не о моделировании данных, а об указателях, размещении данных в памяти для лучшего понимания того, с чем имеет дело компилятор.
Несколько слов в заключение этой главы. Многие разработчики программного обеспечения, даже весьма опытные, могут порой испытывать трудности (как правило, временные) в применении указателей. Есть в них что-то ускользающее от внимания, они не такие "конкретные", как, скажем, числа, строки или структуры. Чтобы двигаться дальше в изучении Zig, не требуется, чтобы всё, что касается указателей, было бы кристально ясным для вас. Однако, оттачивание мастерства в применении указателей того стоит, и не только при изучении конкретно Zig. Всевозможные детали могут быть скрыты в таких языках, как Ruby, Python и JavaScript (и в меньшей степени в таких, как C#, Java и Go), но от этого они никуда не деваются. Их понимание влияет на то, как именно вы пишете код и как этот код будет выполняться. Так что не жалейте времени, упражняйтесь с примерами, добавляйте печать значений и адресов различных сущностей; чем больше вы исследуете, тем вопросы, связанные с указателями и их применением, будут становиться для вас всё более и более ясными.