Publi

Ejemplo para analizar y procesar expresiones matemáticas (y más) en PHP (Parsear en PHP)

Parsear expresiones en PHP

Una de las mayores armas de doble filo en cuanto a los lenguajes de programación interpretados es la función o expresión eval. Esta orden nos suele permitir escribir una cadena de caracteres con un texto en el mismo lenguaje que estamos escribiendo y ejecutarla. Es decir, si nos encontramos en Python y escribimos:

1
eval("100+23")

Devolverá 123. O si estamos en PHP y hacemos:

1
2
<?php
eval("echo 100+23;");

En la cadena de caracteres que introducimos podemos utilizar variables, bucles, condiciones… vamos, todo lo que nos permite el lenguaje de programación. Lo que ponemos en la cadena se ejecutará de forma nativa en el lenguaje. Esto puede ser muy útil a veces. Pero tendremos que pensar un poco antes de utilizarlo.

Peligros de utilizar eval

Quiero hacer una introducción más o menos rápida y sin centrarme en un lenguaje de programación. En principio, hemos visto que eval() admite una cadena de caracteres como entrada. Si pensamos un poco, que esa cadena de caracteres sea fija, es decir, que en nuestro programa tuviera algo como los ejemplos de arriba no tiene mucho sentido, porque podemos prescindir de eval y todo se ejecutaría igual. Bueno, es cierto que en algunos casos concretos nos puede interesar la pequeña pérdida de tiempo que introduce eval(), aunque hay formas más elegantes de hacerlo. O incluso podemos hacer que el lenguaje que estemos utilizando no utilice el caché de la expresión que estamos escribiendo. Pero son, de hecho casos muy raros y excepcionales.

Otro de los usos es construir una cadena de caracteres nosotros mismos, en tiempo de ejecución y pasárselo a eval(). En este caso, podemos sacar de una base de datos un fragmento de código para ejecutar o incluso el usuario puede introducir una expresión en un formulario y nosotros ejecutarlo. Imaginad que le pedimos al usuario una fórmula para calcular el precio de venta de un producto y claro, al usuario no vamos a pedirle programación. Eso sí, podemos tener un grave agujero de seguridad en nuestra aplicación. Sencillamente, porque eval() va a ejecutar todo lo que nosotros le pasemos. Lo mismo hace operaciones matemáticas, que llamada a cualquier función del lenguaje y ahí está el problema, al usuario no le podemos dar tanto control. Un gran poder conlleva una gran responsabilidad y, otra cosa no, pero el usuario, de responsable tiene poco y, sobre todo si estamos hablando de una plataforma web en la que cualquiera podría explotar una vulnerabilidad, mucho más.

Incluso si se nos ocurre filtrar lo que le pasamos a eval(), acotando el número de expresiones que le pasamos y cómo se lo pasamos, no me fiaría yo mucho de que algún usuario malintencionado fuera capaz de, incluso pasando los filtros, ejecutar código malicioso. Así que, mi consejo, es que si alguna vez ejecutamos eval() sea solo para hacer pruebas, a la hora de hacer tests de nuestro programa y algún caso contado más, pero no hacerlo con código en producción.

Expresiones del usuario

Así que, ¿qué hacemos si aún así queremos que el usuario ejecute sus propias expresiones? No nos queda otra que analizar nosotros la expresión, y evaluarla. Como si estuviéramos programando un intérprete de un lenguaje de programación nosotros mismos. Así que, manos a la obra, vamos a construir un parser que analice una expresión y luego la procesaremos. Este programa tendrá algunas expresiones internas que evaluará directamente, aunque luego tendremos la opción de añadir funciones personalizadas.

Atención: El código que voy a mostrar tiene ya un tiempo, aunque para escribir el post me he asegurado de que funciona con PHP7. Está basado en un código de hack.code.it de hace mucho tiempo, retocado y con algunas mejoras por mi parte.

No pretendo crear un lenguaje de programación, solo un sistema en el que los usuarios puedan pasar de forma segura expresiones matemáticas, pueda analizarlas, evaluarlas y dar un resultado. Aunque todo se puede complicar, podemos utilizar funciones como senos, cosenos, raíces, etc, incluso funciones creadas por nosotros. Y debemos tener una manera de decirle las funciones que admitimos.

