Prepare su código Android para el futuro, Parte 1: Fundamentos de programación funcional y reactiva

Publicado: 2022-08-31

Escribir código limpio puede ser un desafío: las bibliotecas, los marcos y las API son temporales y se vuelven obsoletos rápidamente. Pero los conceptos y paradigmas matemáticos son duraderos; requieren años de investigación académica e incluso pueden durar más que nosotros.

Este no es un tutorial para mostrarle cómo hacer X con la Biblioteca Y. En cambio, nos enfocamos en los principios perdurables detrás de la programación funcional y reactiva para que pueda construir una arquitectura de Android confiable y preparada para el futuro, y escalar y adaptarse a los cambios sin comprometer eficiencia.

Este artículo sienta las bases, y en la Parte 2, nos sumergiremos en una implementación de la programación reactiva funcional (FRP), que combina la programación funcional y reactiva.

Este artículo está escrito pensando en los desarrolladores de Android, pero los conceptos son relevantes y beneficiosos para cualquier desarrollador con experiencia en lenguajes de programación en general.

Programación Funcional 101

La programación funcional (FP) es un patrón en el que construye su programa como una composición de funciones, transformando datos de $A$ a $B$, a $C$, etc., hasta lograr el resultado deseado. En la programación orientada a objetos (POO), le dices a la computadora qué hacer instrucción por instrucción. La programación funcional es diferente: renuncia al flujo de control y define una "receta de funciones" para producir su resultado en su lugar.

Un rectángulo verde a la izquierda con el texto "Entrada: x" tiene una flecha que apunta a un rectángulo gris claro con la etiqueta "Función: f". Dentro del rectángulo gris claro, hay tres cilindros con flechas que apuntan hacia la derecha: el primero es azul claro con la etiqueta "A(x)", el segundo es azul oscuro con la etiqueta "B(x)" y el tercero es gris oscuro con la etiqueta "C". (X)." A la derecha del rectángulo gris claro, hay un rectángulo verde con el texto "Salida: f(x)". La parte inferior del rectángulo gris claro tiene una flecha que apunta hacia el texto "Efectos secundarios".
El patrón de programación funcional

FP se origina en las matemáticas, específicamente en el cálculo lambda, un sistema lógico de abstracción de funciones. En lugar de conceptos de programación orientada a objetos como bucles, clases, polimorfismo o herencia, FP se ocupa estrictamente de abstracciones y funciones de orden superior, funciones matemáticas que aceptan otras funciones como entrada.

En pocas palabras, FP tiene dos "jugadores" principales: datos (el modelo o información requerida para su problema) y funciones (representaciones del comportamiento y transformaciones entre datos). Por el contrario, las clases de programación orientada a objetos vinculan explícitamente una estructura de datos específica de dominio en particular, y los valores o el estado asociado con cada instancia de clase, a los comportamientos (métodos) que se pretende utilizar con ella.

Examinaremos más de cerca tres aspectos clave de la PF:

  • FP es declarativo.
  • FP utiliza la composición de funciones.
  • Las funciones de FP son puras.

Un buen punto de partida para profundizar en el mundo de FP es Haskell, un lenguaje puramente funcional y fuertemente tipado. Recomiendo Learn You a Haskell for Great Good! tutorial interactivo como un recurso beneficioso.

Ingrediente #1 de FP: Programación declarativa

Lo primero que notará sobre un programa de FP es que está escrito en estilo declarativo, en lugar de imperativo. En resumen, la programación declarativa le dice a un programa lo que debe hacerse en lugar de cómo hacerlo. Aterricemos esta definición abstracta con un ejemplo concreto de programación imperativa versus declarativa para resolver el siguiente problema: Dada una lista de nombres, devuelva una lista que contenga solo los nombres con al menos tres vocales y con las vocales en letras mayúsculas.

Solución imperativa

Primero, examinemos la solución imperativa de este problema en Kotlin:

 fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

