Aprendiendo programación funcional desde cero

Compartir esta publicación

En Apiumhub, nos apasiona compartir conocimientos y animar a los desarrolladores a explorar nuevos paradigmas de programación. Hace un tiempo, lanzamos una interesante serie de vídeos en nuestro canal de YouTube titulada «Aprende programación funcional desde cero». Esta serie utiliza Kotlin para introducir conceptos de programación funcional de una manera atractiva y accesible, empezando por los fundamentos y explorando progresivamente ideas más complejas.

Programación funcional desde cero: ¿Qué entendemos por programación funcional?

Cuando oímos «programación funcional», podríamos pensar instintivamente: «Ah, es programar usando funciones». Incluso podríamos suponer que, puesto que nuestro código ya contiene funciones, hemos practicado la programación funcional sin saberlo. Sin embargo, aunque es posible que estés utilizando algunas construcciones funcionales en tu código, este paradigma de programación va mucho más allá de la simple utilización de funciones. Sigue leyendo para aprender más sobre la programación funcional desde cero.

La programación funcional se basa en varios pilares clave que la diferencian de otros paradigmas de programación:

  1. Inmutabilidad
  2. Funciones puras
  3. Funciones como ciudadanos de primera clase

Estos conceptos fundamentales forman la columna vertebral de la programación funcional y contribuyen a su enfoque único para resolver problemas y estructurar el código.

Inmutabilidad

La inmutabilidad se refiere al hecho de que nuestras estructuras de datos u objetos no pueden modificarse después de su creación. Una vez creado un objeto, siempre será el mismo. Esto garantiza que una vez obtengamos una instancia del mismo, ningún otro proceso podrá modificarlo mientras estemos trabajando con él. En Kotlin, podemos utilizar clases de datos para conseguir este efecto:

data class Student(val age: Int, val name: String)

La ventaja de utilizar clases de datos es que nos proporcionan una solución al problema de, por ejemplo, querer cambiar la edad de un alumno: el operado «copy».

val underAgePeter = Student(17, "Peter")
val adultPeter = underAgePeter.copy(age = 18)
println(underAgePeter)
//Student(age=17, name=Peter)

Copy nos da una nueva instancia con los valores especificados cambiados pero sin modificar la original.

Funciones puras

Las funciones puras deben cumplir dos requisitos:

  1. Que cuando llames a la función con una serie de parámetros el resultado de dicha función sea siempre el mismo. Es decir la función no sea afectada por elementos externos. Esto incluye no leer estados mutables globales, parámetros de entrada mutables, etc.
  2. Que la función no puede tener “side-effects”, es decir una función no puede afectar a elementos externos a ella. No puede mutar nada fuera de su propio scope.

Ejemplo de función pura:

fun add(a: Int, b: Int) = a + b

Ejemplo de función con side effects:

ar sideEffect = "Side Effect"


fun add(a: Int, b: Int): Int {
   if(sideEffect.isEmpty()) return a + b
   else {
       sideEffect = ""
       return 0
   }
}

Esto junto con la inmutabilidad nos garantiza que cuando llamemos a una función el resultado de esta sea siempre el mismo. Puede parecer una tontería pero si la función “suma” cambiará de resultado en función de si antes hemos usado algunos de esos números para hacer una resta nos volveríamos locos para tratar de entender qué está pasando.

Funciones como ciudadanos de primera clase

Si los dos pilares anteriores limitan lo que podemos hacer, este pilar es todo lo contrario: amplía las posibilidades de expresión en nuestro código. Que las funciones sean ciudadanos de primera clase significa que podemos utilizarlas como cualquier otro tipo de datos, por ejemplo, almacenando funciones en variables o devolviendo una función cuando llamamos a otra.

  El principio No te repitas ( DRY )

Podemos almacenar una función en una variable:

fun add(a: Int, b: Int) = a + b
var addFunction = ::add
println(addFunction(1, 2))
//3

O retornarlas de otra función:

fun myFunction() = fun() {
   println("hello")
}

Cuando una función acepta como parámetro o retorna otra función si dice que es una función de orden superior (Higher order function en inglés). De hecho es algo tan común en programación funcional que normalmente se usan funciones sin nombre, anónimas, como en el último ejemplo. En Kotlin hay disponible una sintaxis más amigable para este tipo de funciones y se les denomina expresiones lambda en las que podemos omitir la keyword fun.

val add = { a: Int, b: Int ->
   a + b
}
println(add(4,5))
//9

Usando la sintaxis para lambdas podemos redefinir la suma anterior así. En este caso puede parecer que no sirve de nada pero cuando la función es un parámetro de entrada son extremadamente útiles.

fun printAction(action: (Int) -> String) {
   println(action(4))
}
printAction { number -> "The number I was given is $number" }
//The number I was given is 4

