Swift funcional é fácil

imagem


Os artigos sobre programação funcional escrevem muito sobre como a abordagem FP melhora o desenvolvimento: torna-se fácil escrever, ler, transmitir, codificar, testar, criar arquitetura ruim e o cabelo fica macio e sedoso .


Uma desvantagem é o alto limite de entrada. Tentando entender o FP, deparei-me com uma enorme quantidade de teoria, functores, mônadas, teoria de categorias e tipos de dados algébricos. E como aplicar AF na prática não era claro. Além disso, foram dados exemplos em idiomas desconhecidos para mim - Haskell e rock.


Então eu decidi descobrir o PF desde o começo. Eu descobri e disse ao codefest que o FP é realmente apenas o que já usamos no Swift e podemos usá-lo ainda mais eficientemente.


Programação funcional: funções puras e falta de estados


Determinar o que significa escrever em um paradigma específico não é uma tarefa fácil. Os paradigmas são formados há décadas por pessoas com visões diferentes, incorporadas em linguagens com abordagens diferentes e cercadas de ferramentas. Essas ferramentas e abordagens são consideradas parte integrante dos paradigmas, mas, na realidade, não são.


Por exemplo, acredita-se que a programação orientada a objetos se sustenta em três pilares - herança, encapsulamento e polimorfismo. Mas o encapsulamento e o polimorfismo são implementados em funções com a mesma facilidade que em objetos. Ou encerramentos - eles nasceram em linguagens funcionais puras, mas por tanto tempo migraram para linguagens industriais que deixaram de ser associados ao FP. As mônadas também chegam às línguas industriais, mas ainda não perderam sua participação no Haskell condicional na mente das pessoas.


Como resultado, acontece que é impossível determinar claramente o que é um paradigma específico. Mais uma vez, me deparei com isso no codefest 2019, onde todos os especialistas em PF, falando sobre o paradigma funcional, chamavam coisas diferentes.


Pessoalmente, gostei da definição do wiki:


“A programação funcional é uma seção da matemática discreta e um paradigma de programação no qual o processo de cálculo é tratado como o cálculo dos valores das funções no entendimento matemático das últimas (em oposição a funções como subprogramas na programação processual).”


O que é uma função matemática? Esta é uma função cujo resultado depende apenas dos dados aos quais é aplicado.


Um exemplo de função matemática em quatro linhas de código é semelhante a este:


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

Chamando a função summ com os argumentos de entrada 2 e 3, obtemos 5. Esse resultado é inalterado. Mude o programa, thread, local de execução - o resultado permanecerá o mesmo.


E uma função não matemática é quando uma variável global é declarada em algum lugar.


 var z = 5 

A função soma agora adiciona os argumentos de entrada e o valor de z.


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

Dependência adicionada ao estado global. Agora é impossível prever inequivocamente o valor de x. Ele muda constantemente, dependendo de quando a função foi chamada. Chamamos a função 10 vezes seguidas e a cada vez podemos obter um resultado diferente.


Outra versão da função não matemática:


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

Além de retornar a soma dos argumentos de entrada, a função altera a variável global z. Esse recurso tem um efeito colateral.


A programação funcional possui um termo especial para funções matemáticas - funções puras. Uma função pura é uma função que retorna o mesmo resultado para o mesmo conjunto de valores de entrada e não tem efeitos colaterais.


Funções puras são a pedra angular do FP, todo o resto é secundário. Supõe-se que, seguindo esse paradigma, os utilizemos apenas. E se você não trabalhar com estados globais ou mutáveis, eles não estarão no aplicativo.


Classes e estruturas em um paradigma funcional


Inicialmente, pensei que FP fosse apenas sobre funções, e classes e estruturas são usadas apenas no OOP. Mas as aulas também se encaixam no conceito de FP. Somente eles devem ser, digamos, "limpos".


