Conceitos de Programação: Complexidade Ciclomática

November 12th, 2008 § 5 comments § permalink

Este é o quarto artigo em uma série sobre conceitos de programação. Os demais artigos podem ser encontrados na página de resumo sobre a série. Como nos demais artigos, um aviso: o tratamento dado aos assuntos aqui é informativo e podem existir erros nos mesmos. Como nos demais artigos neste blog, comentários e correções são sempre bem-vindos.

Complexidade Ciclomática

Neste quarto artigo, vamos tratar um pouco de complexidade ciclomática. Apesar do nome esdrúxulo, complexidade ciclomática (também conhecida como complexidade condicional) é uma métrica simples para determinar, como o próprio nome sugere, a complexidade de um programa estruturado (cíclico).

Essa métrica foi desenvolvida em 1976 por Thomas J. McCabe e reflete diretamente o número de caminhos independentes que um programa pode tomar durante a sua execução.

Qualquer desenvolvedor que já tenha testado código em sua vida, sabe que a quantidade de testes necessária para exercitar um determinado trecho de código é diretamente proporcional à sua árvore decisória. Em outras palavras, quanto mais caminhos o código puder tomar (seja por meios de condicionais ou loops), maior a quantidade de testes necessários. E como veremos abaixo, há realmente uma relação direta entre a complexidade ciclomática e a cobertura de um código.

Calculando a complexidade ciclomática

Antes de mostrar exatamente como o cálculo pode ser feito, vamos observar algumas coisas em relação a um programa qualquer. Digamos que você esteja desenvolvendo um programa que lhe dê o maior divisor comum entre dois números. Uma fórmula simples é o algoritmo de Euclides que pode ser descrito da seguinte forma:

Dados dois números naturais a e b, verifique se b é zero. Se sim, a é o maior divisor comum entre os mesmos; caso contrário, repita o processo usando b e o resto da divisão de a por b.

Esse algoritmo pode ser expresso pelo seguinte programa em Ruby (note que ele não está em Ruby idiomático):

require "test/unit"

def euclid(m, n)
  if n > m
    r = m
    m = n
    n = r
  end
  r = m % n
  while r != 0
    m = n
    n = r
    r = m % n
  end
  n
end

class EuclidTest < Test::Unit::TestCase
  
  SETS = [[5, 10,  5], [2,  6,  2], [11,  7,  1], 
    [80, 64, 16], [2, 2, 2]]
  
  def test_euclid
    SETS.each do |set|
      assert_equal set[2], euclid(set[0], set[1])
    end
  end
  
end

Se o programa acima for executado, ele rodará o caso de teste logo abaixo da função que verificará se a mesma está correta. Você pode adicionar mais casos ao conjunto SETS se desejar.

A função euclid pode ser descrita por um grafo simples que conecta os caminhos entre as várias declarações que a mesma contém. Esse grafo é o mostrado abaixo (clique para expandir):

Com base nesse grafo, podemos definir a complexidade ciclomática de um programa da seguinte forma:

CC = A - N + 2C

Nessa fórmula:

  • CC é a complexidade ciclomática
  • A é o número de arestas do grafo
  • N é o número de nós do grafo
  • C é o número de componentes conectados

Como se trata de uma função simples com um único ponto de entrada e saída, o número de componentes é 1 e a fórmula pode ser reduzida para:

CC = A - N + 2

Se a função possuísse múltiplos pontos de saída, entretanto, a complexidade ciclomática seria definida como:

CC = A - N + C + R

Nessa fórmula, R é o número de declarações de saída (em Ruby, o número de returns).

Voltando ao grafo mostra na figura, vemos que o mesmo possui 11 nós e 12 arestas, o que nós dá uma complexidade ciclomática de 12 - 11 + 2, ou seja, 3.

Uma outra maneira bem simples de descobrir a complexidade ciclomática é contar o número de loops fechados no grafo (que são formados por condicionais e loops) e somar ao número de pontos de saída. No grafo acima, temos 2 loops fechados (os if e while) e um ponto de saída, resultando no mesmo valor 3 para a complexidade da função.

Uma coisa interessante é que a complexidade permanece a mesma quando a sintaxe de uma linguagem é levada em questão sem alterar a semântica do programa. Tome por exemplo a versão idiomática do algoritmo em Ruby:

