Zig позволяет запускать код во время компиляции. Каковы последствия?

Вычисления во время компиляции впервые появились в языке программирования Lisp в 1960-х годах. Вычисления во время компиляции означают, что код, который вы позже скомпилируете, — это не только то, что вы записали. То, что вы компилируете позже, также является кодом, который «написан» вашим кодом. Код генерации кода. Хотя это обычная функция в динамически типизированных языках, таких как Lisp и Julia, она была редкостью в статически типизированных языках системного программирования, таких как C. C ++ постепенно развивал эту способность, но часто в ограниченном или неуклюжем виде.

Таким образом, я решил, что при обсуждении вычислений во время компиляции на языке со статической типизацией я выберу Zig в качестве примера языка. Почему не более известный язык? Zig очень близок к C и решил сосредоточить язык на вычислениях во время компиляции. Для C++ это больше похоже на что-то прикрученное. Вы вполне можете писать код на C++, даже не прибегая к вычислениям во время компиляции. Zig переворачивает сценарий и делает вычисления во время компиляции одной из самых центральных и хорошо поддерживаемых функций языка. Вот почему Zig — отличный язык для изучения концепции. В мире Zig мы называем вычисления во время компиляции comptime по ключевому слову, используемому для обозначения кода, который должен выполняться во время компиляции, или переменных, которые должны быть известны во время компиляции.

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

Позвольте мне показать вам несколько примеров кода, чтобы лучше объяснить, в чем заключается вся идея и почему это важно. Рассмотрим следующую простую функцию, чтобы найти максимум двух значений a и b. Без дженериков или кода comptime нам пришлось бы запрограммировать такую ​​функцию для работы с определенными типами переменных, такими как 32-битные целые числа, называемые i32 в Zig.

fn maximum(a: i32, b: i32) i32 {
    var result: i32 = undefined;

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

Обычно исполняемая программа в Zig будет иметь функцию main, точно так же, как программа C/C++. Оттуда мы можем вызвать нашу функцию maximum. В следующем примере кода не обращайте слишком много внимания на то, как мы получаем stdout или почему нам нужно ставить перед нашим вызовом функции print ключевое слово try. Последнее связано с обработкой ошибок Zig, которую мы не будем рассматривать в этой статье.

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const a = 10;
    const b = 5;

    const biggest = maximum(a, b);

    try stdout.print("Max of {} and {} is {}\n", 
                     .{ a, b, biggest });
}

Очевидно, что данное решение является весьма ограничивающим. maximum работает только с 32-битными целыми числами. Программисты на C хорошо знакомы с этой проблемой. В мире программистов на языке C на помощь приходят макросы препроцессора C. Однако Эндрю Келли разработал Zig специально, чтобы не полагаться на макросы в стиле C. Фактически, вся причина существования Zig заключается в том, что Эндрю просто хотел программировать на C, но без таких плохих частей, как макросы. comptime возник именно для замены C-макросов.

Давайте посмотрим на решение этой проблемы с помощью Zig. Мы определим общую функцию maxiumum в Zig. Аргументы типа i32 будут заменены на anytype и @TypeOf(a). В момент вызова функции maximum anytype примет тип предоставленного аргумента. Имейте в виду, что мы имеем дело не с динамическим языком программирования. Вместо этого Zig будет компилировать разные варианты maximum для каждого случая, где maximum вызывается с другим набором типов аргументов. Тип a и b по-прежнему определяется во время компиляции, а не во время выполнения.

Хотя можно определить тип входного аргумента во время компиляции, сделать это для переменной или возвращаемого типа сложнее. Вы не можете указать, что возвращаемый тип — anytype, потому что конкретный тип не может быть определен на сайте вызова. Вместо этого мы используем встроенную функцию компилятора @TypeOf, которая запускается во время компиляции для получения возвращаемого типа. @TypeOf(a) оценивает тип параметра a во время компиляции. Мы используем тот же трюк, чтобы указать тип переменной result.

