DEV Community

Cover image for Introducción a Elixir
Camilo for JavaScript Chile

Posted on

Introducción a Elixir

¿Se han preguntado que és lo que hace genial a un lenguaje de programación?. Una de las razones por la que Elixir es genial es su consistencia. José Valim tomó ideas de otros lenguajes de programación como Ruby y Erlang, ideas que han sido desarrolladas por más de 30 años y las hizo mucho más consistentes. 30 años de ideas pensadas para diferentes propósitos por diferentes personas condensadas en una serie de bibliotecas mucho más rápidas de usar y entender.

Elixir se ejecuta en la máquina virtual BEAM, una VM realmente antigua, ya que fue creada por Ericsson en 1986 para el lenguaje Erlang (Más antigua que Java). Estas tecnologías fueron las principales protagonistas de las aplicaciones en un entorno de telecomunicaciones que manejan innumerables solicitudes por segundo de una manera confiable y eficiente.

Unas décadas más tarde, esta VM fue considerada por José Valim, quien creó el lenguaje Elixir a mediados de 2011 para utilizar todo el poder de BEAM en un lenguaje moderno y enfocado en la experiencia de desarrollo. Este lenguaje también fue responsable de la popularización de la máquina virtual de Erlang.

Un pequeño vistazo de Elixir

booleano = true || false
numero = 123_456
string = "Un binario" <> "Concatenada" <> "Interpolación #{numero}"
char = ?A
atomo = :un_atomo
funcion = fn _ -> "Hola" end
mapa = %{valor: "hola"}
tupla = {:ok, 1234}
lista = [1, "dos", :tres]
keyword_list = [valor: "hola"]
IO.inspect("Imprimir en Terminal")
# Comentario
_ # variable placeholder
_var # variable omitida
Enter fullscreen mode Exit fullscreen mode
defmodule MiModulo do

  @atributo "Un atributo del módulo"

  # Retorno el resultado de la última expresion
  def mi_funcion_publica(parametro \\ true) do
    "Función Pública con #{parametro}"
  end

  # Pattern matching selecciona cual ejecutar según la firma de la función
  def mi_funcion_publica() do
    "Función Pública Sin parámetros"
  end

  defp mi_funcion_privada() do
    "Función Privada"
  end
end

MiModulo.mi_funcion_publica()
Enter fullscreen mode Exit fullscreen mode

¿Qué significa Consistencia?

La palabra consistencia se puede definir según la RAE como:

  1. f. Duración, estabilidad, solidez.
  2. f. Trabazón, coherencia entre las partículas de una masa o los elementos de un conjunto.

Adicionalmente definiremos coherencia:

  1. f. Conexión, relación o unión de unas cosas con otras.

Así como cohesión:

  1. f. Fís. Fuerza de atracción que mantiene unidas las moléculas de un cuerpo.

En el desarrollo de software los componentes que utilizamos para elaborar el sistema idealmente deben ser consistentes, coherentes y cohesivos, es decir, que tengan una armonía y sean una base sólida para facilitar la mantención e implementación de los requisitos del sistema.

Ejemplos de Inconsistencias

En otras tecnologías podemos encontrar inconsistencias.

Inconsistencias en la API

PHP es un gran ejemplo de inconsistencias en su biblioteca oficial de funciones.
Por ejemplo notemos como las diferencias entre las funciones str_replace y strpos. Ambas funciones para trabajar con strings.

  1. El nombre es inconsistente, la primera utiliza guión bajo para separar str_ y la otra no.
  2. El nombre de los parámetros es incosistente. $search y $haystack son equivalentes.
 str_replace(
    mixed $search,
    mixed $replace,
    mixed $subject,
    int &$count = ?
): mixed
Enter fullscreen mode Exit fullscreen mode
strpos(string $haystack, string $needle, int $offset = 0): int|false
Enter fullscreen mode Exit fullscreen mode

Además la función strpos puede retornar un valor que sea un entero, pero podría ser considerado falso (0).
Por lo que debería usar el operador === para verificar el real valor de retorno.

En Elixir se podría utilizar un retorno de tuplas {:ok, pos} y {:error, motivo}
y utilizar pattern matching, simplificando las validaciones.

Inconsistencias en los Datos

En otros lenguajes de programación una fuente de inconsistencias es la mutabilidad de sus estructuras de datos. Elixir al ser un lenguaje funcional trabaja con estructuras inmutables que permiten una consistencia en los datos.

El siguiente ejemplo escrito en Python, nos muestra los problemas de inconsistencias en lenguajes de programación con estructuras de datos mutables.

