Интерфейсы
В отличие от таких языков как Go или Java, в Zig нет встроенной поддержки интерфейсов. Однако это не означает, что Zig нет возможности использовать интерфейсы. В Zig интерфейсы реализуются с помощью структур и рефлексии. Прежде чем мы рассмотрим, как это сделать, давайте разберемся зачем нужны интерфейсы и как они реализуются в других языках программирования.
Зачем нужны интерфейсы
Интерфейсы это один из основных строительных блоков в программировании для реализации большинства архитектурных шаблонов, таких как Clean Architecture, Domain-Driven Design и другие. Они позволяют разделить ваше приложение на отдельные компоненты и ослабить связи между этими компонентами. Это позволяет упростить разработку, тестирование и вносить изменения в ваш проект, завтрагивая только те компоненты, которые действительно нуждаются в изменении.
Давайте рассмотрим использование интерфейсов в языке Go. Интерфейсы в Go определяются с помощью ключевого слова interface
и содержат только сигнатуры методов, которые должны быть реализованы в структуре для соответствия этому интерфейсу. Рассмотрим стандартный интерфейс Stringer
из пакета fmt
:
type Stringer interface {
String()
}
type Circle struct{}
func (c Circle) String() string {
return fmt.Sprintf("Круг")
}
type Square struct{}
func (s Square) String() string {
return fmt.Sprintf("Квадрат")
}
type Triangle struct{}
func (t Triangle) String() string {
return fmt.Sprintf("Треугольник")
}
func stringifyElements(elements []Stringer) {
for _, element := range elements {
element.String()
}
}
fn main() {
stringifyElements([]Stringer{Circle{}, Square{}, Triangle{}})
}
В данном примере мы используем интерфейс Stringer
для вывода строкового представления наших элементов. Наш интерфейс определяет метод String()
, который должны реализовать все структуры, поддерживающие этот интерфейс.
Мы создаём три конкретные фигуры: круг, квадрат и треугольник, каждая из которых реализует метод String()
, соответствующий нашему интерфейсу. В языке Go не нужно явно указывать, что структура реализует интерфейс — достаточно просто реализовать необходимые методы и использовать структуру там, где ожидается интерфейс. Компилятор сам проверит во время компиляции, что структура реализует все методы интерфейса.
В результате мы получаем возможность создать универсальный метод вывода элементов stringifyElements
, который может напечатать представление любой фигуры, поддерживающую интерфейс Stringer
. Такому методу неважно, какую именно фигуру мы передаём — он просто ожидает, что фигура реализует интерфейс Stringer
. Это позволяет:
- Писать более гибкий код
- Легко расширять набор поддерживаемых фигур
- Не изменять основную логику при добавлении новых типов фигур
Это, конечно, очень упрощённый пример. В реальных проектах интерфейсы применяются для более сложных сценариев. Например, вы можете реализовать интерфейс работы с базой данных и легко поддерживать разные типы СУБД, просто изменяя реализацию этого интерфейса, не затрагивая основной код бизнес-логики.
Такой подход сохраняет архитектурную целостность приложения и упрощает его масштабирование. В целом применений интерфейсов в коде любого проекта достаточно много, и скорее всего вам они точно понадобятся чтобы сделать ваш код более гибким и масштабируемым.
Реализация интерфейсов
Zig не предоставляет встроенной поддержки интерфейсов на уровне синтаксиса языка, поэтому вам придётся реализовывать эту функциональность самостоятельно. Однако не стоит беспокоиться — благодаря мощным возможностям рефлексии в Zig, реализация интерфейсов не представляет особой сложности.
Прежде чем перейти к реализации интерфейсов в Zig, давайте разберёмся, как они работают в языках с нативной поддержкой интерфейсов. Рассмотрим пример на Go: основная “магия” происходит в методе stringifyElements
, который принимает параметры интерфейсного типа, хотя мы передаём туда конкретные структуры.
Как Go понимает, как правильно вывести каждую фигуру? Согласно документации Go, интерфейс представляет собой “двойной” указатель, состоящий из:
- Указателя на конкретную структуру
- Указателя на таблицу методов
Подобная реализация с двумя указателями встречается практически во всех языках программирования. Основные различия между реализациями заключаются в том, когда формируется список методов для интерфейса — во время компиляции или в процессе выполнения программы. Вот как это примерно выглядит внутри языков поддерживающих интерфейсы:
Как мы видим, для реализации поддержки интерфейсов в языке Zig необходимо создать специальный составной указатель, содержащий одновременно указатель на конкретную структуру и указатель на таблицу методов. Давайте рассмотрим практическую реализацию этого подхода в Zig.
Интерфейсы в Zig
Для того чтобы реализовать интерфейс в Zig, нам нужно рассмотреть два момента - описание самого интерфейса и поддержка интерфейса в конкретной структуре. Давайте начнём с описания нашего интерфейса и попробуем реализовать стандартный интерфейс Stringify
:
const Stringify = struct {
ptr: *anyopaque,
stringFn: *const fn (ptr: *anyopaque) anyerror![]u8,
fn string(self: Stringify) ![]u8 {
return self.stringFn(self.ptr);
}
};
Реализация интерфейса Stringify
получилась достаточно лаконичной. Мы создали структуру, которая содержит:
- Указатель на данные (ptr)
- Функцию для их печати строкового представления (stringFn)
- Метод-обёртку для удобного вызова (string)
Вы можете спросить: “А где же VTable, которую мы обсуждали ранее”? В текущей реализации так как у нас пока всего один метод в интерфейсе, то для упрощения мы встроили её прямо в структуру, но реализовать поддержку VTable не сложно, вам просто надо добавить поле vtable: *const VTable
, где VTable будет определена следующим образом:
const VTab = struct {
stringFn: *const fn (ptr: *anyopaque) anyerror![]u8,
...
}
Несмотря на кажущуюся простоту, в коде нашего интерфейса есть несколько важных нюансов, которые требуют пояснения.
Первое, что сразу обращает на себя внимание — использование указателя на anyopaque
вместо конкретного типа. Указатель *anyopaque
в Zig означает указатель на “неизвестный тип”, что позволяет нашему интерфейсу работать с любыми данными. Если бы мы указывали тут конкретный тип, то наш интерфейс не смог бы поддерживать работу с разными структурами. Но мы уже раньше видели вариант с anytype
, который тоже позволяет работать с любыми типами. В чем же отличие этих двух типов. Давайте сравним их:
-
anyopaque: Это непрозрачный указатель (аналог
c_void
) на данные неизвестного типа, который не содержит информации о типе (размер данных, выравнивание). Позволяет приводить тип во время выполнения. Данный тип всегда имеет выравнивание равное 1. -
anytype: Это специальный тип, который может быть использован для обозначения любого типа. Это по сути аналог auto в C++, или any в языке Go. В отличие от
anyopaque
,anytype
сохраняет всю информацию о типе, включая размер и выравнивание. Данный тип не может использоватся во время выполнения и всегда должен быть разрешен в конкретный тип на этапе компиляции.
Теперь рассмотрев отличия наших типов вам должно быть понятно почему мы используем указатель на anyopaque
- потому что нам надо чтобы наш интерфейс работал на этапе выполнения программы, а не на этапе компиляции. Но почему именно указатель на anyopaque
, а не просто anyopaque
? Всё дело в требованиях Zig к размерам типов. Если бы мы хранили anyopaque
напрямую, компилятор не смог бы определить размер структуры — ведь он варьируется в зависимости от типа данных и компилятор не понимал бы сколько памяти надо под нашу переменную. Указатель же всегда имеет фиксированный размер (usize), известный при компиляции.
Второй момент, это то как мы обьявляем функцию для вывода данных. Она принимает указатель на anyopaque
. И это на самом деле проблема, которую мы осознаем, если попытаемся теперь реализовать наш интерфейс для нашей структуры. Давайте рассмотрим как это могло бы выглядеть:
const User = struct {
name: []const u8,
age: u32,
fn string(ptr: *anyopaque) ![]u8 {
...
}
fn stringer(self: *User) Stringify {
return .{
.ptr = self,
.stringFn = string,
};
}
};
Мы видим, что для типа данных User
реализован метод stringer
возвращающий интерфейс Stringify
. Поскольку Zig не предоставляет встроенной поддержки интерфейсов на уровне компилятора, преобразование типов в интерфейс и обратно нам приходится выполнять вручную. В данном случае наш метод stringer
преобразует наш тип User
в интерфейсный тип Stringify
. Этот подход уже знаком нам по работе с аллокаторами. Например, при создании аллокатора через std.heap.DebugAllocator(.{}).init()
мы затем вызывали метод allocator()
, который возвращал интерфейс std.mem.Allocator
. В текущем примере мы применяем аналогичный принцип для получения интерфейса Stringify
.
Остался теперь самый сложный момент - что нам делать в нашем методе string
. На вход нашего метода мы получаем *anyopaque
, так как мы так определеили в нашем интерфейсе. Но нам то надо работать с указателем на наш тип User
. И тут как раз нам помогут возможности рефлексии в Zig. Давайте рассмотрим как мы можем решить нашу проблему:
const std = @import("std");
const Stringify = struct {
ptr: *anyopaque,
stringFn: *const fn (ptr: *anyopaque) anyerror![]u8,
fn string(self: Stringify) ![]u8 {
return self.stringFn(self.ptr);
}
};
const User = struct {
name: []const u8,
age: u32,
fn string(ptr: *anyopaque) ![]u8 {
const self: *User = @ptrCast(@alignCast(ptr));
return try std.fmt.allocPrint(std.heap.page_allocator, "Name: {s}, Age: {d}", .{ self.name, self.age });
}
fn stringer(self: *User) Stringify {
return .{
.ptr = self,
.stringFn = string,
};
}
};
pub fn main() !void {
var users = User{
.name = "John Doe",
.age = 30,
};
const result = try users.stringer().string();
defer std.heap.page_allocator.free(result);
try std.io.getStdOut().writer().print("{s}\n", .{result});
}
Код нашей функции string
получился довольно компактным, но что мы делаем в нем. Когда мы получаем указатель на anyopaque
, физически в памяти находится указатель на структуру User
, но компилятор об этом не знает - использование anyopaque
стирает информацию о реальном типе. Чтобы восстановить эту информацию, мы применяем знакомую функцию @ptrCast
, преобразующую указатель из одного типа в другой. Данная функция позволяет воспринимать некую область памяти как определенный тип данных, при этом компилятор не выполняет никаких проверок за нас что то, что лежит в этой области памяти является корректным для этого типа.
Но зачем нужен @alignCast
? Дело в том, что anyopaque
имеет выравнивание 1 (так как может представлять любой тип), тогда как структура User
требует выравнивания 8. Функция @alignCast
решает эту проблему, преобразуя указатель с выравниванием 1 в указатель с выравниванием 8. То, на сколько важно выравнивать данные в памяти мы уже рассматривали ранее.
Таким образом, в реализации string
мы последовательно используем:
@alignCast
для корректировки выравнивания@ptrCast
для восстановления информации о типе
Это позволяет безопасно преобразовать anyopaque
обратно в User
, восстановив его тип.
В целом наша реализация готова и даже работает, но у нее есть один существенный недостаток - мы не можем использовать метод string
напрямую как user.string()
, так как чтобы вызвать наш метод у типа нужно чтобы первый аргумент был типа User
. И мы можем исправить эту проблему используя все теже возможности рефлексии в Zig. Но конечно решение нашей проблемы потребует изменение нашего интерфейса Stringify
:
const Stringify = struct {
ptr: *anyopaque,
stringFn: *const fn (ptr: *anyopaque) anyerror![]u8,
fn init(ptr: anytype) Stringify {
const T = @TypeOf(ptr);
const ptr_info = @typeInfo(T);
const wrap = struct {
pub fn string(pointer: *anyopaque) anyerror![]u8 {
const self: T = @ptrCast(@alignCast(pointer));
return ptr_info.pointer.child.stringFn(self);
}
};
return .{
.ptr = ptr,
.writeFn = wrap.write,
};
}
pub fn string(self: Stringify) ![]u8 {
return self.stringFn(self.ptr);
}
};
Как мы видим, мы добавили новую функцию init
в которой и заключены все наши изменения. Функция init
на вход принимает всего один параметр anytype
- любой тип. После этого используя уже знакомые нам функции рифлексии @TypeOf
и @typeInfo
мы получаем информацию о конкретном типе T
из нашего универсального типа anytype
. Дальше все довольно просто - мы создаем замыкание wrap
, используя анонимную структуру, где реализуем наш метод string
, в котором мы передаем в конкретную реализацию для нашего интерфейса уже не универсальный тип *anyopaque
, а указатель на конкретный тип *T
. Таким образом мы избавляемся от проблемы что наши методы в конкретной реализации работали с указателем на anyopaque
и теперь мы можем вызвать их как при работе через интерфейс, так и без. Итак давайте теперь напишем весь код нашего интерфейса и его использования, а также сделаем еще ряд оптимизаций и проверок, которые рассмотрим сразу после нашего кода:
const std = @import("std");
const Stringify = struct {
ptr: *anyopaque,
stringFn: *const fn (ptr: *anyopaque) anyerror![]u8,
fn init(ptr: anytype) Stringify {
const T = @TypeOf(ptr);
const ptr_info = @typeInfo(T);
if (ptr_info != .pointer) @compileError("ptr must be a pointer");
if (ptr_info.pointer.size != .one) @compileError("ptr must be a single item pointer");
const wrap = struct {
pub fn string(pointer: *anyopaque) anyerror![]u8 {
const self: T = @ptrCast(@alignCast(pointer));
return try @call(.always_inline, ptr_info.pointer.child.string, .{self});
}
};
return .{
.ptr = ptr,
.stringFn = wrap.string,
};
}
fn string(self: Stringify) ![]u8 {
return self.stringFn(self.ptr);
}
};
const User = struct {
name: []const u8,
age: u32,
allocator: std.mem.Allocator,
buffer: [256]u8,
buffer_len: usize,
pub fn init(name: []const u8, age: u32, allocator: std.mem.Allocator) @This() {
return .{
.name = name,
.buffer = undefined,
.buffer_len = 0,
.age = age,
.allocator = allocator,
};
}
pub fn string(self: *User) ![]u8 {
self.buffer_len = 0;
var fbs = std.io.fixedBufferStream(&self.buffer);
const writer = fbs.writer();
try std.fmt.format(writer, "User: {s}, Age: {d}\n", .{ self.name, self.age });
self.buffer_len = fbs.pos;
return self.buffer[0..self.buffer_len];
}
pub fn stringer(self: *User) Stringify {
return Stringify.init(self);
}
};
const Animal = struct {
kind: []const u8,
age: u32,
allocator: std.mem.Allocator,
buffer: [256]u8,
buffer_len: usize,
pub fn init(kind: []const u8, age: u32, allocator: std.mem.Allocator) @This() {
return .{
.kind = kind,
.buffer = undefined,
.buffer_len = 0,
.age = age,
.allocator = allocator,
};
}
pub fn string(self: *Animal) ![]u8 {
self.buffer_len = 0;
var fbs = std.io.fixedBufferStream(&self.buffer);
const writer = fbs.writer();
try std.fmt.format(writer, "Animal: {s}, Age: {d}\n", .{ self.kind, self.age });
self.buffer_len = fbs.pos;
return self.buffer[0..self.buffer_len];
}
pub fn stringer(self: *Animal) Stringify {
return Stringify.init(self);
}
};
pub fn printStringify(s: Stringify) !void {
try std.io.getStdOut().writeAll(try s.string());
}
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var animal = Animal.init("Dog", 5, allocator);
var user = User.init("John Doe", 30, allocator);
try printStringify(user.stringer());
try printStringify(animal.stringer());
}
Наш код не сильно отличается от того, что мы уже разобрали по частям. По сути, мы добавили две структуры — User
и Animal
— и реализовали в них методы вывода строкового представления. Но есть пару моментов, которые мы изменили в нашем интерфейсе, и хочется их пояснить.
Первое, что мы добавили, — это две проверки: передан ли в наш интерфейс именно указатель на структуру и является ли этот указатель единичным, а не указывающим на коллекцию элементов. Для этого мы использовали информацию о нашем типе, полученную через функцию @typeInfo
.
Второй момент, который мы изменили, — это преобразование вызова нашей функции stringFn
в вызов метода @call
с указанием компилятору встроить нашу функцию. Это позволяет оптимизировать вызов функции и улучшить производительность.
Заключение
В этой главе мы рассмотрели, как реализовать интерфейсы в Zig, несмотря на отсутствие их встроенной поддержки в языке. Хотя Zig не предоставляет синтаксических конструкций для интерфейсов, как Go или Java, мы смогли успешно воссоздать аналогичную функциональность с помощью структур, функций и возможностей рефлексии.
Основной подход, который мы использовали, заключается в:
- Создании структуры-интерфейса с указателем на данные и таблицей функций
- Использовании указателя на
anyopaque
для обеспечения универсальности - Применении функций рефлексии (
@ptrCast
,@alignCast
) для безопасного преобразования типов - Создании удобных методов-оберток для использования интерфейса
Реализованный нами интерфейс Stringify
позволяет различным типам данных предоставлять строковое представление, при этом пользователи интерфейса могут работать с объектами разных типов через единый интерфейс. Мы продемонстрировали это на примере структур User
и Animal
, каждая из которых реализует свою логику преобразования в строку.
Такой подход сохраняет основные преимущества интерфейсов, которые мы обсудили вначале:
- Абстракция и разделение ответственности
- Возможность замены реализаций без изменения пользовательского кода
- Поддержка полиморфизма во время выполнения программы
Хотя наша реализация требует больше ручного кода по сравнению с языками с нативной поддержкой интерфейсов, она предоставляет полный контроль над процессом и позволяет адаптировать концепцию интерфейсов под конкретные требования вашего проекта. Более того, реализованные таким образом интерфейсы полностью соответствуют философии Zig о явности и контроле.
В итоге, несмотря на отсутствие встроенной поддержки интерфейсов, Zig предоставляет все необходимые инструменты для их эффективной реализации, что позволяет применять современные архитектурные подходы и шаблоны проектирования в ваших проектах на Zig.