fn maximum(a: anytype, b: anytype) @TypeOf(a) {
    var result: @TypeOf(a) = undefined;

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

Хотя это решение является улучшением, оно имеет ряд проблем:

  1. Ничто не мешает вам вызывать maximum со значениями, которые не являются числами.
  2. Если b является большим значением, оно может содержать значение, которое требует больше битов, чем может содержать тип @TypeOf(a).

Чтобы проверить, имеют ли a и b правильный тип, мы можем создать функцию, которая будет запускаться во время компиляции и проверяет, является ли тип числом. Давайте определим функцию assertNumber с одним параметром T, представляющим тип, а не значение. Определению параметра предшествует ключевое слово comptime, сообщающее компилятору, что аргумент должен быть известен во время компиляции.

Также обратите внимание на оператор switch-case. В Zig switch-case может возвращать значения. Включаем аргумент типа T. Если T соответствует числовому типу, оператор switch-case возвращает true, который присваивается переменной is_num. В противном случае мы используем ключевое слово else для возврата false по умолчанию.

fn assertNumber(comptime T: type) void {
    const is_num = switch (T) {
        i8, i16, i32, i64 => true,
        u8, u16, u32, u64 => true,
        comptime_int, comptime_float => true,
        f16, f32, f64 => true,
        else => false,
    };

    if (!is_num) {
        @compileError("Inputs must be numbers");
    }
}

// testing function
pub fn main() !void {
    assertNumber(bool);
}

Особый интерес в этом определении функции представляет встроенная функция компилятора @compileError. Он используется для отправки ошибок компилятора пользователю. В этом примере кода я предоставляю нечисловой тип в качестве аргумента toassertNumber. bool чтобы быть точным. Если вы попытаетесь скомпилировать эту программу, вы получите следующие сообщения об ошибках:

assert-number.zig:11:9: error: Inputs must be numbers
        @compileError("Inputs must be numbers");
        ^
assert-number.zig:17:17: note: called from here
    assertNumber(bool);
                ^
assert-number.zig:16:21: note: called from here
pub fn main() !void {

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

Мы можем использовать assertNumber для проверки входных данных нашей функции maximum. Чтобы убедиться, что тип возвращаемого значения достаточно велик, нам потребуется, чтобы оба входа были одного типа.

fn maximum(a: anytype, b: anytype) @TypeOf(a) {
    const A = @TypeOf(a);
    const B = @TypeOf(b);

    assertNumber(A);
    assertNumber(B);

    var result: @TypeOf(a) = undefined;

    if (A != B) {
        @compileError("Inputs must be of the same type");
    }

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

Когда maximum вызывается во время выполнения, весь код времени компиляции уже запущен и заменен своим результатом.

Текущее решение не решает всех проблем с нашим первоначальным наивным решением. Мы вынуждены сделать параметры a и b одного типа. Что, если мы хотим разрешить как 8-битный, так и 32-битный целочисленный аргумент со знаком? В Zig это будут аргументы типа i8 и i32. В этом случае мы должны убедиться, что возвращаемый тип — i32. Наше текущее решение этого не делает. Нам нужна функция, которая запускается во время компиляции и сравнивает типы a и b и возвращает тип с наибольшей разрядностью.

Для этого сделаем ряд функций:

  • Функция nbits для определения количества битов в типе T
  • Функция largestType для выбора наибольшего из двух типов A и B

Обратите внимание, что в следующем примере кода мы помечаем аргументы типа с помощью comptime, чтобы сообщить Zig, что эти входные данные должны быть известны во время компиляции. Мы используем встроенную функцию компилятора @typeInfo, которая во время компиляции возвращает составной объект info, описывающий тип: является ли тип подписанным или беззнаковым? Сколько бит используется для представления типа?

fn nbits(comptime T: type) i8 {
    return switch (@typeInfo(T)) {
        .Float => |info| info.bits,
        .Int => |info| info.bits,
        else => 64,
    };
}

fn largestType(comptime A: type, comptime B: type) type {
    if (nbits(A) > nbits(B)) {
        return A;
    } else {
        return B;
    }
}

fn maximum(a: anytype, b: anytype) largestType(@TypeOf(a),
                                               @TypeOf(b)) {
    var result: @TypeOf(a) = undefined;

    if (a > b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

Оператор switch в приведенном выше примере кода может быть не совсем очевиден. Позвольте мне уточнить. Тип, возвращенный из @typeInfo(T), относится к типу std.builtin.TypeInfo, который представляет собой объединенный тип. Типы Union немного похожи на структуры. У них есть несколько полей, но эти поля совместно используют память. Следовательно, нам нужно выяснить, какое поле на самом деле используется. Switch-case позволяет нам определить, используется ли в настоящее время поле .Int или .Float. Синтаксис |info| используется Zig для развертывания значений. В этом случае мы разворачиваем структуру, описывающую тип.

Объект info будет иметь тип TypeInfo.Int или TypeInfo.Float, однако оба типа struct имеют поле bits.

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

Использование временного кода компиляции для реализации обобщений

Чтобы продемонстрировать, насколько мощным является Zig comptime, я покажу вам, как его можно использовать для реализации дженериков. Здесь мы реализуем функцию minimum, которая выглядит более знакомой разработчикам, привыкшим к дженерикам или программированию на основе шаблонов. Ключевое отличие состоит в том, что аргумент типа T предоставляется как обычный аргумент. Разработчики C++, Java и C# будут вызывать эту функцию, написав что-то вроде minimum<i8>(x, y), а разработчики Zig напишут minimum(i8, x, y).

fn minimum(comptime T: type, a: T, b: T) T {
    assertNumber(T);

    var result: T = undefined;
    if (a < b) {
        result = a;
    } else {
        result = b;
    }

    return result;
}

В таких языках, как C++, Java, C++ и Swift, вы обычно можете вывести тип, просматривая входные аргументы. В Zig такой вывод типа невозможен, потому что параметр T предоставляется как обычный аргумент и, следовательно, не может быть обработан специальным образом. Хотя это ограничение является недостатком comptime по сравнению с дженериками, преимущество заключается в том, что comptime более гибок в том, как вы его используете.

Мы можем использовать код comptime для определения универсальных типов. Я продемонстрирую простой 2D-векторный класс, используемый для представления таких вещей, как сила, скорость или положение.

Тип 2D-вектора

Мы начнем с определения необобщенного составного типа Vector2D, используемого для представления смещения на плоскости. Два поля dx и dy используются для хранения смещения по осям x и y. Значения по умолчанию для каждого поля равны нулю. У нас есть sub и add функций-членов для обработки вычитания и сложения векторов.

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

const Vector2D = struct {
    dx: f64 = 0,
    dy: f64 = 0,

    fn sub(u: Vector2D, v: Vector2D) Vector2D {
        return Vector2D { 
			.dx = u.dx - v.dx,
			.dy = u.dy - v.dy
		};
    }

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

    pub fn format(
        v: Vector2D,
        comptime _: []const u8,
        _: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        try writer.print("Vector2D({}, {})", .{ v.dx, v.dy });
    }
};

Мы можем определить функцию main, используя это определение Vector2D. Обратите внимание, как мы используем {} в качестве заполнителей для значений Vector2D. Благодаря функции format print будет знать, какой текст вставить в {} места-заполнители.

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const u = Vector2D{ .dx = 5, .dy = 4 };
    const v = Vector2D{ .dx = 10, .dy = 2 };

    try stdout.print("Adding u and v gives {}\n", .{u.add(v)});
    try stdout.print("Subtracting u from v gives {}\n", 
                     .{v.sub(u)});
}

Запустив эту программу, мы получим следующий ожидаемый результат:

Adding u and v gives Vector2D(15, 6)
Subtracting u from v gives Vector2D(5, -2)

Проблема с текущим решением заключается в том, что оно жестко привязано к двумерным векторам с 64-битными значениями с плавающей запятой для смещения. Что, если мы хотим использовать целые числа или другую длину в битах? Для этого потребуется дублировать код в нашем текущем решении. Давайте посмотрим, как comptime может помочь нам избежать дублирования кода.

Общий 2D-векторный тип

Чтобы можно было быстро создать тип Vector2D для заданного типа поля T, мы определим функцию Vector2D(T: type), которая принимает параметр типа T в качестве входных данных и возвращает тип 2D-вектора, параметризованный для этого типа. Итак, мы работаем со значениями типа type. Переменная, содержащая тип, имеет тип type. Во время компиляции Zig позволяет нам возвращать или принимать в качестве аргументов такие типы, как структуры. Мы используем эту возможность в приведенном ниже коде. Функция возвращает определение типа struct.

fn Vector2D(comptime T: type) type {
    return struct {
        dx: T = 0,
        dy: T = 0,

        fn sub(u: Vector2D(T), v: Vector2D(T)) Vector2D(T) {
            return Vector2D(T) { 
				.dx = u.dx - v.dx, 
				.dy = u.dy - v.dy 
			};
        }

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

        pub fn format(
            v: Vector2D(T),
            comptime _: []const u8,
            _: std.fmt.FormatOptions,
            writer: anytype,
        ) !void {
            try writer.print("Vector2D({d}, {d})", .{ v.dx, v.dy });
        }
    };
}

Давайте рассмотрим пример использования созданной нами функции Vector2D. Эта функция позволяет нам штамповать новые типы 2D-векторов по запросу. Во время компиляции вызов Vector2D(i8) будет заменен фактическим двумерным векторным типом, имеющим поля dx и dy в виде 8-битных целых чисел со знаком. Следовательно, после вызова кода эти универсальные типы исчезают. Код содержит только конкретные типы.

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const u = Vector2D(i8){ .dx = 5, .dy = 4 };
    const v = Vector2D(i8){ .dx = 10, .dy = 2 };

    try stdout.print("Adding u and v gives {}\n", .{u.add(v)});
    try stdout.print("Subtracting u from v gives {}\n", 
                     .{v.sub(u)});
}

Заключительные замечания

Что я нахожу таким прекрасным в решении Zig comptime, так это то, что разработчики сами могут создавать все эти интересные функции, такие как универсальные типы ad-hoc. Разработчику языка никогда не приходилось явно проектировать язык, чтобы иметь шаблоны. Эндрю Келли просто хотел найти способ заменить макросы C, и все эти вещи выпали как побочный эффект. Я большой поклонник языков с динамической типизацией, таких как Lisp, Julia и Lua. Многое из того, что мне нравится в динамических языках, — это гибкость, которую мы получаем с точки зрения метапрограммирования. Zig привносит это в мир статической типизации благодаря comptime.

Я был разработчиком C++ около 15 лет, и меня всегда беспокоило, что мне приходилось писать так много повторяющегося шаблонного кода, несмотря на то, что я работал с ужасно сложным языком. Меня всегда привлекали простые языки с большой мощностью и гибкостью. Zig делает то, что я считал почти невозможным, а именно, добивается простоты, гибкости и мощности в языке со статической типизацией. Zig — это язык очень низкого уровня, похожий на C, что еще более примечательно тем, что он может иметь такой уровень гибкости.

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