viernes, 18 de septiembre de 2009

Perl inteligente

[English translation]
En el artículo anterior vimos un ejemplo de Perl moderno, hoy vamos a profundizar un poquito más en la comparación inteligente de Perl 5.10 y como al combinarla con las características dinámicas del lenguaje obtenemos un programa ridículamente pequeño, pero más fácil de comprender y mantener.
Alguna vez leí (creo que de Paul Graham) que cuando alguna sección de código parece duplicada generalmente hace falta un nivel de abstracción, claro que él programa en Lisp, y tiene defmacro. Sin embargo Perl también tiene lo suyo, y en este caso nuestra primera solución podría basarse en un hash que incluya las funciones permitidas en nuestra calculadora:
 1 #!/usr/bin/perl
 2 
 3 use Modern::Perl;
 4 use Scalar::Util qw( looks_like_number );
 5 use Statistics::Descriptive;
 6 
 7 use constant SYNTAX_ERROR => "Error: tipee 'help' para ayuda";
 8 
 9 my %FUNCS = (
10     sum                => 0,
11     mean               => 0,
12     count              => 0,
13     variance           => 0,
14     standard_deviation => 0,
15     min                => 0,
16     mindex             => 0,
17     max                => 0,
18     maxdex             => 0,
19     sample_range       => 0,
20     median             => 0,
21     harmonic_mean      => 0,
22     geometric_mean     => 0,
23     mode               => 0,
24     trimmed_mean       => 0,
25 );
26 
27 my $s = Statistics::Descriptive::Full->new();
28 while (1) {
29     print "Listo> ";
30     my $command = readline(STDIN) // last;
31     $command =~ s/^\s+//; $command =~ s/\s+$//;
32     given ($command) {
33         when ( looks_like_number($_) ) { $s->add_data($command) }
34         when (/^(exit|quit)$/)         {last}
35         default {
36             if   ( exists $FUNCS{$command} ) { ... }
37             else                             { say SYNTAX_ERROR}
38         }
39     }
40 }
Esto es un avance importante, porque estamos simplificando el código en la parte complicada del programa, y reemplazándolo por una simple declaración de un hash, donde incluir una nueva función es tan simple como agregar una línea.
Claro que cualquier lector astuto se ha dado cuenta de que hago trampa porque el programa está incompleto; la línea 36 necesita una acción, nuestro problema ahora es como ejecutar el método correcto para la operación, y en Perl como de costumbre hay varias formas de hacerlo, una de ellas (la peor) podría utilizar referencias a las funciones de la clase en el hash, así:

10     sum  => \&Statistics::Descriptive::sum,
Para luego hacer algo como:

36     if ( exists $FUNCS{$command} ) { say "$command = " . $FUNCS{$command}($s) }
Digo que esa es la peor forma porque hay que saber mucho Perl para entender como funciona eso, y Perl tiene la capacidad de despachar métodos simbólicamente haciendo que nuestra intención quede perfectamente clara:
36     if ( exists $FUNCS{$command} ) { say "$command = " . $s->$command }
El costo de esta operación es mayor que el de la alternativa anterior, sin embargo es un precio que se paga con gusto, porque el programa es mucho más fácil de entender, y últimamente la gente es mucho más cara que las máquinas.
Finalmente si la flojera es uno de tus principios fundamentales, puedes reescribir la asignación del hash así:
 9 my %FUNCS = map { $_ => 0 } qw( sum mean count variance standard_deviation
10     min mindex max maxdex sample_range median harmonic_mean geometric_mean
11     mode trimmed_mean );
Lo que particularmente aprecio, porque ahorro puntuación (lo que parece abrumar a mucha gente) y tengo menos probabilidad de cometer un error de sintaxis.
Básicamente estoy construyendo una lista de palabras (los nombres de los métodos) con "qw", a partir de esta lista construyo otra (usando map) que contiene cada elemento de la lista original ($_) acompañado del número  0, perl convierte automáticamente esta lista en un hash donde cada nombre tiene asociado 0 como valor.
Si la explicación anterior te parece complicada o incomprensible, puedes ver la documentación de map en Perl, lo que te vendrá de maravilla porque además podrás aprender algo de programación funcional que de seguro te será muy provechoso.
Ahora me voy a deshacer del "if", prefiero los condicionales de múltiples vias, son más planos y se ve mejor el flujo, por eso opino que el given/when es lo mejor que le ha pasado a Perl en mucho tiempo, además me voy a deshacer de la expresión regular en la línea 34 por algo que tenga más sentido para un extraño a Perl:
 1 #!/usr/bin/perl
 2 
 3 use Modern::Perl;
 4 use Scalar::Util qw( looks_like_number );
 5 use Statistics::Descriptive;
 6 
 7 use constant SYNTAX_ERROR => "Error: tipee 'help' para ayuda";
 8 
 9 my %FUNCS = map { $_ => 1 } qw( sum mean count variance standard_deviation
10     min mindex max maxdex sample_range median harmonic_mean geometric_mean
11     mode trimmed_mean );
12 
13 my $s = Statistics::Descriptive::Full->new();
14 while (1) {
15     print "Listo> ";
16     my $command = readline(STDIN) // last;
17     $command =~ s/^\s+//; $command =~ s/\s+$//;
18     given ($command) {
19         when ( looks_like_number($_) ) { $s->add_data($command) }
20         when ( ["exit", "quit"] )      {last}
21         when (%FUNCS)                  { say "$command = " . $s->$command }
22         default                        { say SYNTAX_ERROR }
23     }
24 }
Ahora se ve mucho mejor (hasta parece Erlang).
Me estoy valiendo de algunas  funciones del smart matching que explicaré a continuación.
En la línea 20 se compara un valor contra un arreglo:
$command ~~ ["exit", "quit"]
Cuando se compara un escalar (a la izquierda) contra un arreglo (a la derecha) el efecto es equivalente a lo siguiente:
sub match_scalar_arrayref {
    my ($scalar, $arrayref) = @_;
    for my $item ( @$arrayref ) {
        return 1 if $scalar eq $item;
    }
    return undef;
}

