top of page
Search

Importancia de Scala y la Programación Funcionalen la industria del Software

Importancia de Scala y la Programación Funcional en la industria del Software

Un enfoque práctico para desarrolladores Java


Por Ignacio Gallego Sagastume


Introducción


Jai de UMATR me pidió realizar una colaboración de contenido en su sitio https://www.scalamatters.io y me pareció una gran idea.


Me pareció también una buena oportunidad para generar contenido en español, ya que hay mucho material de calidad de Scala en inglés, pero no tanto en español, y quisiera difundir la importancia de la programación funcional (y especialmente de Scala) en España y América Latina.


¿A quién va dirigido este artículo?


Principalmente a desarrolladores Java o cualquier desarrollador de otras tecnologías que sienta curiosidad y no sepa bien de qué va esto de Scala (que está haciendo ruido cada vez más), y tal vez quiera convertirse en desarrollador Scala.


Acerca de mí


Nací en La Plata, Argentina en 1975. Me licencié en Informática en la Universidad de La Plata (UNLP) en 2004 y en 2015 obtuve un Máster en Ingeniería de Software, también en la UNLP.


Trabajé 2 de años en tecnologías web y lenguajes como PHP, Visual Basic, Delphi, .Net, y luego de licenciarme, otros 14 años como desarrollador full stack en Java (principalmente en J2EE + Spring MVC + Struts + JSP, jQuery, bootstrap).


Desde 2019 vivo en España con mi esposa y mi hija. Desde el año 2021 me desempeño como desarrollador Scala a tiempo completo en el sitio web https://www.itravel.de .


Sí pero… ¿qué es Scala?


Scala es un lenguaje de programación de propósito general, como Java o C++. Es un lenguaje multiparadigma, ya que integra características de la POO (Programación Orientada a Objeto) y la PF (Programación Funcional).


Además, Scala es un lenguaje muy potente, que permite hacer más con menos código, es decir, es más expresivo que Java (al menos). Su sistema de tipado fuerte es más completo y consistente que el de Java, por lo que permite detectar más errores en tiempo de compilación.


Y un aspecto de Scala muy importante para mí (que permitió que este lenguaje sea tan exitoso), es que permite ir conociendo sus características de forma incremental, poco a poco, hasta poder utilizar todo su potencial. Uno puede empezar utilizando Scala como un “mejor Java” en forma imperativa, y de a poco ir incorporando más la Programación Funcional (PF), con todo lo que esto implica en beneficios a largo plazo, como la mantenibilidad, depuración, claridad y comprensibilidad, testabilidad, etc.


Dicho esto, ¿por qué creo que es tan importante este lenguaje?


Yo creo que es sumamente importante para la comunidad tecnológica en general, por las razones que listaré a continuación.


Primero, haré un repaso de algunos conceptos de la PF, y cómo estos influyen en el día a día de un desarrollador. Luego, daré algunas pautas de por qué Scala en particular es una buena opción para cualquier profesional de la informática.


Programación Funcional

No describiré en este artículo todos los beneficios de la programación funcional, porque ya hay infinidad de recursos en Internet, pero citaré algunos (para que el artículo sea autocontenido), y por qué son importantes en el día a día de un desarrollador profesional.


Funciones matemáticas

La programación funcional se basa en usar funciones matemáticas, y luego combinarlas y componerlas de diferentes formas para definir el comportamiento de un programa.


Una función es “matemática” cuando cumple las siguientes propiedades:


  • Total: para cada posible entrada, la función define un valor de salida (no quedan valores en el dominio de la función para los cuales la función no está definida)

  • Determinística: si a la función le asignamos una misma entrada, siempre devolverá un mismo valor de salida (no depende de factores externos ni aleatorios)

  • Pura: No puede tener efectos laterales (como escribir en un disco rígido o en la consola), y su único fin es computar un valor de salida como resultado de la función


¿Por qué es importante esto? Veremos sus ventajas a continuación.


Reduce la complejidad del código

Tener funciones matemáticas puras, nos permite desarrollar funciones tan simples como sea posible, y luego combinarlas para realizar programas más complejos.


Pensamiento local

Uno puede concentrarse en escribir la función que desee, sin que ninguna otra porción de la aplicación afecte a esta función. Es decir, no hay variables globales, no hay parámetros que afecten a mi función o ningún otro factor que “moleste” y ocupe lugar en mi cabeza durante su desarrollo. Sólo se codifica invocando a otras funciones y conectando entradas con salidas, como si de un puzzle se tratara. Toda la información que se necesita para el proceso mental de desarrollar una función cabe en una sola pantalla.


