O post de hoje é uma introdução à programação funcional, para podermos entrar finalmente em Clojure. Mas antes disso, vamos falar sobre como aprendemos a programar na faculdade, em cursos, e em todos os lugares. Vamos falar de “orientação a objetos”, principalmente, e vamos falar sobre “abstração”. A programação, como sabemos, é um exercício total de abstração – ao fazer um software, temos apenas um objetivo – fazer com que um trabalho, que provavelmente seria realizado de forma ineficaz ou manual, torne-se automático. Parece uma super-simplificação, mas é verdade. Processadores de texto substituem máquinas de escrever, editores de imagem automatizam vários trabalhos de restauradores, pintores, e desenhistas, e sistemas de folha de pagamento substituem o trabalho de vários matemáticos, contadores, etc. A profissão de todos continua válida – apenas simplificamos um pouco (ou MUITO!) o trabalho deles. E o nosso trabalho, de programadores, é simplificado com linguagens mais modernas, nas quais se escreve menos e se sub-entende mais. E para isso, precisamos aprender a escrever nessas linguagens. E aí entram os cursos, ou a faculdade.
Basicamente, aprendemos a programar nesses cursos, ou na faculdade, pensando em orientação a objetos. Para muita gente, essa é a única maneira sadia de se programar – afinal, orientação a objetos é o paradigma que representa melhor o mundo real, uma frase que muito se ouve. E essa frase é verdadeira, mas com uma pegadinha muito difícil de encontrar: o mundo real é um lugar complicado.
Esse será um post grande, portanto, está dividido em partes. Falaremos sobre a imprevisibilidade, depois mutabilidade e imutabilidade, e na última parte teremos exemplos em Ruby e Clojure sobre trabalhar com dados mutáveis e imutáveis.
Mas primeiro, falemos sobre teoria do caos: por exemplo, vamos pensar em previsão do tempo. Ao olhar para o céu, vemos nuvens, vemos o movimento delas, sentimos o vento, e a temperatura. Podemos dizer, com certo grau de segurança, se vai chover ou não – há muitas nuvens no céu? O vento as sopra pra nossa direção? As nuvens são objetos, na orientação a objetos – são componentes mutáveis que possuem internações entre umas e as outras, e provocam ações.
E, assim como na orientação a objetos, a medida que o tempo vai passando, não conseguimos mais prever seu comportamento.
Crescimento e imprevisibilidade
Quantas vezes, num sistema qualquer, sofremos com a imprevisibilidade? Olhamos para nosso código e não temos idéia de como chegamos naquele resultado naquele momento. E o código não tem absolutamente nenhum problema de estilo: não há muitos if
s alinhados, não há códigos imensos e classes monstruosas. Apenas que aquele objeto foi mutado de uma forma não esperada, e quando interagiu com outro objeto que também está num estado inesperado, gerou algo inconsistente que escapou das nossas previsões, testes automatizados, etc.
Na programação, um dos maiores desafios é manter as coisas simples. É um desafio imenso afunilar algo complexo em algo que possa ser entendido, explicado, raciocinado, racionalizado, e dentro desses limites, expandido. Imitar o mundo real, com suas complexidades, é insano – por isso tentamos fazer isso com matemática, fórmulas, e lógica. E matemática, fórmulas e lógica são imutáveis.
A matemática, tecnicamente, é bem simples. Temos adição. A subtração pode ser entendida como uma soma de um número com uma dívida – 3 - 2
é a mesma coisa que dizer que temos 3 reais, e ganhamos uma dívida de 2. A multiplicação é a adição feita várias vezes, e a divisão é o inverso dela. Potenciação é a multiplicação várias vezes, radiciação é o contrário dela, e aplicando isso a figuras geométricas (e um pouco de tentativa e erro) chegamos em seno, cosseno, logaritmos, etc. Tudo partindo de um único ponto – a adição. Não dá pra imaginar algo mais simples que isso.
Ainda assim, matemática é complicada. Até você pegar o jeito, é tudo muito difícil. E assim é, também, programação funcional. As coisas são imutáveis, tal como números o são. Representamos mudanças como séries de operações simples que atuam sobre nossos dados, e produzem outros dados como resultado, sem alterar o que já tínhamos. Temos peças simples que, quando são compostas, tomam um poder grande. É difícil imaginar como a gente acaba tendo um sistema super complexo compondo sequencias de pequenas funções, objetos, etc, mas no fim, é exatamente isso que acontece.
Problemas com mutabilidade
Mas e quando precisamos de mutabilidade? Lembrando, o mundo não é imutável – de fato, as coisas mudam de um instante pro outro. O fato é que precisamos de coisas mutáveis muito menos do que imaginamos. Por exemplo, vejamos o código abaixo, em Ruby:
person = Person.new(params) if person.valid? person.save! render json: person, status: :ok else render json: person.errors, status: :error end
O que aconteceu aqui? Temos uma classe que foi iniciada com um parâmetro. Perguntamos se aquela instância é válida; se for, renderizamos um JSON, se não for, também renderizamos um JSON, com os erros. Os problemas:
- Até chamarmos
valid?
, não sabemos se aquela pessoa está em estado consistente. - Antes de chamarmos
valid?
, operson.errors
é vazio - Se
valid?
fortrue
, mas durante ovalid?
e a próxima linha, alguém alterar algum estado global,save!
salvará um objeto inválido (ou lançará uma exception) - principalmente, aquele registro de
person
, logo antes dovalid?
, é um registro que efetivamente não existe – ele não representa nada. Ele deveria representar uma pessoa, mas não sabemos se ela representa, pois não sabemos se ela é valida. Sevalid?
forfalse
, a instânciaperson
é uma aberração: ela representa uma pessoa que nunca será salva pois não é válida. Se fortrue
, entãoperson.errors
é desnecessário.
Ou seja, Person
é uma classe que assume que pode estar errada, e que fatalmente possui dados que não são significantes: se person.valid?
, então person.errors
é um dado irrelevante. Se !person.valid?
, então person.attributes
é irrelevante (duvidoso? não exatamente. Claro que o Rails usa esses atributos para renderizar o formulário com os dados que mandamos para o post. Porém, ele poderia facilmente fazer isso com os params
que ele mandou… o problema aqui está na divisão de informação: o que se renderiza no formulário não é person
. A responsabilidade da classe Person
é mapear o banco de dados, não mapear a view). Aliás, person.attributes[:name] == person.name
, e person.name == params[:name]
. Temos duplicidade de informações, e alterar uma não vai alterar a outra…
Imutabilidade
Vamos agora pensar na imutabilidade, usando Scala, pattern match, e outras coisas (a versão em Clojure está logo após, mas por hora vamos usar a sintaxe de Scala pois é mais semelhante a Ruby):
savePerson(params) match { case Valid(person) => render('json -> person, 'status -> 'ok) case Invalid(errors) => render('json -> errors, 'status -> 'error) }
Primeira coisa: menos linhas. Segunda coisa: nenhum momento, criamos uma “pessoa” intermediária que pode ou não estar certa. Só há uma saída: ou a pessoa é válida, e foi salva, ou a pessoa é inválida, e não foi. Nesse caso, nem permitimos a criação de um registro que determine que aquilo é uma pessoa. Para quê? Qual o motivo de criarmos um registro de algo que não é válido, que é inconsistente? Não temos “linhas intermediárias”, ou “perguntas”. Pedimos para salvar, e checamos se foi válido (com Valid
) ou não (com Invalid
). Com Clojure, por exemplo:
(let [[success data] (save-person params)] (render {:json data, :status (if success :ok :error)))
Ou seja, tudo é mais simples. Podemos “automatizar” certas chamadas em Ruby, usando if user.save
, mas não muda o fato que pedimos uma ação – salvar – que Ruby pode, ou não fazer, e se não fizer, não vai nos dizer o motivo – precisaremos de uma outra chamada para sabermos do motivo pelo qual o registro não foi salvo.
O maior problema da orientação a objetos tradicional, como aprendemos e como tentamos usar, é:
user = User.new user.name = "Foo" user.accounts << Account.new(login: 'bar') user.last_login = 10.days.ago user.save # Qual o estado do user aqui? user.accounts # => [Account(login: 'bar')] user.reload # Exception? user.accounts # => [] # então não salvou a conta?
O problema acima é bem visível: precisamos testar para ver se o usuário foi salvo. Caso contrário, podemos ter uma exception. Mas, se o usuário foi salvo, a conta pode ter sido, ou não. Se ela não foi salva, precisamos testar também. Como vamos compor isso tudo? E se temos uma função adicional que faz mais alterações no user
, e que precisa entrar nesse processo. Qual a garantia de que nosso user estará num estado correto no fim? Qual a farantia que essa função adicional receberá um user válido, um inválido, ou sequer se ele receberá um user que foi salvo no banco? Sim, podemos usar builders, mas o fato é que não usamos. Com a facilidade de getters e setters, achamos que essa é a melhor forma de programar, quando na verdade, não é certo. Não deveríamos permitir que um objeto seja mutado de forma inconsistente, essa é a primeira regra. A segunda, é que não é fácil saber o que significa de forma inconsistente. A melhor forma de evitar isso é tornar difícil a mutação de um objeto, e tornar fácil trabalhar de forma imutável. Vejamos por exemplo o caso abaixo:
Exemplo de caso
Puxamos, de um determinado sistema, todos os usuários. Desses usuários, temos contas. Queremos saber quais são as pessoas que dividem a mesma conta (ou seja, cujo login
da conta é igual), e queremos listar tais pessoas. Em determinadas situações, teremos que filtrar apenas um determinado número de logins, ou mesmo só retornar quais são os logins duplicados de uma determinada lista de usuários. Para complicar um pouco, digamos que recebemos uma lista de usuários, que contém suas contas – ou seja, não conseguimos fazer o contrário – buscar contas depois usuários (que seria mais simples nessa situação).
Primeiramente, é necessário (e visível que é necessário) ter um mapeamento de logins e os usuários que o usam. Nesse passo, não há muito o que fazer – temos que primeiro agrupar os dados e depois remover os que não estão duplicados. Em Ruby, isso é simples:
duplicates = {} users.each do |user| user.accounts.each do |account| duplicates[account.login] ||= [] duplicates[account.login] << user.id end end duplicates = duplicates.reject { |_, users| users.size == 1 }
E em Clojure, como não usamos mutabilidade, primeiramente usamos um for-comprehension para transformarmos nossa lista de usuários com contas “nested” em uma lista “flatten” – ou seja, [login, id]
. Depois disso, agrupamos por login, jogando os ids para uma nova lista, e por fim, filtramos os que count
é acima de um. O filter
não retorna um Map – retorna uma “Lazy Sequence”, e por hora, vamos deixar esse passo assim. Veremos o motivo depois.
(let [flat (for [user users account (:accounts user)] [(:login account) (:id user)]) reduced (reduce (fn [h [login id]] (update-in h [login] conj id)) {} flat)] (filter (fn [[_, users]] (> (count users) 1)) reduced))
Aqui começam as complicações: tanto em Ruby quanto em Clojure, temos que criar um objeto intermediário – um hash completo, para depois filtramos o que não estão, de fato, duplicados. Isso é custoso, mas necessário. Porém, agora queremos um novo filtro – queremos filtrar somente um grupo de usuários, ou um grupo de logins, para buscarmos os duplicados. Vamos ver uma possível implementação em Ruby:
def duplicates(users, logins: [], user_ids: []) duplicates = {} users.each do |user| user.accounts.each do |account| duplicates[account.login] ||= [] duplicates[account.login] << user.id end end duplicates = duplicates.reject do |_, users| users.size == 1 end duplicates = duplicates.select do |login, _| logins.include?(login) end if !logins.empty? duplicates = duplicates.select do |_, users| user_ids.any? { |id| users.include?(id) } end if !user_ids.empty? duplicates end
Aqui temos um problema: se passarmos logins
e users
, criamos um monte de coleções em memória sem necessidade. Consumimos memória, e tempo de processamento, à toa… logo, uma melhor implementação é:
def duplicates(users, logins: [], user_ids: []) duplicates = {} users.each do |user| user.accounts.each do |account| next unless logins.empty? || logins.include?(account.login) duplicates[account.login] ||= [] duplicates[account.login] << user.id end end duplicates = duplicates.select do |_, users| users.size > 1 && (user_ids.empty? || users.any? { |id| user_ids.include?(id) }) end end
Agora temos um OUTRO problema: nossa lógica perdeu a noção de “Pipeline” – antes, tínhamos o código fluindo da seguinte maneira: formatamos nossos registros, depois filtramos apenas os duplicados, depois filtramos apenas os que incluem o login que queremos, depois filtramos apenas os usuarios que queremos. Essa noção não existe mais – códigos de montar o hash inicial está misturado com o código que filtra logins, e códigos que checam por duplicados estão misturados ao código que filtra usuários. Vamos tentar voltar à idéia original.
Mais funcional
Ao invés de pensar em objetos, vamos pensar no que queremos – queremos listar os duplicados de uma lista de usuários com suas contas, e apenas isso. O formato final é um login => [ids que usam o login], mas só. Uma das formas de fazermos isso de forma mais fácil é usarmos o “lazy” do Ruby:
def duplicates(users, logins: [], user_ids: []) users = users.lazy.select do |user, _| user_ids.include?(user.id) end if !user_ids.empty? reduced = {} users.each do |user| user.accounts.each do |account| reduced[account.login] ||= [] reduced[account.login] << user.id end end return reduced if user_ids.empty? reduced.select { |_, ids| ids.any? { |id| user_ids.include?(id) } } end
O código ficou maior, mas pelo menos agora, filtramos antes de fazer qualquer coisa, e o filtro não está misturado com outras operações. Temos meio que um “pipeline” – filtramos primeiro, reduzimos depois, e re-filtramos, e aqui é o máximo que conseguimos chegar com Ruby, sem usar métodos auxiliares e outros processos. Agora, vejamos como ficaria esse código em Clojure:
Primeiramente, se a gente fosse traduzir esse código exatamente igual à versão em Ruby, teríamos um monstro. Primeiro porque a ausência de mutabilidade daria trabalho. Segundo, que a forma de declarar variáveis locais (embora, em teoria, não são variáveis pois não variam) é meio chata de lidar, e teríamos uma série de if
s, e muitos parênteses. Mas temos duas coisas que podem nos auxiliar no processo:
O primeiro é o mais simples: em Clojure, a forma de declarar funções – defn
– é só uma junção entre def
e fn
, e quem de fato cria a função é o fn
. Logo, podemos usar uma variável local que, na verdade, é uma função. Essa função (filter-if
) servirá para filtrar apenas se a coleção que passamos (que vai ser logins
, ou user-ids
) não é nula – se for nula, não roda o filtro.
Segundo, que clojure tem pipeline operators, ou seja, algumas construções que permitem que o resultado de uma função seja passada para a outra. A primeira é o ->, que basicamente passa o resultado da função anterior para o primeiro parâmetro da próxima. A segunda é o ->>, que faz a mesma coisa mas passa para o último parâmetro.
(defn duplicates [users & {:keys [user-ids logins]}] (let [filter-if (fn [coll f users] (if coll (filter f users) users))] (->> (for [user users account (:accounts user)] [(:login account) (:id user)]) (filter-if logins (fn [[login _]] (contains? logins login))) (reduce (fn [h [login id]] (update-in h [login] conj id)) {}) (filter (fn [[_ users]] (> (count users) 1))) (filter-if user-ids (fn [[_ users]] (some #(contains? user-ids %) users))))))
Pode não parecer grande coisa, mas quando damos nomes melhores às funções de filtro, temos um “pipeline” muito bom:
(defn duplicates [users & {:keys [user-ids logins]}] (let [filter-if (fn [coll f users] (if coll (filter f users) users)) have-login (fn [[login _]] (contains? logins login)) only-dups (fn [[_ users]] (> (count users) 1)) have-id (fn [[_ users]] (some #(contains? user-ids %) users))] (->> (for [user users account (:accounts user)] [(:login account) (:id user)]) (filter-if logins have-login) (reduce (fn [h [login id]] (update-in h [login] conj id)) {}) (filter only-dups) (filter-if user-ids have-id))))
De novo, pode não parecer grande coisa. Mas a vantagem dessa abordagem é que fica muito fácil plugar novos filtros. Por exemplo, poderiamos ter um parâmetro extra com filters
, que traria novos filtros para esse objeto. Ou mesmo podemos filtrar fora da função, já que o resultado dela é uma lazy sequence, e próximos filtros não vão criar objetos.
Outra vantagem grande é que nenhum parâmetro que estamos passando corre o risco de mudar. Logo, podemos passar parâmetros para as funções sem medo de que algo estranho venha a acontecer (o famoso filter = params.delete(:filter_by)
do Ruby, que normalmente parece inofensivo mas pode dar muitas encrencas).
Conclusão: o que ganhamos?
Os exemplos acima parecem difíceis de entender, e de fato muitos o são mesmo. Estou programando uma sequencia de posts sobre Clojure, ClojureScript, React e outras formas de programar. Mas a idéia geral é:
- Programação funcional faz o possível para evitar mutabilidade
- Evitar mutabilidade tem o benefício de que você pensa melhor no que você está fazendo – é muito custoso manter dados duplicados ou desnecessários, logo é comum não fazê-lo
- Como é normal não mudar coisas, então temos estruturas para facilitar merges de maps
- Como a regra é não mudar coisas, não temos problemas de concorrência
Os códigos acima mostram claramente que não há muita diferença no número de linhas (ou de caracteres) em um código funcional e um não funcional. Logo, não temos motivo que justifique a mutabilidade com base nesses argumentos. Nos próximos posts, pretendo até mostrar como mutabilidade controlada pode facilitar uma série de códigos usando observers e outros elementos.
1 Comment
Clojure, gentilmente | Maurício Szabo · 2016-04-19 at 20:33
[…] Ver as postagens anteriores, sobre Clojure e simplicidade. Se tiver curiosidade sobre a forma diferente de se programar, há o post sobre programação com o LightTable. Por fim, um pouco mais sobre programação funcional, e imutabilidade. […]
Comments are closed.