miércoles, 9 de diciembre de 2009

Python: Dinamismo aplicado

Me suele pasar que cuando comento alguna característica superior de un lenguaje a otra persona que no lo conoce, la persona reaccione con frases del estilo "y eso cuando lo vas a usar en la realidad?", o "todo muy lindo, pero eso es para la teoría, en la práctica no sirve", etc...

Es normal, ya que al no estar acostumbrado a pensar de una manera distinta, no va a ver tan fácilmente la utilidad de dicha característica.
Estas no son grandes iluminaciones mías, jeje, son ideas que Paul Graham explicó muy bien en este artículo (recomendado).

Pero lo que sí puedo aportar desde mi humilde experiencia, es un buen ejemplo de cómo una característica puede sonar "muy rara", pero cuando es bien aprovechada, puede resultar "muy práctica".

Puede haber cientos de cosas equivocadas en lo que estoy por decir, estaría bueno que me hagan conocer al menos algunas :D

La característica "extraña"

Ahora es donde hace su entrada la característica "extraña" que tiene Python. Va a sonar "extraña" para quien no esté acostumbrado a cosas por el estilo, se entiende.

La característica es la función getattr: llamándola podemos obtener un atributo o método de un objeto.
Este ejemplo es bien ilustrativo:



yo = "juan pedro"
met = getattr(yo, "upper")
met() #esto nos devuelve "JUAN PEDRO"


Para explicarlo un poquito:
Con getattr(yo, "upper") obtuvimos el método upper del objeto yo.
Atención! Dije "obtuvimos el método", no "el resultado de llamar al método". Son cosas bien diferentes.
Obtener el método es como darle un nuevo nombre con el cual después podemos llamarlo, como hicimos en "met()". Met es un nuevo nombre para llamar a ese método en particular, el método upper de yo.

Es destacable que usar una variable (met en este caso) es algo que hice solo para que sea más entendible a primera vista. Pero lo anterior perfectamente puede escribirse como:



yo = "juan pedro"
getattr(yo, "upper")() #esto nos devuelve "JUAN PEDRO"


No guardamos el método en una variable, simplemente lo llamamos en ese momento. Lo obtenemos y lo llamamos en una misma línea.


El problema

Tenemos una clase Foo. Esta clase Foo define 5 acciones diferentes, que se representan con 5 métodos: accion1, accion2, accion3, accion4, accion5.
La complicación se da en el hecho de que cada una de estas acciones es realizada comunicándose con un servicio, y hay 4 servicios completamente diferentes en los cuales se pueden realizar las acciones: A, B, C y D.
Ejemplo:
"Hacer acción 1 en el servicio B"
"Hacer acción 3 en el servicio D"
etc...

En la implementación, cada servicio cambia por completo el código que se tiene que ejecutar para realizar una acción. Es decir, el código de la acción 1 para el servicio A es completamente diferente al código de la acción 1 para el servicio B, etc.

La clase Foo necesita recibir el nombre del servicio como un parámetro de cada acción, para saber en qué servicio ejecutarla. De forma que después se utilice de la siguiente manera:



miFoo = Foo() #creamos un nuevo objeto foo
miFoo.accion1("A") #llamamos a la accion 1 en el servicio A
miFoo.accion1("C") #llamamos a la accion 1 en el servicio C
miFoo.accion3("B") #llamamos a la accion 3 en el servicio B



Primer solución "no dinámica"

Para muchos de los que lean esto, la primera solución que les vendrá a la mente será que cada método (accionX...) tenga dentro de sí un gran if, para cada servicio. Algo así:



class Foo:
def accion1(self, servicio):
if servicio == "A":
#codigo de la accion 1 en el servicio A
elif servicio == "B":
#codigo de la accion 1 en el servicio B
elif servicio == "C":
#codigo de la accion 1 en el servicio C
elif servicio == "D":
#codigo de la accion 1 en el servicio D



Esto va a funcionar, eso no lo voy a negar. ¿Pero qué es lo que no me gusta de esta opción? No me gustan estas cosas:

1) Este if va a estar repetido en cada una de las acciones, que son 5. Cuando se agreguen o modifiquen servicios, tengo que mantener actualizado el mismo if en los 5 métodos "accionX".
2) El código rápidamente se hace ilegible cuando es mucho código por acción.
3) Da la sensación de que estamos "amontonando" peras con manzanas, que esto podría separarse un poco para ordenarse mejor.
4) Estos if son 2 lineas por cada acción y servicio, con lo que para 5 acciones en 4 servicios, son 40 líneas de código solamente en los if, no incluyendo el código de las acciones mismas. Son 40 líneas de código que no hacen lo que queremos hacer, y que las necesitamos solo para decidir qué código ejecutar.

Mejorando la solución "no dinámica"

Para el problema del amontonamiento y el orden, a más de uno ya se le debe haber ocurrido la solución. Algo así:



class Foo:
def accion1(self, servicio):
if servicio == "A":
self.accion1_en_A()
elif servicio == "B":
self.accion1_en_B()
elif servicio == "C":
self.accion1_en_C()
elif servicio == "D":
self.accion1_en_D()

