Вещи, которые легко запомнить при повторном посещении языка программирования Zig

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

Эта статья представляет собой набор разделов, описывающих случайный набор концепций, которые мне пришлось освежить, или проблемы, с которыми я столкнулся, пытаясь освежить свои знания о Zig. Я предполагаю, что большинство из вас могут легко понять такие вещи, как операторы if, циклы while и определение функции. Здесь основное внимание уделяется различным угловым случаям или концепциям, которые могут вас сбить с толку.

Скомпилируйте и запустите Zig-программы

Я забыл о разнице между компиляцией одного файла и созданием целого проекта. Это легко сделать. Давайте рассмотрим пример с файлами исходного кода, расположенными в подкаталоге src. Вы можете либо скомпилировать, либо запустить непосредственно файл исходного кода main.zig следующим образом:

❯ zig build-exe src/main.zig
❯ zig run src/main.zig

Имейте в виду, что, как и в случае с языком программирования Go, в большинстве случаев вам не нужна сложная настройка сборки. Файл main.zig может указывать на множество других файлов исходного кода Zig, которые также могут указывать на другие файлы. Вот пример включения файла colors.zig в файл main.zig:

const colors =  @import("colors.zig");

Для старых разработчиков C/C++, таких как я, это довольно радикально. На самом деле, когда я впервые писал этот раздел, я объяснял build-exe так, как если бы это было похоже на компиляцию одного файла исходного кода C/C++, который впоследствии нужно будет связать с объектным кодом для всех зависимых файлов исходного кода. Однако это было бы ошибочным мнением. Нет отдельного этапа компоновки, который вам нужно выполнить с помощью Zig. С помощью build-exe в принципе можно построить целый проект, состоящий из сотен файлов с исходным кодом.

Зачем тогда вообще нужен файл build.zig? Преимущество заключается в том, что он позволяет вам настроить несколько исполняемых файлов, библиотек и тому подобное для сборки. Вы также можете определить процедуры установки и определить, с какими флагами вы хотите скомпилировать. Таким образом, хотя вы можете избежать чего-то похожего на Makefile во многих случаях, это все же полезно с файлом, определяющим ваши настройки сборки в Zig. В отличие от make-файлов для кода C/C++, вам не нужно изучать новый язык. Файл build.zig содержит обычный Zig-код. У него есть только определенное требование: он должен содержать функцию с именем build со следующей подписью:

const Builder = @import("std").build.Builder
pub fn build(b: *Builder) void

Это означает, что это должна быть общедоступная функция, принимающая указатель на объект Builder и ничего не возвращающая. В документации стандартной библиотеки Zig более подробно объясняется, как настроить конфигурацию сборки: BuildMode. Вот простой пример скрипта сборки:

const Builder = @import("std").build.Builder;

pub fn build(b: *Builder) void {
    const exe = b.addExecutable("example", "example.zig");
    exe.setBuildMode(b.standardReleaseOptions());
    b.default_step.dependOn(&exe.step);
}

Получив файл сборки, вы можете выполнять такие действия, как сборка исполняемого файла, сборка и запуск или установка.

❯ zig build         # just build the project
❯ zig build run     # build project and run executable
❯ zig build install # install executable
❯ zig build uninstall

У Икримы есть отличное, более подробное руководство по системе сборки Zig.

Файлы являются структурами

Первое, что я забыл о Zig, это то, что файлы исходного кода действуют как структуры. Я был сбит с толку этим, когда читал некоторый код стандартной библиотеки Zig, в котором использовалось ключевое слово @This, относящееся к типу текущей окружающей структуры. Мы можем увидеть пример этого в файле std/rand.zig, где у нас есть определение типа SequentialPrng. Мы используем @This() для определения типа Self.

const SequentialPrng = struct {
    const Self = @This();
    next_value: u8,

    pub fn init() Self {
        return Self{
            .next_value = 0,
        };
    }

    pub fn random(self: *Self) Random {
        return Random.init(self, fill);
    }

    pub fn fill(self: *Self, buf: []u8) void {
        for (buf) |*b| {
            b.* = self.next_value;
        }
        self.next_value +%= 1;
    }
};

Что меня сбило с толку, так это найти следующую строку в файле std/mem/Allocator.zig на верхнем уровне.

const Allocator = @This();