Ahora analizaremos nuestra solución imperativa teniendo en cuenta algunos factores clave de desarrollo:

  • Más eficiente: esta solución tiene un uso de memoria óptimo y funciona bien en el análisis Big O (basado en un número mínimo de comparaciones). En este algoritmo, tiene sentido analizar el número de comparaciones entre caracteres porque esa es la operación predominante en nuestro algoritmo. Sea $n$ el número de nombres y sea $k$ la longitud media de los nombres.

    • Número de comparaciones en el peor de los casos: $n(10k)(10k) = 100nk^2$
    • Explicación: $n$ (bucle 1) * $10k$ (para cada carácter, lo comparamos con 10 vocales posibles) * $10k$ (ejecutamos la comprobación isVowel() de nuevo para decidir si se debe escribir en mayúsculas el carácter; de nuevo, en el peor de los casos, esto se compara con 10 vocales).
    • Resultado: dado que la longitud promedio del nombre no superará los 100 caracteres, podemos decir que nuestro algoritmo se ejecuta en $O(n)$ tiempo.
  • Complejo con poca legibilidad: en comparación con la solución declarativa que consideraremos a continuación, esta solución es mucho más larga y más difícil de seguir.
  • Propenso a errores: el código muta el result , vowelsCount y transformedChar ; estas mutaciones de estado pueden conducir a errores sutiles como olvidarse de restablecer vowelsCount de nuevo a 0. El flujo de ejecución también puede volverse complicado, y es fácil olvidarse de agregar la instrucción break en el tercer ciclo.
  • Mantenibilidad deficiente: debido a que nuestro código es complejo y propenso a errores, puede ser difícil refactorizar o cambiar el comportamiento de este código. Por ejemplo, si el problema se modificara para seleccionar nombres con tres vocales y cinco consonantes, tendríamos que introducir nuevas variables y cambiar los bucles, dejando muchas oportunidades para errores.

Nuestra solución de ejemplo ilustra el aspecto complejo del código imperativo, aunque podría mejorar el código refactorizándolo en funciones más pequeñas.

Solución declarativa

Ahora que entendemos lo que no es la programación declarativa, desvelemos nuestra solución declarativa en Kotlin:

 fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

Usando los mismos criterios que usamos para evaluar nuestra solución imperativa, veamos cómo se sostiene el código declarativo:

  • Eficiente: las implementaciones imperativa y declarativa se ejecutan en tiempo lineal, pero la imperativa es un poco más eficiente porque he usado name.count() aquí, que continuará contando las vocales hasta el final del nombre (incluso después de encontrar tres vocales ). Podemos solucionar fácilmente este problema escribiendo una simple hasThreeVowels(String): Boolean . Esta solución utiliza el mismo algoritmo que la solución imperativa, por lo que aquí se aplica el mismo análisis de complejidad: nuestro algoritmo se ejecuta en $O(n)$ tiempo.
  • Conciso con buena legibilidad: la solución imperativa es de 44 líneas con sangría grande en comparación con la longitud de nuestra solución declarativa de 16 líneas con sangría pequeña. Las líneas y tabulaciones no lo son todo, pero es evidente a partir de un vistazo a los dos archivos que nuestra solución declarativa es mucho más legible.
  • Menos propenso a errores: en esta muestra, todo es inmutable. Transformamos una List<String> de todos los nombres en una List<String> de nombres con tres o más vocales y luego transformamos cada palabra String en una palabra String con vocales mayúsculas. En general, no tener mutación, bucles anidados o interrupciones y renunciar al flujo de control hace que el código sea más simple con menos margen de error.
  • Buena capacidad de mantenimiento: puede refactorizar fácilmente el código declarativo debido a su legibilidad y solidez. En nuestro ejemplo anterior (digamos que el problema se modificó para seleccionar nombres con tres vocales y cinco consonantes), una solución simple sería agregar las siguientes declaraciones en la condición de filter : val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 .

Como un aspecto positivo adicional, nuestra solución declarativa es puramente funcional: cada función en este ejemplo es pura y no tiene efectos secundarios. (Más sobre la pureza más adelante).

Solución declarativa adicional

Echemos un vistazo a la implementación declarativa del mismo problema en un lenguaje puramente funcional como Haskell para demostrar cómo se lee. Si no está familiarizado con Haskell, tenga en cuenta que el . operador en Haskell se lee como "después". Por ejemplo, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels traduce como "asignar vocales a mayúsculas después de filtrar los nombres que tienen tres vocales".

 import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]

Esta solución funciona de manera similar a nuestra solución declarativa de Kotlin, con algunos beneficios adicionales: es legible, simple si comprende la sintaxis de Haskell, puramente funcional y perezosa.

Conclusiones clave

La programación declarativa es útil tanto para FP como para Programación reactiva (que trataremos en una sección posterior).

  • Describe "qué" desea lograr, en lugar de "cómo" lograrlo, con el orden exacto de ejecución de las declaraciones.
  • Abstrae el flujo de control de un programa y en su lugar se enfoca en el problema en términos de transformaciones (es decir, $A \rightarrow B \rightarrow C \rightarrow D$).
  • Fomenta un código menos complejo, más conciso y más legible que es más fácil de refactorizar y cambiar. Si su código de Android no se lee como una oración, probablemente esté haciendo algo mal.

Si su código de Android no se lee como una oración, probablemente esté haciendo algo mal.