Como se puede observar al llamar printAction le pasamos una lambda que define cómo convertir un número en un string mientras que la función printAction solo se encarga de hacer el printado y no sabe cómo se va a transformar el número.

Otra cosa relevante es el tipo del parámetro «action» (Int) -> String. Este tipo indica que se trata de una función que recibe un entero como parámetro y devuelve un String. Los tipos de funciones siguen el patrón: (tipos de entrada separados por comas) -> tipo de retorno.

() -> Int
(Int, Int) -> Int
((Int) -> Int) -> String
(Int) -> ((String ) -> Int)

Evidentemente estos tipos se pueden usar tanto como tipo de entrada como de retorno ya que las funciones son un tipo más dentro de nuestro sistema de tipos. Cuando una lambda tiene un único parámetro podemos omitir su definición y tendremos acceso por defecto a él con el nombre “it”.

val printIt: (Any) -> Unit = { println(it) }
printIt(4)
//4

Programación funcional desde cero: Constructos funcionales

Ahora que ya tenemos una idea de que es la programación funcional así como unas bases para poder interpretar el código vamos a ver una serie de constructos funcionales que nos pueden resultar útiles tanto dentro de este paradigma como en programación orientada a objetos si nuestro lenguaje nos lo permite.

Antes de empezar debemos aclarar la idea de “extension functions” en kotlin. Kotlin nos proporciona una forma de definir funciones fuera de la propia clase. Esto nos permite extender el comportamiento de clases sobre las que no tenemos el control y es equivalente a escribir un método estático que recibe una instancia de la clase como primer parámetro.

Usando como ejemplo la clase Student de antes, podemos definir una función “greeting” que nos retorna un string en el que se presenta.

fun Student.greeting() = "Hello I'm $name and I am $age years old"
val joe = Student(18, "Joe")
println(joe.greeting())
//Hello I'm Joe and I am 18 years old

Para escribir esta función no hace falta tener acceso al código de Student para modificarlo.

En esta sección usaremos esta sintaxis para facilitarnos la definición de funciones.

Map

fun <T, R> List<T>.map(transform: (T) -> R): List<R>

Map como concepto es bastante fácil pero cuando lo vemos por primera vez puede parecer complicado. Map lo que hace es mapear elementos de un conjunto T a un conjunto R, donde por cada elemento de T le corresponde un solo elemento de R, dicho de otra manera transforma Ts en Rs.

  Desmitificando Redux

Veamos un ejemplo concreto:

val names = listOf("Joe", "Jane", "Danny")
val nameLengths = names.map { it.length }
println(nameLengths)
//[3, 4, 5]

En este ejemplo tenemos una lista de nombres y queremos saber el tamaño de esos nombres, para ello usamos “map”. A map le pasamos la función de cómo queremos mapear o transformar los elementos de la lista, en este caso queremos saber el tamaño.

También podemos encadenar tantos maps como queramos:

val students = listOf(Student(19, "Joe"), Student(18, "Jane"), Student(17, "Danny"))
val fieldLenghts = students
   .map { it.name }
   .map { it.length }
   .map { it*2 }
println(fieldLenghts)

En este caso, sacamos el tamaño de los nombres, después lo duplicamos. Esto podría representar por ejemplo el tamaño máximo necesario para algún tipo de formulario, por ejemplo. Como se puede ver en el último paso, la transformación no tiene porque llevarnos a tipos distintos, podemos simplemente transformar de Int a Int si hace falta.

Además del map normal, Kotlin nos proporciona otras funciones para cubrir casos que se dan a menudo:

mapIndexed

Nos proporciona además del elemento a transformar su posición en la lista.Si la lista representa los resultados de una carrera. Podríamos escribir algo como esto:

val results = names.mapIndexed { index, element ->
   "$element ended in position ${index+1}"
}
println(results)
//[Joe ended in position 1, Jane ended in position 2, Danny ended in position 3]
mapNotNull

Nos retorna el resultado de la transformación pero elimina cualquier resultado nulo que pueda haber:

val names = listOf("Joe", null, "Jane", "Danny")
val results = names.map { it?.length }
//[3, null, 4, 5]
val resultsNoNulls = names.mapNotNull { it?.length }
//[3, 4, 5]

Filter

fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>

Comparado map, filter es más sencillo de visualizar, filtra elementos de una lista. Le damos una función que ha de retornar true si el elementos debe quedarse y false si nos queremos deshacer de él.

val students = listOf(Student(19, "Joe"), Student(18, "Jane"), Student(17, "Danny"))
val adultStudents = students.filter { it.age >= 18 }
println(adultStudents)
//[Student(age=19, name=Joe), Student(age=18, name=Jane)]

Al igual que con el map podemos combinar estos operadores para crear lógicas más complejas.