def accion1_en_A(self):
#codigo de la accion 1 en el servicio A

def accion1_en_B(self):
#codigo de la accion 1 en el servicio B

def accion1_en_C(self):
#codigo de la accion 1 en el servicio C

def accion1_en_D(self):
#codigo de la accion 1 en el servicio D



No puedo negarlo, la separación en varios métodos ayuda un poco a la legibilidad y mantenibilidad. Consideremos esa parte como resuelta y correcta, nos olvidamos de los métodos "accionX_en_Y".
Pero tenemos todavía esto:



def accion1(self, servicio):
if servicio == "A":
self.accion1_en_A()
elif servicio == "B":
self.accion1_en_B()
elif servicio == "C":
self.accion1_en_C()
elif servicio == "D":
self.accion1_en_D()



Esto es lo que sigue sin gustarme. ¿Por qué?
Porque seguimos teniendo el problema de los ifs horribles desparramados por todos lados.
Seguimos teniendo que mantener esas 40 líneas de código que solo sirven para elegir el código a ejecutar.
Mi opinión es que debería poder hacerse de otra forma.

La rareza viene al rescate: La solución dinámica

Bien, en teoría la cosa rara debería ahora ayudarnos con nuestro problema. ¿Y cómo nos va a ayudar esa cosa rara de Python?.
Recordemos que nos habíamos olvidado del código de los métodos "accionX_en_Y", esos estaban aprobados :). Lo feo era el código que elegía cuál método ejecutar según el servicio.

Veamos entonces la versión "rara" de ese código:



def accion1(self, servicio):
getattr(self, "accion1_en_" + servicio)()



Se nota lo que falta?? Ya no tenemos al if!

Antes teníamos esas 40 líneas de ifs, 8 líneas por cada acción que solo decidían qué código ejecutar. Ahora esa decisión se toma con 1 línea en cada acción, con lo que nos quedan (con 5 acciones) un total de.... 5 lineas!
5 líneas contra 40 es un ahorro del 87% menos de código.
Cuidado. La cuestión no es "tener pocas líneas porque es más lindo". En este caso, la ventaja es no tener que mantener código repetitivo e innecesario.

Y no solo eso, ganamos también otra ventaja muy importante: Si mañana agregamos o sacamos servicios, no es necesario tocar nada del código que elige el método a ejecutar. Solo agregamos las implementaciones (métodos accionX_en_Y), y la clase sabrá por si sola llamarlos, sin que tengamos que decirle nada extra. Así de práctico.

Conclusión

En un ejemplo bastante simple, se ve cómo una característica "extraña", bien usada puede transformarse en una característica "práctica".
Y cuidado, porque cuando se empiezan a aprovechar estas características, resulta bastante molesto volver a los lenguajes que no las tienen... Es adictivo, jeje.



PD: crédito a C.B. que me mostró el artículo de Paul Graham :D

4 comentarios:

  1. Esta bueno eso, en ruby algo similar seria usando los metodos get_methods que te devuelven la lista de metodos del objeto. Entonces haciendo un find podes obtener el metodo que queres, y luego con un eval ejecutas el objeto con el metodo concatenado. Aunque no tengo mucha XP como para asegurar que funciona.

    ResponderEliminar
  2. Muy bueno!

    También podés asignar el valor de la función *sin* los parentesis a una variable y después llamar a esa variable:

    yo = "juan pedro"
    met = yo.upper
    met
    [built-in method upper of str object at 0x7f86fd1f5538]
    met()
    'JUAN PEDRO'

    que es básicamente lo mismo que vos hiciste pero sin usar getattr().

    ResponderEliminar
  3. Los métodos "accionX_en_Y" estan amontonados en la clase Foo.

    Se puede crear la clase Servicio, con sus subclases ServicioA, ServicioB, etc. Dentro de estas clases tenemos los métodos accion1, accion2, etc.


    miFoo = Foo() #creamos un nuevo objeto foo
    miFoo.accion1("A") #llamamos a la accion 1 en el servicio A

    se transforma en:

    miFoo = Foo()
    miFoo.servicio("A").accion1()

    servicio() en Foo puede usar el truco dinámico, o un diccionario para encontrar el constructor de ServicioA dada la "A".

    def servicio(self, nombre_servicio):
    return eval("Servicio"+ nombre_servicio+"(self)")

    Las instacias de la clase Servicio deben guardar una referencia a la instancia de Foo desde la cual fueron construidos, para encontrar los valores que seguramente necesitan de dicha instancia Foo.

    Ahora cada acción tiene un nombre propio y esta al lado de sus parientes de servicio.

    Nos leemos...

    ResponderEliminar
  4. Muy buena Cesar, mejor que mi solucion :D
    De hecho voy a aplicarlo, mi ejemplo salio de algo que hice en la realidad.

    Como bien dijiste en el mail, eval es maligno pero divertido, jeje.

    ResponderEliminar