Como anécdota para graficar la importancia de esto: recuerdo que un día estaba desarrollando en Java una nueva característica de una aplicación para una entrega importante. Estuve como 12 horas sin parar frente a mi portátil trabajando y testeando la nueva funcionalidad. Cuando finalmente fuí a integrar mi código con el resto del código de la aplicación, realicé la mezcla (o “merge”) de mi código, y la misma no funcionaba en conjunto con el resto de la aplicación. Luego de depurar un buen rato, me dí cuenta de que era lo que estaba pasando: otro compañero había cambiado otra parte de la aplicación que hacía que mi código fuera incorrecto. Esto simplemente no pasaría si utilizamos funciones matemáticas puras, ya que el código que se escribe solo depende de sus entradas, y no de otras partes de la aplicación o de variables globales, como ya hemos mencionado.



Confiabilidad en el código

Tener funciones puras, permite desarrollar una función, testear para diferentes entradas, y confiar en que ella es correcta. Luego de que se tiene certeza de que funciona bien, se puede olvidar su implementación y simplemente utilizarla en otras partes de la aplicación (nos podemos “abstraer” de su implementación y confiar en ella).


La única razón por la cual el código puede “romperse” (dejar de funcionar), es que se produjera una excepción fatal, como que se agotara la memoria RAM física del computador por utilizar más memoria de la que que se tiene disponible, o que hubiera un fallo de electricidad, se desconectara un servidor, se cayera Internet o situaciones similares. En estas situaciones ningún lenguaje puede hacer mucho para seguir ejecutando.


Razonamiento ecuacional

Si tengo funciones matemáticas puras en mi código, puedo pensar en cómo mejorarlo y optimizarlo, pensando de la misma manera que lo haría con funciones y expresiones matemáticas, es decir:


si A = B y B = C, entonces A = C, en todo el programa.


Esto es tan simple y tan importante a la vez: podemos pensar sobre los programas ya implementados y realizar “refactorings”, es decir, reorganizarlos de la mejor manera posible en cuanto a comprensión, mantenibilidad, complejidad algorítmica y eficiencia, sin alterar su comportamiento.


Es importante aclarar que esto NO ocurre con la programación imperativa (es decir donde tengo una secuencia de instrucciones que el computador debe seguir).


Es decir que si tengo:


programa {

A := 1;

B := 1;

C := 1;

imprimir(A);

imprimir(B);

imprimir(C);

}


Este programa depende del orden de sus comandos o instrucciones. Además, puede ocurrir que cualquier comando o subrutina puede alterar la condición anterior. Por lo que si lo ordeno de otra manera o invoco a una función NO pura, puedo cometer errores y cambiar el comportamiento del programa.


Imaginemos que refactoricemos el anterior programa a:


programa {

A := 1;

B := 1;

C := 1;


funcion_NO_pura(A); // si cambiamos el valor de A sin saberlo,

// se rompería el invariante, es decir,

// la condición previa: A = B = C


imprimir(A);

imprimir(B);

imprimir(C);

}


Si invocáramos a una función no pura funcion_NO_pura(), y A se modificara dentro de esa función o subrutina, se rompería el invariante de A = B = C, y el programa cambiaría su comportamiento (se imprimirían valores distintos en la consola). Esto hace que debamos pensar en el orden de los comandos, además de rastrear dónde se modifica cada valor, y se pierde la capacidad de razonar matemáticamente o ecuacionalmente.


Transparencia referencial

La propiedad anterior va de la mano con la transparencia referencial. La transparencia referencial es la propiedad que dice que si una función o expresión f = a, entonces puedo reemplazar con seguridad, una expresión por otra en cualquier punto de mi aplicación. Cada vez que aparezca a, puedo usar f (y viceversa) y el programa NO alterará su comportamiento. Por ejemplo, si:


val cinco: Int = suma(2, 3)


val diez: Int = suma(2, 3) + suma(2, 3)


como cinco es igual (por definición) a suma(2, 3), entonces, puedo reemplazar la última línea del programa por:


val diez: Int = cinco + cinco


y de esta manera, hacer a la vez el programa más eficiente (ya que la suma de 2 + 3 se calcula sólo una vez en vez de dos). Además, ahora el programa es más claro y/o menos repetitivo, y como es transparente referencial, es equivalente.