Я спросил себя: «Где находится окружающая структура?» Я начал размышлять над сумасшедшими идеями, такими как импорт файла в середине определения struct. Программисты на C иногда делают такие вещи. Тем не менее, это не что-то сумасшедшее. Все определения верхнего уровня в файле исходного кода являются полями безымянной структуры, охватывающей весь файл исходного кода. Легко забыть, что структуры в Zig повсюду, и на самом деле они не имеют имен.

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

var labels = Dict(i16).init(allocator);

Я хотел лучше понять, как работает это выделение словаря, поэтому я нажал на функцию init, чтобы перейти к реализации (вы можете сделать это в VS Code с языковым сервером Zig ZLS). Это заставило меня обнаружить, что он принимает аргумент типа Allocator, который я щелкнул, чтобы узнать, что он был определен в файле std/mem.zig как:

pub const Allocator = @import("mem/Allocator.zig");

Это означает, что Allocator — это структура, содержащая все определения верхнего уровня в файле std/mem/Allocator.zig. На верхнем уровне файл Allocator.zig определяет следующие переменные:

ptr: *anyopaque,
vtable: *const VTable,

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

const Allocator = @This();

pub fn init(...) Allocator {   // edited out parameters
	// edit out code
    return .{
        .ptr = pointer,
        .vtable = &gen.vtable,
    };
}

Первый аргумент функции получает специальную обработку

Обратите внимание, что функцию add можно рассматривать как метод структуры Vec2D. Первый параметр u считается аргументом self. Вы видите, что u.add(v) эквивалентно Vec2D.add(u, v).

const std = @import("std");

const Vec2D = struct {
    dx: i32,
    dy: i32,

    fn add(u: Vec2D, v: Vec2D) Vec2D {
        return Vec2D {
            .dx = u.dx + v.dx,
            .dy = u.dy + v.dy,
        };
    }
};

pub fn main() anyerror!void {   
    const u = Vec2D { .dx = 3, .dy = 4 };
    const v = Vec2D { .dx = 2, .dy = 1 };
    
    const w = u.add(v);
    const z = Vec2D.add(u, v);
    
    std.log.info("u.add(v) == {d}", .{w});
    std.log.info("Vec2D.add(u, v) == {d}", .{z});
}

Zig не имеет интерфейсов

Структура Zig не может реализовать интерфейс, подобный структуре Go. Я замечаю, что лично меня этот факт часто сбивает с толку. Я изо всех сил пытался определить функцию, принимающую аргумент std.io.Writer, чтобы я мог выбрать, будут ли выходные данные написанного мной ассемблера отправляться в stdout, полученным std.io.getStdOut().writer(), или во что-то еще, например, в файл. По наивности я написал сигнатуру функции так:

// Doens't work
const Writer = std.io.Writer;
fn assemble(allocator: Allocator, 
            file: File, 
            writer: Writer   // naive assumption about interfaces
   ) !void

Вопиющая проблема здесь в том, что std.io.Writer — это не тип, а функция. Каждый писатель в Zig создает свой тип, а это означает, что нет способа справиться с этим во время выполнения. Там, где вы обычно используете интерфейс в Go, способ Zig заключается в использовании вместо этого типа anytype. Функция std.io.Writer используется только для создания структур, используемых для представления писателей. Но каждая созданная структура будет отличаться. Вы не можете рассматривать их как подтипы какого-то интерфейса Writer, поскольку в Zig нет концепции интерфейсов.

fn assemble(allocator: Allocator, 
            file: File,
            writer: anytype) !void

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

Если вы посмотрите на исходный код Zig, вы обнаружите, что anytime часто используется для писателей. Например, именно так вы реализуете функцию format. Мы можем добавить функцию format к пользовательскому типу данных, чтобы функция print могла правильно его отображать. Обратите внимание, что аргумент writer имеет тип anytype.

const Vec2D = struct {
    dx: i32,
    dy: i32,

    fn add(u: Vec2D, v: Vec2D) Vec2D {
        return Vec2D {
            .dx = u.dx + v.dx,
            .dy = u.dy + v.dy,
        };
    }

    pub fn format(
        v: Vec2D,
        comptime fmt: []const u8,
        options: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        try writer.print("Vec2D({d}, {d})", .{ v.dx, v.dy });
		
        // Make compiler shut up
        _ = fmt; _ = options;
    }
};

Аргументы функции постоянны в Zig