def euclid(m, n)
  m, n = n, m if n > m
  m, n = n, m % n while m % n != 0
  n
end

O grafo gerado nesse caso é (clique para expandir):

Node que embora o número de nós e arestas tenha mudado, a relação entre eles não mudou e a complexidade permanece a mesma.

Testando

De uma forma geral, o valor da complexidade ciclomática define um limite superior para a quantidade de testes necessários para cobrir todos os caminhos decisórios no código em questão. Esse é um limite superior já que nem todos os caminhos são necessariamente realizáveis.

Disso se infere que quanto menor a complexidade, menor a quantidade de testes necessários para o método em questão. Esse fato implica em outro curioso: quebra um método em vários reduz a complexidade dos métodos mas aumenta a complexidade geral do código e, de forma geral, mantém a testabilidade do programa completo no mesmo nível.

Referências

Obviamente, já que a complexidade é um valor específico, é possível extrair da mesma uma referência. Baseado no trabalho de McCabe, esses valores de referência são:

  • 1-10, métodos simples, sem muito risco
  • 11-20, métodos medianamente complexos, com risco moderado
  • 21-50, métodos complexos, com risco alto
  • 51 ou mais, métodos instáveis de altíssimo risco

Conclusão

Essa foi uma pequena introdução ao assunto com o objetivo de abrir o caminho para artigos posteriores mostrando ferramentas de apoio ao cálculo e monitoramento da complexidade ciclomática. Como de usual, sugestões e correções são bem vindos.

Concurso e Conceitos de Programação

March 9th, 2008 § 7 comments § permalink

Segundo os logs de acesso deste blog, o dia de hoje representou o momento em que o mesmo ultrapassou os mil leitores conhecidos nos diversos feeds disponíveis. Eu não sei se acredito nas estatísticas ou não, mas vou supor por um momento que sejam verdadeiras e aproveitar para brincar um pouco.

Concurso

A primeira brincadeira é um concurso. Leitores freqüentes sabem que este ano eu estou pensando bastante sobre a questão de paradigmas de programação–especialmente no que tange ao desenvolvimento Web. O objetivo do concurso é simples: escreva um texto sobre esse assunto. Você pode:

  • Descrever algo que está pensando sobre o assunto
  • Introduzir algo que aprendeu e que modificou sua forma de pensar sobre desenvolvimento Web
  • Explicar as falhas do seu framework favorito e como você as corrigiria
  • Etc

De fato, qualquer coisa nesse linha vale. O prêmio vai para o texto que eu pessoalmente considerar mais interessante sobre o assunto. Para participar, basta enviar um e-mail como a URL do texto ou criar um trackback ou pingback para este texto.

E falando no prêmio: eu tenho uma cópia–nova, intocada–do Code Complete, segunda edição, do Steve McConnell, considerado um melhores livros sobre boas práticas em desenvolvimento. Não é autografado, mas é um livro muito bom e acho que cabe bem em qualquer biblioteca de programação.

Conceitos de Programação

A segunda brincadeira é tentar continuar a série Conceitos de Programação através de textos de convidados. Se você tem interesse em escrever um texto no estilo dos que eu estava fazendo–e eu espero poder contribuir novamente a partir da semana que vem–entre em contato comigo no email mtblog [arroba] reflectivesurface [ponto] com.

O único requisito é que o texto seja similar aos que já foram feitos (balanço de material explicativo e código de exemplo). Obviamente, eu me reservo o direito de adequar o texto ao estilo do Superfície Reflexiva, mas isso não significa mudar o conteúdo do mesmo. Quando muito, inserir alguns sub-títulos, reformatar código para se encaixar na coluna de texto e coisas similares.

Espalhem

Agora é hora de testar se existem leitores mesmo. :-) Quem sentir vontade, espalhe a notícia do concurso e quem sabe podemos criar um diálogo interessante sobre o tema e presentear alguém.

Conceitos de Programação: Big O Notation

February 7th, 2008 § 8 comments § permalink

Este é o terceiro artigo em uma série sobre conceitos de programação. O primeiro falava sobre Lazy Evaluation e como simular isso em uma linguagem sem suporte nativo ao conceito de uma forma simples mas funcional. O segundo falava sobre Tail Recursion e como aproveitar isso em linguagens que implementam a característica.

