Paradigma Funcional en la
Programación Orientada a Objetos
Colecciones: Expresiones Lambda, Orden
Superior, Optionals
por Clara Allende
Junio 2019
Basado en Programación Funcional: Expresiones Lambda
por Fernando Dodino
Franco Bulgarelli
Matías Freyre
Carlos Lombardi
Nicolás Passerini
Daniel Solmirano
Versión 2.0
Marzo 2017
Distribuido bajo licencia C
reative Commons Share-a-like
Contenido
1 Introducción
2 Uso efectivo de lambdas
2.1 Lambdas con filter y map
2.2 Consecuencias de las lambdas
3 Lambdas en otros Lenguajes
3.1 Scala
3.2 Java
4 (Una MUY breve explicación de) Option/Optional
2 de 12
DISCLAIMER: Hay MUCHO que contar sobre las ideas de programación
funcional, en particular sobre lambdas y orden superior. En este apunte
queremos enfocarnos puntualmente en el uso de lambdas como argumentos de
las funciones de orden superior que usamos para manejar colecciones
(map/filter/etc) y cómo las usamos en la api de Streams; haciendo un contraste
con las implementaciones de otros lenguajes más funcionales que Java.
Si quieren saber más:
● Sobre aprender a programar en funcional: pueden meterse en M umuki.
● Sobre Haskell: chequear Learn You A Haskell (For Great Good)
● Sobre Scala: F
unctional Programming Principles in Scala
● Sobre Streams API: Stream in Java
1 Introducción
Las expresiones lambdas permiten definir funciones anónimas que se usan en
un contexto limitado (el de la misma función que estoy definiendo). Al no tener
nombre es una variante menos expresiva que una función cuadrado o suma,
que además se pueden utilizar en diferentes contextos. No obstante son una
herramienta muy útil, como veremos más adelante.
En H askell se codifica así:
\x -> x * x
Y también podemos definirla como:
cuadrado = \x -> x * x
La contrabarra (\) es el símbolo que remite a la letra griega lambda λ que como
habrán notado es el ícono de la programación funcional. Luego de los
parámetros que se separan por espacios, la flecha -> termina de definir el
cuerpo de la función.
Se evalúa de esta manera:
λ (\x -> x * x) 2
4
Otro ejemplo:
λ (\x y -> x + y) 2 3
5
3 de 12
2 Uso efectivo de lambdas
2.1 Lambdas con filter y map
Las lambdas son útiles cuando queremos trabajar con funciones de orden
superior (funciones que reciben otras funciones como argumentos) y no
tenemos necesidad de reutilizar una expresión en otro contexto:
λ filter (\cliente -> edad cliente > 40) clientes
λ map (\cliente -> edad cliente) clientes
2.2 Consecuencias de las lambdas
Algo importante a tener en cuenta es que si no le damos un nombre a nuestras
funciones podríamos perder abstracciones útiles que podrían luego ser
utilizadas en otros puntos de nuestro programa, por lo tanto es importante ser
criteriosos respecto nombrar o no una función.
Por lo general, si tengo una forma sencilla de nombrar una determinada lógica
que forma parte de una función más grande, lo más probable es que no quiera
definir ese pedacito de lógica usando una lambda, sino con una función que se
llame como la idea que tenemos en la cabeza. Si no hay un nombre claro
asociado a ese pedacito de lógica, lo más probable es que no sea un concepto
del dominio que merezca la pena modelar como algo aparte.
4 de 12
3 Lambdas en otros Lenguajes
3.1 Scala
Scala y otros lenguajes multi-paradigma (que implementan ideas de más de un
paradigma a la vez, como JavaScript, Python, Kotlin, etc) tomaron estas idea de
funciones anónimas y las llevaron a la programación orientada a objetos.
Por ejemplo, las mismas funciones de antes en Scala se ven así:
3.2 Java
En Java todo era bastante más difícil, en particular las operaciones sobre las
colecciones eran bastante tediosas, ya que como no existía una abstracción
sobre cómo se recorre la colección
Por ejemplo, para obtener el subconjunto de clientes mayores de 40 años:
public List<Cliente> filterClientesMayores() {
List<Cliente> clientesMayores = new ArrayList();
for (cliente: clientes) {
if (cliente.getEdad() > 40) clientesMayores.add(cliente);
}
return clientesMayores;
}
5 de 12
y si quiero los clientes menores a 21…
public List<Cliente> clientesMenoresA21() {
List<Cliente> clientesMayores = new ArrayList();
for (cliente: clientes) {
if (cliente.getEdad() < 21) clientesMayores.add(cliente);
}
return clientesMayores;
}
Algo similar pasa si quiero por ejemplo quedarme con las edades de los clientes:
public List<Integer> mapEdadesClientes() {
List<Integer> edades = new ArrayList();
for (cliente: clientes) {
clientesMayores.add(cliente.getEdad());
}
return edades;
}
Y si ahora quiero los nombres:
public List<String> mapNombresClientes() {
List<String> nombres = new ArrayList();
for (cliente: clientes) {
clientesMayores.add(cliente.getNombre());
}
return nombres;
}
Lo que está faltando ahí es una abstracción, algo que “encapsule” como se
recorre la colección para ver cuáles elementos cumplen (lo que hace f ilter) o
para aplicarles una transformación (lo que hace map); y poder pasar como
parámetro la función que quiero usar en cada caso...
Durante mucho tiempo, se desarrollaron librerías que “agregaban” este tipo de
abstracciones más funcionales a las colecciones de Java. Las más conocidas son
Google Guava (que agregaba otras ideas funcionales como Optionals) y Apache
Commons. Sin embargo, ninguna era una solución completamente feliz, ya que
6 de 12
seguía siendo difícil leer el código, dado que el lenguaje no soportaba estas
abstracciones nativamente. Los mismos métodos en Guava se ven así:
public List<Cliente> filterClientesMayores() {
return Iterables.filter(clientes, new Predicate<Cliente>() {
@Override
public boolean apply(Cliente cliente) {
return cliente.getEdad() > 40;
}})
}
public List<Integer> mapEdadesClientes() {
return Iterables.transform(clientes, new Function<Cliente,
Integer>() {
@Override
public Integer apply(Cliente cliente) {
return cliente.getEdad();
})
}
Si bien representaba una mejora respecto a repetir los for, tiene la complejidad
de tener que conocer cómo funcionan Predicate y Function y es bastante
verboso. ApacheCommons funcionaba de manera similar…
Ahora bien, lo interesante es que estas abstracciones “Predicate” y “Function”
son lo que en Java se conoce como clases anónimas, o sea, clases que creamos
ad-hoc, en el lugar que necesitamos, y no las usamos más. Esto es lo más
parecido que teníamos en Java a una función anónima o una lambda
expression… pero seguíamos sin tener nada propio del lenguaje que nos
permitiera hacer estas operaciones de manera menos compleja.
Hasta que en Java 8 finalmente escucharon a la comunidad, e introdujeron la api
de Streams.
public List<Cliente> filterClientesMayores() {
return clientes.stream().filter(cliente -> cliente.getEdad() > 40)
.collect(Collectors.toList());
}
public List<Integer> mapEdadesClientes() {
return clientes.stream().map(cliente -> cliente.getEdad());
}
7 de 12
¿Por qué tardaron tanto? Básicamente porque para que pudiésemos escribir
expresiones lambda, había que cambiar como el compilador hace la traducción
del código Java a código de máquina.
¿Qué es un Stream?
En programación funcional, las operaciones no tienen efecto de lado, porque los
objetos son inmutables: no se modifican. Entonces, las operaciones como map y
filter no cambian la colección original, sino que devuelven una nueva, del mismo
tipo, con el resultado de aplicar la operación a los elementos de la lista.
Eso permite “encadenarlas” de manera de formar una secuencia de pasos, algo
así como un “tubo” (pipeline en inglés) por el que a medida que pasamos,
aplicamos una función al resultado del paso anterior:
En Java, la manera de modelar este pipeline es con Stream. Para mantener la
retrocompatibilidad con las versiones anteriores no modificaron las interfaces
de Collection, si no de generar un objeto intermedio, el stream, al que si le
definieron abstracciones funcionales. Por eso no es tan feliz como nos gustaría,
pero simplifica muchísimo las cosas.
8 de 12
Como “cultura general” les dejo una tabla que compara algunas operaciones
sobre colecciones entre Haskell, Java y Scala.
La nomenclatura es:
● f(x) es una transformación. Si el tipo de la colección es A, entonces el tipo
de f es f: A => B (donde B es otro tipo distinto)
● c(x) es un criterio, una condición. Si el tipo de la colección es A, entonces el
tipo de c es c: A => Boolean (es decir, devuelve true o false)
Operación Haskell Scala Java
aplicar una map (\x -> f(x)) lista lista.map(x => f(x)) lista.stream().map(x -> f(x))
transformación .collect(CollectorsToList());
obtener un filter (\x -> c(x)) lista lista.filter(x => c(x)) lista.stream().filter(x -> c(x))
subconjunto .collect(Collectors.toList());
según una
condición
saber si al menos any (\x -> c(x)) lista lista.exists(x => c(x)) lista.stream()
un elemento .anyMatch(x -> c(x));
cumple una
condición
saber si todos los all (\x -> c(x)) lista lista.forall(x => c(x)) lista.stream()
elementos .allMatch(x -> c(x))
cumplen con una
condición
reducción fold/foldl/foldr fold/reduce reduce
(obtener un
elemento a partir
de una lista)
9 de 12
4. (Una MUY breve explicación de) Option/Optional
Un tipo de operación que suele ser común cuando trabajamos con colecciones
es lo que en los lenguajes funcionales se conoce como find: obtener un
elemento de una colección que cumpla una condición (cualquiera, generalmente
el primero que cumple).
Una manera fácil de implementar esta operación es usar filter:
Scala:
val clientes = List(new Cliente("Clari", 29), new Cliente("Leo",
33), new Cliente("Claudio", 60))
def unoMayorA20 = clientes.filter(cliente => cliente.edad > 20).head
Java:
public Cliente unoMayorA20() {
return clientes.stream()
.filter(cliente -> cliente.edad > 20)
.collect(Collectors.toList())
.get(0);
}
(En Java no existe un método head, así que hacemos un get(0) que nos devuelve
el primer elemento de la colección).
Ambas implementaciones tienen el mismo problema: ¿qué pasa si la colección
está vacía? Lo mismo, explota (tira una excepción):
En Java lanza IndexOutOfBounds, porque estamos queriendo acceder a un
posición que no existe.
10 de 12
Entonces… el paradigma funcional tiene una abstracción para modelar estos
casos en los que el dato puede estar o no: en Haskell se llama Maybe, en Scala
se llama Option y en Java Optional. Y así está implementado find en los tres
lenguajes.
Volviendo al ejemplo:
Scala:
def unoMayorA20 = clientes.find(cliente => cliente.edad > 20)
Java:
public Optional<Cliente> unoMayorA20() {
return clientes.stream()
.filter(cliente -> cliente.edad > 20)
.findFirst()
}
Detalles: en Java sólo existe findAny/findFirst; no hay ninguna versión que
permita parametrizar un criterio :(
Básicamente, en ambos casos el tipo Option/Optional modela un dato que
puede estar o no; por eso en ambos casos tenemos que hacer un get() del valor
contenido. Si no está presente, lanza una excepción (NoSuchElementException).
¿Por qué/para qué usar Optional?
● de alguna manera elimina la necesidad de chequear por null. El resultado
de la operación es de tipo Optional y lo puedo manejar
“polimórficamente”; ya que existen muchos métodos que están
preparados para hacer cosas siempre que haya algún valor presente.
● queda explícito en la firma del método/variable que el resultado de la
operación es algo que puede estar o no, lo cual nos permite tomar
decisiones al respecto.
● Las cosas que en Java devuelven null podemos modelarlas usando
Optional, permitiéndonos modelarlas con objetos (null no es un objeto,
por lo tanto, no le puedo mandar mensajes, por eso hay que hacer los
chequeos)
11 de 12
Desventajas:
● Como queda explícito en la firma, todos los métodos deben manejar el
hecho de que ese valor puede no estar, ya sea devolviendo Option, o
manejando explícitamente que pasa cuando no está.
Otras operaciones que devuelven Optional en la api de streams de Java son max
y min, que sirven para obtener el mayor/menor bajo un cierto criterio (una
relación de orden):
clientes.stream()
.max(Comparator.comparing(cliente-> cliente.getEdad())).get();
Además, max y min necesitan como argumento una instancia de Comparator,
que es cómo se modela la relación de orden que usamos de criterio.
12 de 12