Una nota sobre transparencia referencial y I/O

Las funciones puras no permiten alterar sus entradas o realizar efectos laterales en su código, como escribir en la consola o leer de un archivo.


Por ejemplo:


def funcion1(): String = {

// leerArchivo accede al HD del computador

val unString: String = leerArchivo("documento.pdf")

unString ++ "otra cadena"

}


funcion1() no es una función pura, ya que contiene un efecto lateral: si el documento “documento.pdf” cambia su contenido, el resultado de la función también cambiará su valor de retorno, rompiendo de esta manera el determinismo: “para la misma entrada siempre se devuelve el mismo valor de salida”.


Si quisiéramos componer esta función con otras más complejas, estaríamos propagando esta “incertidumbre” al resto del programa.


Pero: ¿cómo programamos si no podemos leer de un archivo? Se puede, para esto hacemos explícito en la firma de la función que estamos realizando una acción secundaria, como leer o escribir en un archivo o escribir en la consola:


def funcion1_pura(): IO[String] = {

// NO se accede al HD del computador

val unIO: IO[String] = leerArchivoIO("documento.pdf")

unIO.map( (str: String) => str ++ "otra cadena")

}


En el tipo de retorno de la función indicamos que se trata de una operación de entrada-salida (como lectura de disco rígido del computador) y que esta operación retorna una cadena de caracteres (se retorna IO[String] en lugar de simplemente String). Luego trabajamos con el valor IO para componer su resultado (función map y su expresión lambda) y devolver el resultado deseado. De esta manera, sí puedo componer esta función con otras de mi programa. En realidad, se reemplaza la acción en sí de leer un archivo por una descripción de la operación. Luego se especifica cómo operar con los valores una vez que se realice la acción, que será diferida hasta el último momento, por ejemplo al ejecutar la función principal de mi programa.


Existen frameworks o librerías como ZIO y Cats-Effect en Scala (sistemas de efectos), que permiten implementar y combinar este tipo de funciones de manera más sencilla, aunque lleva tiempo familiarizarse con ellas y utilizarlas de manera efectiva.



Facilidad de comprensión

Otro aspecto clave de la programación funcional (para mí) es que cuando programamos en un lenguaje imperativo (como C o Java), para comprender un programa, y pensar sobre el mismo, y tratar de verificar que es correcto, debemos simular su ejecución en nuestra cabeza.


Esto se ve mejor con un ejemplo. Supongamos que tenemos un array con los valores enteros 1, 2, 3, 4 y 5, y queremos sumar 10 a cada uno de ellos. En Java, haríamos algo como lo siguiente:


public static void sumar10() {

int[] arr = {1, 2, 3, 4, 5};


int i = 0;

while (i < arr.length) {

arr[i] = arr[i] + 10;

i = i + 1;

}

// imprimir el resultado

}


Ahora, ¿cómo sabemos si este programa es correcto? Seguramente tomamos un pedazo de papel o lo depuramos en un IDE para verificar su comportamiento. Es decir, anotamos i = 0, avanzamos el puntero del programa, verificamos si la condición del bucle while es verdadera, realizamos la suma de la posición 0 del array, anotamos arr[0] = 11, sumamos 1 a i, anotamos el nuevo valor de i en el papel, comprobamos la condición del bucle nuevamente, etc, etc. etc. Luego de un buen rato, determinamos que la función “es correcta” o “parece comportarse según lo esperado”. Si trabajamos en una forma un poco más profesional, podemos crear tests unitarios para probar los casos límites (arreglo de longitud 0, arreglo de longitud muy grande, comprobar que la suma es correcta en cada posición del array luego de ejecutar la función, etc.). Aunque esto tampoco asegura que la función esté libre de errores (los test unitarios pueden demostrar la presencia de errores, pero no su ausencia).


Otra forma de pensar este problema es mediante la PF. La gran diferencia, es que no tengo que ejecutar el programa en mi mente para saber si es correcto. Ejemplo, usamos la función de alto orden map(), que me permite cambiar cada elemento de un array pasando como parámetro una función lambda:


def sumar10(): Unit = {

val arr: Array[Int] = Array(1, 2, 3, 4, 5)

val res: Array[Int] = arr.map(i => i + 10)


// imprimir el resultado “res”

}