Uma classe "pura" é uma classe, cujos métodos são funções puras e propriedades imutáveis. (Este é um termo não oficial, cunhado na preparação do relatório).


Dê uma olhada nesta classe:


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

Pode ser considerado como encapsulamento de dados ...


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

e funções para trabalhar com eles.


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

Do ponto de vista do FP, usar a classe User não é diferente de trabalhar com primitivas e funções.


Declare o valor - usuário Vanya.


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

Aplique a função getFullname a ela.


 let fullName = ivan.getFullname() 

Como resultado, obtemos um novo valor - o nome de usuário completo. Como você não pode alterar os parâmetros da propriedade ivan, o resultado da chamada de getFullname permanece inalterado.


Obviamente, um leitor atento pode dizer: "Espere um minuto, o método getFullname retorna o resultado com base em valores globais para ele - propriedades de classe, não argumentos". Mas, na verdade, um método é apenas uma função na qual um objeto é passado como argumento.


Swift ainda suporta esta entrada explicitamente:


 let fullName = User.getFullname(ivan)() 

Se precisarmos alterar algum valor do objeto, por exemplo, email, teremos que criar um novo objeto. Isso pode ser feito pelo método apropriado.


 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 funcionais no Swift


Eu já escrevi que muitas ferramentas, implementações e abordagens consideradas parte de um paradigma podem realmente ser usadas em outros paradigmas. Por exemplo, mônadas, tipos de dados algébricos, inferência automática de tipos, tipagem estrita, tipos dependentes e validação de programa durante a compilação são considerados parte do FP. Mas muitas dessas ferramentas podemos encontrar no Swift.


Digitação forte e inferência de tipo fazem parte do Swift. Eles não precisam ser entendidos ou introduzidos no projeto, apenas os temos.


Não há tipos dependentes, embora eu não me recusei a verificar a sequência de caracteres pelo compilador: é um email, uma matriz, que não está vazio, um dicionário, que contém a chave da apple. A propósito, também não há tipos dependentes em Haskell.


Tipos de dados algébricos estão disponíveis, e isso é uma coisa matemática interessante, mas difícil de entender. A beleza é que ele não precisa ser entendido matematicamente para usá-lo. Por exemplo, Int, enum, Opcional, Hashable são tipos algébricos. E se Int estiver em muitos idiomas e Protocol estiver em Objective-C, então enum com valores associados, protocolos com implementação padrão e tipos associativos estão longe de qualquer lugar.


A validação de compilação é frequentemente referida quando se fala em idiomas como ferrugem ou haskell. Entende-se que a linguagem é tão expressiva que permite descrever todos os casos extremos para que sejam verificados pelo compilador. Portanto, se o programa foi compilado, certamente funcionará. Ninguém contesta que ele pode conter erros na lógica, porque você filtrou incorretamente os dados para exibição no usuário. Mas não cairá, porque você não recebeu dados do banco de dados, o servidor retornou a resposta errada para a qual você estava contando ou o usuário digitou sua data de nascimento como uma sequência, não como um número.


Não posso dizer que a compilação de código rápido possa detectar todos os erros: por exemplo, é fácil evitar um vazamento de memória. Mas digitação forte e opcional protegem contra muitos erros estúpidos. O principal é limitar a extração forçada.


Mônadas: não faz parte do paradigma FP, mas uma ferramenta (opcional)


Freqüentemente, FPs e mônadas são usados ​​no mesmo aplicativo. Ao mesmo tempo, eu até pensei que as mônadas são programação funcional. Quando os compreendi (mas isso não é exato), tirei várias conclusões:


  • eles são simples;
  • eles são confortáveis;
  • entendê-los opcionalmente, basta poder aplicar;
  • você pode facilmente ficar sem eles.

O Swift já possui duas mônadas padrão - Opcional e Resultado. Ambos são necessários para lidar com efeitos colaterais. Opcional protege contra possível nulo. Resultado - de várias situações excepcionais.


