# Глава VIII. Интерфейсы

После того, как вы начали изучать Zig, пройдёт совсем немного времени до того момента, когда вы осознаете, что в нём нет никакого специального синтаксиса (как в Java и Go, например) для создания интерфейсов. Но, вероятно, вы заметите некоторые вещи, которые номинально не являются интерфейсами, но очень на них похожи, например, std.mem.Allocator. Это потому, что в Zig действительно нет простого механизма для создания интерфейсов (например, ключевых слов interface и implements), но, тем не менее, сам по себе язык вполне пригоден для достижения аналогичных целей.

# Простой интерфейс и пример его реализации

Мы сейчас сконструируем некий интерфейс, который будет называться Writer. Он будет достаточно прост для понимания сути дела и будет содержать всего одну функцию. Если надо больше, они легко добавляются по аналогии. Прежде всего, вот сам интерфейс:

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.

Теперь скелет того самого нечто, что реализует данный интерфейс:

const File = struct {
  fd: os.fd_t,

  fn writeAll(ptr: *anyopaque, data: []const u8) !void {
    // и что делать с таким 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:

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выводит требуемый фактор выравнивания исходя из типа левой части оператора присваивания.

Итак, вот полная реализация:

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 не гарантирует, что запишутся все данные сразу, поэтому тут
    // надо смотреть на возвращённое значение (сколько байт было записано)
    // и как-то обрабатывать случаи, когда записано было не всё
    _ = 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 был нормальным методом, без необходимости манипуляций с обобщёнными указателями, как-то вот так:

fn writeAll(self: *File, data: []const u8) !void {
  _ = try std.os.write(self.fd, data);
}

Это можно сделать, но для этого придётся изменить сам интерфейс:

const Writer = struct {
  // эти два поля не поменялись
  ptr: *anyopaque,
  writeAllFn: *const fn (ptr: *anyopaque, data: []const u8) anyerror!void,

  // это новое
  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,
    };
  }

  // это осталось неизменённым
  pub fn writeAll(self: Writer, data: []const u8) !void {
    return self.writeAllFn(self.ptr, data);
  }
};

Тут у нас появилась функция init. Она довольно заковыристая и, чтобы понять, что там делается, лучше сначала думать в рамках нашей изначальной реализации. Суть всего кода init в том, чтобы превратить *anyopaque в конкретный тип, такой как *File. Это легко было сделать внутри самого File, поскольку там мы по самому определению знаем, к чему надо привести универсальный указатель. Но тут, чтобы это понять, нам нужна некоторая дополнительная информация.

Чтобы лучше понять init, давайте посмотрим, как эта функция используется. Функция File.writer, которая ранее создавала интерфейс непосредственно, теперь делает это не так:

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):

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 делает ещё кое-что после получения информации о типе:

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 это разные вещи).

Также вместо вызова функции посредством

ptr_info.pointer.child.writeAll(self, data);

можно встретить такое

@call(.always_inline, ptr_info.pointer.child.writeAll, .{self, data});

Вызов функции напрямую (как мы это и делали) по сути ничем не отличается от вызова функции про помощи встроенной функции @call, за исключением того, что использование @call предоставляет некоторые дополнительные возможности посредством её первого параметра (CallModifier). Как можно видеть из приведённого примера, мы можем попросить компилятор не делать вызов функции в буквальном смысле, а встроить тело функции в точку вызова.

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

# Использование маркированных объединений

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

const std = @import("std");
const os = std.os;

const Writer = union(enum) {
  file: File,

  fn writeAll(self: Writer, data: []const u8) !void {
    switch (self) {
      .file => |file| return file.writeAll(data),
    }
  }
};

const File = struct {
  fd: os.fd_t,

  fn writeAll(self: File, data: []const u8) !void {
    _ = try std.os.write(self.fd, data);
  }
};


pub fn main() !void {
  const file = File{.fd = std.io.getStdOut().handle};
  const writer = Writer{.file = file};
  try writer.writeAll("hi\n");
}

Напомним, что когда в switch используется переменная, представляющая собой маркированное объединение, захваченные значения (в примере |file|) всегда имеют правильный тип (для примера это File).

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

Если интерфейс используется только внутри какого-то приложения, это ограничение роли не играет. Вы можете построить, к примеру, объединение Cache, которое будет включать в себя все нужные реализации, например, InMemory, Redis и Postgresql. Если появляются новые механизмы, вы просто обновляете объединение.

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

switch (self) {
  .null => {},  // ничего не делаем
  inline else => |impl| return impl.writeAll(data),
}

Такой оборот else автоматически разворачивается, то есть для каждого варианта получается правильный тип impl. Здесь показана ещё одна вещь - интерфейсы могут привносить свою логику, в данном примере учитывается пустая реализация (.null). Сколько логики вы добавляете в сам интерфейс, это ваше дело, но по большей части интерфейсы должны в основном выполнять диспетчеризацию.