From 02bf5b0b983756e01a93877cf9fcd8a45e5749b6 Mon Sep 17 00:00:00 2001 From: zed Date: Fri, 17 Nov 2023 21:27:44 +0300 Subject: [PATCH] On branch main modified: src/ch08.md --- src/ch08.md | 355 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) diff --git a/src/ch08.md b/src/ch08.md index 7b8bf3a..e2f8841 100644 --- a/src/ch08.md +++ b/src/ch08.md @@ -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, а также, возможно, обрисовала такие возможности +языка, которые не были никак затронуты в предыдущих главах. +Однако, для простых случаев, когда все реализации заведомо известны, +можно применять другой подход, о чём мы поговорим в следующем разделе. + +## Использование маркированных объединений