Pío

Aún así, la programación declarativa tiene ciertas desventajas. Es posible terminar con un código ineficiente que consume más RAM y funciona peor que una implementación imperativa. La clasificación, la retropropagación (en el aprendizaje automático) y otros "algoritmos de mutación" no encajan bien con el estilo de programación inmutable y declarativo.

Ingrediente FP #2: Composición de funciones

La composición de funciones es el concepto matemático en el corazón de la programación funcional. Si la función $f$ acepta $A$ como entrada y produce $B$ como salida ($f: A \rightarrow B$), y la función $g$ acepta $B$ y produce $C$ ($g: B \rightarrow C$), luego puede crear una tercera función, $h$, que acepta $A$ y produce $C$ ($h: A \rightarrow C$). Podemos definir esta tercera función como la composición de $g$ con $f$, también anotada como $g \circ f$ o $g(f())$:

Un cuadro azul con la etiqueta "A" tiene una flecha, "f", que apunta a un cuadro azul con la etiqueta "B" que tiene una flecha, "g", que apunta a un cuadro azul con la etiqueta "C". El recuadro "A" también tiene una flecha paralela, "g o f", que apunta directamente al recuadro "C".
Funciones f, g y h, la composición de g con f.

Cada solución imperativa se puede traducir a una declarativa descomponiendo el problema en problemas más pequeños, resolviéndolos de forma independiente y recomponiendo las soluciones más pequeñas en la solución final a través de la composición de funciones. Veamos nuestro problema de nombres de la sección anterior para ver este concepto en acción. Nuestros problemas más pequeños de la solución imperativa son:

  1. isVowel :: Char -> Bool : dado un Char , devuelve si es una vocal o no ( Bool ).
  2. countVowels :: String -> Int : Dado un String , devuelve el número de vocales que contiene ( Int ).
  3. hasThreeVowels :: String -> Bool : Dado un String , devuelve si tiene al menos tres vocales ( Bool ).
  4. uppercaseVowels :: String -> String : dado un String , devuelve un nuevo String con vocales mayúsculas.

Nuestra solución declarativa, lograda a través de la composición de funciones, es map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels .

Un diagrama superior tiene tres cuadros azules "[String]" conectados por flechas que apuntan hacia la derecha. La primera flecha está etiquetada como "filter has3Vowels" y la segunda está etiquetada como "map uppercaseVowels". A continuación, un segundo diagrama tiene dos cuadros azules a la izquierda, "Char" en la parte superior y "String" a continuación, que apunta a un cuadro azul a la derecha, "Bool". La flecha de "Char" a "Bool" tiene la etiqueta "isVowel" y la flecha de "String" a "Bool" tiene la etiqueta "has3Vowels". El cuadro "Cadena" también tiene una flecha que apunta a sí misma etiquetada como "mayúsculas".
Un ejemplo de composición de funciones usando nuestro problema de nombres.

Este ejemplo es un poco más complicado que una simple fórmula $A \rightarrow B \rightarrow C$, pero demuestra el principio detrás de la composición de funciones.

Conclusiones clave

La composición de funciones es un concepto simple pero poderoso.

  • Proporciona una estrategia para resolver problemas complejos en los que los problemas se dividen en pasos más pequeños y simples y se combinan en una solución.
  • Proporciona bloques de construcción, lo que le permite agregar, eliminar o cambiar fácilmente partes de la solución final sin preocuparse por romper algo.
  • Puede componer $g(f())$ si la salida de $f$ coincide con el tipo de entrada de $g$.

Al componer funciones, puede pasar no solo datos sino también funciones como entrada a otras funciones, un ejemplo de funciones de orden superior.

Ingrediente FP #3: Pureza

Hay un elemento clave más para la composición de funciones que debemos abordar: Las funciones que compongas deben ser puras , otro concepto derivado de las matemáticas. En matemáticas, todas las funciones son cálculos que siempre producen el mismo resultado cuando se les llama con la misma entrada; esta es la base de la pureza.

Veamos un ejemplo de pseudocódigo usando funciones matemáticas. Supongamos que tenemos una función, makeEven , que duplica la entrada de un entero para hacerlo par, y que nuestro código ejecuta la línea makeEven(x) + x usando la entrada x = 2 . En matemáticas, este cálculo siempre se traduciría en un cálculo de $2x + x = 3x = 3(2) = 6$ y es una función pura. Sin embargo, esto no siempre es cierto en la programación: si la función makeEven(x) x duplicándola antes de que el código arrojara nuestro resultado, entonces nuestra línea calcularía $2x + (2x) = 4x = 4(2) = 8$ y, lo que es peor, el resultado cambiaría con cada llamada de makeEven .

