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 |
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; publicfunction __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. */ publicfunction 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. */ publicfunction 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; } publicfunction evaluate(){ if(!$this->_tree ){ thrownew \spex\exceptions\ParseTreeNotFoundException(); } return$this->_tree->evaluate(); } /*** accessors and mutators ***/ publicfunction getTree(){ return$this->_tree; } publicfunction setContent($content=null){ $this->_content =$content; return$this; } publicfunction 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. *******************************************************/ publicfunction pushContext( $context){ array_push($this->_context_stack,$context); $this->getContext()->setBuilder($this); } publicfunction popContext(){ returnarray_pop($this->_context_stack ); } publicfunction getContext(){ returnend($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; publicfunction __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)) thrownew \spex\exceptions\MaxDepthException($maxdepth); } publicfunction setBuilder( \spex\Parser $builder){ $this->_builder =$builder; } publicfunction __toString(){ returnimplode('',$this->_raw_content); } protectedfunction addOperation($operation){ $this->_operations[]=$operation; } protectedfunction searchFunction ($functionName){ $functions= \spex\Util::av($this->options,'functions',array()); $func= \spex\Util::av($functions,$functionName); if(!$func) thrownew \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. */ publicfunction 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){ caseself::T_NUMBER: caseself::T_OPERATOR: $this->_operations[]=$token; break; caseself::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 extendsnamespace\Scope { private$fun=null; publicfunction __construct(&$options,$depth,$callable){ parent::__construct($options,$depth); $this->fun=$callable; } publicfunction evaluate(){ $arguments= parent::evaluate(); returncall_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){returnsin(deg2rad($rads));}, 'upper'=>function($str){returnstrtoupper($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:
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: Chris Liverani
The post Ejemplo para analizar y procesar expresiones matemáticas (y más) en PHP (Parsear en PHP) appeared first on Poesía Binaria.