En programación orientada a objetos, nos pasamos el día utilizando interfaces. Se podría decir que una interfaz es un contrato que especifica que métodos y propiedades deben tener las clases que implementen dicho contrato. Así, si decimos que una clase implementa el contrato (interfaz) IPrintable
, esa clase deberá tener un método PrintMessage
que hará algo con un string
.
Aunque Elixir no es un lenguaje orientado a objetos, también tenemos la posibilidad de utilizar algo parecido a las interfaces: los behaviours (comportamientos). Con muchas diferencias claro, porque en Elixir todo se resuelve en tiempo de ejecución. Pero antes de hablar de behaviours hay que hablar de otra cosa: los typespecs.
Typespecs
Elixir es un lenguaje dinámico, por lo que los tipos de datos se infieren en tiempo de ejecución. Esto tiene sus cosas buenas, y sus cosas malas, pero no vamos a discutirlas aquí. A pesar de ser dinámico, Elixir nos permite definir tipos personalizados y declarar el tipo que devolverá una función (especificaciones). Y eso lo podemos hacer a través de los typespecs.
Declarando especificaciones
Una especificación en una función, nos permite saber qué tipo de dato va a devolver al ejecutarse. Para hacer esto, deberemos usar @spec
. Un ejemplo:
defmodule Test do
@spec increment(number) :: integer
def increment(a) do
a + 1
end
end
Cómo veis el ejemplo es muy sencillo. Definimos una función increment
, que lo único que hace es sumar uno, al número pasado como parámetro
¿Y para qué sirve esto en un lenguaje como Elixir? Al fin y al cabo, esto no va a devolver ni si quiera un warning al compilar. Pues sirve para utilizar herramientas como ExDoc, (utilizada para documentar); o Dialyzer que se utiliza para analizar el código para encontrar posibles problemas con los tipos.
Por ejemplo si modificamos el código anterior:
defmodule Test do
@spec increment(number) :: integer
def increment(a) do
"Ahora devolvemos un string"
end
end
Cuando compilamos, no recibimos ningún error. Elixir es dinámico, y ese código se lo traga perfectamente. Pero si utilizamos Dialyzer nos cantará el error antes que nadie.
Declarando tipos personalziados
Además de los tipos por defecto que existen en Elixir, también podemos declarar nuestros tipos personalizados, lo cual puede ser útil en algunas circunstancias. Por ejemplo, GenServer, uno de los módulos más interesantes de Elixir (y del que espero hablar pronto), define tipos como el siguiente:
@type on_start :: {:ok, pid} | :ignore |
{:error, {:already_started, pid} | term}
El tipo personalizado on_start
puede ser una tupla {:ok, pid}
, :ignore
, etc.
Los tipos personalizdos como este, después se utilizan en las especificaciones de las funciones:
@spec start_link(module, any, options) :: on_start
def start_link(module, args, options \\ [])
when is_atom(module) and is_list(options) do
do_start(:link, module, args, options)
end
Como vemos, la función start_link
devuelve un elemento de tipo on_start
.
Insisto, aunque los typespecs parecen una tontería, es muy buena práctica usarlos. De hecho si os pasáis por el repo de código de Elixir veréis que se hace uso extensivo de los typespecs en todos los módulos.
Comportamientos (behaviours) en Elixir.
Y después del rollo, vamos a hablar de los behaviours. En Elixir los comportamientos, sirven para definir las funciones que debe implementar un módulo, y “asegurarnos” que todos los módulos que implementan ese comportamiento tengan dichas funciones. Y digo “asegurarnos”, entre comillas, porque a diferencia de los lenguajes que se evaluan en tiempo de compilación como C# o Java, el compilador solo nos va a devolver un warning. Si no implementamos todas las funciones indicadas por el comportamiento, podremos ejecutar el programa sin problemas. O al menos hasta que nos falle estreptiosamente porque intentamos llamar a una función que no existe.
En defninitiva, que para definir un comportamiento deberemos crear un módulo que utilice alguna vez la directiva callback
. Esta directiva nos dice, que los módulos que implementen este comportamiento, deberían tener esa función definida. Por ejemplo:
defmodule CalculadorImpuestos do
@callback importe_con_impuestos(importe :: float) :: float
end module
Tenemos un módulo, que se llama CalculadorImpuestos
que define un @callback
que todos los módulos que implemente el comportamiento deben tener. Este comportamiento, podremos usarlo en otros módulos con la directiva @behaviour
.
defmodule CalculadorIVAReducido do
@behaviour CalculadorImpuestos
def importe_con_impuestos(importe) do
importe * 1.10
end
end
defmodule CalculadorIVASuperReducido do
@behaviour CalculadorImpuestos
def importe_con_impuestos(importe) do
importe * 1.04
end
end
En ambos casos decimos que el módulo va a implementar el comportamiento CalculadorImpuestos
con la directiva @behaviour
. ¿Pero qué pasa si no implementamos la función importe_con_impuestos
? En ese caso recibiremos un warning al compilar.
defmodule CalculadorIVA do
@behaviour CalculadorImpuestos
end
warning: undefined behaviour function importe_con_impuestos/1 (for behaviour CalculadorImpuestos)
¿Y qué pasa si definimos una función importe_con_impuestos
que devuelva un tipo diferente (por ejemplo un string)? Pues no pasaría absolutamente nada. Aunque hemos definido que tipo debe devolver la función con una especificación, no recibimos ni un triste warning. No debemos olvidar que estamos ante un lenguaje dinámico y esos detalles no le importan mucho al compilador.
Y entonces ¿de qué vale esto?
Como hemos visto, a pesar de que Elixir nos da algunas opciones para gestionar tipos, estas no van más allá de poder utilizar algunas herramientas estáticas y generar documentación. En el caso de implementar un comportamiento, lo único que vamos a ver es algún que otro warning en el compilador. Esto que parece poca cosa, es muy útil si hacemos las cosas bien. Hay que tener en cuenta que aquí el compilador es nuestro amigo, y nos está advirtiendo de que algo puede ir mal si ejecutamos el programa. Podemos hacerle caso o no, pero oye, él nos ha avisado.
Ahora vamos a rizar el rizo, y vamos cambiar nuestro CalculadorDeImpuestos
para hacer que implemente por defecto una función importe_con_impuestos
.
defmodule CalculadorImpuestos do
@callback importe_con_impuestos(importe :: float) :: float
defmacro __using__(_) do
quote do
@behaviour CalculadorImpuestos
@impuestos 21
def importe_con_impuestos(importe) do
importe * (@impuestos/100 + 1)
end
defoverridable [importe_con_impuestos: 1]
end
end
end
Nuestor módulo, sigue implementando el behaviour, pero ahora usamos una macro, para hacer añadir la implementación de la función importe_con_impuestos
de forma predeterminada. Además con defoverridable
, indicamos que esta función puede sobreescribirse en otros módulos. ¿Y cómo se utiliza en otros módulos? Pues utilizando la macro use
que ya os expliqué en un post anterior.
Veamos un par de ejemplos:
defmodule CalculadorIVA do
use CalculadorImpuestos
end
defmodule CalculadorIVAReducido do
use CalculadorImpuestos
def importe_con_impuestos(importe) do
importe * 1.10
end
end
defmodule CalculadorIVASuperReducido do
use CalculadorImpuestos
def importe_con_impuestos(importe) do
importe * 1.04
end
end
En estos ejemplos, en lugar de utilizar el comportamiento directamente, estamos utilizando use
para hacer que el compilador incluya la función importe_con_impuestos
directamente dentro de esos módulos. Esa es la razón de que en el módulo CalculadorIVA
no haga falta implementar la función, ya que estamos usando la que existe por defecto. Es decir, que si llamamos a la función CalculadorIVA.importe_con_impuestos(11222)
desde IEx
va a funcionar sin problemas, ya que el compilador ha añadido la función como si fuera del propio módulo.
En el caso de CalculadorIVAReducido
y CalculadorIVASuperReducido
estamos sobreescribiendo la función original, para utilizar un cálculo nuevo. Ahí hay que tener en cuenta que estamos definiendo como sobreescribible (toma palabro), esa función. Lo hacemos con defoverridable
, y un array de funciones con especificadas con su arity. Si no hacemos esto, el compilador nos va a lanzar un warning diciendo que existen dos funciones haciendo lo mismo (la añadida con el use
y la que hemos sobreescrito) y que una se va a ignorar.
Y ahora vamos a ver porque los comportamientos pueden ser muy útiles. Imaginemos que los requisitos de nuestra aplicación cambian. Además de devolver el importe final con impuestos, hay que dar la opción de poder devolver solo el importe de los impuestos. Lo primero que deberíamos hacer es incluir otro @callback
en nuestro comportamiento:
defmodule CalculadorImpuestos do
@callback importe_con_impuestos(importe :: float) :: float
@callback solo_impuestos(importe :: float) :: float
defmacro __using__(_) do
quote do
@behaviour CalculadorImpuestos
@impuestos 21
def importe_con_impuestos(importe) do
importe * (@impuestos/100 + 1)
end
defoverridable [importe_con_impuestos: 1]
end
end
end
Hecho. Ahora estamos diciendo que además de importe_con_impuestos
, nuestros módulos deberán tener también la función solo_impuestos
. Si compilamos, pasa esto:
warning: undefined behaviour function solo_impuestos/1 (for behaviour CalculadorImpuestos)
lib/behaviours/calculadorIVA.ex:1
warning: undefined behaviour function solo_impuestos/1 (for behaviour CalculadorImpuestos)
lib/behaviours/calculadorIVAsuper.ex:1
Aquí tendríamos dos opciones, añadir una nueva función a la macro __using__
para hacer una implementación por defecto (y el correspondiente elemento en defoverridable
), o definir la función en cada módulo.
Polimorfismo con behaviours
¿Cómo podemos utilizar los comportamientos que hemos definido? Imaginemos que tenemos una función que recibe una lista con tuplas de dos elementos. Cada tupla será una línea de pedido. El primer elemento será el importe, y el segundo la cantidad. A partir de eso debemos ser capaces de calcular el importe total con los impuestos aplicados. Eso sí, haciendo que el cálculo dependa del tipo de impuesto (IVA normal, reducido o superreducido). El módulo lo llamaremos Contabilidad
y la función será calcular_impuestos
.
defmodule Contabilidad do
@default_calculator CalculadorIVA
def calcular_impuestos(lineas_pedido, opts \\ []) do
{calculador, _} = Keyword.pop(opts, :calculator, @default_calculator)
total_sin_impuestos = calcular_total(lineas_pedido)
total_con_impuestos = calcular_total_con_impuestos(total_sin_impuestos, calculador)
{total_sin_impuestos, total_con_impuestos}
end
defp calcular_total(lineas_pedido) do
lineas_pedido
|> Enum.reduce(0, fn({importe, cantidad}, acc) -> acc + (importe * cantidad) end)
end
defp calcular_total_con_impuestos(importe_total, calculador) do
importe_total |> aplicar_impuestos(calculador)
end
defp aplicar_impuestos(importe, calculador) do
calculador.importe_con_impuestos(importe)
end
end
Lo importante aquí es que en la función calcular_impuestos
estamos pasando un parámetro ops
. Este parámetro es una lista de keywords, en la que buscaremos el elemento :calculator
. Un ejemplo de llamada en IEx
:
iex(1)> pedido = [{1.40, 2}, {3.50, 5}, {8.0, 1}]
pedido = [{1.40, 2}, {3.50, 5}, {8.0, 1}]
[{1.4, 2}, {3.5, 5}, {8.0, 1}]
iex(2)> Contabilidad.calcular_impuestos(pedido)
Contabilidad.calcular_impuestos(pedido)
{28.3, 34.243}
El ejemplo es bastante sencillo. Creamos un pedido, pasando cada línea de pedido en una tupla que incluye el importe, y la cantidad. Luego utilizamos esa lista para pasársela a la función calcular_impuestos
. En este caso no estamos pasando nada en el parámetro ops
, así que la función utilizara el calculador por defecto @default_calculator
, que en este caso es CalculadorIVA
. Con ese calculador, y dentro de la función privada aplicar_impuestos
llamamos a la implementación de importe_con_impuestos
correspondiente.
¿Y si queremos cambiar el tipo de calculador de impuestos? Pues chupado:
iex(3)> Contabilidad.calcular_impuestos(pedido, calculator: CalculadorIVAReducido)
Contabilidad.calcular_impuestos(pedido, calculator: CalculadorIVAReducido)
{28.3, 31.130000000000003}
iex(4)> Contabilidad.calcular_impuestos(pedido, calculator: CalculadorIVASuperReducido)
Contabilidad.calcular_impuestos(pedido, calculator: CalculadorIVASuperReducido)
{28.3, 29.432000000000002}
Como podemos ver en el parámetro opts
estamos pasando el calculador, que se extrae de la lista con Keyword.pop
(si no existe se devuelve el calculador por defecto).
Y recuerdo una vez más, que aunque no hubiese ningún behaviour
definido, todo funcionaría perfectamente si todos los calculadores de impuestos tuvieran una función importe_con_impuestos
definida y funcionando.
Conclusión
Puede parecer que los behaviours tienen poca utilidad. Al fin y al cabo solo nos proporcionan un warning al compilar. Cuando uno está acostumbrado a tener un compilador que no te deja seguir si no haces las cosas bien esta libertad es difícil de entender.
Pero en realidad, cuando utilizamos comportamientos, estamos expresando la intención que tiene nuestro código. Estamos expresando que funciones deberían implementarse para que todo funcione como se espera. Además, con los typespecs añadimos todavía más información, pudiendo incluso definir tipos personalizados. En definitiva, estamos haciendo nuestro código más amigable para que otros (o nuetro yo del futuro) lo entiendan mejor.
¿Quiéres que te avisemos cuando se publiquen nuevas entradas en el blog?
Suscríbete por correo electrónico o por RSS