Всем привет!

В этой статье я хочу рассказать о том, как учеба в (моём случае техническом) ВУЗе может повлиять на амбиции и подход к своему хобби, при этом не всегда имея прямую связь. В пример буду ставить свой проект: приложение для моделирования физических процессов.

Введение

Я считаю, верным подходом будет сначала заглянуть в прошлое и рассказать о том, как до этого я подходил к программированию.

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

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

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

Грубо говоря можно его разбить на несколько пунктов:

  • Идея. С неё все и начинается, как ни странно

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

  • Воплощение идеи в жизнь. На данном этапе и появляется тот продукт, который когда‑то был просто мыслью.

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

Дальнейшее повествование я и разобью на эти пункты.

Пункт первый: Идея

На первом курсе у меня было множество разных дисциплин: физика, неорганическая химия, инженерная графика и другие. Тогда я начал восхищаться тем, как человек смог добиться такого понимания мира и серьёзно заинтересовался техническим подходом. В тот момент возник вопрос: что я могу сделать, имея такие знания? В голову пришла мысль: написать собственное приложение для моделирования физических процессов. Да, задача крайне амбициозная, но такие амбиции требуют соответствующей им организации.

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

Идея, конечно, тоже не нова, но гораздо более уникальная, чем то, что я писал до этого. Тогда уже было ясно: будет множество моментов, которые требует разработки и доработки, чтобы это можно было реализовать, поэтому за этим последовало...

Пункт второй: Планирование и совершенствование

Планирование архитектуры

Первое, что встало для меня на пути: а как мне следует это реализовывать? То есть какая архитектура будет у программы. И с этого момента всегда начинается выбор наиболее подходящего варианта, ведь любое решение имеет свои плюсы и минусы.

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

Итоговую архитектуру можно изобразить так(временные названия)
Итоговую архитектуру можно изобразить так(временные названия)

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

Почему так?

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

Язык программирования

Следующий важный выбор это на чём же писать?

Для этого проекта я перепробовал множество языков программирования и в своём выборе пользовался несколькими критериями(один из которых просто основан на моей вкусовщине):

  • Возможная скорость программы и вычислений. Всё‑таки это остаётся важным пунктом, так как это та программа, где важно минимизировать траты системных ресурсов.

  • Простота написания и поддержки кода. Крайне важно, чтобы ваша программа не причиняла трудности в тот момент, когда вы хотите что‑то исправить.

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

В ходе долгих размышлений о том, что лучше я решил остановиться на языке программирования Zig, но почему?

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

Пункт третий: реализация

На данный момент готова лишь первая библиотека, но и про неё есть, что рассказать.

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

Я решил реализовать это следующим образом:

Скрытый текст
// 1. Define a user option (e.g., -Duse_f64=true), defaulting to false
const use_f64 = b.option(bool, "use_f64", "Use f64 instead of f32 for precision") orelse false;
const use_f128 = b.option(bool, "use_f128", "Use f128 instead of f32 for precision") orelse false;
const mathfn = b.option(bool, "mathfn", "compile with mathfn module") orelse false;

// 2. Create an OptionsStep and add our option
const options = b.addOptions();
options.addOption(bool, "use_f64", use_f64);
options.addOption(bool, "use_f128", use_f128);
options.addOption(bool, "mathfn", mathfn);

......

mod.addOptions("cfg", options);

То есть при сборке библиотеки нужно выбрать соответствующий тип.

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

Tokenizer.zig — код токенайзера
pub const TokenType = enum {
    Number,
    Identifier,
    Plus,
    Minus,
    Mul,
    Div,
    Pow,
    LParen,
    RParen,
    Comma,
    EOF,
};

pub const Token = struct {
    tok_type: TokenType,
    value: []const u8,
};