Поскольку значения и ключи в словаре не освобождаются автоматически, когда вы вызываете deinit для словаря, я попытался создать функцию releaseDict для обработки этого.

const Dict = std.StringHashMap;

fn releaseDict(allocator: Allocator, dict:  Dict(i16)) void {
    var iter = dict.iterator();
    while (iter.next()) |entry|
        allocator.free(entry.key_ptr.*);
    dict.deinit();    
}

Запуск этого кода не сработал. Вместо этого я получил следующие сообщения об ошибках:

❯ zig test src/assembler.zig
./src/assembler.zig:213:5: error: expected type '*std.hash_map.HashMap([]const u8,i16,std.hash_map.StringContext,80)', found '*const std.hash_map.HashMap([]const u8,i16,std.hash_map.StringContext,80)'
    dict.deinit();
    ^
./src/assembler.zig:213:5: note: cast discards const qualifier
    dict.deinit();

Если вы посмотрите внимательно, то увидите, что ожидался тип *HashMap (указатель на HashMap), но deinit был задан const *HashMap. Причина такого поведения в том, что все параметры функции передаются в Zig как константы. Из Справочника по языку Zig:

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

Если вы посмотрите на реализацию deinit, вы поймете, почему это проблема:

pub fn deinit(self: *Self) void {
    self.unmanaged.deinit(self.allocator);
    self.* = undefined;
}

В последней строке мы модифицируем указатель self. Мы устанавливаем его на undefined. Это невозможно, если self относится к константному типу, что имеет место, если это параметр функции. В Zig вы часто будете использовать undefined там, где в других языках использовалось бы null. Указатель не может быть null в Zig, если только он не является необязательным типом. В Zig мы можем установить указатель на undefined, но последствия будут отличаться от использования null. В режиме отладки для неопределенных данных устанавливается значение 0xaa, что позволяет Zig обнаруживать, считывается ли из неопределенной памяти. Чтение из памяти undefined запрещено. Напротив, проверка того, является ли переменная null, является вполне допустимой и разумной задачей. Мы могли бы использовать null для завершения связанного списка или двоичного дерева. Однако, как только память будет освобождена, вы больше никогда не захотите обращаться к этой переменной.

Честно говоря, я нахожу это различие между undefined и null довольно умным, и я удивлен, что другие языки не копируют эту идею. Я заметил, что неправильное использование памяти или забывание освободить память мне гораздо легче обнаружить в Zig, чем в любом другом языке, который я использовал с ручным управлением памятью, таком как C/C++.

Правильный способ решения этой проблемы — передать dict в качестве указателя.

fn releaseDict(allocator: Allocator, dict: *Dict(i16)) void {
    var iter = dict.iterator();
    while (iter.next()) |entry|
        allocator.free(entry.key_ptr.*);
    dict.deinit();    
}

Хотя такое поведение немного раздражает, я вижу преимущество в том, что параметры должны быть константами. Такой выбор позволяет передавать структуры наиболее оптимальным образом. Компилятор может скопировать структуру или передать ее по ссылке. В C++ у нас нет этого удобства, которое заставляет вас писать что-то вроде const &Dict dict. Для кода на основе шаблона это неоптимально, поскольку вы не знаете, насколько большим будет тип, который вы передаете. Если это примитивный тип, такой как целое число, то передача по ссылке неэффективна.

Ресурсы Zig

  • Gamedev Guide от Ikrima с отличными ресурсами Zig.
  • Ускоренный курс по Zig — предназначен для опытных программистов или тех, кто просто хочет обновить Zig и ускорить его работу.
  • Comptime in Zig — Отличное освещение выполнения кода во время компиляции в Zig. Предоставляет разработчикам Zig возможности метапрограммирования.
  • Building Zig code — Настройка файла build.zig, используемого для указания проекта Zig.
  • Временной код компиляции Лориса Кро. Содержит интересный пример кода, показывающий, как можно использовать Zig comptime для удаления комбинированного оператора for-loop и оператора switch во время выполнения. Он показывает, как вы можете создать массив операций для применения к некоторым входным данным и превратить все это в серию операторов.
  • Идеальное хеширование Эндрю Келли. У создателя Zig есть увлекательный пример использования кода времени компиляции для создания идеальной функции хеширования. Это немного более сложный пример, но он действительно помогает понять, что функции Zig comptime позволяют вам делать.
  • Интерфейсы Zig — Обновлено для использования толстых указателей, подобных Go.