Exploremos algunos tipos de funciones que no son puras pero que nos ayudarán a definir la pureza de manera más específica:

  • Funciones parciales: estas son funciones que no están definidas para todos los valores de entrada, como la división. Desde una perspectiva de programación, estas son funciones que lanzan una excepción: fun divide(a: Int, b: Int): Float lanzará una ArithmeticException para la entrada b = 0 causada por la división por cero.
  • Funciones totales: estas funciones se definen para todos los valores de entrada, pero pueden producir una salida diferente o efectos secundarios cuando se las llama con la misma entrada. El mundo de Android está lleno de funciones totales: Log.d , LocalDateTime.now y Locale.getDefault son solo algunos ejemplos.

Con estas definiciones en mente, podemos definir funciones puras como funciones totales sin efectos secundarios. Las composiciones de funciones construidas usando solo funciones puras producen un código más confiable, predecible y comprobable.

Sugerencia: para hacer que una función total sea pura, puede abstraer sus efectos secundarios pasándolos como un parámetro de función de orden superior. De esta manera, puede probar fácilmente las funciones totales pasando una función de orden superior simulada. Este ejemplo usa la anotación @SideEffect de una biblioteca que examinamos más adelante en el tutorial, Ivy FRP:

 suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())

Conclusiones clave

La pureza es el ingrediente final requerido para el paradigma de programación funcional.

  • Tenga cuidado con las funciones parciales: pueden bloquear su aplicación.
  • Componer funciones totales no es determinista; puede producir un comportamiento impredecible.
  • Siempre que sea posible, escriba funciones puras. Se beneficiará de una mayor estabilidad del código.

Una vez completada nuestra descripción general de la programación funcional, examinemos el siguiente componente del código de Android preparado para el futuro: la programación reactiva.

Programación reactiva 101

La programación reactiva es un patrón de programación declarativo en el que el programa reacciona a los cambios de datos o eventos en lugar de solicitar información sobre los cambios.

Dos cuadros azules principales, "Observable" y "Estado", tienen dos caminos principales entre ellos. La primera es a través de "Observa (escucha los cambios)". El segundo es a través de "Notifica (del último estado)" al cuadro azul "UI (API en el back-end)", que va a través de "Transforma la entrada del usuario en" al cuadro azul "Evento", que va a través de "Desencadenadores" a azul cuadro "Composición de la función", y finalmente a través de "Produce (nuevo estado)". "Estado" luego también se conecta de nuevo a "Composición de función" a través de "Actúa como entrada para".
El ciclo general de programación reactiva.

Los elementos básicos en un ciclo de programación reactiva son los eventos, la canalización declarativa, los estados y los observables:

  • Los eventos son señales del mundo exterior, normalmente en forma de entrada del usuario o eventos del sistema, que desencadenan actualizaciones. El propósito de un evento es transformar una señal en una entrada de tubería.
  • La canalización declarativa es una composición de función que acepta (Event, State) como entrada y transforma esta entrada en un nuevo State (la salida): (Event, State) -> f -> g -> … -> n -> State . Las canalizaciones deben funcionar de forma asincrónica para manejar varios eventos sin bloquear otras canalizaciones ni esperar a que finalicen.
  • Los estados son la representación del modelo de datos de la aplicación de software en un momento determinado. La lógica del dominio utiliza el estado para calcular el siguiente estado deseado y realizar las actualizaciones correspondientes.
  • Los observables escuchan los cambios de estado y actualizan a los suscriptores sobre esos cambios. En Android, los observables generalmente se implementan mediante Flow , LiveData o RxJava , y notifican a la interfaz de usuario las actualizaciones de estado para que pueda reaccionar en consecuencia.

Hay muchas definiciones e implementaciones de programación reactiva. Aquí, he adoptado un enfoque pragmático centrado en aplicar estos conceptos a proyectos reales.

Conectando los Puntos: Programación Reactiva Funcional

La programación funcional y reactiva son dos paradigmas poderosos. Estos conceptos van más allá de la breve vida útil de las bibliotecas y las API, y mejorarán sus habilidades de programación en los años venideros.

Además, el poder de FP y la programación reactiva se multiplica cuando se combinan. Ahora que tenemos definiciones claras de programación funcional y reactiva, podemos juntar las piezas. En la parte 2 de este tutorial, definimos el paradigma de programación reactiva funcional (FRP) y lo ponemos en práctica con una implementación de aplicación de muestra y bibliotecas de Android relevantes.

El blog de ingeniería de Toptal agradece a Tarun Goyal por revisar los ejemplos de código presentados en este artículo.