El programa en Scala se lee de la siguiente manera:


  1. Se inicializa el array arr con los primeros 5 números enteros consecutivos, comenzando por 1.

  2. Se crea otra variable res, que contiene el resultado de ejecutar la función map sobre el array arr.

  3. En el resultado res, se hace corresponder cada elemento i con i + 10.

  4. Se imprime el resultado.


¿Cómo sabemos ahora si es correcto? En este programa, la clave es la función lambda que pasamos como parámetro de la función map: este lambda simplemente cambia cada elemento del array i, por el elemento i + 10, por lo que no necesitamos ejecutar nada mentalmente. Si el elemento era 1, entonces el nuevo será 1 + 10 = 11, si el elemento era 2, el nuevo elemento será 2 + 10 = 12. El programa es correcto por construcción.


Lo más importante de esto, es que el programa formula el qué se debe hacer, versus el cómo deberíamos hacerlo del listado Java. Podemos entender el programa Scala como la especificación de un problema. Decimos “se debe hacer corresponder cada elemento i del array con el mismo elemento + 10”. En la versión Java, en cambio, se le dice al computador los pasos a seguir para obtener el mismo resultado (“usar una variable i como índice”, “incrementar su valor”, “ejecutar un bucle de instrucciones mientras este índice sea menor que la longitud del array”, etc.).


El programa Scala es más fácil de comprender, mantener, testear, y modificar.


Funciones de alto orden

Las funciones de alto orden como map, flatMap, fold, o reduce, al principio son difíciles de comprender, pero una vez que se comprenden, proveen al programador un gran poder de expresión: se puede hacer más con menos código.


Ya vimos un ejemplo de map en la sección anterior. Veamos algún ejemplo de uso de algunas otras funciones de alto orden. Imaginemos que tenemos un texto y queremos saber cuántas letras suman todas sus palabras, sin contar caracteres especiales.


Lo que tendríamos que hacer en este caso para resolver el problema es:

  1. Partir el texto en palabras

  2. Ver la longitud de cada palabra, ignorando espacios y signos de puntuación (contando sólo letras)

  3. Sumar todas esas longitudes


Listo, especificando el problema lo hemos resuelto.


Veamos:


object EjemploFold {


def contarLetrasTexto(texto: String): Int = {

val palabras: Array[String] = texto.split(" ")


palabras.foldLeft(0)((acc: Int, pal: String) =>

acc + contarLetrasPalabra(pal)

)

}


private def contarLetrasPalabra(palabra: String): Int = {

palabra.count(c => c.isLetter)

}

}


La función foldLeft lo que hace es aplicar una función (u operador) a todos los elementos de una lista o array, de izquierda a derecha, comenzando por un valor inicial, en este caso 0. En el segundo parámetro hemos provisto una expresión lambda. Veamos la firma de la función foldLeft:


def foldLeft[B](z: B)(op: (B, A) => B): B


El primer parámetro de la función, z, es un valor inicial del mismo tipo de retorno del cálculo de la función foldLeft (tipo B). Este tipo B puede ser Int, String, un par ordenado o Tupla, o cualquier otro tipo que tenga sentido en mi cálculo. En este caso, como queremos retornar un número de letras, va a ser un número entero, Int. El segundo parámetro de foldLeft es una función, que toma el resultado parcial anterior B (una longitud parcial entera) y una nueva palabra, e indica cómo computar la longitud de la palabra, e integrarla como un nuevo resultado parcial.


Para nuestro ejemplo, se empieza con un resultado 0 como valor inicial, indicando que si la lista de palabras está vacía (caso base de la recursión), se retorna un 0 como resultado. Luego, en cada paso, se toma el valor parcial anterior acc (por acumulador), y una nueva palabra, y lo que se hace es sumar el acumulado anterior a la cantidad de letras de la palabra actual pal (cómputo que se realiza en una función secundaria contarLetrasPalabra ).


Los pasos que se realizan para un texto de ejemplo son:


contarLetrasTexto("Hola, mi nombre es Juan...")


primero se parte el texto mediante separador espacio “ “, quedando la variable palabras con el valor:


val palabras = Array("Hola,", "mi", "nombre", "es", "Juan...")


luego, se reduce el array anterior palabras con la función foldLeft como sigue:


(((((0 + contarLetrasPalabra("Hola,"))

+ contarLetrasPalabra("mi"))

+ contarLetrasPalabra("nombre"))

+ contarLetrasPalabra("es"))

+ contarLetrasPalabra("Juan..."))