mapa = {
  'lista': [1,2,3],
  'numero': 10
}

lista = mapa['lista']
numero = mapa['numero']

lista += [4]
numero += 10

print(lista)
# [1, 2, 3, 4]

print(numero)
# 20

print(mapa)
# {'lista': [1, 2, 3, 4], 'numero': 10}
Enter fullscreen mode Exit fullscreen mode

Hemos modificado la variable lista y numero, pero sin embargo la variable mapa fue afectada. Esto es debido a que lista fue accedido por referencia y numero accedido por valor.

En Elixir eso no pasa, gracias a su inmutabilidad. Podemos trabajar con las variables lista y numero sin miedo a alterar el valor de la variable mapa.

mapa = %{
  lista: [1, 2, 3],
  numero: 10
}

lista = mapa.lista
numero = mapa.numero

lista = lista ++ [4]
numero = numero + 10

IO.inspect(lista)
# [1, 2, 3, 4]

IO.inspect(numero)
# 20

IO.inspect(mapa)
# %{lista: [1, 2, 3], numero: 10}
Enter fullscreen mode Exit fullscreen mode

Las diferencias con lenguajes OOP

Acoplamiento en la OOP

Los objetos son el acomplamiento de tres componentes: Comportamiento, Estado y Mutabilidad (cambios en el tiempo). Cada vez que se crea un objeto se obtiene una entidad que acopla esos tres componentes. Este acoplamiento causa muchas veces problemas, ¿Cómo puedo solamente utilizar uno de los componentes?.

Una de las mayores fuentes de problemas es la herencia entre objetos. Si por ejemplo tenemos un objeto que tiene acoplado estado y comportamientos, luego deseo añadir más comportamientos, debo recurrir a la herencia. Los objetos por definición existen para encapsular un estado, para modificar el estado debo interactuar con el objeto. Al utilizar herencia se tiene un mecanismo que permite adulterar el estado directamente, invalidando al objeto. Es así como algunos lenguajes de programación incluyen operadores como protected, final, entre otros para controlar la visibilidad de lo que se supone no debería tener acceso.

Básicamente existe un problema cuya solución crea otros problemas, sin incluir cosas como la herencia múltiple. Todo esto debido al acomplamiento inherente de la orientación a objetos.

La Programación Orientada a Objetos (POO) no se trata de crear una taxonomía gigante con una compleja jerarquía de objetos. El uso desmedido de la sintaxis del punto (objeto.metodo()) es una consecuencia de esa mala interpretación. La POO se trata de tener objetos que parpean como un pato, pero no necesariamente son un pato (quack like a duck, but is not a duck). Hay una gran confusión acerca de que en la POO solo existen métodos y no funciones. Hasta Smalltalk (padre de la POO) hay funciones (escondidas bajo una sintaxis especial).

En el conocido libro del Design Patterns: Elements of Reusable Object-Oriented Software mencionan: "Preferir la composición por sobre la herencia".

Los lenguajes funcionales como Haskell, Lisp y Elixir han estado resolviendo problemas complejos sin recurrir a la herencia. Mientras que los lenguajes orientados a objetos pueden utilizar la composición gracias a las interfaces. Ya que las interfaces son un mecanismo de polimorfismo, es decir, que nos permiten trabajar con múltiples formas a través de un contrato pre-establecido.

Los Componentes de Elixir

En Elixir existe comportamiento (Módulos), estado (Datos) y una visión de mutabilidad (Procesos), pero no están acoplados. Lo que nos permite elaborar software utilizando solamente el componente requerido, sin incurrir en los problemas ocasionados por el acoplamiento de la POO. Elixir dispone de tres dimensiones para realizar sistemas, a diferencia de la POO que solamente cuenta con una. Por lo que puede tener un polimorfismo distinto en cada componente (Behaviours, Protocols, Messages).

En el libro Concepts, Techniques, and Models of Computer Programming mencionan la regla de lo menos expresivo. "Cuando se programa un componente, el modelo computacional correcto para lograr un programa natural es el menos expresivo posible". Lo que se puede simplificar como "utilizar la abstracción más simple posible para resolver el problema".

Ejemplo de Acoplamiento

Vamos por un ejemplo simple comparando la forma de contar caracteres en un string.

El siguiente es un ejemplo en Ruby.

"Hola".length
# 4
Enter fullscreen mode Exit fullscreen mode

En Elixir la misma operación sería:

String.length("Hola")
# 4
Enter fullscreen mode Exit fullscreen mode

