Всем привет!
В этой статье я хочу рассказать о том, как учеба в (моём случае техническом) ВУЗе может повлиять на амбиции и подход к своему хобби, при этом не всегда имея прямую связь. В пример буду ставить свой проект: приложение для моделирования физических процессов.
Введение
Я считаю, верным подходом будет сначала заглянуть в прошлое и рассказать о том, как до этого я подходил к программированию.
До поступления на свою специальность я писал совсем непримечательные приложения, такие как всяческие списки дел, простые игры и скрипты. Мне нравился процесс, но по сути на тот момент он не выходил за шаблон. Я писал те программы, решения к которым уже были известны давно и всё их написание сводилось к тем же самым решениям.
Охарактеризовать это я могу как обычное хобби сравнимое с обычной игрой на гитаре, к примеру. Я имею в виду, что когда ты играешь для себя тебе необязательно стремиться к чему‑то новому и совершенствовать свою игру, можно играть одни и те же песни и получать удовольствие. Такой подход имеет место быть, ведь главный смысл хобби это делать то, что приносит тебе радость.
Но всё сильно изменилось, когда я поступил на первый курс специальности, связанной с технологией ядерного топливного цикла. Тогда я постепенно начал осознавать, что любой продукт за собой несёт не просто идею и желание сделать это, а целый ряд трудоёмких действий. Стало понятно: чтобы воплотить свою задумку в жизнь нужно пройти серьёзный мыслительный процесс.
Грубо говоря можно его разбить на несколько пунктов:
-
Идея. С неё все и начинается, как ни странно
-
Планирование и совершенствование. Под этим пунктом я подразумеваю превращение простой идеи в жизнеспособную реализацию на бумаге и обдумывание того, что в конечном продукте должно быть, чтобы всё это действительно можно было воплотить в жизнь
-
Воплощение идеи в жизнь. На данном этапе и появляется тот продукт, который когда‑то был просто мыслью.
Конечно, это очень грубое упрощение. В каждый пункт можно добавить несколько подпунктов, но здесь мы будем придерживаться и такой простой модели.
Дальнейшее повествование я и разобью на эти пункты.
Пункт первый: Идея
На первом курсе у меня было множество разных дисциплин: физика, неорганическая химия, инженерная графика и другие. Тогда я начал восхищаться тем, как человек смог добиться такого понимания мира и серьёзно заинтересовался техническим подходом. В тот момент возник вопрос: что я могу сделать, имея такие знания? В голову пришла мысль: написать собственное приложение для моделирования физических процессов. Да, задача крайне амбициозная, но такие амбиции требуют соответствующей им организации.
Первоначально идея звучала так: написать программу, где пользователь сможет заносить нужные формулы, проводить с ними нужные действия и смотреть на результат в виде графика или численных значений для начала.
Идея, конечно, тоже не нова, но гораздо более уникальная, чем то, что я писал до этого. Тогда уже было ясно: будет множество моментов, которые требует разработки и доработки, чтобы это можно было реализовать, поэтому за этим последовало...
Пункт второй: Планирование и совершенствование
Планирование архитектуры
Первое, что встало для меня на пути: а как мне следует это реализовывать? То есть какая архитектура будет у программы. И с этого момента всегда начинается выбор наиболее подходящего варианта, ведь любое решение имеет свои плюсы и минусы.
Здесь стояло на выбор пара вариантов: писать монолитное приложение или вынести разные компоненты приложения в отдельные библиотеки. Тут уже можно сказать о том, что оба этих варианта имеют свои преимущества. Например, если выбрать монолитное приложение, то разработка будет сама по себе проще, так как все лежит в одном месте, да и тестировать такое будет легче. Но это только по началу, а затем можно будет столкнуться с множеством проблем: от сложности расширения, до полного бардака в реализации. Второй вариант, который я и выбрал имеет сразу несколько преимуществ: я могу отдельно писать куски логики и уже получать первые её версии, а также простота интегрирования одной части в другую. К тому же я смотрел на данный продукт, как тот, который будет очень часто обновляться и далеко не всегда равномерно, что теоретически позволило бы пользователю получить маленький апдейт только одной части программы и не тянуть за собой еще кучу всего.
То есть у нас есть изначальная библиотека, которая умеет парсить и считать математически выражения и функции. От неё зависят: библиотека для матанализа и физическая библиотека. При этом они обе могут использоваться друг в друге. Затем уже зависящая от них(но также может зависеть только от первоначальной библиотеки) библиотека для рисования графиков. А затем весь этот набор идет в само приложение.
Почему так?
Мне важно было сделать так, чтобы пользователь, которому нужна только часть функционала, получал именное её. Да и эти библиотеки в дальнейшем можно будет использовать отдельно от приложения.
Язык программирования
Следующий важный выбор это на чём же писать?
Для этого проекта я перепробовал множество языков программирования и в своём выборе пользовался несколькими критериями(один из которых просто основан на моей вкусовщине):
-
Возможная скорость программы и вычислений. Всё‑таки это остаётся важным пунктом, так как это та программа, где важно минимизировать траты системных ресурсов.
-
Простота написания и поддержки кода. Крайне важно, чтобы ваша программа не причиняла трудности в тот момент, когда вы хотите что‑то исправить.
-
Парадигма языка и его новизна. Этот критерий по сути крайне нелогичен, но тут уже хотелось просто сделать такой выбор, чтобы было крайне приятно писать то, что является по сути моим хобби.
В ходе долгих размышлений о том, что лучше я решил остановиться на языке программирования 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
Заключение
Подводя итоги, хочу ещё раз сказать, как сильно влияет профессиональное образование на наши хобби. Я бы сказал, что именно оно и влияет на то, кем мы становимся и что делаем. Моя история служит тому примером.
Про свой проект хочу сказать, что хоть он и не идеален и даже еще и не близок к тому, чтобы быть законченным, но это ценный опыт, который я получаю. Хочу сказать: никогда не бойтесь реализовывать свои идеи, в этом деле всегда важно начать. Даже просто с ручки и бумаги
Автор: mvkas