Lo que se reduce a:


(((((0 + 4) + 2) + 6) + 2) + 4) = 18


No se cuentan la coma y los puntos suspensivos, ya que hemos filtrado con la función isLetter.


Esta función, difícil de comprender al principio, provee un gran poder de cómputo para transformar cualquier lista en un tipo de retorno distinto, procesando el resultado en pasos sucesivos de izquierda a derecha de la lista. También existe la función equivalente foldRight que procesa los elementos desde la derecha, y la función fold, que sirve para operaciones asociativas, donde no importan los paréntesis (da lo mismo procesar desde la izquierda o desde la derecha).


En la función secundaria contarLetrasPalabra, también se hace uso de una función de alto orden:


def count(p: (Char) => Boolean): Int


count, toma una función como parámetro, que indica dado un caracter, si lo debe contar o no. En nuestro caso, esta función indicaba que se debe contar el caracter solamente si el mismo fuera una letra y no un número o signo de puntuación.


Otro ejemplo de la función foldLeft podría ser. Dada una lista de números, obtener la letra del abecedario correspondiente, y luego concatenar el resultado en una nueva palabra. Podría ser un cifrador/descifrador de códigos de espías. Por ejemplo:


val arr = Array(65, 82, 66, 79, 76)

val palabra = arr.foldLeft("")((acc: String, num: Int) =>

acc.concat(num.toChar.toString))


Notar que la función foldLeft ahora calcula una cadena de caracteres (en vez de un Int), dado el array de números arr. Se comienza con la cadena vacía y en cada paso se va añadiendo una letra. Puedes adivinar qué palabra secreta se oculta en el código?


Todas estas características son de la PF en general, y aplicables a cualquier lenguaje de programación funcional. Pero, ¿qué tiene de especial o diferente Scala de otros lenguajes de programación?



Características particulares de Scala



Lenguaje multi-paradigma (POO + PF)

Como hemos citado anteriormente, Scala puede ser utilizado al principio de forma imperativa o como un Java “mejorado”, y de a poco, se pueden ir incorporando características de la PF para mejorar aspectos de la Ingeniería de Software como la calidad del código y aspectos como su mantenibilidad y testabilidad.


Lenguaje funcional híbrido

También, Scala es un lenguaje funcional híbrido, frente a lenguajes funcionales puros como Haskell. Esto permite utilizar la programación imperativa en porciones de código donde se necesita mejorar la eficiencia o performance de la aplicación, por ejemplo, donde una solución recursiva o demasiado abstracta no sea viable, o donde se utilicen librerías “legacy” (o heredadas) en donde el código deba ser imperativo.


Interoperabilidad con Java

Tanto Java como Scala, son lenguajes de propósito general, es decir, se pueden usar para casi cualquier proceso computacional. Por ejemplo, para construir sitios web, para sistemas financieros, sistemas de cómputos, APIs REST, etc. Además, ambos lenguajes se compilan a bytecodes y corren sobre la JVM, la máquina virtual de Java.


La interoperabilidad de Scala con Java es uno de los aspectos fundamentales del éxito de Scala, porque cualquier librería de Java (biblioteca JAR o WAR) puede ser reutilizada desde un sistema escrito en Scala. Más aún, la misma aplicación puede contener código Java y código Scala (cada uno en su carpeta o paquete fuente). Por ejemplo, la carpeta src/main/java puede contener código Java y la carpeta src/main/scala puede contener código Scala. Esto permite reutilizar (potencialmente) las billones de aplicaciones Java existentes en el mundo actualmente.


Por ejemplo, la clase LegacyJava puede ser una clase Java heredada (como también podría ser dentro de una librería o dependencia):






y la aplicación Main en Scala hace uso (crea una instancia y ejecuta un método) de esta clase Java dentro del mismo proyecto:






Como vemos en la consola, se ha impreso la cadena “Esta es una aplicación Java” ejecutando la aplicación Scala.



Representación de estructuras de datos usando Case classes

Las case classes de Scala son una forma de definir clases, que tienen ya un comportamiento definido y que permiten utilizar el pattern matching en ellas. Por ejemplo, la siguiente definición en Scala:


case class TemperatureAlarm(temp: Double)


Es equivalente al siguiente código Java:


