On branch main

modified:   src/ch08.md
This commit is contained in:
zed
2023-11-17 21:27:44 +03:00
parent 11b1cd9378
commit 02bf5b0b98

View File

@@ -1,4 +1,359 @@
# Интерфейсы
Если Вы начали изучать Zig, скорее всего, пройдёт немного времени до того
момента, когда Вы осознаете, что в нём специального синтаксиса для
создания интерфейсов. Но, вероятно, Вы заметите некоторые вещи, которые
номинально не являются таковыми, но очень на них похожи, например,
`std.mem.Allocator`. Это потому, что в Zig действительно нет простого
механизма для создания интерфейсов (например, ключевых слов `interface` и
`implements`), но, тем не менее, сам по себе язык вполне пригоден для
достижения аналогичных целей.
## Простая реализация интерфейса
Мы сейчас реализуем простой интерфейс, который будет называться `Writer`.
Он будет достаточно прост для понимания сути и будет содержать
всего одну функцию. Если надо больше, они легко добавляются.
Прежде всего, вот сам интерфейс:
```zig
const Writer = struct {
ptr: *anyopaque,
writeAllFn: *const fn (ptr: *anyopaque, data: []const u8) anyerror!void,
fn writeAll(self: Writer, data: []const u8) !void {
return self.writeAllFn(self.ptr, data);
}
};
```
Это вполне себе законченный интерфейс. Это структура, в которой два поля,
`ptr` (это указатель на *нечто*, что этот интерфейс реализует,
а про `*anyopaque` мы вскоре поговорим) и `writeAllFn`, это
указатель на функцию, которая реализует данный метод.
Заметим, что функция `writeAll`, входящая в состав нашей стркутуры-интерфейса,
просто вызывает реализацию по указателю `writeAllFn`, передавая ей,
наряду с другими параметрами, указатель `ptr`.
Теперь скелет того самого нечто, что реализует данный интерфейс:
```zig
const File = struct {
fd: os.fd_t,
fn writeAll(ptr: *anyopaque, data: []const u8) !void {
// What to do with ptr?!
}
fn writer(self: *File) Writer {
return .{
.ptr = self,
.writeAllFn = writeAll,
};
}
};
```
Сразу отметим, что функция `writer` это то, как мы из нашей сущности
"достаём" `Writer`, то есть собственно, интерфейс. Это примерно так же,
как мы получаем `std.mem.Allocator` при помощи вызова `gpa.allocator()`.
В целом, тут нет ничего странного и необычного, за исключением, пожалуй,
одного факта, а именно, присваивания `.ptr = self`, слева у нас
`*anyopaque`, справв `*File`. В общем, мы тут просто создаём некую
структуру, а что касается этого присваивания, то в нём тоже нет чего-то
сверх-особенного. Автоматическое приведение типов в Zig должно
гарантировать безопасность и отсутствие неоднозначностей, а эти два
требования при присваивании какого угодно указателя указателю с типом
`*anyopaque` выполняются всегда.
В примере мы опустили часть, которая должна связать всё воедино, а
именно: что делать с `ptr: *anyopaque`, который передаётся из интерфейса
обратно в нашу его реализацию? Указатель с типом `*anyopaque` это
указатель на что-то с неизвестным типом и размером, то есть это своего
рода обобщённый указатель, который может хранить адрес чего угодно (тут
уместно вспомнить `void*` из языка C, это по сути абсолютно то же самое).
Вполне ясно, почему `Writer.ptr` должен иметь именно такой тип - ведь
рассматриваемый интерфейс может быть реализован не только в структуре
`File`, но в других тоже, поэтому типом `ptr` не может быть `*File`.
Природа интерфейсов такова, что в процессе компиляции совершенно
неизвестно, какие нам попадутся реализации, поэтому использование
"универсального" указателя остаётся единственной возможностью.
Тут важно понимать один (возможно, не вполне очевидный) момент: когда мы
в функции `writer` делаем `.ptr = self`, происходит так называемое
"стирание типа". Указатель `self` как показывал на то место в памяти, где
находится экземпляр структуры `File`, так и продолжает показывать, но,
поскольку слева от знака присваивания обобщённый указатель, компилятор
после этого уже не знает, на что именно он показывает. И как нам вернуть
это знание обратно? Это можно сделать при помощи встроенных функций
`@ptrCast` и `@alignCast`:
```zig
fn writeAll(ptr: *anyopaque, data: []const u8) !void {
const self: *File = @ptrCast(@alignCast(ptr));
_ = try std.os.write(self.fd, data);
}
```
Функция `@ptrCast` как бы преобразует указатель одного типа в указатель
другого типа, при этом тип, к которому нужно привести, выводится
из типа того, чему присваивается значение обобщённого указателя,
в примере это `*File`. Мы как бы говорим компилятору: "дружище,
верь мне, я знаю, что делаю - дай мне указатель, который показывает
*туда же*, куда и `ptr`, но только теперь рассматривай содержимое
этой области памяти как `File`". Как Вы, наверное, поняли, `@ptrCast` это
мощная и полезная штука, поскольку позволяет нам рассматривать какую-то
область памяти как что угодно. Но если мы ошибёмся и преобразуем
"универсальный" указатель к типу, который не соответствует тому, что
реально содержится в памяти по данному адресу, то, кроме себя, винить
будет некого, компилятор тут нам не поможет. А последствия такой ошибки
могут быть самые печальные, причём аварийное завершение программы это
далеко не самый худший исход.
Функция `@alignCast` это более сложная штука. Существуют зависящие от
вида процессора правила по поводу выравнивания данных в памяти. Например,
для некоторых моделей CPU необходимо, чтобы, скажем, число с плавающей
точкой (`f32`) располагалось по адресу, кратному размеру этого числа, то
есть четырём. Тип `anyopaque` всегда имеет фактор выравнивания, равный
единице (то есть может располагаться по любому адресу). А вот у структуры
`File` фактор выравнивания равен четырём. Если хотите, Вы можете сами это
увидеть, напечатав значения `@alignOf(File)` и `@alignOf(anyopaque)`.
Поэтому подобно тому, как нам нужна `@ptrCast` для указания компилятору,
какой именно тип мы имеем ввиду, так и `@alignCast` нужна для того,
чтобы сообщить информацию о выравнивании. При этом, как и `@ptrCast`,
`@alignCast`выводит требуемый фактор выравнивания исходя из типа левой
части оператора присваивания.
Итак, вот полная реализация:
```zig
const Writer = struct {
ptr: *anyopaque,
writeAllFn: *const fn (ptr: *anyopaque, data: []const u8) anyerror!void,
fn writeAll(self: Writer, data: []const u8) !void {
return self.writeAllFn(self.ptr, data);
}
};
const File = struct {
fd: os.fd_t,
fn writeAll(ptr: *anyopaque, data: []const u8) !void {
const self: *File = @ptrCast(@alignCast(ptr));
// os.write might not write all of `data`, we should really look at the
// returned value, the number of bytes written, and handle cases where data
// wasn't all written.
_ = try std.os.write(self.fd, data);
}
fn writer(self: *File) Writer {
return .{
.ptr = self,
.writeAllFn = writeAll,
};
}
};
```
Вся суть сводится к двум вещам: использованию `*anyopaque` для хранения
указателя на реализацию в любой структуре и `@ptrCast(@alignCast(ptr))`
для восстановления корректной информации о типе.
И ещё одно (второстепенное) замечание. Поле `ptr` в интерфейсе не может
иметь тип `anyopaque`, оно должно быть именно указателем, то есть иметь
тип `*anyopaque`. Это связано с тем, что `anyopaque` это тип с
неизвестным размером, а в Zig все типы должны иметь известный размер.
Интерфейс `Writer` имеет вполне определённый размер, поскольку это два
указателя, размеры которых известны всегда. А если бы мы использовали тип
`anyopaque`, то размер бы этой структуры был бы неизвестен.
## Делаем лучше
Вышеприведённая реализация интерфейса `Writer` вполне хороша и, к тому
же, она требует знания лишь небольшого "кусочка магии". Некоторые
интерфейсы из стандартной библиотеки (например, `std.mem.Allocator`)
именно так примерно и сделаны. Что касается конкретно интерфейса
`std.mem.Allocator`, в него входит несколько функций (а не одна, как а
нашем примере с `Writer`), поэтому там указатели на все эти функции
объединены в структуру, которая называется `VTable`.
Главный недостаток такой схемы состоит в том, что использовать методы
структур, реализующих какой-то интерфейс, можно только через этот
интерфейс, а "напрямую" нельзя. Мы не можем использовать `file.writeAll`
непосредственно, поскольку у реализаций `writeAll` нет параметра с типом
`*File`, там везде `*anyopaque`. Вообще говоря, использование чего-то
только через интерфейс это само по себе очень даже неплохо, но если нам
по каким-то причинам нужно, чтобы реализации можно было бы использовать
как сами по себе, так и через интерфейс, то описанная в предыдущем
разделе схема не сработает.
Иными словами, мы хотим, чтобы `File.writeAll` был нормальным методом,
без необходимости манипуляций с обобщёнными указателями, как то вот так:
```zig
fn writeAll(self: *File, data: []const u8) !void {
_ = try std.os.write(self.fd, data);
}
```
Это можно сделать, но для этого придётся изменить сам интерфейс:
```zig
const Writer = struct {
// These two fields are the same as before
ptr: *anyopaque,
writeAllFn: *const fn (ptr: *anyopaque, data: []const u8) anyerror!void,
// This is new
fn init(ptr: anytype) Writer {
const T = @TypeOf(ptr);
const ptr_info = @typeInfo(T);
const gen = struct {
pub fn writeAll(pointer: *anyopaque, data: []const u8) anyerror!void {
const self: T = @ptrCast(@alignCast(pointer));
return ptr_info.Pointer.child.writeAll(self, data);
}
};
return .{
.ptr = ptr,
.writeAllFn = gen.writeAll,
};
}
// This is the same as before
pub fn writeAll(self: Writer, data: []const u8) !void {
return self.writeAllFn(self.ptr, data);
}
};
```
Тут у нас появилась функция `init`. Она довольно заковыристая и, чтобы
понять, что там делается, лучше сначала думать в рамках нашей изначальной
реализации. Суть всего кода `init` в том, чтобы превратить `*anyopaque` в
конкретный тип, такой как `*File`. Это легко было сделать внутри самого
`File`, поскольку там мы по самому определению знаем, к чему надо
привести универсальный указатель. Но тут, чтобы это понять, нам нужна
некоторая дополнительная информация.
Чтобы лучше понять `init`, давайте посмотрим, как эта функция
используется. Функция `File.writer`, которая ранее создавала интерфейс
непосредственно, теперь далает это не так:
```zig
fn writer(self: *File) Writer {
return Writer.init(self);
}
```
Отсюда мы видим, что в `init` передаётся ссылка на структуру с
реализацией интерфейса, в данном случае это `*File` (а в сигнатуре `init`
мы видим `anytype`!). Далее вступают в дело встроенные функции `@TypeOf`
и `@typeInfo`, которые в Zig являются основными для работы во время
компиляции. Первая из них возвращает тип указателя, в нашем случае это
будет `*File`, а вторая возвращает маркированную структуру (tagged
union), которая полностью описывает тип. Далее мы видим, что создаётся
некоторая вложенная структура (`gen`), которая также содержит реализацию
`writeAll`. Это как раз и есть то место, где `*anyopaque` преобразуется к
нужному типу и далее вызывается функция с конкретной реализацией. Эта
структура необходима, потому что в Zig нет анонимных функций. Поэтому нам
нужно, чтобы `Writer.writeAllFn` была оформлена в виде этой небольшой
обёртки и использование вложенной структуры это единственный способ
сделать это.
Очевидно, что `file.writer()` это что-то, что будет работать во время
выполнения программы, что подбивает нас думать, что всё внутри
`Writer.init`, вызываемой из `file.writer()`, создаётся во время
исполнения. Тут впору спросить про время жизни внутренней структуры
`gen`, особенно с учётом того, что интерфейс могут реализовывать многие
структуры. Но в действительности всё, кроме инструкции возврата, `init`
это генерация кода во время компиляции. То есть для каждого типа `ptr`,
используемого в программе, компилятор Zig создаст свою версию `init`. В
этом смысле `init` это некий шаблон для компилятора, и всё это потому,
что параметр `ptr` имеет тип `anytype`. Когда функция `file.writer()`
вызывается во время работы программы, для каждого типа, реализующего
интерфейс `Writer`, будет своя функция `Writer.init`.
В первоначальной версии каждая реализация интерфейса была ответственна за
преобразование `*anyopaque` к тому, что нужно для данной реализации, что
и делалось с помощью `@ptrCast(@alignCast(ptr))`. В новой, более
изощрённой версии, каждая реализация всё так же должна это делать, мы
просто изловичились и встроили это в сам интерфейс, задействовав
возможности Zig по генерации исходного кода во время компиляции.
Последняя часть этого кода это вызов функции посредством
`ptr_info.Pointer.child.writeAll(self, data)`. Встроенная функция
`@@typeInfo(T)` возвращает `std.builtin.Type`, а это, как уже было
отмечено, маркированная объединение, которое описывает тот или иной тип.
Она может описывать 24 разновидности типов, как то целые числа,
необязательные значения, структуры, указателя и т.д. Каждый тип имеет
свои особенные свойства. Так, целые числа обладают "знаковостью"
(`signedness`), в то время как другие типы такого свойства не имеют. Вот
так выглядит `@typeInfo(*File)`:
```zig
builtin.Type{
.Pointer = builtin.Type.Pointer{
.address_space = builtin.AddressSpace.generic,
.alignment = 4,
.child = demo.File,
.is_allowzero = false,
.is_const = false,
.is_volatile = false,
.sentinel = null,
.size = builtin.Type.Pointer.Size.One
}
}
```
Поле `child` это фактический тип сущности, на которую показывает
указатель. Когда мы вызываем `init`, передавая ей `*File`,
`ptr_info.Pointer.child.writeAll(...)` транслируется в
`File.writeAll(...)`, то есть получается именно то, что мы и хотели.
Если Вы посмотрите на другие применения такой витиеватой схемы,
Вы там можете обнаружить, что `init` делает ещё кое-что после
получения информации о типе:
```zig
if (ptr_info != .Pointer) @compileError("ptr must be a pointer");
if (ptr_info.Pointer.size != .One) @compileError("ptr must be a single item pointer");
```
Таким образом делаются дополнительные проверки во время компиляции,
а именно, проверяется, что `ptr` это указатель и не просто указаьтель,
а указатель на одиночную сущность (а не на массив, в Zig это разные вещи).
Также вместо вызова функции посредством
```zig
ptr_info.Pointer.child.writeAll(self, data);
```
можно встретить такое
```zig
@call(.always_inline, ptr_info.Pointer.child.writeAll, .{self, data});
```
Вызов функции напрямую (как мы это и делали) по сути ничем не отличается
от вызова функции про помощи встроенной функции `@call`, за исключением того,
что использование `@call` предоставляет некоторые дополнительные
возможности посредством её первого параметра (`CallModifier`). Как можно
видеть из приведённого примера, мы можем попросить компилятор не делать
вызов функции в буквальном смысле, а встроить тело функции в точку вызова.
Будем надеяться, что эта глава прояснила детали конструирования
интерфейсов в Zig, а также, возможно, обрисовала такие возможности
языка, которые не были никак затронуты в предыдущих главах.
Однако, для простых случаев, когда все реализации заведомо известны,
можно применять другой подход, о чём мы поговорим в следующем разделе.
## Использование маркированных объединений