En Ruby el string "Hola" es un objeto que además de tener
la estructura de datos, tiene una serie de comportamientos asociados.
Esta altamente acoplado.

En cambio en Elixir el string "Hola" solamente es un dato
que no tiene comportamientos. Para poder realizar operaciones
debemos utilizar las funciones del módulo String. Existe
un desacople entre datos y comportamientos.

¿Por qué es importante este desacople?

Veamos un ejemplo. Si asignamos una variable a "Hola",
con el tiempo podemos cambiar el contenido de la variable
y ya no podremos utilizar el método asociado a los tipos string.

variable = "Hola"
variable.length
# 4

variable = 1
variable.length
# (irb):4:in `<main>': undefined method `length' for an instance of Integer (NoMethodError)
Enter fullscreen mode Exit fullscreen mode

Esto puede ser un problema, sobre todo si existe una jerarquía de herencias y combinado con la mutabilidad del lenguaje, es una receta para el caos y soluciones poco elegantes si no se maneja adecuadamente los riesgos.

En Elixir al estar totalmente desacoplados dato, comportamiento y cambios de estado, se puede asegurar productos de software libres de problemas asociados a jerarquías de herencias y mutabilidad.

Pattern Matching

Esta técnica propia de los lenguajes funcionales, se utiliza para buscar patrones y decidir qué hacer en cada momento. Debemos pensar en el operador = no como un signo igual típico de otros lenguajes, sino como el que nos encontramos en una función matemática del tipo x = a + 1. Es decir que estamos diciendo que x y a + 1 tienen el mismo valor.

# a = 1
# b = "elixir"
# c = "ninjas"
{a, b, c} = {1, "elixir", "ninjas"}
Enter fullscreen mode Exit fullscreen mode

El Operador Pipe

Los sistemas operativos inspirados por Unix vienen usando Pipelines desde sus inicios.
En este ejemplo, listamos el contenido del directorio, filtramos solo las líneas que contienen "archivo.txt", y redirigimos la salida a un archivo llamado resultado.txt.

ls -l | grep "archivo.txt" > resultado.txt
Enter fullscreen mode Exit fullscreen mode

https://www.swhosting.com/es/comunidad/manual/uso-de-pipes-en-sistemas-unix

En el caso de Elixir, el operador pipe (tubería) |> es una hermosa herramienta que nos permite expresar una cadena de funciones como una secuencia de acciones.

Aún si no has usado Elixir podrías entender la lógica del siguiente código.

parametros_formulario
|> validar_formulario()
|> insertar_usuario()
|> crear_reporte()
|> mostrar_resultado()
Enter fullscreen mode Exit fullscreen mode

Utilizar una serie de operadores pipe se conoce como un pipeline. Es simple de leer y comprender lo que ocurre. Pero por su simpleza también tiene algunas limitaciones. Debido a que las funciones están encadenadas, dependen del resultado anterior. Si alguna de las funciones falla quebraría el flujo completo. No se puede hacer mucho frente a esto a menos que se maneje los casos de error en cada función.

https://blog.appsignal.com/2022/07/19/writing-predictable-elixir-code-with-reducers.html

En Elixir Todo es un Reductor (Reducer)

Primero partiremos explicando los conceptos de acumulador y reductor.

Acumulador

Un acumulador es una variable que durante la ejecución de un programa va referenciar así misma y almacenar el resultado de realizar operaciones con los valores contenidos en otras variables.

acumulador = acumulador + variable
Enter fullscreen mode Exit fullscreen mode

Reductor

Un reductor es una forma de procesar una tarea grande poco a poco. Utiliza un acumulador para facilitar las operaciones intermedias y entrega un único resultado final.

La estructura común de un reductor es la siguiente.

reductor(elementos_enumerables, valor_inicial_acumulador, funcion_reductora)
Enter fullscreen mode Exit fullscreen mode
  • elementos_enumerables: Una lista de elementos que pueden ser enumerados. Ejemplo [1, 2, 3].
  • valor_inicial_acumulador: El valor que tendrá nuestro acumulador en la primera ejecución de la función reductora. Ejemplo 0.
  • funcion_reductora: Es la función que recibe dos parámetros. El elemento en la lista y el valor actual del acumulador. Ejemplo fn elemento, acc -> acc + elemento end

El reductor ejecutará la función reductora por cada elemento y retornará el valor final del acumulador cuando cada elemento haya sido procesado.

https://redrapids.medium.com/learning-elixir-its-all-reduce-204d05f52ee7

Ejemplo

Vamos a ver un ejemplo concreto de cómo funciona un reductor. Primero definiremos una función para sumar dos valores. Utilizaremos la sintaxis simplificada con el operador de captura (&).
Lo que nos permite expresar una función de forma más corta.