public class TemperatureAlarm {

private final double temp;

public TemperatureAlarm(double temp) {

this.temp = temp;

}


public double getTemp() {

return temp;

}


@Override

public String toString() {

return "TemperatureAlarm [temp=" + temp + "]";

}


@Override

public int hashCode() {

final int prime = 31;

int result = 1;

long temp;

temp = Double.doubleToLongBits(this.temp);

result = prime * result + (int) (temp ^ (temp >>> 32));

return result;

}


@Override

public boolean equals(Object obj) {

if (this == obj)

return true;

if (obj == null)

return false;

if (getClass() != obj.getClass())

return false;

TemperatureAlarm other = (TemperatureAlarm) obj;

if (Double.doubleToLongBits(temp) !=

Double.doubleToLongBits(other.temp))

return false;

return true;

}

}


Como vemos, la case class de 1 línea de código se corresponde con la clase Java de 40 líneas de código. Desde la case class, el compilador de Scala define los métodos toString, equals, y hashCode, junto con los getters y setters de las propiedades que definamos en nuestra case class, en este caso la variable temp de tipo Double. Además la case class define otros métodos útiles como apply y unapply, que son especiales en Scala y permiten construir y deconstruir una instancia en sus partes constituyentes. También se definen constructores, y azúcar sintáctico, de manera que no hay que usar la palabra reservada new para crear una instancia (ver más abajo cómo se crea una instancia de la clase Persona o Perro).


Además, podemos representar cualquier estructura de datos mediante case classes, anidando las definiciones y utilizando colecciones en sus propiedades.


Otro aspecto importante es que si tenemos por ejemplo, la siguiente definición de case classes:


object Animales extends App {


case class Persona(nombre: String, edad: Int)


trait Animal

case class Perro(nombre: String, dueño: Persona) extends Animal

case class Gato(nombre: String, dueño: Persona) extends Animal


def imprimirDueño(a: Animal): Unit = {

a match {

case Perro(n, d) =>

println(s"El dueño del perro $n es ${d.nombre}")

case Gato(n, d) =>

println(s"El dueño del gato $n es ${d.nombre}")

}

}

// creación de una instancia (sin “new”)

val p: Perro = Perro(nombre = "Holly", dueño = Persona("Juan", 16))


imprimirDueño(p)

}


Podemos hacer pattern matching sobre una instancia de Animal y descomponerlo en sus partes constituyentes, para en este caso imprimir el nombre y el dueño del animal.



Sistema de tipos mejorado

Otro de los aspectos que hacen a Scala un lenguaje más moderno, es su sistema de tipos. Este sistema de tipos representa una mejora considerable sobre el de Java, que tiene algunos inconvenientes.


Ejemplo 1:


public void error1(List<String>... l) {

Object[] oArray = l;

oArray[0] = Arrays.asList(Double.valueOf(3.5));

String s = l[0].get(0);

}


Esta porción de código se compila sin problemas, aunque al ejecutar esta función, se produce un error ya que se quiere convertir un valor numérico en una cadena de caracteres.


Ejemplo 2:


public void error2() {

String[] directions = { "a", "b", "c" };


Object[] objects = directions;

objects[0] = 4; // compiler warning

String s = directions[0];

System.out.println(s);

}


Este ejemplo es similar al anterior, el compilador nos advierte de que el código puede tener problemas, pero nos permite compilar el programa. Al ejecutarlo, otra vez se tiene un error de conversión de tipos, ya que la posición 0 del array objects contiene un valor numérico 4 que no puede convertirse al tipo String.


public void error3() {

List<String> stringList = new ArrayList<>();

List list = stringList;

// compiler warning: unchecked call to add(E) as a member of the raw type java.util.List

list.add(1);

// throws java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

String s = stringList.get(0);

}


Aquí otro tipo de error parecido que podemos tener en Java usando tipos de datos genéricos.


El sistema de tipos de Scala, si bien no es perfecto, es mucho más avanzado y completo que el de Java, y no permite realizar este tipo de conversiones no seguras. Se solucionan varios problemas que tenía o tiene Java (actualmente va por la versión 19).


Además se agregan características como el control de subtipado en tipos genéricos (anotaciones de varianza), y la definición no ambigua de jerarquías de tipos con herencia múltiple (problema del diamante que tenía C++), con la linearización de tipos.


Para el primer caso, la varianza de tipos genéricos, si un tipo Empleado es subtipo del tipo Persona, entonces se puede definir (por ejemplo) si el tipo List[Empleado] debe ser o no un subtipo de un tipo List[Persona].