pub const Tokenizer = struct {
    input: []const u8,
    pos: usize,
    current_tok: Token,

    pub fn init(alloc: std.mem.Allocator, input: []const u8) !Tokenizer {
        const clean_str = try remove_whitespaces(alloc, input);
        var t = Tokenizer{
            .input = clean_str,
            .pos = 0,
            .current_tok = undefined,
        };
        t.advance();
        return t;
    }

    pub fn advance(self: *Tokenizer) void {
        if (self.pos >= self.input.len) {
            self.current_tok = .{ .tok_type = .EOF, .value = "" };
            return;
        }

        const start = self.pos;
        const char = self.input[start];

        if (ascii.isDigit(char) or char == '.') {
            make_num(self, start);
        } else if (ascii.isAlphabetic(char) or char == '_') {
            make_identifier(self, start);
        } else {
            make_symbol(self, char);
        }
    }

    pub fn deinit(self: *Tokenizer, alloc: std.mem.Allocator) !void {
        return alloc.free(self.input);
    }

    fn make_num(self: *Tokenizer, start: usize) void {
        while (self.pos < self.input.len and (std.ascii.isDigit(self.input[self.pos]) or self.input[self.pos] == '.')) : (self.pos += 1) {}
        self.current_tok = .{
            .tok_type = .Number,
            .value = self.input[start..self.pos],
        };
    }

    fn make_identifier(self: *Tokenizer, start: usize) void {
        while (self.pos < self.input.len and (std.ascii.isAlphanumeric(self.input[self.pos]) or self.input[self.pos] == '_')) : (self.pos += 1) {}
        self.current_tok = .{
            .tok_type = .Identifier,
            .value = self.input[start..self.pos],
        };
    }

    fn make_symbol(self: *Tokenizer, c: u8) void {
        self.pos += 1;
        switch (c) {
            '+' => self.current_tok = .{ .tok_type = .Plus, .value = "+" },
            '-' => self.current_tok = .{ .tok_type = .Minus, .value = "-" },
            '*' => self.current_tok = .{ .tok_type = .Mul, .value = "*" },
            '/' => self.current_tok = .{ .tok_type = .Div, .value = "/" },
            '^' => self.current_tok = .{ .tok_type = .Pow, .value = "^" },
            '(' => self.current_tok = .{ .tok_type = .LParen, .value = "(" },
            ')' => self.current_tok = .{ .tok_type = .RParen, .value = ")" },
            ',' => self.current_tok = .{ .tok_type = .Comma, .value = "," },
            else => self.current_tok = .{ .tok_type = .EOF, .value = "" },
        }
    }
};
Parser.zig — код самого парсера
pub const Node = struct {
    kind: Kind,
    data: Data,

    pub const Kind = enum { Number, Identifier, BinaryOp, UnaryOp, FunctionCall };
    pub const Data = union(enum) {
        Number: Float,
        Identifier: []const u8,
        BinaryOp: struct { op: Op, left: *Node, right: *Node },
        UnaryOp: struct { op: Op, operand: *Node },
        FunctionCall: struct { name: []const u8, args: []*Node },
    };

    fn deinit(self: *Node, alloc: std.mem.Allocator) void {
        switch (self.*.data) {
            .Number => {},
            .Identifier => {},
            .BinaryOp => |str| {
                str.left.deinit(alloc);
                alloc.destroy(str.left);
                str.right.deinit(alloc);
                alloc.destroy(str.right);
            },
            .UnaryOp => |str| {
                str.operand.deinit(alloc);
                alloc.destroy(str.operand);
            },
            .FunctionCall => |str| {
                for (str.args) |arg| {
                    arg.deinit(alloc);
                    alloc.destroy(arg);
                }
            },
        }
    }

    pub fn cleanup(self: *Node, alloc: std.mem.Allocator) void {
        self.deinit(alloc);
        alloc.destroy(self);
    }
};

pub const ParseError = error{
    InvalidNumber,
    UnexpectedToken,
    MissingRParen,
    DivisionByZero,
    UnknownVariable,
    UnknownFunction,
    InvalidFunctionArgs,
    OutOfMemory,
};