La siguiente forma de expresar una función con dos parámetros

sumar = fn elemento, acc -> elemento + acc end
Enter fullscreen mode Exit fullscreen mode

Puede ser simplificada utilizando el operador de captura &.

sumar = &(&1 + &2)
Enter fullscreen mode Exit fullscreen mode

Sumaremos la lista de elementos [1, 3, 4] para que podamos obtener la sumatoria que es 8.

# sumar = fn elemento, acc -> elemento + acc end
sumar = &(&1 + &2)
Enter fullscreen mode Exit fullscreen mode
&:erlang.+/2
Enter fullscreen mode Exit fullscreen mode

Si utilizamos el módulo Enum y la función reduce obtendremos nuestro resultado

# reduce(enumerable, acumulador, funcion_reductora)
Enum.reduce([1, 3, 4], 0, sumar)
Enter fullscreen mode Exit fullscreen mode
8
Enter fullscreen mode Exit fullscreen mode

Es equivalente a llamar a la funcion sumar de forma anidada.

sumar.(4, sumar.(3, sumar.(0, 1)))
Enter fullscreen mode Exit fullscreen mode
8
Enter fullscreen mode Exit fullscreen mode

También puede ser expresada como un pipeline de la función sumar, cuyo valor incial es. 0.

0
|> sumar.(1)
|> sumar.(3)
|> sumar.(4)
Enter fullscreen mode Exit fullscreen mode
8
Enter fullscreen mode Exit fullscreen mode

Este pipeline se podría expresar como llamar a la función suma utilizando el resultado de la función anterior. En este caso se podría expresar como lo siguiente:

# 1
sumar.(0, 1)
# 4
sumar.(1, 3)
# 8
sumar.(4, 4)
Enter fullscreen mode Exit fullscreen mode
8
Enter fullscreen mode Exit fullscreen mode

CRC: Crear, Reducir y Convertir

Elixir utiliza módulos y tipos de datos, lo que permite una forma de organizar nuestro código en lo que se denomina CRC (Constructores, Reductores y Conversores). Por lo que tendremos funciones para (crear) un acumulador, funciones que realizarán operaciones (reductores) con este acumulador y finalmente funciones que transformarán el acumulador en un formato final (conversores). Ésto es algo que ha existido por largo tiempo en diferentes lenguajes de programación como Haskell o Lisp. Lo más importante que puedes hacer en un lenguaje es unir ideas utilizando composición.

La idea es crear un pipeline que reciba como primer parámetro un acumulador y realizar una serie de operaciones reduce hasta llegar al conversor final.

  entrada
  |> constructor() # crea el acumulador inicial
  |> reductor()
  |> reductor()
  |> reductor()
  |> conversor()  
  # salida de la función listo para ser utilizado por otro pipeline
  # o ser mostrado al usuario final
Enter fullscreen mode Exit fullscreen mode

Por esta razón podemos considerar que todo en Elixir es un conjunto de acumuladores y reductores. Mucha de las funciones del core de Elixir pueden ser implementadas usando nada más que un acumulador y Enum.reduce.

¿Por qué es importante?

Gracias a ésta forma de organización podemos ver nuestro código de forma coherente
y unificada. Al tener una estructura de datos en común con varias funciones
podemos realizar operaciones y expresarnos con una facilidad de lectura mayor.
Nuestros sistemas serán más fáciles de entender y mantener en el futuro.

La consistencia es un factor importante de calidad en nuestros sistemas y utilizar CRC es una gran herramienta para lograr eso.


# Creamos un nuevo mapa con nuestro acumulador
constructor = &%{acc: &1}

# Retornamos el valor del acumulador actualizado
suma = &%{acc: &1.acc + &2}

# Mostramos solamente el valor que deseamos
conversor = & &1.acc

0
|> constructor.()
|> suma.(1)
|> suma.(3)
|> suma.(4)
|> conversor.()
Enter fullscreen mode Exit fullscreen mode
8
Enter fullscreen mode Exit fullscreen mode

Ejemplo de CRC

Vamos a ejemplificar un poco utilizando un mapa del tesoro. En este mapa vamos a dar una serie de direcciones norte, sur, este y oeste. Se puede ver como tenemos una función de creación que retorna una tupla {x, y}, la cual será nuestra estructura de datos del acumulador. Luego tenemos una serie de reductores que modifican la tupla y devuelven una nueva tupla con los valores apropiados. Finalmente tenemos nuestro conversor que retorna un String con un mensaje final.