Ejemplo:


class Persona

class Empleado extends Persona


class Lista[+T]


val listaEmp: Lista[Persona] = new Lista[Empleado]


Aquí, con la anotación de varianza en la definición de la clase genérica Lista sobre el tipo T usando el símbolo “+”, indicamos que la clase Lista es covariante en el tipo T. Esto nos permite definir una lista de empleados y tiparla como una Lista de Persona.


Para el segundo caso, para el problema del diamante de C++, veamos como ejemplo el siguiente programa:


object DiamondProblem extends App {


trait Animal { def sonido: String }

trait Leon extends Animal { override def sonido: String = "ruge" }

trait Perro extends Animal { override def sonido: String = "ladra" }


class Mutante extends Leon with Perro


val m: Mutante = new Mutante

println(m.sonido)

}


Este programa compila perfectamente, aunque la clase Mutante hereda de Leon y de Perro, y no se haya definido el comportamiento para el método o función sonido. ¿Qué creen que se imprimirá en la consola? La linearización de tipos en Scala define el comportamiento por defecto para este tipo de casos donde se tiene herencia múltiple con métodos conflictivos. En este caso, se heredará el método de Perro y se imprimirá en la consola “ladra”.


Otras características importantes del sistema de tipos de Scala son:


  • Jerarquía bien definida de tipos (wrappers de tipos Java)

  • Traits

  • Clases

  • Herencia y mix-in composition

  • Case Classes

  • Singleton Objects

  • Path dependent types

  • Generics (Listas de un tipo T parametrizado por ejemplo)

  • Tipos abstractos

  • Tipos de alto orden (higher kinded types)

  • Y muchos más !




Conclusiones

Hemos visto algunas ventajas de la PF en general, y cómo se aplican a Scala en particular. Scala provee flexibilidad para adaptarse al estilo de programación del usuario, y da un soporte para escribir código imperativo, funcional o una mezcla de ambos, con preferencia por el estilo funcional.


La curva de aprendizaje de Scala es alta, ya que el lenguaje provee muchos “features” o características, pero una vez que se dominan, el programador puede expresar programas complejos con poco esfuerzo, gracias a la expresividad del lenguaje y sus librerías disponibles.


Las ideas de la PF no son nuevas (vienen de los años ‘50), pero Scala hace posible y real la PF en el día de hoy con una eficiencia similar a Java.


Scala es aplicable en sistemas web, paralelos, distribuidos, y sistemas de alta escalabilidad, como pueden ser sitios con millones de visitas diarias o juegos online, procesamiento de Big Data con Spark y otras librerías y un largo etcétera.


La característica más atractiva para un desarrollador Java es que todo el código Java que hay disponible allí fuera es todavía aprovechable y reutilizable desde Scala.


Scala es un lenguaje en pleno crecimiento, con infinitas posibilidades y con una comunidad muy amplia y accesible, con varios congresos al año en todo el mundo. Hay mucha bibliografía para aprender, como cursos online de Coursera (oficiales), Udemy, YouTube y muchas otras plataformas.


Otro aspecto interesante es que muchas compañías pagan sueldos altos para desarrolladores o consultores Scala, por lo que este lenguaje es atractivo desde el punto de vista laboral.





Referencias

[1] Scala es elegante, definición de tipos bien hechos, https://medium.com/techwomenc/scala-es-elegante-fa5c1c40e4f9


[2] Advanced Scala and Functional Programming, Udemy course by Daniel Ciocîrlan from Rock the JVM, https://www.udemy.com/course/advanced-scala/


[3] Why Should You Care About Referential Transparency? Rock the JVM Blog: https://blog.rockthejvm.com/referential-transparency/


[4] Java vs. Scala: Why should I learn Scala? https://www.toptal.com/scala/why-should-i-learn-scala


[5] The Well-Grounded Java Developer, Manning, https://livebook.manning.com/book/the-well-grounded-java-developer/


[6] Functional Programming in Scala Specialization, Coursera online, https://www.coursera.org/specializations/scala


[7] Functional Programming in Scala, (the “red” book), Manning, https://www.manning.com/books/functional-programming-in-scala


[8] Why Functional Programming Matters, John Hughes, The University, Glasgow, https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf


[9] Why Functional Programming Matters, John Hughes, Functional Conf, 2016, https://www.youtube.com/watch?v=XrNdvWqxBvA



Comments


bottom of page