Пользователи постоянно меняют логику подсчёта? Они не довольны скоростью, с которой ты меняешь код? Тебя самого достало, так часто менять одно и тоже? Если да, то вот тебе решение: пускай пользователи сами пишут формулы на языке, который им более знаком: это формулы эксель. Тебе лишь надо научить своё приложение считать это. Как это сделать? С помощью чёрной магии конечно!
И так, предположим, мы хотим использовать у себя следующую формулу:
=ЕСЛИ(A1>A2; "Больше"; "Меньше")
Но у нас же может не быть ячеек A1 и A2, но может быть и да, что угодно может. Поэтому давай честно скажем: A1 и A2 - это переменные, и наша система должна обрабатывать формулу следующего типа:
=ЕСЛИ(Переменная_1>Переменная_2; "Больше"; "Меньше")
Всё, с синтаксисом определились. Функции у нас могут быть любые, желательно как в эксель. Что ещё ? Числа , строки и операторы и вроде всё! Пора заняться чёрной магией ! (если что, весь код есть на github )
И так нам нужны следующие заклинания:
1) Lexer
2) Абстрактное синтаксическое дерево. Далее по тексту AST.
3) Parser
Lexer.
Его задача просто пройтись по тексту и сказать, что вот тут функция , тут строка , оператор и т.д. Такие разметки называют токенами.

