Swift funcional es fácil

imagen


Los artículos sobre programación funcional escriben mucho sobre cómo el enfoque de FP mejora el desarrollo: se vuelve fácil escribir, leer, transmitir, codificar, probar, construir una arquitectura pobre y el cabello se vuelve suave y sedoso .


Un inconveniente es el alto umbral de entrada. Intentando comprender la PF, me encontré con una gran cantidad de teoría, functores, mónadas, teoría de categorías y tipos de datos algebraicos. Y cómo aplicar AF en la práctica no estaba claro. Además, se dieron ejemplos en idiomas desconocidos para mí: Haskell y rock.


Entonces decidí resolver el FP desde el principio. Me di cuenta y le dije a codefest que FP es realmente solo que ya lo usamos en Swift y podemos usarlo aún más eficientemente.


Programación funcional: funciones puras y falta de estados.


Determinar lo que significa escribir en un paradigma particular no es una tarea fácil. Los paradigmas se han formado durante décadas por personas con diferentes visiones, incorporados en idiomas con enfoques diferentes, y están rodeados de herramientas. Estas herramientas y enfoques se consideran una parte integral de los paradigmas, pero en realidad no lo son.


Por ejemplo, se cree que la programación orientada a objetos se basa en tres pilares: herencia, encapsulación y polimorfismo. Pero la encapsulación y el polimorfismo se implementan en funciones con la misma facilidad que en los objetos. O cierres: nacieron en lenguajes funcionales puros, pero migraron tanto tiempo a idiomas industriales que dejaron de estar asociados con la FP. Las mónadas también llegan a los idiomas industriales, pero aún no han perdido su membresía en el Haskell condicional en la mente de las personas.


Como resultado, resulta que es imposible determinar claramente qué es un paradigma particular. Una vez más, me encontré con esto en codefest 2019, donde todos los expertos en FP, hablando sobre el paradigma funcional, llamaron cosas diferentes.


Personalmente, me gustó la definición de la wiki:


"La programación funcional es una rama de las matemáticas discretas y un paradigma de programación en el que el proceso de cálculo se interpreta como el cálculo de los valores de las funciones en la comprensión matemática de este último (a diferencia de las funciones como subprogramas en la programación de procedimientos)".


¿Qué es una función matemática? Esta es una función cuyo resultado depende solo de los datos a los que se aplica.


Un ejemplo de una función matemática en cuatro líneas de código se ve así:


func summ(a: Int, b: Int) -> Int { return a + b } let x = summ(a: 2, b: 3) 

Llamando a la función summ con los argumentos de entrada 2 y 3, obtenemos 5. Este resultado no cambia. Cambie el programa, el hilo, el lugar de ejecución: el resultado seguirá siendo el mismo.


Y una función no matemática es cuando una variable global se declara en algún lugar.


 var z = 5 

La función suma ahora agrega los argumentos de entrada y el valor de z.


 func summ(a: Int, b: Int) -> Int { return a + b + z } let x = summ(a: 2, b: 3) 

Mayor dependencia del estado global. Ahora es imposible predecir sin ambigüedad el valor de x. Cambiará constantemente dependiendo de cuándo se llamó la función. Llamamos a la función 10 veces seguidas, y cada vez podemos obtener un resultado diferente.


Otra versión de la función no matemática:


 func summ(a: Int, b: Int) -> Int { z = b - a return a + b } 

Además de devolver la suma de los argumentos de entrada, la función cambia la variable global z. Esta característica tiene un efecto secundario.


La programación funcional tiene un término especial para funciones matemáticas: funciones puras. Una función pura es una función que devuelve el mismo resultado para el mismo conjunto de valores de entrada y no tiene efectos secundarios.


Las funciones puras son la piedra angular de FP, todo lo demás es secundario. Se supone que, siguiendo este paradigma, solo los usamos. Y si no trabaja con estados globales o mutables, entonces no estarán en la aplicación.


Clases y estructuras en un paradigma funcional


Inicialmente, pensé que FP se trata solo de funciones, y las clases y estructuras se usan solo en OOP. Pero resultó que las clases también encajan en el concepto de FP. Solo ellos deberían ser, digamos, "limpios".


Una clase "pura" es una clase, cuyos métodos son funciones puras y las propiedades son inmutables. (Este es un término no oficial, acuñado en preparación para el informe).


Echa un vistazo a esta clase:


 class User { let name: String let surname: String let email: String func getFullname() -> String { return name + " " + surname } } 