Antes de começar o artigo, um aviso: o tratamento que eu estou dando aos assuntos aqui é informativo, com a intenção de incentivar os leitores que desconhecem ou querem aprender sobre o assunto em questão a procurar mais sobre o assunto. Como tal, podem existir erros nos mesmos, derivados da minha própria incompreensão de alguns pontos. Nesse caso, como sempre, a parte de comentários está aberta. Aceito tanto sugestões como correções. Isso vale para todo e qualquer artigo da série. Dito isso, podemos continuar.

Neste terceiro artigo, vamos falar um pouco sobre Big O Notation, ou Notação O, que, em teoria da computação é usada para descrever como o tamanho da entrada fornecido a um algoritmo qualquer afeta os recursos que o mesmo utiliza, sejam estes de memória ou tempo de execução.

Conhecer esse tipo de informação sobre um algoritmo é a base de qualquer otimização que possa ser feita sobre o mesmo. Não só para entender como o mesmo opera em termos de eficiência geral mas também para decidir corretamente quando for necessário sacrificar alguma aspecto de performance ou complexidade em favor de outro. Isso vale tanto para uso de CPU, memória como para rede, disco e qualquer outro recurso similar.

A Notação O, na verdade, é uma classe inteira de notações. Nesse artigo vamos cobrir somente uma parte no que tange à análise de complexidade de um algoritmo.

Complexidade versus performance

Antes de prosseguirmos, uma distinção deve ser feita aqui.

Performance é uma medida de quanto dos recursos disponíveis um algoritmo usa. Isso depende tanto do código quanto da máquina onde o mesmo está rodando.

Complexidade, por outro lado, é como o algoritmo escala, ou seja, o que acontece quando mais e mais dados são passados ao mesmo? Complexidade afeta performance, obviamente, mas o converso não é verdadeiro.

Sendo assim, a Notação O descreve a complexidade do algoritmo e não sua performance. Isso será visto mais adiante com um exemplo.

Entendendo a notação

Como mencionado acima, o principal objetivo da notação O, no que tange à descrição de complexidade de um algoritmo é explicar de maneira sucinta como o tamanho do problema afeta o número de operação necessárias para executar o mesmo. Operações podem ser atribuições, comparações, cálculos númericos, leituras, escritas, e assim por diante, tomadas em unidade.

A notação não descreve o número exato de operações, mas está interessada em casos específicos de operação. Em outras palavras, ela é igualmente válida para descrever o melhor caso, o pior caso, ou o caso médio de um algoritmo qualquer.

Para um dado problema, podemos ter as seguintes situações comuns:

Constante: O(1), ou ordem 1
Isso significa que o uso de recursos é constante seja qual for o tamanho da entrada. Obviamente, esse é o melhor nível de complexidade possível.
Linear: O(n), ou ordem n
Isso significa que o uso de recursos é proporcional ao tamanho da entrada.
Logarítmico: O(log n), ou ordem logaritmo n
Isso significa que o uso de recursos varia em proporção logarítmico ao tamanho da entrada.
Quadrático: O(n2), ou ordem n ao quadrado
Isso significa que o uso de recursos varia em proporção exponencial ao tamanho da entrada.

Para colocar isso em contexto, alguns exemplos:

  • Determinar se um número é par ou ímpar é O(1)
  • Encontrar um item em uma lista ordenada por busca binária é O(log n)
  • Resolver o problema do caixeiro viajante por força bruta é O(n!)

A importância da escolha

Para entender a importância da escolha correta de implementação, podemos usar um exemplo concreto:

O famoso Quicksort, em seu caso típico é O(n log n). Em seu pior caso, O(n2). Isso para tempo de execução. É claro que isso depende de uma implementação correta do mesmo, mas em qualquer implementação correta, essa é a complexidade.

Em termos de uso de memória, porém, para um implementação correta é sempre O(n log n), a menos que a execução seja in-place, caso qual o uso de memória é O(1). Em outras palavras, uma mudança de implementação aqui, mesmo correta, pode ter uma impacto enorme em um dos recursos usados pelo algoritmo.

O caso converso também é verdadeira para a escolha. Há momentos em que você deve saber balancear as expectativas. Se você descobre que o seu algoritmo é O(n3) e que existe uma forma de transformá-lo em O(n2) mas que isso vai tomar um ano e tornar o código incompreensível, não faz muito sentido ganhar com a redução por maior que ela aparentemente seja.