Considere o exemplo levado ao ponto do absurdo. Suponha que tenhamos funções que retornam um número inteiro do banco de dados e do servidor. O segundo pode retornar nulo, mas usamos a extração implícita para obter o comportamento do Objective-C-time.


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

Continuamos a ignorar Opcional e implementamos uma função para somar esses números.


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

Chamamos a função final e usamos o resultado.


 let result = summInts() print(result) 

Este exemplo funcionará? Bem, definitivamente compila, mas não sabemos se a falha ocorre em tempo de execução ou não. Esse código é bom, mostra perfeitamente nossas intenções (precisamos da soma de dois números) e não contém nada de supérfluo. Mas ele é perigoso. Portanto, apenas juniores e pessoas confiantes escrevem dessa maneira.


Mude o exemplo para torná-lo 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 é bom, é seguro. Usando extração explícita, defendemos o possível nulo. Mas tornou-se complicado e, entre as verificações seguras, já é difícil discernir nossa intenção. Ainda precisamos da soma de dois números, não de uma verificação de segurança.


Nesse caso, o Opcional possui um método de mapa, herdado do tipo Maybe de Haskell. Nós o aplicamos e o exemplo mudará.


 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) } 

Ou ainda mais compacto.


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

Usamos map para converter intFromServer no resultado que precisamos sem extração.


Nos livramos da verificação dentro de summInts, mas a deixamos no nível superior. Isso é feito intencionalmente, pois no final da cadeia de computação devemos escolher um método para processar a falta de resultado.


Ejetar


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

Usar valor padrão


 print(result ?? 0) 

Ou exiba um aviso se os dados não forem recebidos.


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

Agora, o código no exemplo não contém muito, como no primeiro exemplo, e é seguro, como no segundo.


Mas o mapa nem sempre funciona como deveria


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

Se passarmos uma função para mapear, cujo resultado é opcional, obtemos um Duplo opcional. Mas não precisamos de dupla proteção contra nada. Um é o suficiente. O método flatMap permite resolver o problema, é um análogo do mapa com uma diferença, implanta os bonecos de nidificação.


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

Outro exemplo em que o map e flatMap não é muito conveniente de usar.


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

E se uma função receber dois argumentos e ambos forem opcionais? Obviamente, o FP tem uma solução - este é um functor aplicativo e currying. Mas essas ferramentas parecem bastante estranhas sem o uso de operadores especiais que não estão em nosso idioma, e escrever operadores personalizados é considerado uma má forma. Portanto, consideramos uma maneira mais intuitiva: escrevemos uma função 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) } 

São necessários dois valores opcionais como argumentos e uma função com dois argumentos. Se ambas as opções tiverem valores, uma função será aplicada a elas.
Agora podemos trabalhar com várias opções sem implantá-las.


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

A segunda mônada, Result, também possui os métodos map e flatMap. Assim, você pode trabalhar exatamente da mesma maneira.


 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) } 

Na verdade, é isso que une as mônadas - a capacidade de trabalhar com o valor dentro do contêiner sem removê-lo. Na minha opinião, isso torna o código conciso. Mas se você não gostar, basta usar extratos explícitos, isso não contradiz o paradigma do FP.


Exemplo: reduzindo o número de funções sujas


Infelizmente, em programas reais, estados globais e efeitos colaterais estão por toda parte - solicitações de rede, fontes de dados, interfaces de usuário. E apenas funções puras não podem ser dispensadas. Mas isso não significa que o FP seja completamente inacessível para nós: podemos tentar reduzir o número de funções sujas, que geralmente são muitas.


Vejamos um pequeno exemplo próximo ao desenvolvimento da produção. Crie uma interface do usuário, especificamente um formulário de inscrição. O formulário tem algumas limitações:


1) Faça login com menos de 3 caracteres
2) Senha com pelo menos 6 caracteres
3) O botão "Login" estará ativo se ambos os campos forem válidos.
4) A cor do quadro do campo reflete seu estado, preto - é válido, vermelho - não é válido