Ya no recuerdo cuantas veces he escrito código como ese, o como este:
if ( grep { $scalar eq $_ } @$arrayref ) ...
Y que  ahora podré escribir con mas claridad y menos esfuerzo:
if ( $scalar ~~ $arrayref ) ...
Probablemente ya adivinaste que en la línea 21, el smart match entre un escalar y un hash es equivalente a:
if ( exists $hash{$scalar} ) ...

Un poco de tentación

Una sugerencia que recibí de un lector daba una solución todavía más corta y fácil de mantener, la idea era cambiar la línea:

21    when (%FUNCS) { say "$command = " . $s->$command }
por:
21    when ($s->can($command)) { say "$command = " . $s->$command }
El método can es provisto por la clase UNIVERSAL, de la cual derivan todos los objetos en Perl, y el propósito de este método es averiguar si un objeto o clase tiene un método determinado.
Al utilizar esta mejora ni siquiera necesito el hash %FUNCS, y además nuestro interpretador se actualizará automáticamente con nuevos comandos a medida que evolucione Statistics::Descriptive, lo cual suena muy bien desde el punto de vista de mantenibilidad, sin embargo, tiene un problema fatal para mí: no es seguro.
El problema es que pierdo el control sobre lo que Perl ejecuta automáticamente, y aunque probablemente este módulo no pueda hacer mucho daño, esta misma técnica utilizando algún otro módulo, podría ser peligrosa. Así que prefiero la seguridad y me quedo con el hash como mecanismo de despacho (y autorización de uso).
La moraleja es que se debe terner cuidado al utilizar mecanismos de control de ejecución dinámicos, sobre todo cuando se utilizan datos de fuentes externas no confiables en estos mecanismos de control de ejecución.

Completando el programa


La línea 22 da un error cuando no se conoce un comando, el mensaje dice que use "help" para obtener ayuda, pero el comando "help" todavía no está implementado, una manera rápida de implementarlo es:

23         when ("help") {
24             say "Los comandos válidos son: "
25                 . join( ", ", qw(exit quit help), keys %FUNCS )
26         }
Guau, eso fue fácil, lo mejor es que además de fácil es consistente, porque se utiliza la misma estructura de datos para informar los comandos, para seleccionarlos y para autorizarlos.
Una de las funciones que se me olvidó incluir en la calculadora en el artículo anterior fue "clear", agregar esta función ahora es tan sencillo como poner una nueva palabra en la definición de %FUNCS:
 9 my %FUNCS = map { $_ => 1 } qw( sum mean count variance standard_deviation
10     min mindex max maxdex sample_range median harmonic_mean geometric_mean
11     mode trimmed_mean clear )
Fue fácil, ¿o no?. Lo mejor es que el nuevo comando aparece automáticamente en la ayuda porque el programa es consistente.
Recapitulemos los logros del día, tenemos un programa:
  • Muy compacto
  • Fácil de comprender
  • Fácil de mantener
  • Consistente
  • Seguro
Perl tan válido como cualquier otro lenguaje, pero además, muy pocos lenguajes brindan mecanismos similares a los aquí expuestos para lograr un programa con estas características.
En el próximo artículo veremos como agregarle el manual de funciones estadísticas a nuestra calculadora con mucha facilidad.
A continuación los dejo con la versión final del programa:
 1 #!/usr/bin/perl
 2 
 3 use Modern::Perl;
 4 use Scalar::Util qw( looks_like_number );
 5 use Statistics::Descriptive;
 6 
 7 use constant SYNTAX_ERROR => "Error: tipee 'help' para ayuda";
 8 
 9 my %FUNCS = map { $_ => 1 } qw( sum mean count variance standard_deviation
10     min mindex max maxdex sample_range median harmonic_mean geometric_mean
11     mode trimmed_mean clear );
12 
13 my $s = Statistics::Descriptive::Full->new();
14 while (1) {
15     print "Listo> ";
16     my $command = readline(STDIN) // last;
17     $command =~ s/^\s+//;
18     $command =~ s/\s+$//;
19     given ($command) {
20         when ( looks_like_number($_) ) { $s->add_data($command) }
21         when (%FUNCS)                  { say "$command = " . $s->$command }
22         when ( [ "exit", "quit" ] )    {last}
23         when ("help") {
24             say "Los comandos válidos son: "
25                 . join( ", ", qw(exit quit help), keys %FUNCS )
26         }
27         default { say SYNTAX_ERROR };
28     }
29 }

1 comentario:

  1. Felicidades por el post. Me encantó.
    Creo que es muy buena idea esto de escojer un problema, e irlo refinando y mejorando para que quede mas idiomático.

    No conocia la flexibilidad de given/when.

    ResponderEliminar