Digo aparentemente, porque você pode descobrir que para o tamanho de sua entrada, a complexidade não faz diferença. Se o seu conjunto de entrada é distribuído demais para que o algoritmo caia no melhor caso, faz sentido explorar o pior caso, por exemplo.

O que não é trivial

Obviamente, a equação que determinada a eficiência pode expressar qualquer relação. Entretanto, ela não usa constantes ou termos de ordem inferior porque para à medida que n aumenta, o impacto desses termos se torna insignificante. Para entender isso, considere o Bubblesort, um dos piores algoritmos possíveis para ordenação (quase um Bogosort):

A implementação trivial em Ruby é a seguinte:

def bubblesort(*items)
  loop do
    swapped = false
    for i in (0..items.size - 2)
      if items[i] > items[i + 1]
        items[i], items[i + 1] = items[i + 1], items[i]
        swapped = true
      end
    end
    break unless swapped
  end
  items
end

Essa implementação é O(n2). Você pode reparar que ela gasta n * (n + 1) passagens em média, ou seja, O(n2 + n), o que é o mesmo que dizer O(n2).

Agora, suponha que a implementação seja melhorada:

def bubblesort(*items)
  l = items.size - 1
  loop do
    swapped = false
    l -= 1
    for i in (0..l)
      if items[i] > items[i + 1]
        items[i], items[i + 1] = items[i + 1], items[i]
        swapped = true
      end
    end
    break unless swapped
  end
  items
end

Você verá que agora o algoritmo faz a metade das comparações, porque para antes de chegar aos números que já foram ordenadas. O número de operações agora é n + (n-1) + (n-2) + … + 1, que dá n(n + 1) / 2. Como demonstrado acima, entretanto, isso ainda é O(n2).

A razão é que a entrada ainda influencia exponencialmente a execução. Se você introduzir computar o número de computações feitas verá que esse número cresce exatamente da forma descrita na notação.

Com isso em mãos, uma forma rápida de descobrir quão bem um algoritmo escala é plotar a equação que determina sua complexidade. O crescimento do gráfico mostra isso de forma bem visual. A ordem é:

Determinando complexidade

A maneira de determinar complexidade é verificar quantas operações simples o algoritmo precisa fazer antes de terminar sua execução.

Tome os loops abaixo, por exemplo:

for i in (0..n)
end

for i in (0..m)
end

A complexidade do primeiro é O(n) e a do segundo é O(m). A de ambos é O(n + m), que é o mesmo que O(max(n, m)).

Veja agora o caso abaixo:

for i in (0..n)
  for j in (0..n)
  end
end

A complexidade agora é O(n * n), ou O(n2).

A maioria dos casos pode ser estimada igualmente, verificado as condições que limitam cada caso. Na pior das hipóteses, é possível instrumentar a função e depois lançar os resultados em um programa que gere um gráfico do resultado colhido. Veja o resultado do pior caso da ordenação descrita anteriormente:

Computações versus Itens

Conclusão

Saber estimar ou pelo menos procurar a complexidade de um algoritmo é fundamental para qualquer programador. Qualquer tipo de otimização mais pesada é irrelevante sem isso. O maior problema está no fato de que certas otimizações são contra-intuitivas como demonstrado acima. Entender as variações se torna, então, a necessidade mais premente para otimizar de maneira adequada.

Obviamente, em muitos casos, uma otimização simples pode ser suficiente para os propósitos do código. Mesmo assim, uma análise um pouco mais profunda pode render benefícios inesperados.

Conceitos de Programação: Tail Recursion

February 1st, 2008 § 8 comments § permalink

Este é o segundo artigo em uma série sobre conceitos de programação. O primeiro falava sobre Lazy Evaluation e como simular isso em uma linguagem sem suporte nativo ao conceito de uma forma simples mas funcional.

Neste segundo artigo, vamos explorar um assunto que possui aplicações bem práticas: Tail Recursion. Como a maioria dos programadores pode atestar, recursividade é uma maneira elegante de resolver várias classes de problemas sem muito esforço. Embora seja um conceito que pode confundir programadores inexperientes, uma marca de um bom programador é saber usar recursividade com eficiência.