Se puede considerar como encapsulación de datos ...


 class User { let name: String let surname: String let email: String } 

y funciones para trabajar con ellos.


 func getFullname() -> String { return name + " " + surname } 

Desde el punto de vista de FP, usar la clase Usuario no es diferente de trabajar con primitivas y funciones.


Declare el valor - usuario Vanya.


 let ivan = User( name: "", surname: "", email: "ivanov@example.com" ) 

Aplique la función getFullname.


 let fullName = ivan.getFullname() 

Como resultado, obtenemos un nuevo valor: el nombre de usuario completo. Como no puede cambiar los parámetros de la propiedad ivan, el resultado de llamar a getFullname no cambia.


Por supuesto, un lector atento puede decir: "Espera un minuto, el método getFullname devuelve el resultado basado en valores globales para él: propiedades de clase, no argumentos". Pero en realidad un método es solo una función en la que se pasa un objeto como argumento.


Swift incluso admite esta entrada explícitamente:


 let fullName = User.getFullname(ivan)() 

Si necesitamos cambiar algún valor del objeto, por ejemplo, correo electrónico, tendremos que crear un nuevo objeto. Esto puede hacerse por el método apropiado.


 class User { let name: String let surname: String let email: String func change(email: String) -> User { return User(name: name, surname: surname, email: email) } } let newIvan = ivan.change(email: "god@example.com") 

Atributos funcionales en Swift


Ya escribí que muchas herramientas, implementaciones y enfoques que se consideran parte de un paradigma en realidad pueden usarse en otros paradigmas. Por ejemplo, las mónadas, los tipos de datos algebraicos, la inferencia automática de tipos, la tipificación estricta, los tipos dependientes y la validación de programas durante la compilación se consideran parte del FP. Pero muchas de estas herramientas las podemos encontrar en Swift.


La tipificación fuerte y la inferencia de tipos es parte de Swift. No necesitan ser entendidos o introducidos en el proyecto, solo los tenemos.


No hay tipos dependientes, aunque no me negaría a verificar la cadena por el compilador que es un correo electrónico, una matriz, que no está vacío, un diccionario, que contiene la clave de Apple. Por cierto, tampoco hay tipos dependientes en Haskell.


Los tipos de datos algebraicos están disponibles, y esta es una cosa matemática genial, pero difícil de entender. La belleza es que no necesita ser entendido matemáticamente para usarlo. Por ejemplo, Int, enum, Opcional, Hashable son tipos algebraicos. Y si Int está en muchos idiomas, y Protocol está en Objective-C, entonces enumeración con valores asociados, protocolos con implementación predeterminada y tipos asociativos están lejos de todas partes.


A menudo se hace referencia a la validación de compilación cuando se habla de lenguajes como el óxido o el haskell. Se entiende que el lenguaje es tan expresivo que le permite describir todos los casos límite para que el compilador los verifique. Entonces, si el programa fue compilado, entonces ciertamente funcionará. Nadie discute que puede contener errores en la lógica, porque filtró incorrectamente los datos para mostrarlos al usuario. Pero no caerá, porque no recibió datos de la base de datos, el servidor devolvió la respuesta incorrecta que estaba contando o el usuario ingresó su fecha de nacimiento como una cadena, no como un número.


No puedo decir que compilar un código rápido puede detectar todos los errores: por ejemplo, es fácil evitar una pérdida de memoria. Pero la tipificación fuerte y la protección opcional contra muchos errores estúpidos. Lo principal es limitar la extracción forzada.


Mónadas: no forma parte del paradigma FP, sino una herramienta (opcional)


Muy a menudo, los FP y las mónadas se usan en la misma aplicación. En un momento, incluso pensé que las mónadas son programación funcional. Cuando los entendí (pero esto no es exacto), hice varias conclusiones:


  • son simples
  • son cómodos
  • entiéndelos opcionalmente, es suficiente para poder aplicar;
  • puedes prescindir fácilmente de ellos.

Swift ya tiene dos mónadas estándar: Opcional y Resultado. Ambos son necesarios para hacer frente a los efectos secundarios. Opcional protege contra posibles nulos. Resultado - de varias situaciones excepcionales.


Considere el ejemplo llevado al punto del absurdo. Supongamos que tenemos funciones que devuelven un número entero de la base de datos y del servidor. El segundo puede devolver nulo, pero usamos extracción implícita para obtener un comportamiento de tiempo objetivo-C.


 func getIntFromDB() -> Int func getIntFromServer() -> Int! 