Programando…

Vale, voy a poner un montón de código por aquí, para tener funcionando esto. El código puede tener bugs, y, por supuesto podéis enviármelos para que poco a poco vayamos mejorando el programa. Yo lo he utilizado para unos pocos casos, pero realmente son muy pocos. El script soporta funciones, variables y operaciones como suma, resta, multiplicación, división y potencia.

Primero, aunque no utilizaremos ningún paquete, vamos a configurar composer para generar el autoload de los ficheros:
composer.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
    "name": "gasparfm/simplePHPexpr",
    "keywords": ["math", "expressions", "functions"],
    "homepage": "https://github.com/gasparfm/simplePHPexpr",
    "description": "An expression parser in PHP",
    "license": "MIT",
    "authors": [
        {
            "name": "smassey",
            "homepage": "http://codehackit.blogspot.fr/"
        },{
            "name": "Gaspar Fernández",
            "homepage": "https://gaspar.totaki.com/"
        }
    ],
    "require": {
        "php": ">=5.6"
    },
    "autoload":     {
        "psr-0": {
            "spex": "src/"
        }
    }
}

Crearemos un fichero principal (main.php) en el mismo directorio que composer.json. El esquema de archivos y directorios será el siguiente

|- composer.json
|- main.php
|- src/
| |-spex/
| | |-exceptions/
| | | |- DivisionByZeroException.php
| | | |- MaxDepthException.php
| | | |- OutOfScopeException.php
| | | |- ParseTreeNotFoundException.php
| | | |- ParsingException.php
| | | |- UnknownFunctionException.php
| | | |- UnknownTokenException.php
| | |-scopes/
| | | |- Scope.php
| | | |- FunScope.php
| | |- Parser.php
| | |- Util.php

spex/Parser.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<?php
namespace spex;

/**
 * this model handles the tokenizing, the context stack functions, and
 * the parsing (token list to tree trans).
 * as well as an evaluate method which delegates to the global scopes evaluate.
 */


class Parser {
    protected $_content = null;
    protected $_context_stack = array();
    protected $_tree = null;
    protected $_tokens = array();
    protected $_options;

    public function __construct($options = array(), $content = null) {
        $this->_options = $options;
        if ( $content ) {
            $this->set_content( $content );
        }
    }

    /**
     * this function does some simple syntax cleaning:
     * - removes all spaces
     * - replaces '**' by '^'
     * then it runs a regex to split the contents into tokens. the set
     * of possible tokens in this case is predefined to numbers (ints of floats)
     * math operators (*, -, +, /, **, ^) and parentheses.
     */

    public function tokenize() {
        $this->_content = str_replace(array("\n","\r","\t"), '', $this->_content);
        $this->_content = preg_replace('~"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"(*SKIP)(*F)|\'[^\'\\\\]*(?:\\\\.[^\'\\\\]*)*\'(*SKIP)(*F)|\s+~', '', $this->_content);
        $this->_content = str_replace('**', '^', $this->_content);
        $this->_content = str_replace('PI', (string)PI(), $this->_content);
        $this->_tokens = preg_split(
            '@([\d\.]+)|([a-zA-Z_]+\(|,|=|\+|\-|\*|/|\^|\(|\))@',
            $this->_content,
            null,
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
        );
        return $this;
    }

    /**
     * this is the the loop that transforms the tokens array into
     * a tree structure.
     */

    public function parse() {
        # this is the global scope which will contain the entire tree
       $this->pushContext( new \spex\scopes\Scope($this->_options) );
        foreach ( $this->_tokens as $token ) {
            # get the last context model from the context stack,
            # and have it handle the next token
            $this->getContext()->handleToken( $token );
        }
        $this->_tree = $this->popContext();

        return $this;
    }

    public function evaluate() {
        if ( ! $this->_tree ) {
            throw new \spex\exceptions\ParseTreeNotFoundException();
        }
        return $this->_tree->evaluate();
    }

    /*** accessors and mutators ***/

    public function getTree() {
        return $this->_tree;
    }