Um dos maiores problemas da técnica, é claro, é o consumo espaço na pilha. Como cada chamada precisa criar um novo frame, funções profundamente recursivas podem esgotar rapidamente o espaço disponível gerando problemas de difícil depuração. Além disso, há o fato de que é muito fácil escrever uma função recursiva mas não tão fácil escreve uma função que saia rapidamente quando as condições não são apropriadas.

Esses dois problemas dependem muito da capacidade do programador em criar bons algoritmos. Mas o primeiro pode também receber ajuda do compilador em uma otimização conhecida como tail call optimization.

Na prática

Para mostrar como isso funciona, considere a seguinte função em Ruby:

def factorial(i)
  return 1 if i <= 0
  return i * factorial(i - 1)  
end

Essa é uma implementação trivial de um cálculo de fatorial que sofre do problema mencionado acima. À medida que as chamadas são feitas, o espaço na pilha é eventualmente consumido até que haja um estouro. Na minha máquina isso acontece depois de aproximadamente três mil chamadas para o Ruby 1.8.6 compilado nativamente.

Suponha que você faça a seguinte chamada:

factorial(5)

O resultado é 120 com a seguinte seqüência de chamadas:

factorial(5)
5 * factorial(4)
5 * 4 * factorial(3)
5 * 4 * 3 * factorial(2)
5 * 4 * 3 * 2 * factorial(1)
5 * 4 * 3 * 2 * 1

Veja que a função é chamada internamente mais quatro vezes e sempre ao final da execução. Apesar disso, esse não é um exemplo de tail recursion. O motivo é que a multiplicação acontece depois da chamada, ou seja, a invocação é feita ao final mas o retorno só pode ser conhecido com a multiplicação posterior.

Agora, veja a seguinte implementação:

def factorial_accumulate(n, a)
  return a if n <= 1
  return factorial_accumulate(n - 1, n * a)
end

def factorial(n)
  factorial_accumulate(n, 1)
end

Note que agora a chamada vem ao final, mas o resultado não depende de algo posterior. As chamadas seriam assim, para o mesma invocação factorial(5):

factorial(5)
  factorial_accumulate(5, 1)
    factorial_accumulate(4, 5)
      factorial_accumulate(3, 20)
        factorial_accumulate(2, 60)
          factorial_accumulate(1, 120)
          120
        120
      120
    120
  120
120

É aqui que entra a otimização.

Considerando a forma como funções executam, é fácil perceber que cada chamada poder ser implementada de maneira bem mais eficiente com duas substituições.

Ao invés de criar um novo frame na pilha, salvar o endereço de retorno, empilhar as parâmetros, executar a função, repetir recursivamente e ao final ir desempilhando com os retornos, é possível substituir os parâmetros na pilha e pular novamente para o início da função.

Em outras palavras, você teria algo assim, em um pseudo-código:

:fa
  
  n <= 1 ?
    goto :fe
  
  n = n - 1
  a = a * n
  
  goto :fa
  
:fe

A otimização está toda centrada no fato de que se a função é chamada de maneira similar, a invocação pode ser substituída por um salto direto precedido por uma troca dos parâmetros de maneira apropriada.

Um segundo exemplo

Para ilustrar um pouco mais o tipo de transformação necessária, vamos usar a clássica função de Fibonacci:

def fib(n)
  return 1 if n <= 1
  return fib(n - 1) + fib(n - 2)
end

Essa função também não é tail recursive mas pode ser transmudada facilmente com a seguinte implementação:

def fib(i, current = 0, next = 1)
  return current if i == 0
  return fib(i - 1, next, current + next)
end

Obviamente, esse é um exemplo bem básico mas ilustra o tipo de mudança necessária para uma otimização.

Uma pequena distinção

Na literatura sobre o assunto, dois termos são usados: tail recursion elimination e tail call optimization. O primeiro é um caso especial do segundo em que a chamada é para a própria função.

A função factorial_accumulate, mostrada acima, poderia sofrer tail recursion elimination, demonstrado no pseudo-código. Já a segunda implementação de factorial, que chama factorial_accumulate poderia sofrer tail call otimization.

Implementações