pub const Parser = struct {
    t: *tokenizer.Tokenizer,

    pub fn init(tz: *tokenizer.Tokenizer) !Parser {
        return .{ .t = tz };
    }

    pub fn parseExpression(self: *Parser, alloc: std.mem.Allocator) ParseError!*Node {
        return self.parseAddition(alloc);
    }

    fn parseAddition(self: *Parser, alloc: std.mem.Allocator) ParseError!*Node {
        var left = try self.parseMultiplication(alloc);

        while (self.t.current_tok.tok_type == .Plus or self.t.current_tok.tok_type == .Minus) {
            const op_token = self.t.current_tok;
            self.t.advance();
            const right = try self.parseMultiplication(alloc);
            const op = if (op_token.tok_type == .Plus) Op.Add else Op.Sub;
            left = self.makeNode(alloc, .BinaryOp, .{ .BinaryOp = .{ .op = op, .left = left, .right = right } }) catch {
                return error.OutOfMemory;
            };
        }

        return left;
    }

    fn parseMultiplication(self: *Parser, alloc: std.mem.Allocator) ParseError!*Node {
        var left = try self.parsePower(alloc);

        while (self.t.current_tok.tok_type == .Mul or self.t.current_tok.tok_type == .Div) {
            const op_token = self.t.current_tok;
            self.t.advance();
            const right = try self.parsePower(alloc);
            const op = if (op_token.tok_type == .Mul) Op.Mul else Op.Div;
            left = self.makeNode(alloc, .BinaryOp, .{ .BinaryOp = .{ .op = op, .left = left, .right = right } }) catch {
                return error.OutOfMemory;
            };
        }

        return left;
    }

    fn parsePower(self: *Parser, alloc: std.mem.Allocator) ParseError!*Node {
        const base = try self.parseUnary(alloc);
        if (self.t.current_tok.tok_type == .Pow) {
            self.t.advance();
            const exp = try self.parsePower(alloc); // Right-associative
            return self.makeNode(alloc, .BinaryOp, .{ .BinaryOp = .{ .op = Op.Pow, .left = base, .right = exp } }) catch {
                return error.OutOfMemory;
            };
        }
        return base;
    }

    fn parseUnary(self: *Parser, alloc: std.mem.Allocator) ParseError!*Node {
        if (self.t.current_tok.tok_type == .Plus or self.t.current_tok.tok_type == .Minus) {
            const op_token = self.t.current_tok;
            self.t.advance();
            const operand = try self.parseUnary(alloc);

            if (op_token.tok_type == .Minus) {
                return self.makeNode(alloc, .UnaryOp, .{ .UnaryOp = .{ .op = Op.Neg, .operand = operand } }) catch {
                    return error.OutOfMemory;
                };
            }

            return operand;
        }

        return self.parsePrimary(alloc);
    }

    fn parsePrimary(self: *Parser, alloc: std.mem.Allocator) ParseError!*Node {
        const tok = self.t.current_tok;
        switch (tok.tok_type) {
            .Number => {
                self.t.advance();
                const val = std.fmt.parseFloat(Float, tok.value) catch return error.InvalidNumber;
                return self.makeNode(alloc, .Number, .{ .Number = val }) catch {
                    return error.OutOfMemory;
                };
            },
            .Identifier => {
                self.t.advance();
                if (self.t.current_tok.tok_type == .LParen) {
                    self.t.advance();
                    return self.parseFunctionCall(alloc, tok.value);
                }

                return self.makeNode(alloc, .Identifier, .{ .Identifier = tok.value }) catch {
                    return error.OutOfMemory;
                };
            },
            .LParen => {
                self.t.advance();
                const expr = try self.parseExpression(alloc);
                if (self.t.current_tok.tok_type != .RParen) return error.MissingRParen;
                self.t.advance();
                return expr;
            },
            else => return error.UnexpectedToken,
        }
    }

    fn parseFunctionCall(self: *Parser, alloc: std.mem.Allocator, name: []const u8) ParseError!*Node {
        var args_list = std.ArrayList(*Node).empty;
        defer args_list.deinit(alloc);

        if (self.t.current_tok.tok_type != .RParen) {
            args_list.append(alloc, try self.parseExpression(alloc)) catch {
                return error.OutOfMemory;
            };
            while (self.t.current_tok.tok_type == .Comma) {
                self.t.advance();
                args_list.append(alloc, try self.parseExpression(alloc)) catch {
                    return error.OutOfMemory;
                };
            }
        }
        if (self.t.current_tok.tok_type != .RParen) return error.MissingRParen;
        self.t.advance();

        const args = alloc.dupe(*Node, args_list.items) catch {
            return error.OutOfMemory;
        };
        return self.makeNode(alloc, .FunctionCall, .{ .FunctionCall = .{ .name = name, .args = args } }) catch {
            return error.OutOfMemory;
        };
    }

    inline fn makeNode(self: *Parser, alloc: std.mem.Allocator, kind: Node.Kind, data: Node.Data) !*Node {
        _ = self;
        const node = try alloc.create(Node);
        node.* = .{ .kind = kind, .data = data };
        return node;
    }
};
Eval.zig — код произведения вычислений
const isMathFnEnabled = @import("cfg").mathfn;
const Mathfn = if (isMathFnEnabled) @import("mathfn/mathfn.zig") else struct {};
const Fun = if (isMathFnEnabled) Mathfn.Fun else struct {};

