4 Recursion, Corecursion, and Memoization - The Joy of Kotlin
4 Recursion, Corecursion, and Memoization - The Joy of Kotlin
4 Recursion, Corecursion, and Memoization - The Joy of Kotlin
Neste capítulo
4.1Correcursão e recursão
vamossuponha que você tenha à sua disposição uma append função que recebe
um par de (String, Char) como argumentos e retorna a string do argumento
com o caractere do argumento anexado a ela:
Você não pode fazer nada com esta função porque não tem string para começar.
Você precisa de um elemento adicional: ocadeia vazia "" . Com esses dois elemen-
tos, você já pode construir o resultado pretendido. A Figura 4.1 mostra o processa-
mento da lista.
Figura 4.1 Transformando correcursivamente uma lista de caracteres em uma string
Aqui você terá que usar uma função de bloco, ou seja, uma função com chaves,
para a toString função delimitadora. Você também deve indicar explicitamente
o tipo de retorno de ambas as funções por dois motivos:
oimplementação anterior só foi possível porque você tinha à sua disposição o ap-
pend função, que acrescenta um caractere a uma string, o que significa adicionar
o caractereaté o final da corda. Agora pense no que você faria se tivesse acesso
apenas à seguinte função:
Você pode começar com o final da lista. Para isso, você pode inverter a lista antes
de iniciar o cálculo ou alterar a implementação para retornar o último elemento
da lista e a lista sem seu último elemento. Aqui está uma implementação possível:
Isso funcionaria, no entanto, apenas com listas que oferecem esse tipo de acesso,
como a lista indexada ou a lista duplamente encadeada, também conhecida como
deque (pronuncia -se dek e significafila dupla ). Se você estivesse trabalhando com
listas encadeadas individualmente, não teria escolha a não ser inverter a lista, o
que está longe de ser eficiente.
Como um cenário de pior caso, pegue listas infinitas. Você pode pensar que não
há nada que possa fazer com listas infinitas, mas isso não é verdade. Com Kotlin,
você pode aplicar funções a listas infinitas, que produzem outras listas infinitas.
Você pode compor essas funções para obter o resultado pretendido e, em seguida,
truncar o resultado antes de ser calculado.
Pense neste exemplo: se você quiser calcular a lista dos 100 primeiros primos
(não que isso seja extremamente útil, mas é apenas um exemplo), você teria que
iterar a lista de inteiros (que é infinita), testando que são primos, e parando após
o 100º primo. Neste exemplo, você certamente não poderia começar invertendo a
lista. A solução é usar recursão em vez de corecursão conforme mostrado na fi-
gura 4.2 .
Figura 4.2 Transformando recursivamente uma lista de caracteres em uma string
Como você pode ver na figura, nenhum cálculo pode ser realizado até que a con-
dição terminal (aqui, o último elemento da lista) seja encontrada. Como resultado,
as etapas intermediárias devem ser armazenadas em algum lugar até que possam
ser avaliadas. Esse processo pode ser expresso em código como
Como você verá, o principal problema com esse tipo de código é armazenar as
etapas intermediárias. Isso ocorre porque a JVM usa um espaço de memória
muito limitado paraisto.
Considerandoo que você aprendeu na escola, você pode pensar que os exemplos
recursivos e corecursivos são, de fato, funções recursivas , ou seja, funções que
chamam a si mesmas. Bem, se você seguir essa definição de recursão, você está
certo. Mas isso não é recursão.
Isso não parece mais um loop infinito do que um método recursivo? Embora seja
implementado como um método recursivo, na verdade é um método corecursivo
que pode se traduzir em um loop infinito! A boa notícia é que o Kotlin é capaz de
fazer essa traduçãoautomaticamente.
Com corecursion, cada etapa pode ser avaliada assim que é encontrada.
Com a recursão, todas as etapas devem ser armazenadas em algum lugar de al-
guma forma. Isso permite atrasar a avaliação até que a condição terminal seja
encontrada. Só então cada etapa anterior pode ser avaliada, na ordem inversa.
A distinção entre recursão e correcursão é ilustrada nas figuras 4.3 e 4.4. Esses nú-
meros representam o cálculo da soma de uma lista de números inteiros. A adição
é um pouco especial porque
Figura 4.3 Um cálculo correcursivo
Na figura 4.3 , você pode ver um processo correcursivo. Cada etapa é avaliada as-
sim que é encontrada. Como resultado, a quantidade de memória necessária para
todo o processo éconstante, simbolizada pelo retângulo na parte inferior da fi-
gura. A Figura 4.4 , por outro lado, mostra como um processo recursivo pode ser
usado para calcular o mesmo resultado.
Figura 4.4 Uma computação recursiva
Nenhum resultado intermediário pode ser avaliado até que todas as etapas sejam
desenvolvidas. Como consequência, a memória necessária para a computação re-
cursiva é muito maior; as etapas intermediárias devem ser empilhadas em algum
lugar antes de serem processadas na ordem inversa.
Usar mais memória não é o pior problema da recursão. O que torna o problema
ainda pior é que as linguagens de computador usam a pilha para armazenar eta-
pas de computação. Isso é inteligente porque as etapas de computação devem ser
avaliadas na ordem inversa em comparação com a ordem em que foram empilha-
das. Infelizmente, o tamanho da pilha é limitado, portanto, se houver muitas eta-
pas, a pilha transbordará, travando o encadeamento de computação.
Quantas etapas de computação podem ser colocadas com segurança na pilha de-
pende do idioma e pode ser configurada. Em Kotlin, é cerca de 20.000; em Java, é
cerca de 3.000. Configurar o tamanho da pilha para um valor maior pode não ser
uma boa ideia porque o mesmo tamanho de pilha (mas não a mesma área de pi-
lha) é usado por todos os encadeamentos. E um tamanho de pilha maior geral-
mente é um desperdício de memória, visto que os processos não recursivos usam
pouco espaço na pilha.
Como você pode ver nas duas figuras, a memória necessária para o processo core-
cursivo é constante. Ele não aumentará se o número de etapas de computação au-
mentar. Por outro lado, a memória necessária para o processo recursivo cresce
com o número de passos (e pode ser muito pior do que um crescimento linear,
como você verá em breve). É por isso que você deve evitar processos recursivos,
exceto quando tiver certeza de que o número de etapas permanecerá baixo em to-
dos os casos . Como consequência, você deve aprender a substituir a recursão
pela corecursão toda vezé possível.
Noneste ponto, você pode ter dúvidas. Eu disse que corecursion usa espaço de pi-
lha constante. Mas você deve saber que uma função que chama a si mesma even-
tualmente usa espaço de pilha, mesmo que não tenha muito para colocar na pilha.
Parece que a corecursion também esgotaria a pilha, embora mais lentamente. É
possível eliminar completamente o problema. O truque é transformar uma fun-
ção correcursiva em um bom e velho loop. Isso não poderia ser mais fácil - basta
substituir a implementação corecursiva
Kotlin detecta que esta função é recursiva e aplica o TCE. Novamente, você deve
indicar que esta é sua intenção. Pode parecer chato no começo, e você pode prefe-
rir que o Kotlin lide com isso silenciosamente. Como você descobrirá em breve,
pensar que a implementação de uma função é recursiva quando não é é um bug
comum. Mas se você indicar que sua função tem uma implementação recursiva
de cauda, o Kotlin pode verificar a função e informar se você cometeu um erro.
Caso contrário, o Kotlin usaria uma função recursiva sem cauda e um StackO-
verflowException ocorreria potencialmente em tempo de execução. E pior, isso
pode aparecer apenas uma vez na produção porque depende dos dados de
entrada.
4.2.2Mudando de loops para corecursion
Embora você tenha visto que a recursão é muito menos útil que a corecursão, de-
vido às limitações de memória implícitas, a recursão tem uma grande vantagem:
geralmente é muito mais simples de escrever. A versão recursiva da soma dos in-
teiros de 1 a 10 pode ser escrita como no exemplo a seguir, assumindo que
sum(n) significa soma dos inteiros de 1 a n :
Não poderia ser mais simples. Mas como você viu, a adição não pode ser realizada
até que você saiba o resultado de sum(n - 1) . O estado da computação neste es-
tágio deve ser armazenado na pilha até que todas as etapas tenham sido processa-
das. Você desejará transformar isso em uma implementação corecursiva para se
beneficiar do TCE. Muitos programadores que estão aprendendo a usar corecur-
sion têm problemas com essa transformação, embora seja simples.
Vamos fazer uma pausa e ver como os programadores tradicionais resolveriam o
problema. Os programadores tradicionais desenhariam um fluxograma da com-
putação usando mutação de estado e teste de condição. O problema é tão simples
que eles provavelmente desenhariam o fluxograma apenas em suas cabeças, mas
vamos ver o fluxograma ( figura 4.5 ).
Não é grande coisa, mas esse código contém muitos lugares onde pode dar errado,
especialmente porque tive que traduzir o fluxograma em uma while implemen-
tação de loop. É fácil mexer com as condições. Deve ser <= ou < ? Deve i ser in-
crementado antes ou depois s ? Obviamente, esse estilo de programação é para
programadores inteligentes que não cometem esse tipo de erro. Mas para o resto
de nós, que precisaria escrever muitos testes para verificar possíveis erros, é pos-
sível escrever uma implementação corecursiva fazendo a mesma coisa? Claro.
O que você pode fazer é substituir as variáveis por parâmetros adicionados à fun-
ção. Em vez de uma função sum(n) , você deve escrever uma função auxiliar:
`sum(n, sum, idx) :
Mas, na verdade, n nunca vai mudar. Você pode se beneficiar das funções locais
do Kotlin para remover um parâmetro, tornando a função auxiliar local para a
função principal e fechando sobre o parâmetro da função principal. Isso é muito
mais fácil de fazer do que descrever:
Como eu disse anteriormente, agora você precisa de uma função de bloco com um
explícito return no final do bloco e um tipo de retorno explícito. Então, você pre-
cisa implementar a função auxiliar.
Pense na versão em loop. Em cada iteração do loop, você obtém versões modifica-
das das variáveis: s + i e i + 1 . Tudo o que você precisa fazer é fazer com que
a função auxiliar chame a si mesma com estes parâmetros modificados:
soma divertida(n: Int): Int {
fun soma(s: Int, i: Int): Int = soma(s + i, i + 1)
retorna soma(0, 0)
}
Isso não funcionará porque nunca termina. O que você está perdendo é o teste de
condição terminal:
Tudo o que resta a fazer é informar ao Kotlin que você deseja que o TCE seja apli-
cado à função auxiliar. Você faz isso adicionando o tailrec palavra-chave:
Exercício 4.1
Dica
Solução
Se y = 0, retorna x .
Caso contrário, some 1 a x , subtraia 1 de y e comece de novo.
Isso pode ser escrito usando um loop da seguinte maneira:
Aqui, a condição foi alterada para melhor ajustar um while loop. Seria possível
usar a condição original mas com um resultado feio:
Observe também que, ao contrário do Java, o Kotlin não permite o uso dos parâ-
metros x e y diretamente porque os parâmetros podem ser apenas val referên-
cias. Você terá que fazer cópias. Então tudo que você precisa fazer é substituir as
variáveis por parâmetros em uma chamada para a add função:
tailrec fun add(x: Int, y: Int): Int = if (y == 0) x else add(inc(x), dec(y))
Neste exercício, você não precisa modificar nada. Em vez de armazenar os valo-
res atuais em var referências mutáveis, você precisa chamar recursivamente a
função com os novos valores como parâmetros. Agora você pode chamar sua fun-
ção com qualquer valor de argumento sem causar um StackOverflowExcep-
tion . Como você verá em breve, escrever programas mais seguros geralmente
envolve muito esforço para transformar uma implementação de função recursiva
(não caudal) em uma recursiva caudal. E às vezes, não serápossível!
Comovocê acabou de ver, definir uma função recursiva é simples. Às vezes, a im-
plementação mais simples é a cauda recursiva, como você viu no exemplo ante-
rior. Mas este não foi um exemplo real. Ninguém jamais criaria esse tipo de fun-
ção para realizar uma adição. Vamos tentar com um exemplo mais útil. A função
factorial(int n) podeser definido como retornando 1 se seu argumento for
0, e n * factorial(n – 1) caso contrário:
Esta função, obviamente, não é recursiva, portanto, esteja ciente de que ela trans-
bordará a pilha se n for maior que alguns milhares. E não use esse tipo de código
em produção, a menos que você tenha certeza de que o número de etapas de re-
cursão permanecerá baixo.
Escreva uma função de valor fatorial recursiva. Lembre-se que uma função de va-
lor é uma função declarada com a val palavra-chave:
Dica
Solução
Como a função deve chamar a si mesma, ela já deve estar definida quando essa
chamada acontecer, o que implica que ela deve ser definida antes de você tentar
defini-la! Deixe de lado esse problema do ovo e da galinha por enquanto. Conver-
tendo um único argumento fun função em uma função de valor é simples. Ele
usa um lambda com a mesma implementação da fun função:
Agora para a parte mais complicada. Este código não compilará porque o compila-
dor reclamará que a variável factorial ainda não foi inicializada. O que isto
significa? Quando o compilador lê este código, está no processo de definição
do factorial função. Durante esse processo, ele encontra uma chamada para a
função fatorial, que ainda não está definida. Este é o mesmo problema que
val x: Int = x + 1
Você pode resolver esse problema declarando primeiro a variável e depois alte-
rando seu valor, o que pode ser feito em um inicializador como o seguinte:
Isso funciona porque os membros são definidos antes da execução dos inicializa-
dores. o lateinit A palavra-chave declara que a variável será inicializada poste-
riormente. Se você chamá-lo antes de ser inicializado, receberá uma exceção. Mas
esta função permite usar um tipo não anulável. Sem lateinit , você seria for-
çado a usar um tipo anulável ou inicializar a referência a um valor fictício. Este
truque é totalmente inútil no caso anterior, mas você pode usá-lo para definir a
função fatorial:
objeto fatorial {
lateinit var fatorial: (Int) -> Int
iniciar {
fatorial = { n -> if (n <= 1) n else n * fatorial(n - 1) }
}
}
objeto fatorial {
val fatorial: (Int)-> Int por preguiçoso { { n: Int ->
if (n <= 1) n else n * fatorial(n - 1)
} }
}
objeto fatorial {
val fatorial: (Int)-> Int por lazy { ... }
}
O ... agora deve ser substituído pelo lambda, que é quase o mesmo do exemplo
anterior, incluindo as chaves:
objeto fatorial {
private lateinit var fato: (Int) -> Int
iniciar {
fato = { n -> if (n <= 1) n else n * fato(n - 1) }
}
val fatorial = fato
}
Dessa forma, você pode ter certeza de que nada pode alterar o valor de factori-
al uma vez inicializado. Mas lembre-se, uma função de valor recursiva, embora
possa ser recursiva de cauda, não pode ser otimizada por meio do TCE, portanto,
pode estourar a pilha. Se você precisar de uma função de valor recursiva de
cauda, use uma referência de função.
Observe também que esta função não funcionará em nenhum caso para valores
acima de 16, pois causará um estouro aritmético, produzindo um resultado nega-
tivo. Pior, acima de 33, produzirá um resultado 0 porque a multiplicação de –
2.147.483.648 (o resultado da chamada da função com o parâmetro 33) por 34 re-
sulta em 0. Isso faz com que todos os resultados subsequentes sejam0.(Isso acon-
tece porque Int os valores Kotlin são números de 32 bits.)
4.3 Funções e listas recursivas
Se a lista estiver vazia, a função retorna 0. Caso contrário, ela retorna o valor do
primeiro elemento (o início da lista) mais o resultado da aplicação da sum função
ao restante da lista (o fim da lista). Pode ser mais claro se você definir funções au-
xiliares para retornar o início e o final de uma lista. Você não precisa restringir
essas funções a listas de números inteiros porque elas podem funcionar em qual-
quer lista:
Ou, melhor ainda, você pode criar head e tail funcionar como funções de ex-
tensão do List classe:
Esta função não é recursiva, então você não pode usar o tailrec palavra-chave,
e você não poderá usar esta função com listas de mais de alguns milhares de ele-
mentos. Mas você pode reescrever esta função para colocar a chamada sum na
posição final:
Aqui o sumTail A função auxiliar é recursiva e pode ser otimizada por meio do
TCE. Como essa função auxiliar nunca será usada em outro lugar, o melhor lugar
para colocá-la é dentro da sum função.
Você pode definir a função auxiliar ao lado da função principal. Mas você teria
que tornar pública a função auxiliar private ou internal e a função principal.
Nesse caso, a chamada para a função auxiliar pela função principal seria um fe-
chamento. Os principais motivos para preferir uma função auxiliar definida local-
mente em vez de uma função auxiliar privada é evitar conflitos de nomes e poder
fechar alguns parâmetros da função envolvente.
Outra prática atual é chamar a função auxiliar com o mesmo nome da função
principal com um sublinhado anexado, como sum_ . Também é possível dar à
função auxiliar o mesmo nome da função principal porque as funções terão uma
assinatura diferente. Seja qual for o sistema escolhido, é útil ser consistente. No
restante deste livro, usarei o sublinhado para denotar funções auxiliares recursi-
vas de cauda.
Nãolivro falando sobre funções recursivas pode evitar o exemplo da série Fibo-
nacci. Embora seja totalmente inútil para a maioria de nós, é onipresente porque
é um dos exemplos mais simples de uma função duplamente recursiva , ou seja,
uma função que chama a si mesma duas vezes em cada etapa. Vamos começar
com os requisitos caso você nunca tenha cumprido essa função.
Esta é a série original de Fibonacci, na qual os dois primeiros números são iguais
a 1. Supõe-se que cada número seja uma função de sua posição na série. A maio-
ria dos programadores geralmente prefere começar em 0 em vez de 1. Muitas ve-
zes você encontrará definições onde f(0) = 0, que não faz parte da série original de
Fibonacci. De qualquer forma, isso não muda o problema.
Por que essa função é tão interessante? Em vez de responder a essa pergunta
agora, vamos tentar uma implementação ingênua:
Com base no que você sabe sobre recursão em Kotlin, você pode pensar que essa
função será bem-sucedida no cálculo f(n) de n , até alguns milhares antes de es-
tourar a pilha. Bem, vamos verificar. Substitua 10 por 1000 e veja o que acon-
tece. Inicie o programa e faça uma pausa para o café. Ao retornar, você perceberá
que o programa ainda está em execução. Terá alcançado algo em torno de
1.836.311.903, que é apenas a 47ª etapa (sua milhagem pode variar — você pode
até obter um número negativo!), mas nunca terminará. Sem estouro de pilha, sem
exceção - apenas pendurado na natureza. O que está acontecendo?
O problema é que cada chamada para a função cria duas chamadas recursivas.
Para calcular f(n) , você precisa de 2 n chamadas recursivas. Digamos que sua
função precise de 10 nanossegundos para ser executada. (Apenas supondo, mas
logo você verá que isso não muda nada.) O cálculo f(5000) levará 2 5000 × 10 na-
nossegundos. Você tem ideia de quanto tempo isso é? O programa nunca termi-
nará porque precisaria de mais tempo do que a duração esperada do sistema so-
lar (se não do universo).
Para tornar um utilizávelFunção de Fibonacci, você deve alterá-la para que use
uma única chamada recursiva de cauda. Há também outro problema: os resulta-
dos são tão grandes que logo você terá um estouro aritmético, primeiro resul-
tando em números negativos e logo em 0.
Exercício 4.3
Solução
Você deve lidar com as condições terminais. Se o argumento for 0 , você retorna
1:
tailrec
fun fib(val1: BigInteger, val2: BigInteger, x: BigInteger): BigInteger =
quando {
(x == BigInteger.ZERO) -> BigInteger.ONE
...
}
Se o argumento for 1 , você retorna a soma dos dois parâmetros val1 e val2 :
tailrec
fun fib(val1: BigInteger, val2: BigInteger, x: BigInteger): BigInteger =
quando {
(x == BigInteger.ZERO) -> BigInteger.ONE
(x == BigInteger.ONE) -> val1 + val2
...
}
Eventualmente, você tem que lidar com a recursão. Para fazer isso, você deve fa-
zer o seguinte:
tailrec
fun fib(val1: BigInteger, val2: BigInteger, x: BigInteger): BigInteger =
quando {
(x == BigInteger.ZERO) -> BigInteger.ONE
(x == BigInteger.ONE) -> val1 + val2
else -> fib(val2, val1 + val2, x - BigInteger.ONE)
}
Esta é apenas uma implementação possível. Você pode organizar parâmetros, va-
lores iniciais e condições de uma maneira ligeiramente diferente, desde que fun-
cione. Agora você pode ligar fib(10_000) e ele lhe dará o resultado em alguns
nanossegundos. Bem, levará algumas dezenas de milissegundos, mas apenas por-
que a impressão no console é uma operação lenta. Voltarei a isso em breve. De
qualquer forma, o resultado é impressionante, seja pelo resultado da computação
(2.090 dígitos) ou pelo aumento de velocidade devido à transformação de uma
chamada dual recursiva em uma única corecursiva1.
Exercício 4.4
Escreva uma versão recursiva de cauda do makeString função. (Tente não olhar
para a versão recursiva de cauda de sum .)
Solução
Você precisa aplicar a mesma técnica que usou no exemplo anterior: criar uma
função auxiliar com um parâmetro adicional acumulando o resultado. Se você co-
locar essa função auxiliar dentro da função principal, poderá fazê-la fechar sobre
a delim parâmetro porque não muda a cada passo recursivo:
Ok, era simples, mas repetir isso para cada função recursiva seria tedioso. Você
pode abstrair isso? Você pode. A primeira coisa a fazer é dar um passo para trás e
dar uma olhada na figura inteira. O que você tem?
Isso parece ser diferente do que você tinha no sum exemplo, mas na verdade é o
mesmo, embora T e U fossem do mesmo tipo ( Int ). Para o string exemplo,
T was Char e U was String . Para o makeString function, T já era genérico e
U era String .
Exercício 4.5
Crie uma versão genérica de sua função recursiva de cauda que pode ser usada
para sum , string e makeString . Chame esta função foldLeft , então escreva
sum , string , e makeString em termos desta nova função.
Dica
Você terá que introduzir o valor inicial do tipo U (para o acumulador) e a função
de (U, T) a U como parâmetros adicionais.
Solução
A função que você criou aqui é uma das mais importantes na hora de programar
sem loops. Essa função permite que você abstraia a corecursão de maneira segura
para a pilha, de modo que raramente terá que pensar em tornar as funções recur-
sivas. Mas às vezes você precisará fazer as coisas da maneira oposta, usando re-
cursão em vez de corecursão.
Suponha que você tenha uma lista de caracteres [a, b, c] e queira construir a
string "abc" usando apenas o head e tail e prepend funções que você desen-
volveu no anteriorseção. Você não pode acessar os elementos da lista por seu ín-
dice. Mas você poderia escrever a seguinte implementação recursiva:
Você pode abstrair várias coisas neste código da mesma forma que abstraiu a
foldLeft função. Você pode abstrair o Char tipo a ser digitado T para que a
função funcione com listas de qualquer tipo. Você pode abstrair o String tipo de
retorno para U poder construir um resultado de qualquer tipo. E você teria que
abstrair a prepend função em uma função genérica (T, U) -> U . Você tam-
bém deve substituir o valor inicial "" pelo identity valor do tipo U correspon-
dente a esta função.
Exercício 4.6
Solução
Se a lista não estiver vazia, use o mesmo código da string função, substituindo
prepend pela função de parâmetro:
É isso! Agora você pode definir string em termos foldRight chamando-o com
os valores que você substituiu pelos tipos genéricos:
Ao usar a List classe do Kotlin, você não precisa criar foldRight e fol-
dLeft porque o Kotlin já define essas funções (embora foldLeft seja chamado
simplesmente fold ).
4.3.3 Invertendo uma lista
Invertendouma lista às vezes é útil, embora essa operação geralmente não seja
ideal em termos de desempenho. Encontrar outras soluções que não exijam a in-
versão de uma lista é preferível, mas nem sempre é possível. Uma dessas soluções
é usar uma estrutura de dados diferente que permita o acesso de ambas as
extremidades.
Definir uma reverse função com uma implementação baseada em loop é fácil:
iterar para trás na lista. Você deve ter cuidado, porém, para não mexer com os
índices:
Mas não é assim que deve ser feito em Kotlin. Kotlin já tem um reversed função
em listas.
Exercício 4.7
Lembre-se que foldRight pode transbordar a pilha quando usado com listas
longas, então você deve preferir foldLeft sempre que possível. Você também
deve criar a prepend função trabalhando na lista e adicionando um elemento na
frente da lista. Não se preocupe com o desempenho. Esse é um problema que você
abordará no capítulo 5. Faça sua função funcionar com listas imutáveis usando o
+ operador.
Solução
Então, tudo que você precisa fazer para inverter uma lista é dobrá-la para a es-
querda usando esta função:
Isso funciona, mas é uma espécie de trapaça. Você poderia definir o reverse fun-
ção sem criar esta lista de itens únicos?
Exercício 4.8
Dica
O que você precisa para este exercício é definir a prepend função sem usar con-
catenação. Tente começar com uma função copiando uma lista através de uma
dobra esquerda.
Solução
A prepend função que adiciona um elemento na frente de uma lista pode ser im-
plementada dobrando a lista à esquerda, usando um acumulador contendo o ele-
mento a adicionar em vez da lista vazia:
Agora você pode usar a mesma reverse implementação com esta nova pre-
pend função:
fun <T> reverse(lista: Lista<T>): Lista<T> =
foldLeft(list, listOf(), ::prepend)
Este código é uma composição de duas abstrações: uma lista corecursiva e algum
processamento. A lista corecursiva é uma lista de inteiros de 0 (incluídos) a
limit (excluídos). Como eu disse, uma maneira de tornar os programas mais se-
guros é, entre outras coisas, levar a abstração ao limite para que você possa reuti-
lizar o máximo de código. Vamos abstrair a construção dessa lista correcursiva.
listOf(0, 1, 2, 3, 4).forEach(::println)
Tanto a lista quanto o efeito foram abstraídos. Mas você pode levar a abstração
ainda mais longe.
Exercício 4.9
Escreva uma implementação baseada em loop de uma função que produza uma
lista usando um valor inicial, um limite e a função x -> x + 1 . Você vai chamar
issofunction range , e terá a seguinte assinatura:
Solução
Exercício 4.10
Escreva uma versão genérica de uma função semelhante a um intervalo que fun-
cione para qualquer tipo e qualquer condição. Como a noção de intervalo funci-
ona apenas para números, vamos chamar essa função unfold e dê-lhe a seguinte
assinatura:
Solução
Exercício 4.11
Solução
Exercício 4.12
Escreva uma versão recursiva de range com base nas funções definidas nas se-
ções anteriores.
Dica
A única função necessária é prepend , embora você possa escolher outras imple-
mentações usando funções diferentes.
Solução
Exercício 4.13
Dica
A solução é simples:
Agora você pode redefinir range em termos desta função. Esteja ciente, no en-
tanto, que a unfold função recursivaexplodirá a pilha após alguns milhares de
passos recursivos.
Exercício 4.14
Você pode fazer uma versão recursiva da cauda desta função? Tente responder a
esta pergunta na teoria antes de fazer o exercício.
Dica
Solução
A unfold função é de fato correcursiva, como o foldLeft função que você de-
senvolveu anteriormente. Você pode imaginar que pode fazer uma versão recur-
siva usando uma função auxiliar usando um acumulador como um parâmetro
adicional:
fun <T> desdobrar(seed: T, f: (T) -> T, p: (T) -> Boolean): List<T> {
tailrec divertido desdobrar_(acc: List<T>,
semente: T,
f: (T) -> T, p: (T) -> Booleano): List<T> =
se (p(semente))
desdobrar_(acc + semente, f(semente), f, p)
senão
acc
return desdobra_(listaOf(), semente, f, p)
}
O uso de uma função local permite simplificar este código removendo os parâme-
tros constantes da função auxiliar ( f e p ) e fazendo esta função fechar sobre a
função envolventeParâmetros:
Esse problema surge porque você não deveria usar listas para isso. As listas são
estruturas de dados estritas. Mas você tem que começar de algum lugar. No capí-
tulo 9, você aprenderá a usar coleções preguiçosas que resolverãoistoproblema.
4.4Memorização
DentroNa seção 4.3.1, você implementou uma função para exibir uma série de nú-
meros de Fibonacci. Um problema com essa implementação da série de Fibonacci
é que, se você quiser imprimir a string que representa a série até f(n) , terá que
calcular f(1) , f(2) , até f(n) . Mas para calcular f(n) , você precisa calcular
recursivamente a função para todos os valores anteriores. Eventualmente, para
criar a série até n , você terá calculado f(1) n vezes, f(2) n – 1 vezes e assim
por diante. O número total de computações será então a soma dos inteiros 1 a n .
Isso pode parecer incompatível com os princípios que expus anteriormente por-
que uma função memorizada mantém um estado mutável, que é um efeito colate-
ral. Mas não é incompatível porque o resultado da função é o mesmo quando é
chamada com o mesmo argumento. (Você poderia argumentar que é ainda mais o
mesmo porque não é calculado novamente!) O efeito colateral de armazenar os
resultados não deve ser visível de fora da função. Na programação tradicional,
como a manutenção do estado é a maneira universal de calcular os resultados, a
memoização nem épercebido.
Dica
Solução
③ Formata a lista em uma string separada por vírgulas por meio de uma chamada
para makeString. Isto é apenas para um exercício. Você poderia ter usado a fun-
ção list.joinToString (", ") padrão do Kotlin.
④ O sinal + deve estar no final desta linha e não no início da linha seguinte; caso con-
trário, o código não será compilado.
1, 1, 2, 3, 5, 8, 13, 21,...
(1, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 13), (13, 21), ...
Nesta série, cada tupla pode ser construída a partir da anterior. O segundo ele-
mento da tupla n torna-se o primeiro elemento da tupla n + 1. O segundo ele-
mento da tupla n + 1 é igual à soma dos dois elementos da tupla n . Em Kotlin,
você pode escrever uma função para isso:
Para substituir a função recursiva por uma corecursiva, você precisará de duas
funções adicionais: map e iterate .
Exercício 4.16
Defina a iterate função que funciona como unfold , exceto que, em vez de
chamar a si mesma recursivamente até que uma condição seja atendida, ela
chama a si mesma um determinado número de vezes.
Dica
Solução
A função usa uma função auxiliar tail-recursiva que é idêntica àquela usada por,
unfold exceto pela condição:
Defina uma map função que aplica uma função (T) -> U a cada elemento de a
List<T> , produzindo a List<U> .
Dica
Você pode definir uma função recursiva de cauda ou pode definir sua função em
termos de foldLeft ou foldRight . Uma boa ideia seria começar do copy fun-
ção que você criou ao definir reverse .
Solução
Exercício 4.18
Defina uma versão corecursiva da função de Fibonacci produzindo uma string re-
presentando os n primeiros números de Fibonacci.
Solução
Você precisa iterar usando um par dos dois primeiros números e uma função que
calcula o próximo par a partir do anterior. Isto lhe dará uma lista de pares. Você
pode então mapear esta lista com uma função retornando o primeiro elemento de
cada par e converter a lista resultante em umacorda:
Memorizaçãonão é usado apenas para funções recursivas. Pode ser usado para
acelerar qualquer função. Pense em como você realiza a multiplicação. Se você
precisar multiplicar 234 por 686, provavelmente precisará de uma caneta e papel
ou uma calculadora. Mas se lhe pedirem para multiplicar 9 por 7, você pode res-
ponder imediatamente, sem fazer nenhum cálculo. Isso ocorre porque você está
usando uma multiplicação memorizada.
⑥ Retorna o resultado
Acontece que neste caso particular, o fluxo de teste e controle já foram abstraídos
no computeIfAbsent função:
objeto Duplicador {
Você pode usar esse objeto sempre que quiser calcular um valor:
val y = Doubler.double(x);
Com esta solução, o mapa deixa de ser acessível a partir do exterior. Agora você
resolveu o segundo problema. O que você pode fazer para resolver o primeiro?
Vamos começar com os requisitos. O que você precisa é uma maneira de fazer o
seguinte:
objeto complementar {
fun <T, U> memoize(função: (T) -> U): (T) -> U = ②
Memoizer<T, U>().doMemoize(function)
}
}
A listagem a seguir mostra como essa classe pode ser usada. O programa simula
uma longa computação para mostrar o resultado da memorização da função.
③ A função memorizada
Agora você pode criar funções memorizadas a partir das comuns chamando uma
única função em Memoizer , mas para usar essa técnica na produção, você teria
que lidar com possíveis problemas de memória. Esse código é aceitável se o nú-
mero de entradas possíveis for baixo, para que você possa manter todos os resul-
tados na memória sem causar estouro de memória. Caso contrário, você pode
usar referências suaves ou referências fracas para armazenarvalores.
4.4.5 Implementando memoização de funções multi-argumentais
ComoEu disse antes, não existe no mundo uma função com vários argumentos. As
funções representam relacionamentos entre um conjunto (o conjunto de origem)
e outro conjunto (o conjunto de destino). Eles não podem ter vários argumentos.
Funções que parecem ter vários argumentos são
Funções de tuplas
Funções retornando funções retornando funções ... retornando um resultado
Usar funções de tuplas pode parecer a escolha mais simples. Você poderia usar
o Pair ou Triple classes oferecidas pelo Kotlin, ou você pode definir as suas pró-
prias se precisar agrupar mais de três elementos. A segunda opção é muito mais
fácil, mas você precisa usar a versão ao curry das funções como fez na seção
“Funções ao curry” (capítulo 3).
Memorizar funções curried é fácil, embora você não possa usar a mesma forma
simples de antes. Você tem que memorizar cada função:
Você pode usar a mesma técnica para memorizar uma função de três argumentos:
val f3 = { x: Int -> { y: Int -> { z: Int -> x + y - z } } }
Essa saída mostra que o primeiro acesso à função memorizada levou 6.786 milis-
segundos e o segundo retornou imediatamente.
Por outro lado, usar uma função de uma tupla pode parecer mais fácil depois de
definir a Tuple classe porque você pode usar um data class para o qual o Ko-
tlin fornecerá equals e hashCode funções automaticamente. O exemplo a seguir
mostra a implementação de Tuple4 (se você precisar apenas de Tuple2 ou
Tuple3 , poderá usar as classes Pair ou ): Triple
Uma variação no tempo pode ser um problema. Uma função como a função origi-
nal de Fibonacci que precisa de muitos anos para ser concluída pode ser chama-
danon-terminating , portanto, um aumento no tempo pode criar um problema.
Por outro lado, tornar uma função mais rápida não deve ser um problema real. Se
for, há um problema muito maiorEm outro lugar!
Resumo
Uma função recursiva chama a si mesma, usando essa chamada para si mesma
como um elemento para computação posterior.
As funções recursivas empurram o estado de computação atual para a pilha
antes de se chamarem recursivamente.
O tamanho da pilha padrão do Kotlin é limitado. Se o número de etapas recur-
sivas for muito alto, você obterá um arquivo StackOverflowException .
Funções recursivas de cauda são funções nas quais a chamada recursiva está
na última posição ( cauda ).
No Kotlin, as funções recursivas de cauda são otimizadas usando a eliminação
de chamada de cauda (TCE).
Lambdas podem ser feitos recursivos.
A memoização permite que as funções se lembrem do resultado calculado
para acelerar o acesso posterior.
A memoização pode ser automática.