    public function setContent($content = null) {
        $this->_content = $content;
        return $this;
    }

    public function getTokens() {
        return $this->_tokens;
    }


    /*******************************************************
     * the context stack functions. for the stack im using
     * an array with the functions array_push, array_pop,
     * and end to push, pop, and get the current element
     * from the stack.
     *******************************************************/


    public function pushContext(  $context ) {
        array_push( $this->_context_stack, $context );
        $this->getContext()->setBuilder( $this );
    }

    public function popContext() {
        return array_pop( $this->_context_stack );
    }

    public function getContext() {
        return end( $this->_context_stack );
    }
}

spex/Util.php
Este archivo proporciona compatibilidad con PHP5. Podemos adaptar el código para PHP7 y eliminar esta dependencia.

1
2
3
4
5
6
7
8
9
<?php
namespace spex;

class Util {
    public static function av($arr, $key, $default=null) {
        return (isset($arr[$key]))?$arr[$key]:$default;
    }

};

spex/scopes/Scope.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
<?php
namespace spex\scopes;

class Scope {
    protected $_builder = null;
    protected $_children_contexts = array();
    protected $_raw_content = array();
    protected $_operations = array();
    protected $options = array();
    protected $depth = 0;

    const T_NUMBER = 1;
    const T_OPERATOR = 2;
    const T_SCOPE_OPEN = 3;
    const T_SCOPE_CLOSE = 4;
    const T_FUNC_SCOPE_OPEN = 5;
    const T_SEPARATOR = 6;
    const T_VARIABLE = 7;
    const T_STR = 8;

    public function __construct(&$options, $depth=0) {
        $this->options = &$options;
        if (!isset($this->options['variables']))
            $this->options['variables'] = array();

        $this->depth = $depth;
        $maxdepth = \spex\Util::av($options, 'maxdepth',0);
        if ( ($maxdepth) && ($this->depth > $maxdepth) )
            throw new \spex\exceptions\MaxDepthException($maxdepth);
    }

    public function setBuilder( \spex\Parser $builder ) {
        $this->_builder = $builder;
    }

    public function __toString() {
        return implode('', $this->_raw_content);
    }

    protected function addOperation( $operation ) {
        $this->_operations[] = $operation;
    }

    protected function searchFunction ($functionName) {
        $functions = \spex\Util::av($this->options, 'functions', array());
        $func = \spex\Util::av($functions, $functionName);
        if (!$func)
            throw new \spex\exceptions\UnknownFunctionException($functionName);

        return $func;
    }

    /**
     * handle the next token from the tokenized list. example actions
     * on a token would be to add it to the current context expression list,
     * to push a new context on the the context stack, or pop a context off the
     * stack.
     */

    public function handleToken( $token ) {
        $type = null;
        $data = array();

        if ( in_array( $token, array('*','/','+','-','^','=') ) )
            $type = self::T_OPERATOR;
        if ( $token == ',' )
            $type = self::T_SEPARATOR;
        if ( $token === ')' )
            $type = self::T_SCOPE_CLOSE;
        if ( $token === '(' )
            $type = self::T_SCOPE_OPEN;
        if ( preg_match('/^([a-zA-Z_]+)\($/', $token, $matches) ) {
            $data['function'] = $matches[1];
            $type = self::T_FUNC_SCOPE_OPEN;
        }

        if ( is_null( $type ) ) {
            if ( is_numeric( $token ) ) {
                $type = self::T_NUMBER;
                $token = (float)$token;
            } elseif (preg_match('/^".*"$|^\'.*\'$/', $token)) {
                $type = self::T_STR;
            } elseif (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $token)) {
                $type = self::T_VARIABLE;
            } else
                  echo "**".$token."**";
        }

