Olá, pessoal!
Muitos programadores se assustam um pouco quando falamos de recursividade, mas é quase certo que você já conhece o conceito e/ou já precisou dele.
Também já deve ter escutado sobre funções recursivas serem mais lentas que loops tradicionais, mas será que é isso mesmo?
Recursividade é uma técnica bastante popular para resolver problemas que possam ser decompostos em partes menores, problemas como a sequência fibonacci ou percorrer estruturas de dados, por exemplo.
No entanto, existem alguns problemas que cercam o uso de recursividade. Não é nenhum segredo que, de maneira geral, elas são mais lentas que um laço de repetição tradicional. Além disso, há um limite de chamadas de funções que podemos realizar, e é esse o ponto que tocaremos hoje.
Primeiro vamos entender isso melhor. Por que há um limite de chamadas?
A aplicação contém uma estrutura chamada pilha de chamadas. Essa estrutura de dados funciona como uma pilha comum, armazenando o local para onde a execução do programa deve retornar quando uma função é chamada.
Na prática funciona da seguinte maneira: sempre que você realiza uma chamada para qualquer função no seu código (mesmo as que não são recursivas), um ponteiro com o local de execução código é armazenado nesta pilha.
Depois disso, a função é executada. Ao terminar sua execução, o ponteiro é restaurado da pilha e o código volta para seu local de origem.
Vamos criar uma função que simplesmente escreve “Hello” no console:
let helloFunction() =
printf "Hello"
[<EntryPoint>]
let main argv =
helloFunction()
|> ignore
Como você pode ver na imagem abaixo, se colocarmos um breakpoint podemos visualizar a pilha de chamadas:
Apesar de super útil, a pilha de chamadas pode causar um erro bastante popular: o Stack Overflow. Você já deve ter escutado esse nome quando foi tirar uma dúvida na internet. Pois é, esse erro é tão famoso que o site fez uma brincadeira/homenagem a ele.
Esse erro acontece porque a pilha de chamadas possui um limite fixo de memória. Isso pode variar de linguagem para linguagem, mas geralmente não é muito diferente de 1MB. Então, se realizarmos mais chamadas do que o suportado, acabará a memória e essa exceção será lançada.
Fazer um exemplo simples: uma função recursiva que soma os números de uma lista. Vamos lá:
let rec sum list =
match list with
| [] -> 0
| head::tail -> head + sum tail
Se a lista estiver vazia, eu simplesmente retorno zero. Caso contrário, retorno a soma do head (primeiro elemento da lista) com a soma do tail (restante da lista). Até aqui, tudo tranquilo.
Vamos criar uma lista de 1000 posições e chamar essa função:
[<EntryPoint>]
let main argv =
List.init 1000 (fun index -> index)
|> sum
|> Console.WriteLine
Console.ReadKey()
|> ignore
0
Sucesso!
Mas e se aumentarmos o tamanho da lista para 10000?
Ops, talvez não tenha sido tanto sucesso assim. Acabamos de estourar a pilha de chamadas.
Isso acontece porque não estamos utilizando recursão em cauda.
Mas afinal de contas, o que é isso?
Recursão em cauda é um tipo específico de recursividade que está bastante relacionada ao que chamamos de Tail Call. Uma tail call ocorre quando é feita uma chamada recursiva, garantindo que não há mais nada para ser executado depois da chamada da função recursiva.
Com isso, é justo falar que o retorno da função que realizou a chamada recursiva é o mesmo retorno da função chamada. Afinal, nada será executado depois disso.
Por conta disso, o programa não precisa manter armazenado o ponteiro com o endereço de retorno. Afinal, pode-se assumir que o local é o mesmo da nova chamada.
Internamente o compilador irá substituir a chamada recursiva (quando a tail recursion é utilizada) por uma instrução de JUMP, voltando para a primeira linha de código da função e melhorando, inclusive, a performance, por não precisarmos alocar nada na pilha de chamadas.
Agora precisamos entender o motivo do nosso código não estar realizando a tail call. Uma tail call só é utilizada quando a função recursiva é chamada em uma tail position.
E o jeito mais simples de definir a tail position, é: ela é última instrução que acontece. Só isso. É só um nome bonito para uma coisa muito simples.
Veja alguns exemplos:
let function1 n =
n //Tail position
let function2 n =
if n % 2 = 0
then n //Tail position
else 0 //Tail position
let function3 n =
let value = n + 1
value //Tail position
let function4 n =
match n with
| 2 -> n //Tail position
| _ -> 0 //Tail position
Repare na function4. Parece que o nosso código estava em uma tail position afinal de contas, certo? Errado.
Ele quase estava, mas a expressão em nosso patern matching anterior era:
let rec sum list =
match list with
| [] -> 0 //Tail position
| head::tail -> head + sum tail //Não é uma tail position
Apesar de parecer muito, a chamada da função ocorre antes da soma com o head, portanto, não estamos em uma tail position, e por consequência disso, não estamos realizando uma tail call.
Neste ponto, a solução já deve estar óbvia pra você – tudo que precisamos fazer é que a chamada da função esteja na tail position. Para fazer isso vamos fazer uma nova função.
Essa nova função será bastante semelhante à anterior, mas neste caso vamos utilizar um acumulador via parâmetro, ao invés de acumular os retornos – simples assim.
Para melhorar o consumo dessa função vamos manter o acumulador em uma chamada interna, sem a necessidade do consumidor informar qualquer valor.
let tailRecursionSum list =
let rec _internalSum list acc =
match list with
| [] -> acc
| head::tail -> _internalSum tail (head + acc)
_internalSum list 0
Talvez o código pareça um pouco menos natural pra você, mas quando falamos de programação funcional, recursão em cauda é muito importante, visto que se dá preferência para recursividade ao invés de laços de repetição.
Agora vamos testar nosso novo código:
Sem StackOverflow!
De fato, se colocarmos um breakpoint no código e olharmos a pilha de chamada, ela nunca aumenta:
Legal, né?
Com isso você acaba de ver uma implementação de recursão em cauda, mas como podemos checar se tudo está de acordo?
O teste mais simples é a checagem da pilha de chamadas. Outro ponto é simplesmente executar o algoritmo com entradas maiores, mas claro: são testes do tipo “tentativa e erro”.
Se você quiser fazer uma análise mais profunda, pode utilizar o IL Disassembler para ver o código gerado.
Atenção
Se você não conhece o IL, sugiro que verifique este link. De maneira rápida (e incompleta), o IL é a linguagem intermediária gerada pelo .NET.
Quando compilamos nosso código em .NET ele é transformado nessa linguagem, que por sua vez é compilada em tempo de execução (JIT).
Para usar o IL Disassembler você precisa abrir o console de desenvolvedor do VS. Para isso, basta procurar por “developer command” no Windows que ele será listado.
Navegue até a pasta do seu projeto (utilize o comando cd normalmente) e depois disso execute o comando: ildasm nome-do-exe.exe
, conforme na imagem abaixo:
Agora navegaremos pelo IL DASM. Primeiro daremos uma olhada na primeira função sum, que gerava o problema de StackOverflow
:
.method public static int32 sum(class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> list) cil managed
{
// Code size 39 (0x27)
.maxstack 4
.locals init (class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> V_0,
class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> V_1,
class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> V_2,
int32 V_3)
IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: call instance class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<!0> class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>::get_TailOrNull()
IL_0008: brfalse.s IL_000c
IL_000a: br.s IL_000e
IL_000c: ldc.i4.0
IL_000d: ret
IL_000e: ldloc.0
IL_000f: stloc.1
IL_0010: ldloc.1
IL_0011: call instance class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<!0> class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>::get_TailOrNull()
IL_0016: stloc.2
IL_0017: ldloc.1
IL_0018: call instance !0 class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>::get_HeadOrDefault()
IL_001d: stloc.3
IL_001e: ldloc.3
IL_001f: ldloc.2
IL_0020: call int32 Program::sum(class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>)
IL_0025: add
IL_0026: ret
} // end of method Program::sum
Talvez essa linguagem pareça bastante estranha para você, mas o ponto aqui não é entendermos todas as instruções – vamos focar na instrução call, ela acontece em quatro momentos distintos:
- 1. IL 003: call para a função get_TailOrNull();
- 2. IL 011: call para a função get_TailOrNull();
- 3. IL 018: call para a função get_HeadOrDefault();
- 4. IL 020: call para a função sum;
A linha 20 demonstra que a recursão está acontecendo, portanto, a pilha de chamadas acaba sendo preenchida normalmente.
Agora vamos ver a função tailRecursionSum
:
.method public static int32 tailRecursionSum(class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> list) cil managed
{
// Code size 17 (0x11)
.maxstack 5
.locals init (class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>,class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<int32,int32>> V_0)
IL_0000: newobj instance void Program/_internalSum@13::.ctor()
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: ldarg.0
IL_0008: ldc.i4.0
IL_0009: tail.
IL_000b: call !!0 class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>,int32>::InvokeFast<int32>(class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<!0,class [FSharp.Core]Microsoft.FSharp.Core.FSharpFunc`2<!1,!!0>>, !0, !1)
IL_0010: ret
} // end of method Program::tailRecursionSum
A primeira grande diferença é o tamanho do código gerado. Essa função parece bem mais simples que a anterior, certo?
Além disso, também temos uma instrução call na última linha. O que isso significa?
Significa que estamos vendo a função errada. Precisamos abrir a função interna – esta, sim, possui o código real.
A função aninhada não é listada diretamente, mas podemos abri-la conforme a imagem a seguir:
Agora daremos uma olhada no código:
.method public strict virtual instance int32
Invoke(class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> list,
int32 acc) cil managed
{
// Code size 40 (0x28)
.maxstack 7
.locals init (class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> V_0,
class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> V_1,
class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32> V_2,
int32 V_3)
IL_0000: ldarg.1
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: call instance class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<!0> class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>::get_TailOrNull()
IL_0008: brfalse.s IL_000c
IL_000a: br.s IL_000e
IL_000c: ldarg.2
IL_000d: ret
IL_000e: ldloc.0
IL_000f: stloc.1
IL_0010: ldloc.1
IL_0011: call instance class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<!0> class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>::get_TailOrNull()
IL_0016: stloc.2
IL_0017: ldloc.1
IL_0018: call instance !0 class [FSharp.Core]Microsoft.FSharp.Collections.FSharpList`1<int32>::get_HeadOrDefault()
IL_001d: stloc.3
IL_001e: ldloc.2
IL_001f: ldarg.2
IL_0020: ldloc.3
IL_0021: add
IL_0022: starg.s acc
IL_0024: starg.s list
IL_0026: br.s IL_0000
} // end of method _internalSum@13::Invoke
Se notarmos, ainda ocorrem três instruções de call
, mas desta vez, apenas para obter o head e o tail
da lista.
Ao invés de termos uma última call para a própria função, temos uma instrução br.s
que funciona como um JUMP para a instrução listada como parâmetro. Ou seja, temos um JUMP para a primeira linha de código da função.
Esse JUMP está ocorrendo logo depois de duas instruções starg.s, que são utilizadas para armazenar um valor em um argumento, então, o que nosso código está de fato fazendo, é atualizando o valor dos parâmetros em cada iteração e utilizando um JUMP para voltar à primeira linha.
Por conta disso, nada de StackOverflows
!
Como eu disse antes, o entendimento desse tipo de implementação é fundamental para escrevermos um código funcional de alta performance e evitar problemas bobos como o StackOverflow
.
Muitas vezes não paramos para realizar uma autocrítica de como estamos desenvolvendo, mas esse é um ótimo exemplo de um código que pode funcionar “na sua máquina” e em produção começar a gerar exceções.
Espero que tenham gostado deste tipo de artigo!
Qualquer dúvida ou sugestão, deixem nos comentários!
Até mais!