Continuamos ignorando Opcional e implementamos una función para sumar estos números.


 func summInts() -> Int! { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer()! let summ = intFromDB + intFromServer return summ } 

Llamamos a la función final y usamos el resultado.


 let result = summInts() print(result) 

¿Funcionará este ejemplo? Bueno, definitivamente se compila, pero nadie sabe si tenemos el bloqueo en tiempo de ejecución o no. Este código es bueno, muestra perfectamente nuestras intenciones (necesitamos la suma de algunos dos números) y no contiene nada superfluo. Pero él es peligroso. Por lo tanto, solo los jóvenes y las personas seguras escriben de esta manera.


Cambie el ejemplo para que sea seguro.


 func getIntFromDB() -> Int func getIntFromServer() -> Int? func summInts() -> Int? { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() if let intFromServer = intFromServer { let summ = intFromDB + intFromServer return summ } else { return nil } } if let result = summInts() { print(result) } 

Este código es bueno, es seguro. Usando extracción explícita, nos defendimos contra posibles nulos. Pero se volvió engorroso, y entre los controles seguros ya es difícil discernir nuestra intención. Todavía necesitamos la suma de algunos dos números, no un control de seguridad.


En este caso, Opcional tiene un método de mapa, heredado del tipo Quizás de Haskell. Lo aplicamos y el ejemplo cambiará.


 func getIntFromDB() -> Int func getIntFromServer() -> Int? func summInts() -> Int? { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() return intFromServer.map { x in x + intFromDB } } if let result = summInts() { print(result) } 

O incluso más compacto.


 func getIntFromDB() -> Int func getintFromServer() -> Int? func summInts() -> Int? { return getintFromServer().map { $0 + getIntFromDB() } } if let result = summInts() { print(result) } 

Usamos map para convertir intFromServer al resultado que necesitamos sin extracción.


Nos deshicimos del cheque dentro de las sumas, pero lo dejamos en el nivel superior. Esto se hace intencionalmente, ya que al final de la cadena de cálculo debemos elegir un método para procesar la falta de resultados.


Expulsar


 if let result = summInts() { print(result) } 

Usar valor predeterminado


 print(result ?? 0) 

O muestre una advertencia si no se reciben datos.


 if let result = summInts() { print(result) } else { print("") } 

Ahora el código en el ejemplo no contiene demasiado, como en el primer ejemplo, y es seguro, como en el segundo.


Pero el mapa no siempre funciona como debería


 let a: String? = "7" let b = a.map { Int($0) } type(of: b)//Optional<Optional<Int>> 

Si pasamos una función al mapa, cuyo resultado es opcional, obtenemos un doble Opcional. Pero no necesitamos doble protección contra nada. Uno es suficiente El método flatMap permite resolver el problema, es un análogo del mapa con una diferencia, despliega las muñecas de anidación.


 let a: String? = "7" let b = a.flatMap { Int($0) } type(of: b)//Optional<Int>. 

Otro ejemplo donde map y flatMap no es muy conveniente de usar.


 let a: Int? = 3 let b: Int? = 7 let c = a.map { $0 + b! } 

¿Qué pasa si una función toma dos argumentos y ambos son opcionales? Por supuesto, FP tiene una solución: este es un functor aplicativo y curry. Pero estas herramientas se ven bastante incómodas sin usar operadores especiales que no están en nuestro idioma, y ​​escribir operadores personalizados se considera una mala forma. Por lo tanto, consideramos una forma más intuitiva: escribimos una función especial.


 @discardableResult func perform<Result, U, Z>( _ transform: (U, Z) throws -> Result, _ optional1: U?, _ optional2: Z?) rethrows -> Result? { guard let optional1 = optional1, let optional2 = optional2 else { return nil } return try transform(optional1, optional2) } 

Toma dos valores opcionales como argumentos y una función con dos argumentos. Si ambas opciones tienen valores, se les aplica una función.
Ahora podemos trabajar con varias opciones sin implementarlas.


 let a: Int? = 3 let b: Int? = 7 let result = perform(+, a, b) 

La segunda mónada, Result, también tiene métodos map y flatMap. Por lo tanto, puede trabajar con él exactamente de la misma manera.


 func getIntFromDB() -> Int func getIntFromServer() -> Result<Int, ServerError> func summInts() -> Result<Int, ServerError> { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() return intFromServer.map { x in x + intFromDB } } if case .success(let result) = summInts() { print(result) } 