Lexer
class Lexer { private int $position = 0; private int $column = 0; private int $row = 0; /** @var Token[] */ private array $tokens = []; /** @var string[] */ protected array $conditionalOperators = ['=', '<', '>']; /** @var string[] */ protected array $operators = ['*', '/', '+', '-', '^']; /** @var string[] */ protected array $spatialSymbol = ['\\', '$']; private string $text; public function __construct() { } /** * @param string $code * * @throws SyntaxError */ public function setCode(string $code): void { $this->text = $code; $this->tokens = $this->parse(); $this->position = 0; $this->column = 0; $this->row = 0; } /** * @return Token[] */ public function getAllTokens(): array { return $this->tokens; } /** * @return Token[] */ private function parse(): array { $result = []; $this->position = 0; while (!$this->isEnd()) { $value = $this->readNext(); if ($value !== null) { $result[] = $value; } } return $result; } private function readNext(): ?Token { while (!$this->isEnd()) { if ($this->isEscaped()) { return $this->readString(); } if ($this->isNumber()) { return $this->readNumber(); } if ($this->isConditionalOperator()) { return $this->readConditionalOperator(); } if ($this->isOperator()) { return $this->readOperator(); } if ($this->isSpatialSymbol()) { return $this->readSpatialVariable(); } if ($this->isVariable()) { return $this->readVariable(); } $currentSymbol = $this->getCurrentSymbol(); if ($currentSymbol === ';') { $result = $this->getToken(TokenType::Separator, $currentSymbol, $this->column); $this->nextSymbol(); return $result; } if ($currentSymbol === '(' || $currentSymbol === ')') { $result = $this->getToken(TokenType::Parentheses, $currentSymbol, $this->column); $this->nextSymbol(); return $result; } if ($currentSymbol !== ' ' && $currentSymbol !== '' && !$this->isNewLine()) { $symbol = $currentSymbol; throw new SyntaxError( "Неизвестный символ: {$symbol}", new Token(TokenType::Unknown, $currentSymbol, $this->row, $this->column) ); } $this->nextSymbol(); } return null; } private function getToken( TokenType $type, mixed $value, int $column, ): Token { return new Token($type, $value, $this->row, $column); } private function getCurrentSymbol(): string { return mb_substr($this->text, $this->position, 1); } private function nextSymbol(): void { $this->position++; if ($this->isNewLine()) { $this->position++; $this->column = 0; $this->row++; } else { $this->column++; } } private function isNewLine(): bool { $currentSymbol = $this->getCurrentSymbol(); if ($currentSymbol == "\n\r" || $currentSymbol == "\n" || $currentSymbol == "\r") { return true; } return false; } private function isNumber(): bool { if (preg_match('/[0-9]/i', $this->getCurrentSymbol())) { return true; } return false; } private function isVariable(): bool { if (preg_match('/[a-zа-я._]/i', $this->getCurrentSymbol())) { return true; } return false; } private function isConditionalOperator(): bool { if (array_search($this->getCurrentSymbol(), $this->conditionalOperators) !== false) { return true; } return false; } private function isOperator(): bool { if (array_search($this->getCurrentSymbol(), $this->operators) !== false) { return true; } return false; } private function isSpatialSymbol(): bool { if (array_search($this->getCurrentSymbol(), $this->spatialSymbol) !== false) { return true; } return false; } private function isEscaped(): bool { $currentSymbol = $this->getCurrentSymbol(); if ($currentSymbol === '"') { return true; } if ($currentSymbol === "'") { return true; } return false; } private function isEnd(): bool { return $this->position >= \strlen($this->text); } private function readString(): Token { $startColumn = $this->column; $startRow = $this->row; if ($this->isEscaped()) { $this->nextSymbol(); } $result = ''; while (!$this->isEscaped() && !$this->isEnd()) { $result .= $this->getCurrentSymbol(); $this->nextSymbol(); } $this->nextSymbol(); return new Token(TokenType::String, $result, $startRow, $startColumn); } private function readNumber(): Token { $hasDot = false; $result = ''; $startColumn = $this->column; $startRow = $this->row; while ($this->isNumber() && !$this->isEnd()) { $result .= $this->getCurrentSymbol(); $this->nextSymbol(); $next = $this->getCurrentSymbol(); if ($next === '.' || $next === ',') { $hasDot = true; $result .= $next; $this->nextSymbol(); } } if ($hasDot) { return new Token(TokenType::Float, (float)$result, $startRow, $startColumn); } return new Token(TokenType::Int, (int)$result, $startRow, $startColumn); } private function readConditionalOperator(): Token { $result = ''; $startColumn = $this->column; $startRow = $this->row; while ($this->isConditionalOperator() && !$this->isEnd()) { $result .= $this->getCurrentSymbol(); $this->nextSymbol(); } return new Token(TokenType::ConditionalOperator, $result, $startRow, $startColumn); } private function readOperator(): Token { $result = ''; $startColumn = $this->column; $startRow = $this->row; while ($this->isOperator() && !$this->isEnd()) { $result .= $this->getCurrentSymbol(); $this->nextSymbol(); } return new Token(TokenType::BinaryOperator, $result, $startRow, $startColumn); } private function readSpatialVariable(): Token { $startColumn = $this->column; $startRow = $this->row; if ($this->isSpatialSymbol()) { $this->nextSymbol(); } $result = ''; while (!$this->isSpatialSymbol() && !$this->isEnd()) { $result .= $this->getCurrentSymbol(); $this->nextSymbol(); } $this->nextSymbol(); return new Token(TokenType::Variable, $result, $startRow, $startColumn); } private function readVariable(): Token { $result = ''; $isFunction = false; $startColumnt = $this->column; $startRow = $this->row; while (!$this->isEnd()) { $currentSymbol = $this->getCurrentSymbol(); if ($currentSymbol === '(') { $isFunction = true; $this->nextSymbol(); break; } if (!$this->isVariable() && !$this->isNumber()) { break; } $result .= $currentSymbol; $this->nextSymbol(); } if ($isFunction) { return new Token(TokenType::Function, strtoupper($result), $startRow, $startColumnt); } return new Token(TokenType::Variable, $result, $startRow, $startColumnt); } }
Token
class Token { public function __construct( public TokenType $type, public mixed $value, public int $row = 0, public int $column = 0, ) { } }
TokenType
enum TokenType: string { case String = 'string'; case Int = 'int'; case Float = 'float'; case Function = 'function'; case ConditionalOperator = 'conditionalOperator'; case BinaryOperator = 'operator'; case Variable = 'variable'; case Parentheses = 'parentheses'; case Separator = 'separator'; case Unknown = 'unknown'; }
AST
И так, давай посмотри ещё раз на нашу формулу:
=ЕСЛИ(Переменная_1>Переменная_2; "Больше"; "Меньше")
По сути, это выражение, состоящее из других выражений. В нашем случае, выражение "ЕСЛИ" имеет следующую структуру:
ЕСЛИ(Выражение; Выражение; Выражение)
AST - когда мы каждое такое выражение представляем в виде класса. И так, давай сначала опишем интерфейс нашего выражения:
interface Expression { /** * /** * Подсчитывает и возвращает значение. * * @param ValueRepositoryInterface $repository * * @return mixed */ public function calculate(?ValueRepositoryInterface $repository = null): mixed; }
Как видим, в нём есть функция: calculate, которая должна вернуть посчитанное значение. Она принимает класс репозиторий для поиска значений переменных.
interface ValueRepositoryInterface { /** * Возвращает значение по идентификатору переменной;. * * @param string $identificator * * @return mixed */ public function getValueByIdentifier(string $identificator): mixed; }
Зачем так? Чтобы получать значения только тех переменных, которые нужны, так получается производительней и удобней.
Теперь можно описать каждое наше выражение:
Строка
class StringExpression implements Expression { public function __construct(protected Token $token) { } public function calculate(?ValueRepositoryInterface $repository = null): mixed { return (string)$this->token->value; } }
Переменная
class VariableExpression implements Expression { public function __construct(private string $identifier, protected Token $token) { } public function calculate(?ValueRepositoryInterface $repository = null): mixed { if ($repository === null) { throw new UnsupportedError('Не указан репозиторий для получения значения для переменной'); } return $repository->getValueByIdentifier($this->identifier); } }
Функция ЕСЛИ
class Funif implements Expression { public function __construct( protected Token $token, protected array $args, ) { } public function calculate(?ValueRepositoryInterface $repository = null): mixed { $condition = $this->args[0]->calculate($repository); if ($condition) { return $this->args[1]->calculate($repository); } return $this->args[2]->calculate($repository); } }
Код оператора ">"
class Operator implements Expression { public function __construct( protected Token $token, protected Expression $leftExpression, protected Expression $rightExpression, ) { } public function calculate(?ValueRepositoryInterface $repository = null): mixed { return $left > $riht; } }
Думаю, дополнить своими вам уже не составит труда.
Parser
Осталось дело за малым, преобразовать наши токены в AST. Этим и займется наш parser.
Код Parser
class Parser { // приоритет операторов public const BINOP_PRECEDENCE = [ '-' => 20, '+' => 20, '*' => 40, '/' => 40, '^' => 80, '=' => 20, '<' => 20, '>' => 20, ]; /** @var Token[] */ private $tokens = []; private int $currentPosition = 0; public function __construct( private Lexer $lexer = new Lexer(), private VariableRepositoryInterface $repository = new EmptyVariableRepository(), private FunctionBuilder $functionBuilder = new FunctionBuilder(), ) { } /** * @param string $code * * @return FormulaAST */ public function parse(string $code): ?Expression { $this->lexer->setCode($code); $this->tokens = $this->lexer->getAllTokens(); return $this->parseExpression(); } private function nextToken(): void { $this->currentPosition++; } /** * получить приоритет текущего оператора. */ private function getTokPrecedence(Token $operatorToken): int { $operator = (string)$operatorToken->value; if (!\array_key_exists($operator, self::BINOP_PRECEDENCE)) { return -1; } return self::BINOP_PRECEDENCE[$operator]; } private function isEnd(): bool { if ($this->currentPosition >= \count($this->tokens)) { return true; } return false; } private function getCurrentToken(): ?Token { if ($this->currentPosition >= \count($this->tokens)) { return null; } return $this->tokens[$this->currentPosition]; } /** * Распарсить текущий токен. */ private function parsePrimary(): ?Expression { $token = $this->getCurrentToken(); if (!$token) { return null; } switch ($token->type) { case TokenType::String: return $this->parseStringExpr($token); case TokenType::Function: return $this->parseFuntion($token); case TokenType::Variable: return $this->parseVariableExpr($token); case TokenType::Parentheses: if ($token->value === '(') { return $this->parseParenthesesExpr(); } break; } $this->logError('Не ожидаенный тип токена: ' . $token->value, $token); return null; } private function parseStringExpr(Token $currentToken): StringExpression { $this->nextToken(); return new StringExpression($currentToken); } private function parseVariableExpr(Token $currentToken): VariableExpression { $identifier = $this->repository->getIdentifierByName((string)$currentToken->value); $this->nextToken(); return new VariableExpression($identifier, $currentToken); } private function parseFuntion(Token $fun): AbstractFunction { $this->nextToken(); $args = []; while (!$this->isEnd()) { $token = $this->getCurrentToken(); if ($token === null) { break; } if ($token->type === TokenType::Parentheses && $token->value === ')') { $this->nextToken(); break; } if ($token->type === TokenType::Separator) { $this->nextToken(); continue; } $expression = $this->parseExpression(); if (!$expression) { break; } $args[] = $expression; $token = $this->getCurrentToken(); if ($token == null) { $this->logError('Ожидается ")" в конце '); break; } if ($token->type !== TokenType::Separator && $token->type !== TokenType::Parentheses) { $this->logError('Ожидается ")" или ";". Дано: ' . $token->value, $token); } } return $this->functionBuilder->build($fun, $args); } private function parseParenthesesExpr(): ?Expression { $this->nextToken(); $expression = $this->parseExpression(); if (!$expression) { return null; } $token = $this->getCurrentToken(); if ($token == null || ($token->type != TokenType::Parentheses && $token->value !== ')')) { $this->logError('Ожидается ")"', $token); return null; } $this->nextToken(); return $expression; } private function parseExpression(): ?Expression { $lhs = $this->parsePrimary(); if ($lhs === null) { return null; } return $this->parseBinOpRHS(0, $lhs); } private function parseBinOpRHS(int $exprPrec, Expression $lhs): ?Expression { while (true) { $operator = $this->getCurrentToken(); if (!$operator) { return $lhs; } $tokPrec = $this->getTokPrecedence($operator); if ($tokPrec < $exprPrec) { return $lhs; } $this->nextToken(); $rhs = $this->parsePrimary(); if (!$rhs) { return null; } $nextToken = $this->getCurrentToken(); if ($nextToken) { $nextPrec = $this->getTokPrecedence($nextToken); if ($tokPrec < $nextPrec) { $rhs = $this->parseBinOpRHS($tokPrec + 1, $rhs); if (!$rhs) { return null; } } } $lhs = new Operator($operator, $lhs, $rhs); } } private function logError(string $error, ?Token $token = null): void { throw new SyntaxError($error, $token); } }
FunctionBuilder
class FunctionBuilder { /** @var array<string, string> */ private array $functions = [ 'ЕСЛИ' => Funif::class, ]; /** * @param Token $token * @param Expression[] $args * * @return AbstractFunction * * @throws SyntaxError */ public function build(Token $token, array $args): AbstractFunction { $funname = $token->value; if (\array_key_exists($funname, $this->functions)) { $className = $this->functions[$funname]; $classObje = new $className($token, $args); if ($classObje instanceof AbstractFunction) { return $classObje; } throw new ASTException("Функция {$funname} не является AbstractFunction"); } throw new SyntaxError('Неизвестная функция: ' . $token->value, $token); } }
Всё... Теперь можно пробовать посчитать нашу формулу, для этого создадим репозиторий:
class ValueRepository implements ValueRepositoryInterface { /** @var array<string, int> */ private array $variables = [ 'Переменная_1' => 3, 'Переменная_2' => 2, ]; public function getValueByIdentifier(string $identificator): mixed { return $this->variables[$identificator]; } }
И, наконец, посчитаем:
$parser = new Parser(); $ast = $parser->parse('ЕСЛИ(Переменная_1>Переменная_2; "Больше"; "Меньше")');// Получили синтаксическое дерево $repository = new ValueRepository();// Наш репозиторий $answer = $ast->calculate($repository); // $answer = "Больше"
Итог:
1) У нас есть эксель-подобный синтаксис, который более понятен конечному пользователю.
2) Значения переменных берутся только когда они действительно нужны, а не инициализируются изначально.
3) Добавлять свои функции, тем самым тонко подстраиваясь под проект.
4) Мы не используем eval в php.
И мы также можем с помощью лексера сделать синтаксическую подсветку. Но это, если будет интересно, уже в другой раз.