O código que descreve essas restrições pode ser assim:


Manipulando qualquer entrada do usuário


 @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 } 

Processamento de conclusão de login:


 @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 } 

Processamento de conclusão de senha:


 @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 } 

Pressionando o botão 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 { /*   */ } } } 

Esse código pode não ser o melhor, mas no geral é bom e funciona. É verdade que ele tem vários problemas:


  • 4 extratos explícitos;
  • 4 dependências no estado global;
  • 8 efeitos colaterais;
  • estados finais não óbvios;
  • fluxo não linear.

O principal problema é que você não pode simplesmente pegar e dizer o que está acontecendo com nossa tela. Olhando para um método, vemos o que ele faz com um estado global, mas não sabemos quem, onde e quando toca o estado. Como resultado, para entender o que está acontecendo, você precisa encontrar todos os pontos de trabalho com as visualizações e entender em que ordem as influências ocorrem. Manter tudo isso em mente é muito difícil.


Se o processo de alteração do estado for linear, você poderá estudá-lo passo a passo, o que reduzirá a carga cognitiva no programador.


Vamos tentar mudar o exemplo, tornando-o mais funcional.


Primeiro, definimos um modelo que descreve o estado atual da tela. Isso permitirá que você saiba exatamente quais informações são necessárias para o trabalho.


 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 } } 

Um modelo que descreve as alterações aplicadas à tela. Ela é necessária para saber exatamente o que mudaremos.


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

Eventos que podem levar a um novo estado de tela. Então, saberemos exatamente quais ações mudam a tela.


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

Agora descrevemos o principal método de mudança. Essa função pura, com base no evento de estado atual, coleta um novo estado da tela.


 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(/**/) } } 

O mais importante é que esse método é o único que pode se envolver na construção de um novo estado - e é limpo. Pode ser estudado passo a passo. Veja como os eventos transformam a tela do ponto A ao ponto B. Se algo quebrar, o problema está exatamente aqui. E é fácil de testar.


Adicione uma propriedade auxiliar para obter o estado atual, este é o único método que depende do estado global.


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

Adicione outro método "sujo" para criar os efeitos colaterais de alterar a tela.


 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) } } 

Embora o método updateView não seja limpo, é o único local em que as propriedades da tela são alteradas. O primeiro e o último item da cadeia de cálculos. E se algo der errado, é aqui que o ponto de interrupção estará.


Resta apenas iniciar a conversão nos lugares certos.


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

O método loginPressed saiu um pouco exclusivo.


 @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) } } 

O fato é que clicar no botão "Login" inicia duas cadeias de cálculos, o que não é proibido.


Conclusão


Antes de estudar FP, fiz uma forte ênfase em paradigmas de programação. Era importante para mim que o código seguisse OOP, não gostasse de funções estáticas ou objetos sem estado, não escrevesse funções globais.


Agora, parece-me que todas as coisas que considerei parte de um paradigma são bastante arbitrárias. O principal é um código limpo e compreensível. Para atingir esse objetivo, você pode usar tudo o que é possível: funções puras, classes, mônadas, herança, composição, inferência de tipos. Todos se dão bem e melhoram o código - basta aplicá-los ao local.


O que mais para ler sobre o tema


Definição de programação funcional da Wikipedia
Haskell Starter Book
Explicação dos functores, mônadas e aplicadores nos dedos
Livro de Haskell sobre práticas para o uso do Talvez (opcional)
Livro sobre a natureza funcional do Swift
Definindo tipos de dados algébricos de um wiki
Um artigo sobre tipos de dados algébricos
Outro artigo sobre tipos de dados algébricos
Relatório Yandex sobre programação funcional no Swift
Implementando a Prelude Standard Library (Haskell) no Swift
Biblioteca com ferramentas funcionais no Swift
Outra biblioteca
E mais um

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


All Articles