En realidad, esto es lo que une a las mónadas: la capacidad de trabajar con el valor dentro del contenedor sin eliminarlo. En mi opinión, esto hace que el código sea conciso. Pero si no te gusta, solo usa extractos explícitos, esto no contradice el paradigma FP.


Ejemplo: reducir el número de funciones sucias


Desafortunadamente, en los programas reales, los estados globales y los efectos secundarios están en todas partes: solicitudes de red, fuentes de datos, IU. Y solo las funciones puras no pueden prescindirse. Pero esto no significa que el FP sea completamente inaccesible para nosotros: podemos intentar reducir el número de funciones sucias, que generalmente son muchas.


Veamos un pequeño ejemplo cercano al desarrollo de la producción. Cree una interfaz de usuario, específicamente un formulario de inscripción. El formulario tiene algunas limitaciones:


1) Inicie sesión no menos de 3 caracteres
2) Contraseña de al menos 6 caracteres
3) El botón "Iniciar sesión" está activo si ambos campos son válidos.
4) El color del marco de campo refleja su estado, negro - es válido, rojo - no es válido


El código que describe estas restricciones puede verse así:


Manejo de cualquier entrada del usuario


 @IBAction func textFieldTextDidChange() { // 1.     // 2.   guard let login = loginView.text, let password = passwordView.text else { // 3. - loginButton.isEnabled = false return } let loginIsValid = login.count > constants.loginMinLenght if loginIsValid { // 4. - loginView.layer.borderColor = constants.normalColor } let passwordIsValid = password.count > constants.passwordMinLenght if passwordIsValid { // 5. - passwordView.layer.borderColor = constants.normalColor } // 6. - loginButton.isEnabled = loginIsValid && passwordIsValid } 

Proceso de finalización de inicio de sesión:


 @IBAction func loginDidEndEdit() { let color: CGColor // 1.     // 2.   if let login = loginView.text, login.count > 3 { color = constants.normalColor } else { color = constants.errorColor } // 3.   loginView.layer.borderColor = color } 

Proceso de finalización de contraseña:


 @IBAction func passwordDidEndEdit() { let color: CGColor // 1.     // 2.   if let password = passwordView.text, password.count > 6 { color = constants.normalColor } else { color = constants.errorColor } // 3. - passwordView.layer.borderColor = color } 

Presionando el botón enter:


 @IBAction private func loginPressed() { // 1.     // 2.   guard let login = loginView.text, let password = passwordView.text else { return } auth(login: login, password: password) { [weak self] user, error in if let user = user { /*  */ } else if error is AuthError { guard let `self` = self else { return } // 3. - self.passwordView.layer.borderColor = self.constants.errorColor // 4. - self.loginView.layer.borderColor = self.constants.errorColor } else { /*   */ } } } 

Este código puede no ser el mejor, pero en general es bueno y funciona. Es cierto que tiene varios problemas:


  • 4 extractos explícitos;
  • 4 dependencias del estado global;
  • 8 efectos secundarios;
  • estados finales no obvios;
  • flujo no lineal.

El principal problema es que no puedes simplemente tomar y decir lo que está sucediendo con nuestra pantalla. Mirando un método, vemos lo que hace con un estado global, pero no sabemos quién, dónde y cuándo toca el estado. Como resultado, para comprender lo que está sucediendo, debe encontrar todos los puntos de trabajo con las vistas y comprender en qué orden se producen las influencias. Tener todo esto en mente es muy difícil.


Si el proceso de cambiar el estado es lineal, puede estudiarlo paso a paso, lo que reducirá la carga cognitiva en el programador.


Intentemos cambiar el ejemplo, haciéndolo más funcional.


Primero, definimos un modelo que describe el estado actual de la pantalla. Esto le permitirá saber exactamente qué información es necesaria para el trabajo.


 struct LoginOutputModel { let login: String let password: String var loginIsValid: Bool { return login.count > 3 } var passwordIsValid: Bool { return password.count > 6 } var isValid: Bool { return loginIsValid && passwordIsValid } } 

Un modelo que describe los cambios aplicados a la pantalla. Ella necesita saber exactamente qué cambiaremos.


 struct LoginInputModel { let loginBorderColor: CGColor? let passwordBorderColor: CGColor? let loginButtonEnable: Bool? let popupErrorMessage: String? } 

Eventos que pueden conducir a un nuevo estado de pantalla. Entonces sabremos exactamente qué acciones cambian la pantalla.


 enum Event { case textFieldTextDidChange case loginDidEndEdit case passwordDidEndEdit case loginPressed case authFailure(Error) } 

Ahora describimos el método principal de cambio. Esta función pura, basada en el evento de estado actual, recopila un nuevo estado de la pantalla.


 func makeInputModel( event: Event, outputModel: LoginOutputModel?) -> LoginInputModel { switch event { case .textFieldTextDidChange: let mapValidToColor: (Bool) -> CGColor? = { $0 ? normalColor : nil } return LoginInputModel( loginBorderColor: outputModel .map { $0.loginIsValid } .flatMap(mapValidToColor), passwordBorderColor: outputModel .map { $0.passwordIsValid } .flatMap(mapValidToColor), loginButtonEnable: outputModel?.passwordIsValid ) case .loginDidEndEdit: return LoginInputModel(/**/) case .passwordDidEndEdit: return LoginInputModel(/**/) case .loginPressed: return LoginInputModel(/**/) case .authFailure(let error) where error is AuthError: return LoginInputModel(/**/) case .authFailure: return LoginInputModel(/**/) } } 

Lo más importante es que este método es el único al que se le permite participar en la construcción de un nuevo estado, y está limpio. Se puede estudiar paso a paso. Vea cómo los eventos transforman la pantalla del punto A al punto B. Si algo se rompe, entonces el problema está exactamente aquí. Y es fácil de probar.


Agregue una propiedad auxiliar para obtener el estado actual, este es el único método que depende del estado global.


 var outputModel: LoginOutputModel? { return perform(LoginOutputModel.init, loginView.text, passwordView.text) } 

Agregue otro método "sucio" para crear los efectos secundarios de cambiar la pantalla.


 func updateView(_ event: Event) { let inputModel = makeInputModel(event: event, outputModel: outputModel) if let color = inputModel.loginBorderColor { loginView.layer.borderColor = color } if let color = inputModel.passwordBorderColor { passwordView.layer.borderColor = color } if let isEnable = inputModel.loginButtonEnable { loginButton.isEnabled = isEnable } if let error = inputModel.popupErrorMessage { showPopup(error) } } 

Aunque el método updateView no está limpio, es el único lugar donde cambian las propiedades de la pantalla. El primer y último elemento de la cadena de cálculos. Y si algo salió mal, aquí es donde estará el punto de ruptura.


Solo queda comenzar la conversión en los lugares correctos.


 @IBAction func textFieldTextDidChange() { updateView(.textFieldTextDidChange) } @IBAction func loginDidEndEdit() { updateView(.loginDidEndEdit) } @IBAction func passwordDidEndEdit() { updateView(.passwordDidEndEdit) } 

El método loginPressed salió un poco único.


 @IBAction private func loginPressed() { updateView(.loginPressed) let completion: (Result<User, Error>) -> Void = { [weak self] result in switch result { case .success(let user): /*  */ case .failure(let error): self?.updateView(.authFailure(error)) } } outputModel.map { auth(login: $0.login, password: $0.password, completion: completion) } } 

El hecho es que al hacer clic en el botón "Iniciar sesión" se inician dos cadenas de cálculos, lo cual no está prohibido.


Conclusión


Antes de estudiar FP, hice un fuerte énfasis en los paradigmas de programación. Para mí era importante que el código siguiera a OOP, no me gustaban las funciones estáticas ni los objetos sin estado, no escribía funciones globales.


Ahora me parece que todas esas cosas que consideré parte de un paradigma son bastante arbitrarias. Lo principal es un código limpio y comprensible. Para lograr este objetivo, puede usar todo lo que sea posible: funciones puras, clases, mónadas, herencia, composición, inferencia de tipos. Todos se llevan bien y mejoran el código, simplemente aplíquelos al lugar.


¿Qué más leer sobre el tema?


Definición de programación funcional de Wikipedia
Haskell Starter Book
Explicación de functores, mónadas y fundores aplicativos en los dedos.
Libro de Haskell sobre prácticas para el uso de Maybe (opcional)
Libro sobre la naturaleza funcional de Swift
Definición de tipos de datos algebraicos de una wiki
Un artículo sobre tipos de datos algebraicos
Otro artículo sobre tipos de datos algebraicos
Informe Yandex sobre programación funcional en Swift
Implementación de la biblioteca estándar de Preludio (Haskell) en Swift
Biblioteca con herramientas funcionales en Swift
Otra biblioteca
Y uno mas

Source: https://habr.com/ru/post/455359/


All Articles