Esse tipo de otimização é freqüentemente usado linguagens funcionais já que o uso de recursividade declarativa nas mesmas é extremamente comum e não poderia ser implementado da maneira usual sem tornar as mesmas inúteis para qualquer coisa além de operações básicas. De fato, a otimização é necessária para a implementação de certos estilos de programação que eventualmente discutiremos nessa série.

Surpreendentemente, muitas linguagens modernas suportam automaticamente alguma forma dessa otimização. É o caso, por exemplo, da CLI, a infra-estrutura de linguagens por trás do .NET. Embora nem todas as linguagens sob a CLI façam uso da otimização (C# não faz, por exemplo), ela é perfeitamente possível. F#, que é funcional, usa e, ironicamente, C++ dentro do .NET também em certas condições. Linguagens baseadas em CLI podem fazer uso das funcionalidades caso desejem.

Outra linguagem que possui um suporte muito bom a isso é Lua. O compilador GCC, padrão na maioria das plataformas Unix, também é capaz de otimizar chamadas recursivas em certos casos.

Erlang, que tem chamada bastante a atenção de desenvolvedores modernos e já está em uso em vários locai como na Amazon, é outra com um bom suporte.

Leitores que usam Ruby ficarão felizes em saber tanto YARV como JRuby já suportam uma forma básica de tail call optimization e devem eventualmente suportar formas mais complexas.

O que isso significa

Conhecer e saber aproveitar as características sutis de uma linguagem é um passo essencial para a maestria na mesma. Muitos problemas podem ser resolvidos com chamadas recursivas próprias que podem acabar sendo otimizadas em muitos compiladores atuais sem que você perceba.

Obviamente, existem casos em que uma implementação recursiva pura é mais interessante para efeitos de performance. Por outro lado, se o problema não exige uma pilha enorme, muitas vezes recursividade é um bom componente para melhorar a legibilidade do código. É necessário saber balancear as duas coisas.

Finalmente, é sempre bom lembrar que, como qualquer técnica, a implementação final depende do compilador ou interpretador. Em muitos casos, recursividade simples pode acabar sendo mais eficiente e se você precisa de máxima performance, é interessante testar variações sobre o tema para realmente identificar o que funciona.

Conclusão

Espero que isso tenha ajudado a compreender um pouco do que é tail recursion. Como sempre, quaisquer dúvidas, correções e sugestões podem ser feitas nos comentários. Na próxima sexta-feira retornamos com mais um artigo.

Conceitos de Programação: Lazy Evaluation

January 25th, 2008 § 11 comments § permalink

Inspirado por alguns pedidos aqui no blog, estou começando uma nova série sobre conceitos de programação. Alguns desses conceitos são úteis como técnicas para melhorar a forma como você resolve problemas em seus programas e outros são úteis como forma de entender o que acontece em determinadas situações quando você está programando de modo a aproveitar melhor as características das linguagens em questão.

Para começar, vou experimentar com alguns assuntos que me parecem mais interessantes e postar algo sobre os mesmos a cada sexta-feira. Se houver interesse, mantenho o formato. E é claro, estou sempre aberto a quaisquer sugestões de assuntos.

Lazy Evaluation

O primeiro tema que vamos tratar aqui é o de lazy evaluation. Também conhecida como delayed evaluation essa é uma técnica útil para evitar a necessidade de realmente computar um resultado até que o mesmo seja necessário. Isso significa, é claro, que se por algum motivo o resultado não for necessário, o mesmo também não será calculado.

Dois dos efeitos colaterais mais interessantes do uso dessa técnica são a possibilidade de definir estruturas de dados infinitas (como a lista de todos os números primos por exemplo) e definir estruturas de controle sem a necessidade de funções primitivas da própria linguagem. Se feito de maneira correta, o uso de lazy evaluation é capaz de aumentar consideravelmente a performance de um programa.

Curto-circuito

Em linguagens imperativas, principalmente as que não são dinâmicas, um dos usos mais familiares de lazy evaluation acontece em expressões lógicas. Por exemplo:

rating = (playCount > 10) ? (playCount * 2) : (playCount / 2)

No exemplo acima, dependendo do valor que a variável playCount possuir, somente um dos blocos de código será executado. O outro, compilado ou não, será simplesmente ignorado, embora faça parte da mesma expressão.

Esse é um exemplo bem simples de lazy evaluation mas ilustra a técnica. Esse, inclusive, é um dos poucos casos em que mesmo linguagens que usam execução estrita de maneira geral (call-by-reference e call-by-value) utilizam esse comportamento para otimizar a execução.

Estruturas de controle

Uma função que eu via muito em código ASP era justamente uma forma de criar o operador ternário acima. Aliás, para quem não está familiarizado com esse operador, ele é apenas uma açúcar sintático para a estrutura condicional if. O exemplo usando anteriormente é o mesmo que:

if (playCount > 10)
  rating = playCount * 2;
else
  rating = playCount / 2
end

Em ASP, para emular esse efeito de simplificação, programadores geralmente usavam algo assim:

Function IIf(Expression, TrueValue, FalseValue)
  If Expression Then
    IIf = TrueValue
  Else
    IIf = FalseValue
  End If
End Function

O grande problema desse função é que todos seus parâmetros, por causa da natureza do VBScript, são computados antes da execução da função. Se construirmos um exemplo equivalente ao primeiro, teríamos algo assim:

Rating = IIf(PlayCount > 10, PlayCount * 2, PlayCount / 2)

Ineficientemente, todos os valores acima são computados antes de srem passados adiantw mesmo que somente um deles seja retornado. Em uma linguagem que usasse lazy evaluation, o usual seria substituir as expressões por uma promessa de execução e só computar o valor quando a mesma fosse necessária. Em uma linguagem assim, a função IIf seria exatamente equivalente à estrutura condicional if.

Listas infinitas

Um exemplo muito usado para explicar o assunto, quase que um exemplo canônico, é o cálculo dos números de Fibonacci em uma linguagem lazy como Haskell.

Para comparar, veja a implementação recursiva abaixo em Ruby:

def fib(n)
  return 1 if n <= 1
  return fib(n - 1) + fib(n - 2)
end

Essa é uma implementação ineficiente, é claro, mas serve para comparar com a implementação similar em Haskell:

fib 0 = 0
fib 1 = 1
fib n = fib(n - 1) + fib(n - 2)

Essa é uma implementação que segue a definição matemática da função mas que também é bem ineficiente. Essa implementação recursiva geralmente falha por volta do trigésimo quinto item por causa da quantidade de chamadas feitas.

Em Ruby, isso geralmente é resolvido com uma versão iterativa. Em Haskell, podemos resolver da seguinte forma:

fib n = fibs !! n
  where
    fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

Antes de explicar a expressão, é necessário explicar o que zipWith faz. Essencialmente, zipWith pega duas listas e aplica a função às mesmas, elemento por elemento, resultando em uma única lista final. Por exemplo:

*Main> zipWith (*) [1, 2] [3, 4]
[3, 8]

O uso de (*) tem a ver com outro conceito interessante chamado currying que veremos em um artigo futuro.

Essencialmente, então, o que a definição de fib está dizendo é:

  1. Para o argumento n, fib de n é o elemento n de uma lista chamada fibs. O operador !! é o equivalente Haskell do indexador [] em outras linguagens.

  2. A lista fibs é definida como a concatenação de 0, 1 e do resultado da concatenação de si mesma com sua cauda com a aplicaçâo de (+). Essa última parte pode soar estranha, mas é exatamente isso o que está acontecendo. Essencialmente, o que você tem aqui é uma lista infinita. Digamos que você queira calcular então o quinto termo da série de Fibonacci. Nesse caso, você teria a seguinte execução:

0 : 1 : zipWith (+) (0 : 1 : ...) (1 : ...)
0 : 1 : (0 + 1) : zipWith (+) (1 : 1 : ...) (1 : ...)
0 : 1 : (0 + 1) : (1 + 1) : zipWith (+) (1 : 2 : ...) (2 : ...)
0 : 1 : (0 + 1) : (1 + 1) : (1 + 2) : zipWith (+) (2 : 3 : ...) (3 : ...)

O retorno final seria três, que é o quinto item da lista. Se você está curioso, a própria função zipWith pode ser definida como:

zipWith f (x:xs) (y:ys) = f x y : zipWith f xs ys
zipWith _ _ _ = []

Em outras palavras, zipWith de uma lista cujo primeiro elemento é x e cujo restante é xs e de uma lista cujo primeiro elemento é y e cujo restante é ys para uma função f é f de x e y concatenado a zipWith de xs e ys sobre f. A segunda linha diz que em qualquer outro caso, o retorno é uma lista vazia. A definição recursiva pode ser vista facilmente na chamada em fibs.

Note que a lista sempre é expressa como uma lista sem término, ou seja, infinita. Para retornar um valor específico, basta pegar o elemento em questão. O cálculo necessário é feito somente até aquele ponto e não mais. O resto da lista continua sendo expresso internamente por uma representação que permite obter o próximo valor sem mais do que uma simples continuação do processamento. A função é bem eficiente e não sofre das penalidades recursivas da versão original.

A beleza da função acima, entretanto, não está em evitar recursão explícita (esse é outro conceito que exploraremos futuramente) ou usar uma lista infinita. Antes, está no fato de que o cálculo, além de somente ser executado quanto necessário, não é repetido. Ou seja, seu eu pegar o décimo primeiro elemento e depois pegar o quinto, a lista não é recalculada. E quando eu precisar do vigésimo elemento, só serão necessárias mais nove passagens na lista. O cálculo todo acontece sob demanda.

Um outro exemplo interessante disso é o cálculo do Crivo de Eratóstenes em Haskell:

primes = sieve [2..]
  where
    sieve (p:xs) = p : sieve [x | x <- xs, x `mod` p > 0]

Essencialmente, a função tira todos múltiplos de dois de uma lista infinita de números naturais maiores ou iguais a dois, depois tira todos múltiplos de três, depois todos de cinco, e assim por diante, com cada novo primo descoberto.

Ao contrário da função fib, essa é extremamente ineficiente, o que mostra que o uso de lazy evaluation não é uma panacéia e que também depende de bons algoritmos.

O que isso tudo significa

Em linguagens com suporte direto a lazy evaluation, o uso da mesma pode facilitar a implementação e aumentar muito a performance de um programa.

Em linguagens que não suportam lazy evaluation, uma simulação da técnica pode ser bem útil para gerar resultados com maior performance. É um troca, na verdade, já que geralmente isso envolve um consumo maior de memória, mas pode ser bem útil se o cálculo é tão intensivo que exige uma forma melhor de ser tratado.

Uma forma de fazer algo parecido em Ruby é usando a classe Fiber, disponível a partir da versão 1.9. Você teria algo assim:

require "fiber"
  
fib = Fiber.new do 
  x, y = 0, 1
  loop do 
    Fiber.yield y
    x, y = y, x + y
  end
end

Essa computação é infinita, já que não há uma condição de saída, e você pode obter valores chamando o método resume na classe retornada:

20.times { puts fib.resume }

O problema com a chamada acima é que se eu quisesse recalcular uma número de Fibonacci qualquer, eu teria que criar uma nova Fiber. Isso pode ser resolvido com algo um pouco mais complexo:

require "fiber"

class LazyList
  
  def initialize(&block)
    @memo = []
    @fiber = Fiber.new(&block)
  end
  
  def [](value)
    @memo << @fiber.resume while @memo.size <= value
    @memo[value]
  end
  
end

def fib(n)
  (class << self; self; end).instance_eval do
    ((@global_lazy_dict ||= {})[:fib] ||= LazyList.new {
      x, y = 0, 1
      loop do 
        Fiber.yield y
        x, y = y, x + y
      end      
    })[n]
  end
end

[12, 10, 17].each { |i| puts fib(i) }

O código acima usa uma forma bem básica de memoização para manter a lista em memória mas a idéia é essa. Se você rodar o código, verá que a lista somente é calculada até onde necessário e que valores anteriores são retornados do cache.

Uma aplicação interessante desse tipo de técnica seria executar cálculos pesados em uma thread separada e retornar somente o necessário para a exibição. Se está fosse feita em páginas, por exemplo, você poderia requisitar somente os valores daquela página enquanto os das demais páginas estão sendo calculados. Para quem, inclusive, quer experimentar com isso em Ruby, há justamente uma biblioteca chamada lazy.rb para auxiliar. Veja o método future.

Conclusão

Espero que isso tenha ajudado a entender um pouco o que é lazy evaluation. Qualquer dúvida posterior, coloque nos comentários aqui. Da mesma forma, sugestões e críticas são bem-vindas. Na próxima sexta-feira, continuaremos com mais um artigo.

Where Am I?

You are currently browsing the Conceitos category at Superfície Reflexiva.