defmodule Tesoro do
  # Creador
  def nuevo(x, y), do: {x, y}
  def nuevo, do: nuevo(0, 0)

  # Reductores
  def norte({x, y}), do: {x, y - 1}
  def sur({x, y}), do: {x, y + 1}

  def este({x, y}), do: {x - 1, y}
  def oeste({x, y}), do: {x + 1, y}

  # Conversor
  def mostrar({x, y}), do: "El tesoro se encuentra en las coordenadas #{x},#{y}"
end
Enter fullscreen mode Exit fullscreen mode
{:module, Tesoro, <<70, 79, 82, 49, 0, 0, 11, ...>>, {:mostrar, 1}}
Enter fullscreen mode Exit fullscreen mode
import Tesoro

nuevo()
|> norte()
|> este()
|> este()
|> oeste()
|> oeste()
|> sur()
|> sur()
|> sur()
|> este()
|> este()
|> este()
|> mostrar()
Enter fullscreen mode Exit fullscreen mode
"El tesoro se encuentra en las coordenadas -3,2"
Enter fullscreen mode Exit fullscreen mode

Como se puede apreciar logramos generar nuestro mapa del tesoro utilizando un pipeline de funciones que aceptan una estructura de datos en común como primer parámetro (acumulador). Se realizan las operaciones a esta estructura para finalmente crear una salida con un formato específico.

Hemos logrado algo genial. Tomamos una idea compleja de distintas funciones y las unificamos como si fueran eslabones de una misma cadena. De esta forma puedes ver como las operaciones forman una cascada enviando valores. Hemos creado una composición utilizando reductores.

Las ideas de CRC (Crear, Reducir y Convertir) pueden ser encontradas a lo largo de todo el ecosistema de Elixir, por ejemplo con OTP y el estado de un GenServer o Phoenix para tomar un dato y convertir ese dato a HTML, SVG, JSON o similares.

El ver las operaciones como una cadena simplifica cómo construir, leer y probar todo el software en el ecosistema de Elixir, gracias a una excelente consistencia y decisiones de diseño.

¿Por qué usar Elixir frente a las alternativas?

Algunas razones:

  • Un lenguaje funcional moderno y preciosamente consistente. Permitiendo un nivel más desacoplado y consistente que con otras tecnologías.
  • Se ejecuta en la BEAM con más de 30 años de herramientas para sistemas robustos, escalables y concurrentes. Logrando una mayor confiabilidad y resiliencia a fallos que con otras soluciones.
  • Gran ecosistema de herramientas para IOT, Robótica, IA, Web, Mobile, entre otros.
  • Un mercado atractivo para profesionales con ofertas laborales novedosas y frescas, sin tanta competencia como en otras tecnologías. Mercado laboral en auge en USA, Alemania, Japón, entre otros.
  • Multitud de capacitaciones y certificaciones disponibles (Grox.io, Erlang Solutions).
  • Utilizado por empresas de alto calibre como Facebook, Whatsapp, Discord, Pepsico, Walmart, entre otros.

¿Qué nos depara la industria en el futuro?

La industria informática está llena de cambios. Podemos mencionar la tecnología de los 80's con las primeras computadoras personales como el Commondore o Atari. Luego en los 90's vimos los inicios de internet con HTML, JS y CSS. En los 2000's vimos el auge de las redes sociales como Facebook y plataformas como Youtube. En los 2010's se masificaron las aplicaciones y teléfonos móviles "inteligentes". ¿Qué se viene en la década del 2020's?. Lo más probable es la masificación de la IA y sistemas concurrentes. Casos donde Elixir y Erlang son idóneos, superando a otras alternativas como Python y Ruby. Los profesionales que aprendan lenguajes de la BEAM estarán preparados para las próximas décadas de la industria, debido a que cada día la concurrencia y robustez de los sistemas será mucho más necesaria.

Los lenguajes de programación que son "difíciles de contratar" siempre serán un problema. Desafortunadamente, este no es un problema que realmente puedas resolver, porque nadie conoce el futuro. Si por ejemplo elijes un lenguaje de programación popular hoy, luego a la gente deja de gustarle y no será fácil encontrar personal (VB6, Action Script Flash). ¿Qué pasa con casos como Python, donde la migración de 2.x a 3.x fue tan tortuosa, que prácticamente fue un cambio a un lenguaje distinto?. Las organizaciones que no se casan con un solo lenguaje o tecnología y adoptan estratégicamente nuevas herramientas, estarán mejor capacitadas para las variaciones del mercado e industria.

Top comments (0)