Как обучение в ВУЗе изменило мой подход к программированию: написание собственного приложения для моделирования

от автора

Всем привет!

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

Введение

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Почему так?

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

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

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

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

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

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

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

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

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

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

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

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

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

Скрытый текст
// 1. Define a user option (e.g., -Duse_f64=true), defaulting to falseconst 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 optionconst 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

Заключение

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1058464/