        switch ( $type ) {
            case self::T_NUMBER:
            case self::T_OPERATOR:
                $this->_operations[] = $token;
                break;
            case self::T_STR:
                $delim = $token[0];
                $this->_operations[] = str_replace('\'.$delim, $delim, substr($token, 1, -1)) ;
                break;
            case self::T_VARIABLE:
                $this->_operations[] = array('
v', $token);
                break;
            case self::T_SEPARATOR:
                break;
            case self::T_SCOPE_OPEN:
                $this->_builder->pushContext( new namespace\Scope($this->options, $this->depth+1) );
            break;
            case self::T_FUNC_SCOPE_OPEN:
                $this->_builder->pushContext( new namespace\FunScope($this->options, $this->depth+1, $this->searchFunction($data['
function'])) );
            break;
            case self::T_SCOPE_CLOSE:
                $scope_operation = $this->_builder->popContext();
                $new_context = $this->_builder->getContext();
                if ( is_null( $scope_operation ) || ( ! $new_context ) ) {
                    # this means there are more closing parentheses than openning
                    throw new \spex\exceptions\OutOfScopeException();
                }
                $new_context->addOperation( $scope_operation );
            break;
            default:
                throw new \spex\exceptions\UnknownTokenException($token);
            break;
        }
    }

    private function isOperation($operation) {
        return ( in_array( $operation, array('
^','*','/','+','-','='), true ) );
    }

    protected function setVar($var, $value) {
        $this->options['
variables'][$var] = $value;
    }

    protected function getVar($var) {
        return \spex\Util::av($this->options['
variables'], $var, 0);
    }

    protected function getValue($val) {
        if (is_array($val)) {
            switch (\spex\Util::av($val, 0)) {
                case '
v': return $this->getVar(\spex\Util::av($val, 1));
                default:
                    throw new \spex\exceptions\UnknownValueException();
            }
        }
        return $val;
    }
    /**
     * order of operations:
     * - parentheses, these should all ready be executed before this method is called
     * - exponents, first order
     * - mult/divi, second order
     * - addi/subt, third order
     */
    protected function expressionLoop( & $operation_list ) {
        while ( list( $i, $operation ) = each ( $operation_list ) ) {
            if ( ! $this->isOperation($operation) )
                continue;
            $left =  isset( $operation_list[ $i - 1 ] ) ? $operation_list[ $i - 1 ] : null;
            $right = isset( $operation_list[ $i + 1 ] ) ? $operation_list[ $i + 1 ] : null;

            if ( (is_array($right)) && ($right[0]=='
v') )
                $right = $this->getVar($right[1]);
            if ( ($operation!='
=') && ( (is_array($left)) && ($left[0]=='v') ) )
                $left = $this->getVar($left[1]);

            if ( is_null( $right ) ) throw new \Exception('
syntax error');

            $first_order = ( in_array('
^', $operation_list, true) );
            $second_order = ( in_array('
*', $operation_list, true ) || in_array('/', $operation_list, true ) );
            $third_order = ( in_array('
-', $operation_list, true ) || in_array('+', $operation_list, true )|| in_array('=', $operation_list, true ) );
            $remove_sides = true;
            if ( $first_order ) {
                switch( $operation ) {
                    case '
^': $operation_list[ $i ] = pow( (float)$left, (float)$right ); break;
                    default: $remove_sides = false; break;
                }
            } elseif ( $second_order ) {
                switch ( $operation ) {
                    case '
*': $operation_list[ $i ] = (float)($left * $right); break;
                    case '
/':
                        if ($right==0)
                            throw new \spex\exceptions\DivisionByZeroException();
                        $operation_list[ $i ] = (float)($left / $right); break;
                    default: $remove_sides = false; break;
                }
            } elseif ( $third_order ) {
                switch ( $operation ) {
                    case '
+': $operation_list[ $i ] = (float)($left + $right);  break;
                    case '
-': $operation_list[ $i ] = (float)($left - $right);  break;
                    case '
=': $this->setVar($left[1], $right); $operation_list[$i]=$right; break;
                    default: $remove_sides = false; break;
                }
            }

            if ( $remove_sides ) {
                if (!$this->isOperation($operation_list[ $i + 1 ]))
                    unset($operation_list[ $i + 1 ]);
                unset ($operation_list[ $i - 1 ] );
                $operation_list = array_values( $operation_list );
                reset( $operation_list );
            }
        }
        if ( count( $operation_list ) === 1 ) {
            $val = end($operation_list );
            return $this->getValue($val);
        }
        return $operation_list;
    }

    # order of operations:
    # - sub scopes first
    # - multiplication, division
    # - addition, subtraction
    # evaluating all the sub scopes (recursivly):
    public function evaluate() {
        foreach ( $this->_operations as $i => $operation ) {
            if ( is_object( $operation ) ) {
                $this->_operations[ $i ] = $operation->evaluate();
            }
        }

        $operation_list = $this->_operations;

        while ( true ) {
            $operation_check = $operation_list;
            $result = $this->expressionLoop( $operation_list );

            if ( $result !== false ) return $result;
            if ( $operation_check === $operation_list ) {
                break;
            } else {
                $operation_list = array_values( $operation_list );
                reset( $operation_list );
            }
        }
        throw new \Exception('
failed... here');
    }
}

spex/scopes/FunScope.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
namespace spex\scopes;

class FunScope extends namespace\Scope {
    private $fun = null;

    public function __construct(&$options, $depth, $callable) {
        parent::__construct($options, $depth);
        $this->fun = $callable;
    }

    public function evaluate() {
        $arguments = parent::evaluate();
        return call_user_func_array($this->fun, (is_array($arguments))?$arguments:array( $arguments ) );
    }
}

spex/exceptions/UnknownFunctionException.php

1
2
3
4
5
6
7
8
9
10
<?php

namespace spex\exceptions;

class UnknownFunctionException extends \Exception
{
    function __construct($functionName) {
        parent::__construct('Unkown function '. $functionName);
    }
}

spex/exceptions/DivisionByZeroException.php
Todos los archivos de excecpción serán iguales, cambiando el nombre, el objetivo es diferenciar las excepciones para poder capturarlas.

1
2
3
4
5
6
7
<?php

namespace spex\exceptions;

class DivisionByZeroException extends \Exception
{
}

main.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
include('vendor/autoload.php');

$config = array(
    'maxdepth' => 2,
    'functions' => array(
        'sin' => function ($rads) { return sin(deg2rad($rads)); },
        'upper' => function ($str) { return strtoupper($str); },
        'fact' => function ($n) { $r=1; for ($i=2; $i<=$n; ++$i) $r*=$i; return $r; },
        'word' => function ($text, $nword) { $words=explode(' ', $text); return (isset($words[$nword]))?$words[$nword]:''; },
    )
);

$builder = new \spex\Parser($config);

while ( (fputs(STDOUT,'math > ')) && $e = fgets(STDIN) ) {
    if ( ! ($e = trim($e)) ) continue;
    if ( in_array( $e, array('quit','exit',':q') ) ) break;

    try {
        $result = $builder->setContent($e)->tokenize()->parse()->evaluate();
    } catch ( \spex\exceptions\UnknownTokenException $exception ) {
        echo 'unknown token exception thrown in expression: ', $e, PHP_EOL;
        echo 'token: "',$exception->getMessage(),'"',PHP_EOL;
        continue;
    } catch ( \spex\exceptions\ParseTreeNotFoundException $exception ) {
        echo 'parse tree not found (missing content): ', $e, PHP_EOL;
        continue;
    } catch ( \spex\exceptions\OutOfScopeException $exception ) {
        echo 'out of scope exception thrown in: ', $e, PHP_EOL;
        echo 'you should probably count your parentheses', PHP_EOL;
        continue;
    } catch ( \spex\exceptions\DivisionByZeroException $exception ) {
        echo 'division by zero exception thrown in: ', $e, PHP_EOL;
        continue;
    } catch ( \Exception $exception ) {
      echo 'exception thrown in ', $e, PHP_EOL;
      echo $exception->getMessage(), PHP_EOL;
      continue;
    }

    echo $result, PHP_EOL;
}

Probando el programa

Tras todo esto, podemos hacer una ejecución como esta:

php main.php
1+1
2
upper(“hola mundo”)
HOLA MUNDO
fact(10)
3628800
word(“Hola Mundo Mundial”, 1)
Mundo
sin(fact(3))
0.10452846326765

Publicación del código

Quiero publicar en GitHub el código tal y como ha quedado con algunos ejemplos prácticos más en las próximas semanas.

Foto principal: unsplash-logoChris Liverani

También podría interesarte....

Leave a Reply