Интерфейсы

2025-03-22 2446 12

В отличие от таких языков как 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 получилась достаточно лаконичной. Мы создали структуру, которая содержит:

Вы можете спросить: “А где же VTable, которую мы обсуждали ранее”? В текущей реализации так как у нас пока всего один метод в интерфейсе, то для упрощения мы встроили её прямо в структуру, но реализовать поддержку VTable не сложно, вам просто надо добавить поле vtable: *const VTable, где VTable будет определена следующим образом:

const VTab = struct {
  stringFn: *const fn (ptr: *anyopaque) anyerror![]u8,
  ...
}

Несмотря на кажущуюся простоту, в коде нашего интерфейса есть несколько важных нюансов, которые требуют пояснения.

Первое, что сразу обращает на себя внимание — использование указателя на anyopaque вместо конкретного типа. Указатель *anyopaque в Zig означает указатель на “неизвестный тип”, что позволяет нашему интерфейсу работать с любыми данными. Если бы мы указывали тут конкретный тип, то наш интерфейс не смог бы поддерживать работу с разными структурами. Но мы уже раньше видели вариант с 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 мы последовательно используем:

  1. @alignCast для корректировки выравнивания
  2. @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, мы смогли успешно воссоздать аналогичную функциональность с помощью структур, функций и возможностей рефлексии.

Основной подход, который мы использовали, заключается в:

  1. Создании структуры-интерфейса с указателем на данные и таблицей функций
  2. Использовании указателя на anyopaque для обеспечения универсальности
  3. Применении функций рефлексии (@ptrCast, @alignCast) для безопасного преобразования типов
  4. Создании удобных методов-оберток для использования интерфейса

Реализованный нами интерфейс Stringify позволяет различным типам данных предоставлять строковое представление, при этом пользователи интерфейса могут работать с объектами разных типов через единый интерфейс. Мы продемонстрировали это на примере структур User и Animal, каждая из которых реализует свою логику преобразования в строку.

Такой подход сохраняет основные преимущества интерфейсов, которые мы обсудили вначале:

Хотя наша реализация требует больше ручного кода по сравнению с языками с нативной поддержкой интерфейсов, она предоставляет полный контроль над процессом и позволяет адаптировать концепцию интерфейсов под конкретные требования вашего проекта. Более того, реализованные таким образом интерфейсы полностью соответствуют философии Zig о явности и контроле.

В итоге, несмотря на отсутствие встроенной поддержки интерфейсов, Zig предоставляет все необходимые инструменты для их эффективной реализации, что позволяет применять современные архитектурные подходы и шаблоны проектирования в ваших проектах на Zig.

#interface #zig #zigbook

0%