val adultStudentsShortNames = students
   .filter { it.age >= 18 }
   .map { it.name }
   .filter { it.length < 4 }
println(adultStudentsShortNames)
//[Joe]

Acumuladores

Los acumuladores son funciones terminales. Implican pasar de trabajar con una lista a trabajar con otro tipo de datos. Algunos ejemplos de operadores terminales podrían ser:

  • «max» que devuelve el mayor elemento de la lista
val maxStudentAge = students
   .map { it.age }
   .max()
println(maxStudentAge)
//19
  • «maxOf» que nos permite combinar el map y la llamada a max
val maxStudentAge = students.maxOf { it.age }
println(maxStudentAge)
//19
  • o «count» que cuenta cuántos elementos hay en la lista
val numberOfAdultStudents = students.count { it.age >= 18 }
println(numberOfAdultStudents)
//2

Pero si tenemos necesidades más complejas existen acumuladores donde podemos definir el comportamiento que tienen más al detalle

fold
fun <T, R> Iterable<T>.fold(initial: R, operation: (acc: R, T) -> R): R

Fold tiene, además de la lista dos argumentos más, initial: el valor inicial con el que realizará las operaciones y operation: la operación a realizar. Lo que hace es cogiendo initial como primer valor lo combina con el primer elemento de la lista y el resultado de esta operación lo combina con el segundo y así sucesivamente. Si la lista está vacía retorna directamente el valor inicial. Esto nos permite combinar toda una lista a un solo valor según necesitemos en cada momento. Por ejemplo, este codigo suma el tamaño de todos los nombres de los alumnos:

  Sobre Dioses y Procrastinación: Gestión ágil de proyectos

val totalNamesLength = students.fold(0) { acc, next ->
   acc + next.name.length
}
println(totalNamesLength)
//12

Cabe destacar que esta operación también es lo suficientemente común como para tener su propia función “sumOf” pero nos sirve para ilustrar como el resultado de fold no tiene porqué del mismo tipo contenido en la lista y en la práctica la operación puede ser todo lo compleja que podamos imaginar, por ejemplo teniendo como estado inicial un estado de una vista y cada elemento de la lista una operación a realizar sobre este produciendo al final el estado final de la vista tras todas las operaciones.

reduce
fun <S, T : S> Iterable<T>.reduce(operation: (acc: S, T) -> S): S

Reduce es muy similar a fold pero tiene una diferencia crucial, no le proporcionamos elemento inicial, coge como valor inicial el primer elemento de la lista. Esto tiene consigo dos consecuencias importantes: la primera, si la lista está vacía la función no está definida y fallará. Y la segunda, el tipo de retorno ha de ser el mismo tipo que el de los elementos de la lista (o un supertipo de estos)

Lo mismo que hacíamos en el ejemplo anterior con el fold lo podemos hacer así con el reduce:

val totalNamesLength = students
   .map { it.name.length }
   .reduce { acc, next -> acc+next }
println(totalNamesLength)
//12

Pero si la lista estuviera vacía, nos daría el error:

java.lang.UnsupportedOperationException: Empty collection can't be reduced.

Tiene unos casos de uso más reducidos que “fold” pero en casos en los que tanto el resultado como el tipo de la lista son el mismo y podemos garantizar que la lista no es vacía o queremos fallar en caso de que lo sea, reduce nos permite hacer el mismo trabajo sin tener que inventar un valor inicial que no distorsione el resultado final.

Programación funcional desde cero: conclusión

Con esto, hemos cubierto los cuatro primeros capítulos de nuestra serie Programación Funcional Desde Cero. En el canal de YouTube de Apiumhub tienes dos vídeos más en los que ponemos a prueba los conceptos introducidos en este artículo utilizando ejercicios del Advent of Code 2023, así como dos vídeos adicionales que introducen conceptos algo más avanzados pero no por ello menos útiles para nuestro trabajo diario.

La conclusión que intentamos presentar tanto en la serie como en este artículo es que la programación funcional no tiene porqué ser complicada y que la podemos introducir en nuestro en nuestro flujo de trabajo en la medida en que nos sea cómoda y útil. Personalmente, los conceptos introducidos son cosas que utilizo prácticamente todos los días en mi trabajo y que muchas veces me facilitan mucho la vida.

Espero que os haya gustado y que os haya entrado el gusanillo por ver qué más tiene que ofrecer este paradigma de programación.

Author

  • Eric Martori

    Software engineer with over 6 years of experience working as an Android Developer. Passionate about clean architecture, clean code, and design patterns, pragmatism is used to find the best technical solution to deliver the best mobile app with maintainable, secure and valuable code and architecture. Able to lead small android teams and helps as Android Tech Lead.

    Ver todas las entradas

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos

apiumhub software development projects barcelona
Secured By miniOrange