pub fn evaluate(alloc: std.mem.Allocator, node: *parser.Node, variables: *const std.StringHashMap(Float), funcs: *std.StringHashMap(Fun)) parser.ParseError!Float {
    switch (node.data) {
        .Number => |v| return v,
        .Identifier => |name| {
            return variables.get(name) orelse return error.UnknownVariable;
        },
        .BinaryOp => |b| {
            const l = try evaluate(alloc, b.left, variables, funcs);
            const r = try evaluate(alloc, b.right, variables, funcs);
            switch (b.op) {
                .Add => return l + r,
                .Sub => return l - r,
                .Mul => return l * r,
                .Div => {
                    if (r == 0) return error.DivisionByZero;
                    return l / r;
                },
                .Pow => return std.math.pow(Float, l, r),
                else => unreachable,
            }
        },
        .UnaryOp => |u| {
            const val = try evaluate(alloc, u.operand, variables, funcs);
            switch (u.op) {
                .Neg => return -val,
                else => return val,
            }
        },
        .FunctionCall => |fc| {
            // Temporary buffer for evaluated arguments
            const args = std.heap.page_allocator.alloc(Float, fc.args.len) catch {
                return error.UnexpectedToken;
            };
            defer std.heap.page_allocator.free(args);
            for (fc.args, 0..) |arg, i| {
                args[i] = try evaluate(alloc, arg, variables, funcs);
            }

            return callfn(alloc, fc.name, args, funcs);
        },
    }
}

fn callfn(alloc: std.mem.Allocator, name: []const u8, args: []Float, funcs: *std.StringHashMap(Fun)) parser.ParseError!Float {
    if (is_builtin(name)) {
        return callBuiltin(name, args);
    }
    if (!isMathFnEnabled) {
        std.debug.print("yes", .{});
        return error.UnknownFunction;
    }

    if (!funcs.contains(name)) {
        std.debug.print("yes", .{});
        return error.UnknownFunction;
    }

    const fun = funcs.getPtr(name);
    if (fun) |callable| {
        var map_args = try build_args(alloc, args, callable.*);
        defer map_args.deinit();
        return callable.call(alloc, &map_args, funcs);
    }

    return error.UnknownFunction;
}

fn build_args(alloc: std.mem.Allocator, args: []Float, fun: Fun) !std.StringHashMap(Float) {
    var i: usize = 0;
    var map = std.StringHashMap(Float).init(alloc);
    for (fun.args_names.items) |name| {
        try map.put(name, args[i]);
        i += 1;
    }
    return map;
}

fn is_builtin(name: []const u8) bool {
    if (std.mem.eql(u8, name, "sin")) {
        return true;
    } else if (std.mem.eql(u8, name, "cos")) {
        return true;
    } else if (std.mem.eql(u8, name, "sqrt")) {
        return true;
    } else if (std.mem.eql(u8, name, "abs")) {
        return true;
    } else if (std.mem.eql(u8, name, "log")) {
        return true;
    }

    return false;
}

fn callBuiltin(name: []const u8, args: []const Float) parser.ParseError!Float {
    if (std.mem.eql(u8, name, "sin") and args.len == 1) return @sin(args[0]);
    if (std.mem.eql(u8, name, "cos") and args.len == 1) return @cos(args[0]);
    if (std.mem.eql(u8, name, "sqrt") and args.len == 1) return @sqrt(args[0]);
    if (std.mem.eql(u8, name, "abs") and args.len == 1) return @abs(args[0]);
    if (std.mem.eql(u8, name, "log") and args.len == 1) return @log(args[0]);
    return error.UnknownFunction;
}

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

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

Подробно код можно посмотреть здесь: Mukas / mathexpr · GitLab

Заключение

Подводя итоги, хочу ещё раз сказать, как сильно влияет профессиональное образование на наши хобби. Я бы сказал, что именно оно и влияет на то, кем мы становимся и что делаем. Моя история служит тому примером.

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

Комментарии (2)


  1. andrejstrogonov
    13.07.2026 13:53

    Почему именно Zig,а не Rust,С++,Go?


  1. IVA48
    13.07.2026 13:53

    Писать можно статьи, комментарии, письма любимой или другу, а серьезное программное обеспечение для ЭВМ разрабатывается 1в1 как техническое изделие со всеми ему присущими этапами. То есть это давно уже чисто инженерная сфера деятельности, а создание программного кода как один из этапов разработки. Если вы по основной специальности инженер-физик или химик, то максимум что можете сделать, так это для своей конкретной задачи разработать относительно небольшую программу вычислительного характера с простым интерфейсом для ввода данных и вывода результата в графическом виде. То есть то, чему вас учили по вузовскому курсу информатики и программирования. Разработка же серьезных программных комплексов это коллективный труд разных специалистов, измеряемый десятками и сотнями человеко-лет. Класс разработчика определяется не знанием большого кол-ва конкретных языков программирования и IDE, а системным мышлением и практическим опытом разработки серьезного ПО.