You are on page 1of 68

5Tratamento de dados com listas

Nissocapítulo

Classificando estruturas de dados


Usando a onipresente lista encadeada individualmente
Compreendendo a importância da imutabilidade
Manipulando listas com recursão e funções

As estruturas de dados são um dos conceitos mais importantes na programação,


bem como na vida cotidiana. O mundo como você o vê é uma enorme estrutura
de dados composta de estruturas de dados mais simples, que por sua vez são com-
postas de estruturas mais simples. Cada vez que você modela algo, sejam objetos
ou fatos, você acaba com estruturas de dados.

As estruturas de dados vêm em muitos tipos. Na computação, as estruturas de da-


dos referentes às múltiplas ocorrências de dados de um determinado tipo comum
são geralmente representadas como um todo pelo termo coleções . Uma coleção é
um grupo de itens de dados que têm algum relacionamento entre si. Em sua
forma mais simples, essa relação é que eles pertencem ao mesmo grupo.

Este capítulo aborda estruturas de dados e como criar sua própria implementação
da lista encadeada individualmente. Kotlin tem suas próprias listas, tanto mutá-
veis ​quanto imutáveis. Mas a lista imutável do Kotlin não é realmente imutável e
não implementa o compartilhamento de dados, tornando operações como adicio-
nar e remover elementos menos eficientes. oA lista imutável que você desenvol-
verá neste capítulo é muito mais eficiente para operações de pilha e é imutável.

5.1 Como classificar as coletas de dados

Uma estrutura de dados é uma parte estruturada de dados. As coleções de dados


são uma categoria específica de estruturas de dados. As coletas de dados podem
ser classificadas de muitos pontos de vista diferentes. Você pode classificar cole-
ções de dados em coleções lineares, coleções associativas e coleções de gráficos:

As coleções lineares são coleções nas quais os elementos estão relacionados ao


longo de uma única dimensão. Em tal coleção, cada elemento tem um relaciona-
mento com o próximo elemento. O exemplo mais comum de uma coleção li-
near é a lista.
Coleções associativas são coleções que podem ser vistas como uma função. Dado
um objeto o , uma função f(o) retorna true ou false conforme este objeto
pertença ou não à coleção. Ao contrário das coleções lineares, não há relação
entre os elementos da coleção. Estas coleções não são ordenadas, embora seja
possível definir uma ordem nos elementos. Os exemplos mais comuns de cole-
ções associativas são o conjunto e a matriz associativa (que também é cha-
mada de mapa ou dicionário). Você estudará uma implementação funcional de
mapas no capítulo 11.
Os gráficos são coleções nas quais cada elemento está relacionado a vários ou-
tros elementos. Um exemplo particular é a árvore e, mais especificamente, a
árvore binária, onde cada elemento está relacionado a dois outros elementos.
Você aprenderá mais sobre árvores no capítulo 10.
5.2 Diferentes tipos de listas

DentroNeste capítulo, concentro-me no tipo mais comum de coleção linear: a lista.


A lista é a estrutura de dados mais amplamente usada na programação, portanto,
geralmente é usada para ensinar muitos conceitos relacionados à estrutura de
dados.

IMPORTANTE O que você aprenderá neste capítulo não é específico para listas,
mas é compartilhado por muitas outras estruturas de dados (que podem não ser
coleções).

As listas podem ser classificadas com base em vários aspectos diferentes, in-
cluindo o seguinte:

Acesso —Você pode acessar algumas listas apenas de uma extremidade e ou-
tras de ambas as extremidades. As listas podem ser escritas de um lado e lidas
do outro lado. Finalmente, com algumas listas, você pode acessar qualquer ele-
mento usando sua posição na lista, que também é chamada deíndice .
Tipo de ordenação — Em algumas listas, os elementos são lidos na mesma or-
dem em que foram inseridos. Este tipo de estrutura é dito serFIFO (primeiro a
entrar, primeiro a sair). Em outros, a ordem de recuperação é inversa à ordem
de inserção (LIFO, ouultimo a entrar primeiro a sair). E algumas listas permi-
tem que você recupere os elementos em uma ordem completamente diferente.
Implementação — Os conceitos de tipo de acesso e ordenação estão fortemente
relacionados à implementação que você escolher para sua lista. Se você optar
por representar a lista vinculando cada elemento ao próximo, obterá um resul-
tado completamente diferente doponto de vista de acesso em vez de uma im-
plementação baseada em uma matriz indexada. Ou se você optar por vincular
cada elemento ao próximo e ao anterior, obterá uma lista que pode ser aces-
sada de ambas as extremidades.

A Figura 5.1 mostra diferentes tipos de listas que oferecem diferentes tipos de
acesso. Esta figura mostra o princípio por trás de cada tipo de lista, mas não a ma-
neira como elas sãoimplementado.
Figura 5.1 Diferentes tipos de listas oferecem diferentes tipos de acesso aos seus elementos.

5.3 Desempenho relativo esperado da lista

UmUm critério importante na escolha de um tipo de lista é o desempenho espe-


rado para vários tipos de operações. O desempenho geralmente é expresso na no-
tação Big O. Essa notação é usada principalmente em matemática, mas, quando
usada em computação, indica como a complexidade de um algoritmo muda ao
responder a uma mudança no tamanho da entrada. Quando usada para caracteri-
zar o desempenho de operações de lista, essa notação mostra como o desempenho
varia em função do comprimento da lista. Por exemplo, considere os seguintes
desempenhos:

O(1)—O tempo necessário para uma operação é constante. (Você pode pensar
nisso como significando que o tempo para um elemento é multiplicado por 1
para n elementos.)
O(log( n ))—O tempo para uma operação em n elementos é o tempo para um
elemento multiplicado por log( n ).
O( n )—O tempo para n elementos é o tempo para um elemento multiplicado
por n .
O( n 2 )—O tempo para n elementos é o tempo para um elemento multiplicado
por n 2 .

Seria ideal criar uma estrutura de dados com desempenho O(1) para todos os ti-
pos de operações. Infelizmente, isso ainda não é possível. Cada tipo de lista ofe-
rece um desempenho diferente para diferentes operações. Listas indexadas forne-
cem desempenho O(1) para recuperação de dados e próximo a O(1) para inserção.
As listas vinculadas individualmente oferecem desempenho O(1) para inserção e
recuperação em uma extremidade e O(n) na outra extremidade.

Escolher a melhor estrutura é um compromisso. Na maioria das vezes, você bus-


cará desempenho O(1) para as operações mais frequentes e terá que aceitar O(log(
n )) ou mesmo O( n ) para algumas operações que não ocorrem com frequência.
Esteja ciente de que esta forma de medir o desempenho tem um significado real
para estruturas que podem ser dimensionadas infinitamente. Este não é o caso
das estruturas de dados que você manipulará porque essas estruturas são limita-
das em tamanho pela memória disponível. Uma estrutura com tempo de acesso O(
n ) pode sempre ser mais rápida que outra com O(1) devido a essa limitação de ta-
manho. Se o tempo de um elemento for muito menor para a primeira estrutura, a
limitação de memória pode impedir que a segunda mostre seus benefícios. Geral-
mente é melhor ter desempenho O( n ) com um tempo de acesso de 1 nanosse-
gundo a um elemento em vez de O(1) com um tempo de acesso de 1 ms. (O último
será mais rápido que o primeiro apenas para tamanhos acima de 1 milhão de
elementos.)

5.3.1 Trocando tempo contra espaço de memória e complexidade

Você viu que escolher uma implementação para uma estrutura de dados geral-
mente é uma questão de negociar o tempo contra o tempo. Você escolherá uma
implementação mais rápida em algumas operações, mas mais lenta em outras,
com base nas operações mais frequentes. Mas na hora de negociar, há outras deci-
sões a serem tomadas.

Imagine que você deseja uma estrutura da qual os elementos possam ser recupe-
rados em uma ordem de classificação, o menor primeiro. Você pode optar por
classificar os elementos na inserção ou pode preferir armazená-los à medida que
chegam e procurar o menor apenas na recuperação. Ao decidir qual usar, um cri-
tério importante seria se o elemento recuperado é sistematicamente removido da
estrutura. Caso contrário, pode ser acessado várias vezes sem remoção. Nesse
caso, provavelmente seria melhor ordenar os elementos no momento da inserção
para evitar ordená-los várias vezes na recuperação. Este caso de uso corresponde
ao que é chamado defila de prioridade na qual você está esperando por um deter-
minado elemento. Você pode testar a fila várias vezes até que o elemento espe-
rado seja retornado. Esse caso de uso requer que os elementos sejam classificados
no momento da inserção.

Mas e se você quiser acessar elementos por várias ordens de classificação diferen-
tes? Por exemplo, você pode querer acessar os elementos na mesma ordem em
que foram inseridos ou na ordem inversa. O resultado pode corresponder à lista
duplamente encadeada da figura 5.1 . Parece que, nesse caso, os elementos devem
ser classificados no momento da recuperação.

Você pode favorecer uma ordem, levando a um tempo de acesso O(1) de uma
ponta e O( n ) da outra ponta, ou pode inventar uma estrutura diferente, talvez
dando tempo de acesso O(log( n )) de ambas as pontas. Outra solução seria arma-
zenar duas listas, uma na ordem de inserção e outra na ordem inversa. Dessa
forma, você teria um tempo de inserção mais lento, mas recuperação O(1) de am-
bas as extremidades. Uma desvantagem é que essa abordagem provavelmente
usaria mais memória, portanto, você pode ver que escolher a estrutura certa tam-
bém pode ser uma questão de trocar o tempo pelo espaço de memória.

Mas você também pode inventar alguma estrutura minimizando o tempo de in-
serção e o tempo de recuperação de ambas as extremidades. Esses tipos de estru-
turas já foram inventados e você só precisaria implementá-las, mas essas estrutu-
ras são muito mais complexas que as mais simples, então você estaria trocando
tempo por complexidade.
5.3.2 Evitando mutação no local

A maioria das estruturas de dados muda com o tempo porque os elementos são
inseridos e removidos. Para lidar com essas operações, você pode usar duas abor-
dagens. O primeiro é atualizar no local .

Atualizar no localconsiste em alterar os elementos da estrutura de dados alte-


rando a própria estrutura. Isso foi considerado uma boa ideia quando todos os
programas eram de thread único (embora não fosse). É ainda muito pior agora
que todos os programas são multithread. Não se trata apenas de substituir ele-
mentos. É o mesmo para adicionar ou remover, classificar e todas as operações
que modificam a estrutura. Se os programas tiverem permissão para modificar
estruturas de dados, essas estruturas não poderão ser compartilhadas sem prote-
ções sofisticadas que raramente são feitas corretamente na primeira vez, levando
a deadlock, livelock, thread starving, dados obsoletos e todos os tipos de
problemas.

Atualizar no local

Em um artigo de 1981 intitulado “O conceito de transação: virtudes e limitações”,


Jim Gray escreveu: 1 
Atualização em vigor: uma maçã envenenada?

Quando a contabilidade era feita com tabletes de argila ou papel e tinta, os


contadores desenvolveram algumas regras claras sobre boas práticas contá-
beis. Uma das regras fundamentais é a escrituração por partidas dobradas,
para que os cálculos sejam autoverificáveis, tornando-os fail-fast, ou seja, um
erro é detectado assim que é cometido, em vez de possivelmente aparecer
muito tempo depois, ao verificar o resultado ( ou não sendo aparente). Uma
segunda regra é que nunca se altera os livros; se houver erro, é anotado e
feito novo lançamento compensatório nos livros. Os livros são uma história
completa das transações do negócio...

Qual é a solução? Use estruturas de dados imutáveis. Muitos programadores fi-


cam chocados quando leem isso pela primeira vez. Como você pode fazer coisas
úteis com estruturas de dados se não pode modificá-las? Afinal, muitas vezes você
começa com estruturas vazias e deseja adicionar dados a elas. Como você pode fa-
zer isso se eles são imutáveis?

A resposta é simples. Assim como na contabilidade de dupla entrada, em vez de


alterar o que existia anteriormente, você cria novos dados para representar o
novo estado. Em vez de adicionar um elemento a uma lista existente, você cria
uma nova lista com o elemento adicionado. O principal benefício é que, se outro
thread estiver manipulando a lista no momento da inserção, ele não será afetado
pela alteração porque não a verá. Geralmente, esta concepção levanta imediata-
mente dois protestos:

Se a outra thread não perceber a mudança, ela está manipulando dados


obsoletos.
Se fazer uma nova cópia da lista com o elemento adicionado for um processo
que consome tempo e memória, as estruturas de dados imutáveis ​levarão a um
desempenho ruim.

Ambos os argumentos são falaciosos. O thread que manipula os dados obsoletos


está, de fato, manipulando os dados como estavam quando começou a lê-los. Se a
inserção de um elemento ocorrer após a conclusão da manipulação, não há pro-
blema de simultaneidade. Mas se a inserção ocorrer durante a manipulação, o
que ocorreria com uma estrutura de dados mutável? Ou ele não estaria protegido
contra acesso simultâneo e os dados poderiam estar corrompidos ou o resultado
falso (ou ambos), ou algum mecanismo de proteção bloquearia os dados, atra-
sando a inserção até que a manipulação pelo primeiro thread fosse concluída. No
segundo caso, o resultado final seria exatamente o mesmo de uma estrutura
imutável.

A objeção sobre desempenho é verdadeira se você usar estruturas de dados que


impliquem uma cópia inteira a cada modificação, que é o caso das listas imutá-
veis ​Kotlin. Esse problema, no entanto, é fácil de resolver usando estruturas espe-
ciais que implementam o compartilhamento de dados, como você aprenderá
nestecapítulo.

5.4 Quais tipos de listas estão disponíveis em Kotlin?

KotlinGenericNameoferece dois tipos de listas: mutáveis ​e imutáveis. Ambos são


apoiados por listas Java, mas são aprimorados com um grande número de fun-
ções, graças ao sistema de funções de extensão do Kotlin.
listas mutáveisfuncionam como listas Java. Você pode modificar uma lista adicio-
nando, inserindo ou removendo um elemento; nesse caso, a versão anterior da
lista é perdida. Por outro lado, listas imutáveis ​não podem ser modificadas, pelo
menos por operação direta. Adicionando um elemento a umlista imutável cria
uma cópia da lista original com o novo elemento adicionado. Isso funciona bem,
mas o desempenho é inferior ao ideal para algumas operações porque essas listas
não são persistentes por natureza. Eles são feitos para persistir usando uma téc-
nica bem conhecida chamadacópia defensiva . Embora o termo cópia defensiva
signifique fazer uma cópia para se defender contra mutações concorrentes de ou-
tras threads, ele também pode ser aplicado para defender outros de suas próprias
mutações.

Se você precisa de listas imutáveis ​mais eficientes do que as oferecidas pelo Ko-
tlin, é discutível. Depende principalmente do seu caso de uso. Se você precisa de
uma estrutura LIFO imutável e de alto desempenho, como uma pilha, sem dúvida
precisa de algo mais eficiente do que a lista imutável Kotlin. Mas, de qualquer
forma, mesmo que você não precise de uma lista LIFO de alto desempenho,
aprender a criá-la é fundamental se você deseja escrever programas mais segu-
ros. Cada função que você criará para trabalhar em listas persistentes imutáveis ​
enriquecerá seu conhecimento básico do assunto a tal ponto que você não poderá
evitá-lo.

5.4.1 Usando estruturas de dados persistentes

ComoEu disse, fazer uma cópia da estrutura de dados antes de inserir um ele-
mento é uma operação demorada que leva a um desempenho ruim. Mas este não
é o caso se você usarcompartilhamento de dados , que é a técnica na qual as estru-
turas de dados persistentes imutáveis ​são baseadas. A Figura 5.2 mostra como os
elementos podem ser removidos e adicionados para criar uma nova lista encade-
ada imutável com desempenho ideal.

Figura 5.2 Removendo e adicionando elementos sem mutação ou cópia

Como você pode ver na figura, nenhuma cópia ocorre. Essa lista pode ser muito
mais eficiente para remover e inserir elementos do que uma lista mutável. Não,
as estruturas de dados funcionais (imutáveis ​e persistentes) nem sempre são mais
lentas que as mutáveis. Eles geralmente são ainda mais rápidos (embora possam
ser mais lentos em algumas operações). De qualquer forma, eles são muitomais
seguro.

5.4.2 Implementando listas imutáveis, persistentes e encadeadas


individualmente

oA estrutura da lista encadeada individualmente mostrada nas figuras 5.1 e 5.2 é


teórica. A lista não pode ser implementada dessa forma porque os elementos não
podem ser vinculados uns aos outros. Eles teriam que ser elementos especiais
para permitir esses links, e você deseja que suas listas possam armazenar qual-
quer elemento. A solução é criar uma estrutura de lista recursiva composta do
seguinte:

Um elemento que será o primeiro elemento da lista, também chamado de head


.
O restante da lista, que é uma lista por si só e é chamada de tail .

Você já encontrou um elemento genérico composto por dois elementos de tipos di-
ferentes: o Pair . Uma lista vinculada individualmente de elementos de tipo A é,
na verdade, um arquivo Pair<A, List<A>> . o Pair class não está aberta para
extensão, mas você pode definir sua própria classe:

classe aberta Pair<A, B>(val primeiro: A, val segundo: B)

class List<A>(val head: A, val tail: List<A>): Pair<A, List<A>>(head, tail)

Mas, como expliquei no capítulo 4, você precisa de um caso terminal como em


toda definição recursiva. Por convenção, este caso terminal é chamado Nil e cor-
responde à lista vazia. E como Nil não tem cabeça nem rabo, não é um Pair .
Sua nova definição de uma lista é então

Uma lista vazia ( Nil ) ou


Um par de um elemento e uma lista

Em vez de usar a Pair com propriedades first e second , você criará uma
List classe específica com duas propriedades: head e tail . Isso simplifica o
manuseio do Nil caso. Nil poderia ser declarado como um object , o que signi-
fica que seria um singleton porque é necessárioser apenas uma instância da lista
vazia. Nesse caso, você o criaria como um List<Nothing> que pode ser conver-
tido em uma lista de qualquer tipo. Você pode definir esses elementos como

lista de classe aberta<A>

object Nil : List<Nothing>()

class Cons<A>(principal val head: A, private val tail: List<A>): List<A>()

Mas haveria uma grande desvantagem: qualquer um poderia estender a


List classe, o que poderia levar a uma implementação inconsistente de listas, e
qualquer um poderia acessar o Nil e Cons subclasses, que são detalhes de imple-
mentação que não devem ser tornados públicos. A solução é declarar a
List classe sealed e definir as subclasses Nil e dentro da classe: Cons List

Lista de classes seladas<A> {

objeto interno Nil: List<Nothing>()

class interna Cons<A>(private val head: A,


private val tail: List<A>): List<A>()
}

A Figura 5.3 mostra a listagem completa de sua primeira implementação de lista.


Para experimentar seu novo List , você precisará de algumas funções. A Lista-
gem 5.1 mostra a implementação básica dessa lista incluindo essas funções.

Figura 5.3 A representação da implementação da lista encadeada individualmente

Listagem 5.1 Listas ligadas individualmente


Lista de classes seladas<A> { ①

diversão abstrata isEmpty(): Booleano ②

objeto privado Nil : List<Nothing>() { ④

sobrescrever diversão isEmpty() = true

override fun toString(): String = "[NIL]"


}

classe privada Cons<A>(


cabeça val interna: A,
interno val tail: List<A>) : List<A>() { ⑤

sobrescrever diversão isEmpty() = false

override fun toString(): String = "[${toString("", this)}NIL]"

tailrec private fun toString(acc: String, list: List<A>): String =


quando (lista) { ⑥
é Nil -> acc
é Cons -> toString("$acc${list.head}, ", list.tail)
}
}

objeto complementar {

operador
fun <A> invocar(vararg az: A): List<A> = ⑦
az.foldRight(Nil as List<A>) { a: A, list: List<A> ->
Cons(a, lista) ⑧
}
}
}

① Classes seladas são implicitamente abstratas e seu construtor é implicitamente


privado.

② A função abstrata isEmpty tem implementações diferentes em cada classe de


extensão.

③ Classes de extensão são definidas dentro da classe List e tornadas privadas.

④ A subclasse Nil representa uma lista vazia.

⑤ A subclasse Cons representa listas não vazias.

⑥ A função toString é implementada como uma função correcursiva (como você


aprendeu no capítulo 4).

⑦ A função de chamada declarada com a palavra-chave operator pode ser chamada


como ClassName().

⑧ O primeiro argumento da função foldRight é onde o objeto Nil é explicitamente


convertido em uma List<A>.

Na listagem, a List classe é implementada como uma classe selada . Classes sela-
das permitem definirtipos de dados algébricos (ADT), ou seja, tipos que possuem
um conjunto limitado de subtipos. As classes seladas são implicitamente abstratas
e seu construtor é implicitamente privado. Aqui a List classe é parametrizada
com o parâmetro type A , que representa o tipo dos elementos da lista.

A List classe contém duas subclasses privadas para representar as duas formas
possíveis que uma List pode assumir: Nil para uma lista vazia e Cons para
uma lista não vazia. (O nome Cons significa construct .) A Cons classe recebe
como parâmetros an A (a cabeça) e a List<A> (arabo). Por convenção, o toS-
tring A função sempre inclui NIL como último elemento, então a implementa-
ção retorna [NIL] . Esses parâmetros são declarados internal para que não se-
jam visíveis de fora do arquivo ou do módulo no qual a List classe é declarada.
As subclasses foram feitas private , então você deve construir listas através de
chamadas para a função do objeto companheiro invoke .

A List classe também define o resumo isEmpty() função, que retorna true se
a lista estiver vazia e false caso contrário. o invoke function, juntamente com o
modificador operator , permite chamar a função com uma sintaxe simplificada:

val List<Int> lista = List(1, 2, 3)

Esta não é uma chamada para o construtor (o List construtor é privado), mas
para a função de objeto complementar invoke . o foldRight A função nesta lis-
tagem é uma função Kotlin padrão para matrizes e coleções. Você já definiu essa
função no capítulo 4, mas aprenderá mais sobre ela mais adiante emistocapítulo.
5.5 Compartilhamento de dados em operações de lista

UmUm dos grandes benefícios de estruturas de dados persistentes imutáveis,


como a lista vinculada individualmente, é o aumento de desempenho fornecido
pelo compartilhamento de dados. Você já pode ver que o acesso ao primeiro ele-
mento da lista é imediato. É uma questão de acessar a head propriedade. Remo-
ver o primeiro elemento é igualmente rápido. Tudo o que você precisa fazer é de-
volver o valor do tail imóvel. Vamos agora ver como obter uma nova lista com
um elemento adicional.

Exercício 5.1

Implemente a função cons , adicionando um elemento no início de uma lista.


(Lembrarcons significa construção .)

Dica

Esta função pode ter a mesma implementação para ambas as subclasses, então
você pode defini-la como uma função concreta na List classe.

Solução

Esta função cria uma nova lista que usa a lista atual como cauda e o novo ele-
mento como cabeça:

diversão contras(a: A): List<A> = Contras(a, isso)


Exercício 5.2

Implemente setHead , uma função para substituir o primeiro elemento de a


List por um novo valor.

Dica

Você não pode alterar o cabeçalho de uma lista vazia; portanto, nesse caso, você
deve lançar uma exceção. (No próximo capítulo, você aprenderá como lidar com
esse caso com segurança.)

Solução

Você poderia implementar esta função na List classe, aproveitando a


when construção e as conversões inteligentes:

fun setHead(a: A): List<A> = when (this) {


Nil -> throw IllegalStateException("setHead chamado em uma lista vazia")
é Cons -> tail.cons(a)
}
}

Como você pode ver, não é necessário converter explicitamente List em Nil e
Cons . Isso é feito automaticamente pelo Kotlin. Observe também que, graças ao
uso de uma classe selada, você não precisa de um else cláusula. Kotlin vê que to-
das as subclasses são processadas.
Se você é um ex-programador Java, talvez não goste desse estilo de programação.
Aqui, is é o equivalente a usar o Java instanceof operador, o que geralmente é
considerado uma prática ruim. No entanto, não há nada inerentemente errado
em usar instanceof em Java. É considerado uma prática ruim porque não é ori-
entado a objetos.

OBSERVAÇÃO Kotlin não é apenas uma linguagem de programação orientada a


objetos (OOP). É uma linguagem multiparadigma que favorece o uso da ferra-
menta certa para o trabalho. No final, cabe a você escolher a técnica que preferir.

Por outro lado, verificar os tipos só porque não é proibido também não é uma boa
opção. Usar a ferramenta certa para o trabalho inclui o uso de técnicas OOP
quando essas podem ser melhores. É este o caso aqui? À primeira vista, pode pa-
recer que não. Vamos descobrir o porquê criando um resumo setHead função na
List classe pai com implementações separadas em Nil e Cons :

Lista de classes seladas<A> {

diversão abstrata setHead(a: A): List<A>

objeto privado Nil: List<Nada>() {

override fun setHead(a: Nothing): List<Nothing> =


throw IllegalStateException("setHead chamado em uma lista vazia")

...

}
classe privada Cons<A>(cabeçalho do val interno: A,
cauda val interna: List<A>): List<A>() {

substituir fun setHead(a: A): List<A> = tail.cons(a)


...

Isso parece bom, mas não é. Se você tentar chamar setHead uma lista vazia, re-
ceberá um ClassCastException em vez do esperado IllegalStateExcep-
tion . Isso ocorre porque quando um A é recebido pela setHead função da
Nil classe, ele é convertido em Nothing porque Nothing é o tipo de parâmetro
da classe setHead implementação da função. Isso faz com que um ClassCas-
tException as Nothing não seja um supertipo de A . (Pelo contrário, A é um su-
pertipo de Nothing .) Você pode usar o Nothing tipo para compilar o código,
mas não pode ser instanciado em tempo de execução. Como resultado, nenhuma
função tomada Nothing como um tipo de parâmetro pode ser chamada.

Isso significa que não há como implementar setHead como uma função abstrata
na List classe? Não. Uma solução simples consiste em declarar uma
Empty<A> classe abstrata e um Nil<Nothing> objeto implementando essa
classe. Esta seria uma escolha melhor se você tivesse muitas funções para definir,
cada uma recebendo um A argumento. Mas por enquanto, setHead e cons são
os únicos. Além disso, a cons função pode ter a mesma implementação para am-
bas as subclasses, então você pode mantê-la assim. No capítulo 11, você usará
uma classe abstrata com uma implementação de objeto singleton para
representarárvores.
5.6 Mais operações de lista

Você pode contar com o compartilhamento de dados para implementar várias ou-
tras operações de maneira eficiente - geralmente muito mais eficiente do que
pode ser feito com listas mutáveis. No restante desta seção, você adicionará funci-
onalidade à lista vinculada com base no compartilhamento de dados.

Exercício 5.3

Retornar a tail propriedade de uma lista tem o mesmo efeito que remover o pri-
meiro elemento, embora não ocorra nenhuma mutação. Escreva uma função mais
geral, drop , que remove os primeiros n elementos de uma lista. Esta função não
remove os elementos, mas retorna uma nova lista correspondente ao resultado
pretendido. Esta lista não será nada de novo porque você usará o compartilha-
mento de dados, então nada será criado. A Figura 5.4 mostra como você deve
proceder.
Figura 5.4 Eliminando os n primeiros elementos de uma lista sem alterar ou criar nada

A assinatura da função é esta:

fun drop(n: Int): List<A>

Caso n seja maior que o comprimento da lista, basta retornar uma lista vazia.
(Talvez você prefira chamar essa função dropAtMost .)

Dica

Você deve usar corecursion para implementar a drop função. Você pode imple-
mentar uma função abstrata na List classe com duas implementações diferentes
em Nil e Cons . ou há uma solução melhor?
Solução

A implementação de uma função abstrata na List classe parece simples. Adici-


one o abstract palavra-chave antes da assinatura da função. A implementação
em Nil retorna this . Uma implementação recursiva em Cons poderia ser

override fun drop(n: Int): List<A> = if (n == 0) this else tail.drop(n - 1)

Mas isso aumenta a pilha para n mais de alguns milhares (algo entre 10.000 e
20.000), desde que a lista seja longa o suficiente. Como você viu no capítulo 4, você
deve mudar a recursão para corecursão adicionando um parâmetro adicional.
Isso parece fácil, e a seguinte solução pode vir à mente:

override fun drop(n: Int): List<A> {


tailrec fun drop(n: Int, list: List<A>): List<A> =
if (n <= 0) list else drop(n - 1, list.tail())
return drop(n, this)
}

No entanto, isso não funcionará se n for maior que o comprimento desta lista. O
uso de corecursion evita que o caso terminal seja entregue ao Nil implementa-
ção. Nesse caso, você precisa testar explicitamente Nil e não pode confiar no po-
limorfismo. Mas não depender do polimorfismo significa que você não precisa de-
clarar uma função abstrata na List classe com duas implementações diferentes.
A seguinte função na List classe fará o trabalho:
fun drop(n: Int): List<A> {
tailrec fun drop(n: Int, list: List<A>): List<A> =
if (n <= 0) lista senão quando (lista) {
é Cons -> drop(n - 1, list.tail)
é Nil -> lista
}
return drop(n, this)
}

5.6.1 Beneficiando-se da notação de objetos

colocandofunções em classes é uma escolha. Você também pode definir sua fun-
ção fora da classe no nível do pacote. Como mencionei, declarar uma função den-
tro de uma classe é exatamente o mesmo que adicionar this seus parâmetros.
Você poderia, portanto, definir a drop função como

lista de classes<A> {
...
}

fun <A> drop(aList: List<A>, n: Int): List<A> {


tailrec fun drop_(list: List<A>, n: Int): List<A> = when (list) {
Lista.Nil -> lista
é List.Cons -> if (n <= 0) list else drop_(list.tail, n - 1)
}
return drop_(aLista, n)
}
Então você pode ver que a função auxiliar não é mais necessária porque tem exa-
tamente a mesma assinatura da função principal:

lista de classes<A> {
...
}

tailrec fun drop(list: List<A>, n: Int): List<A> = when (list) {


Lista.Nil -> lista
é List.Cons -> if (n <= 0) list else drop(list.tail, n - 1)
}

Uma desvantagem é que agora você deve declarar a classe Nil and em vez de
porque, ao contrário de Java, a classe envolvente não tem acesso a classes priva-
das internas ou aninhadas. Não é grande coisa, no entanto. Isso pode ser resolvido
declarando a função no Cons internal private objeto companheiro de classe :

objeto complementar {

tailrec fun <A> drop(list: List<A>, n: Int): List<A> = when (list) {


Nil -> lista
is Cons -> if (n <= 0) list else drop(list.tail, n - 1)
}

...
}

Com esta solução, você pode chamar a função prefixando seu nome com o nome
da classe, como fazem os programadores Java ao chamar funções estáticas:
fun main(args: Array<String>) {
val lista = Lista(1, 2, 3)
println(Lista.drop(lista, 2))
}

Alternativamente, você pode importar a função. Por outro lado, as funções de ins-
tância geralmente são mais fáceis de usar do que as funções definidas no nível do
pacote ou em objetos complementares. Isso ocorre porque as funções de instância
permitem compor chamadas de função usando notação de objeto, que é muito
mais fácil de ler. Por exemplo, se você deseja descartar dois elementos de uma
lista de números inteiros e substituir o primeiro elemento do resultado por 0,
pode usar funções de nível de pacote:

val newList = setHead(drop(lista, 2), 0);

Cada vez que você adiciona uma função ao processo, o nome da função é adicio-
nado à esquerda e os argumentos adicionais, além da própria lista, são adiciona-
dos à direita, conforme mostra a figura 5.5 .
Figura 5.5 Sem a notação de objeto (esquerda), as funções compostas podem ser difíceis de ler. O uso da nota-
ção de objeto (à direita) resulta em um código muito mais legível.

O uso da notação de objeto torna o código muito mais fácil de ler:

val newList = list.drop(2).setHead(0);

Como designer de biblioteca, a melhor opção provavelmente seria oferecer ambas


as possibilidades. Dado que você colocou a função no objeto complementar, adici-
onar uma versão de instância é tão simples quanto

fun drop(n: Int): List<A> = drop(this, n)

Agora você tem o melhor dos dois mundos. Você não precisa de uma função auxi-
liar (ou pode considerar que a função no objeto complementar é a função auxi-
liar) e não precisa de uma implementação específica em cada subclasse. Se você
não deseja que a função no objeto complementar seja acessível de fora, pode
torná-la privada. Nesse caso, você também pode colocá-lo na classe de lista e vol-
tar ao ponto de partida.
Quanto à questão de qual solução escolher, não há uma resposta única. Cabe a
você escolher a solução que se adapta às suas necessidades ou ao seu estilo. Uma
solução com menos código geralmente é preferível, porque quanto mais código
você tiver, mais terá que manter. Além disso, tente minimizar as partes visíveis.

Exercício 5.4

Implementar uma dropWhile funçãopara remover elementos da cabeça do


List , desde que uma condição seja verdadeira. Aqui está a assinatura da função:

fun dropWhile(p: (A) -> Boolean): List<A>

Solução

Supondo que você escolheu a abordagem de objeto complementar, veja como im-
plementar a função auxiliar:

privado
tailrec fun <A> dropWhile(lista: Lista<A>,
p: (A) -> Boolean): List<A> = when (list) {
Nil -> lista
is Cons -> if (p(list.head)) dropWhile(list.tail, p) else list
}

A seguinte função de instância na List classe chama o auxiliarfunção:

fun dropWhile(p: (A) -> Boolean): List<A> = dropWhile(this, p)


5.6.2 Listas de concatenação

UMAoperação comum em listas consiste em adicionar uma lista a outra para for-
mar uma nova lista contendo todos os elementos de ambas as listas. Seria bom po-
der vincular as duas listas, mas isso não é possível. A solução é adicionar todos os
elementos de uma lista à outra lista. Masos elementos só podem ser adicionados à
frente (head) da lista, portanto, se você deseja concatenar list1 a list2 , deve
começar adicionando o último elemento de list1 à frente de list2 , conforme
indicado na figura 5.6 .

Figura 5.6 Compartilhando dados por concatenação. Você pode ver que ambas as listas são preservadas e que
list2 é compartilhada pela lista resultante. Mas você também pode ver que não pode proceder exatamente
como está indicado na figura porque teria que acessar primeiro o último elemento de list1. Isso não é possí-
vel devido à estrutura da lista.

Uma maneira de proceder é primeiro inverter list1 , produzindo uma nova


lista, e depois adicionar cada elemento a list2 , desta vez começando do topo da
lista invertida. Mas você ainda não definiu uma função reversa. Você ainda conse-
gue definir concat ? Sim você pode. Considere como você poderia definir essa
função:

Se list1 estiver vazio, retorne list2 .


Else retorna a adição do primeiro elemento ( list1.head ) de list1 à conca-
tenação do restante de list1 ( list1.tail ) a list2 .

Essa definição recursiva pode ser traduzida no seguinte código:

fun <A> concat(lista1: Lista<A>, lista2: Lista<A>): Lista<A> = when (lista1) {


Nil -> lista2
é Cons -> concat(lista1.cauda, ​
lista2).cons(lista1.cabeça)
}

Você pode adicionar uma função de instância em List , chamando a versão do


objeto complementar com this seu primeiro argumento:

fun concat(list: List<A>): List<A> = concat(this, list)

A beleza dessa solução (para alguns leitores) é que você não precisa de uma figura
para expor como ela funciona porque não está funcionando. É uma definição ma-
temática traduzida em código.

A principal desvantagem dessa definição (para outros leitores) é que, pelo mesmo
motivo, ela não pode ser facilmente representada em uma figura. Isso pode soar
como humor, mas não é. esteA solução não representa o processo de concatena-
ção de listas (a partir das quais você pode desenhar um fluxograma). Ele expressa
o resultado diretamente na forma de uma expressão. E este código não calcula o
resultado. É o resultado!

NOTA Programar com funções como um substituto para estruturas de controle ge-
ralmente envolve pensar em termos de qual é o resultado pretendido, em vez de
como obtê-lo. Código funcional é uma tradução direta de uma definição em
código.

Obviamente, esse código transbordará a pilha se list1 for muito longo, embora
você nunca tenha um problema de pilha com o comprimento de list2 . A con-
sequência é que você não terá que se preocupar se tiver o cuidado de adicionar
apenas listas pequenas no início de listas de qualquer tamanho.

Um ponto importante a ser observado é que o que você está fazendo é adicionar
elementos da primeira lista em ordem inversa à frente da segunda lista. Isso é ob-
viamente diferente do entendimento de senso comum de concatenação: adicionar
a segunda lista ao final da primeira. Definitivamente, não é assim que funciona
com a lista encadeada individualmente.

Se precisar concatenar listas de comprimento arbitrário, você pode pensar que


precisa aplicar o que aprendeu no capítulo 4 para fazer o concat pilha de função
segura — substitua recursão por corecursão. Infelizmente, isso não é possível.
Como consequência, esta abordagem é limitada pelo tamanho da pilha. Posterior-
mente, você verá como resolver o problema trocando espaço de memória (pilha)
por tempo. Por enquanto, se você pensar no que fez, pode imaginar que há mais
espaço para a abstração aqui. E se o concat função fosse apenas uma aplicação
específica de uma operação muito mais geral? Talvez você possa abstrair essa
operação, torná-la segura em pilha e reutilizá-la para implementar muitas outras
operações? Espere e veja!

Você deve ter notado que a complexidade dessa operação (e, portanto, o tempo
que ela levará para ser executada pelo Kotlin) é proporcional ao comprimento da
primeira lista. Se você concatenar list1 e list2 de comprimento n1 e n2 , a
complexidade é O(n1) , o que significa que é independente de n2 . Dependendo
de n1 e n2 , esta operação pode ser muito mais eficiente do que concatenar duas
listas mutáveis ​no imperativoprogramação.

5.6.3 Saindo do final de uma lista

Isso éàs vezes é necessário remover elementos do final de uma lista. Embora a
lista encadeada individualmente não seja a estrutura de dados ideal para esse
tipo de operação, você ainda deve ser capaz de implementá-la.

Exercício 5.5

Escreva uma função para remover o último elemento de uma lista. Esta função
deve retornar a lista resultante. Implemente-o como uma função de instância com
a seguinte assinatura:

fun init(): Lista<A>

Você pode estar se perguntando por que isso é chamado init em vez de algo co-
mo dropLast . O termo vem de Haskell (
http://zvon.org/other/haskell/Outputprelude/init_f.html ).
Dica

Pode haver uma maneira de expressar essa função em termos de outra, da qual já
falei. Talvez agora seja o momento certo para criar a função auxiliar.

Solução

Para remover o último elemento, você precisa percorrer a lista (de frente para
trás) e construir a nova lista (de trás para frente) porque o último elemento de
uma lista deve ser Nil . Isso é uma consequência da maneira como as listas são
criadas com Cons objetos. Isso resulta em uma lista com os elementos em ordem
inversa, portanto, a lista resultante deve ser invertida. Isso significa que você só
precisa implementar uma reverse função:

tailrec fun <A> reverse(acc: List<A>, list: List<A>): List<A> =


quando (lista) {
Nil -> acc
é Cons -> reverse(acc.cons(list.head), list.tail)
}

Esse código é a implementação no objeto complementar. E a seguir é a função de


instância List chamando-a com this :

fun reverse(): List<A> = reverse(List.invoke(), this)

Observe que você não pode usar a List() sintaxe para chamar o invoke função
sem um argumento. Você deve chamá-lo explicitamente; caso contrário, Kotlin
pensa que você está chamando um construtor e lança uma exceção porque a
List classe é abstrata. Lembre-se, você pode escolher uma organização diferente,
como colocar a função auxiliar dentro da função de chamada:

fun reverse(): List<A> {


tailrec fun <A> reverse(acc: List<A>,
lista: Lista<A>): Lista<A> = quando (lista) {
Nil -> acc
é Cons -> reverse(acc.cons(list.head), list.tail)
}
return reverse(List.invoke(), this)
}

Com a função reversa, você pode implementar init facilmente:

fun init(): List<A> = reverse().drop(1).reverse()

Estas são as implementações para a Cons classe. Na Nil classe, a init função
lança umexceção.

5.6.4 Usando recursão para dobrar listas com funções de ordem superior
(HOFs)

Dentrono capítulo 4, você aprendeu como dobrar listas; folding também se aplica
a listas persistentes. Mas com listas mutáveis, você pode optar por implementar
essas operações por meio de iteração ou recursivamente. Com listas persistentes,
não há razão para usar a abordagem iterativa. Vamos considerar as operações co-
muns de dobra em listas de números.
Exercício 5.6

Escreva uma função para calcular a soma de todos os elementos de uma lista per-
sistente de números inteiros usando recursão. A implementação pode ser colo-
cada no objeto complementar List ou no nível do pacote no mesmo arquivo se
você criar as subclasses internal em vez de private . Colocá-lo no nível do pa-
cote pode parecer mais apropriado porque é específico para List<Int> .

Solução

A definição recursiva da soma de todos os elementos de uma lista é esta:

0 para uma lista vazia


cabeça + soma da cauda para uma lista não vazia

A maneira de expressar isso em Kotlin é a seguinte:

fun sum(ints: List<Int>): Int = when (ints) {


Nada -> 0
é Cons -> ints.head + sum(ints.tail)
}

Mas isso não vai compilar porque Nil não é um subtipo de List<Int> .

5.6.5 Usando variância

oO problema que você está tendo é que, embora o Nothing tipo seja um filho de
todos os tipos ( Int incluído), você sempre pode converter um Nothing em qual-
quer outro tipo, mas não pode converter um List<Nothing> em um
List<Int> . Se você se lembra do que aprendeu no capítulo 2, isso se deve ao
fato de que List é invariante em A . Para fazê-lo funcionar como esperado, você
deve fazer List covariant in A , o que significa que você deve declará-lo como
List<out A> :

classe selada List<out A> {


...

Se você fizer isso, a List classe não compila mais, exibindo a seguinte mensagem
de erro (muitos erros semelhantes ocorrem em outras linhas):

Erro:(7, 17) Kotlin: o parâmetro de tipo A é declarado como 'out'


mas ocorre na posição 'in' no tipo A

O erro aponta para a seguinte linha:

diversão contras(a: A): List<A> = Contras(a, isso)

Isso significa que a List classe não pode conter funções com um parâmetro do
tipo A . O parâmetro é uma entrada para a função, então está na in posição. As
funções só podem ter um A como tipo de retorno (a out posição).

Entendendo a variância

Para entender a variação, você pode pensar em uma cesta de maçãs. Você pode
realizar dois tipos principais de operações em relação a esta cesta:
Coloque uma maçã na cesta
Tire uma maçã da cesta

An Apple é a Fruit (uma classe pai de Apple ) e a Gala é um Apple . O inverso


não é verdadeiro. A Fruit não é um Apple . Embora às vezes possa ser o caso, o
predicado A Fruit é um Apple não é true . Da mesma forma, an Apple não é a
Gala .

Você pode colocar um Gala em a Basket of Apple , mas não pode fazer isso
com a Fruit porque pode não ser um Apple . Por outro lado, se você precisar de
um Fruit , poderá retirá-lo de um Basket de Apple . Mas se você quiser um
Gala , não poderá fazer isso porque o Basket pode conter outras variedades de
Apple .

Mas e um vazio Basket ? Não seria útil poder dizer que está vazio de tudo o que
você precisa? Se você não fosse capaz de fazer isso, precisaria de uma cesta vazia
para as maçãs, outra para as laranjas e ainda outra para cada tipo de objeto
possível.

Você poderia usar uma cesta vazia de Any (o equivalente Kotlin de Java Object ).
Mas, embora você pudesse colocar qualquer coisa na cesta vazia, não poderia
(utilmente) tirar nada dela porque nunca saberia que tipo de objeto obteria. A so-
lução Kotlin para esse problema é o Nothing type. Ao contrário Any de , que é o
tipo pai de todos os outros tipos, Nothing é um tipo filho de todos os tipos.

Ao declarar o A parâmetro out , você está dizendo que a List<Gala> é a


List<Apple> porque a Gala é an Apple . Por outro lado, a List<Apple> não é
a List<Gala> porque an Apple não é a Gala . E é isso que permite que você te-
nha uma única lista vazia; tudo o que você precisa fazer é declarar a lista vazia
como um arquivo List<Nothing> . A List<Nothing> é um List<Apple> por-
que a Nothing é um Apple . Mas a List<Nothing> também é a
List<Tiger> porque a Nothing também é a Tiger . Se você tiver problemas
para descobrir isso, pense em Nothing como sendo o arquivo Absence of .
Nothing é uma ausência de Apple , e também é uma ausência de Tiger , bem
como a ausência de qualquer outra coisa.

Saindo impune do abuso de variância

Uma das tarefas do compilador é prevenir os erros do programador. Se você de-


clarar o List tipo de parâmetro como out A , o compilador não permitirá que
você use o A tipo na in posição, o que significa que você não poderá colocar um
A em List<A> usando uma função de instância de List que recebe um parâme-
tro do tipo A . Você não pode fazer isso:

classe selada List<out A> {

fun cons(a: A): List<A> = Cons(a, this) { // Erro de compilação


...
}

classe interna Cons<out A>(cabeçalho val interno: A,


cauda val interna: List<A>): List<A>()

objeto interno Nil: List<Nothing>()


}

Este código produz um erro de compilação com a seguinte mensagem:


O parâmetro de tipo A é declarado como 'out', mas ocorre na posição 'in' no tipo 'A'

Você pode pensar que isso não é justo porque sabe com certeza que adicionar um
A a um List<A> está correto. Mas aqui você está errado. Não está nada bem.
Para demonstrar o porquê, pense no que seria a implementação de uma
cons função abstrataem cada subclasse:

classe selada List<out A> {

diversão abstrata contras(a: A): List<A>

classe interna Cons<out A>(cabeçalho val interno: A,


cauda val interna: List<A>): List<A>() {
...
}

objeto interno Nil: List<Nada>() {

override fun cons(a: Nothing): List<Nothing> = Cons(a, this) // Erro


}
}

Se você tentar isso, receberá um erro adicional (além do anotado) na Nil imple-
mentação: a implementação da cons função é marcada pelo compilador comocó-
digo inacessível . Por que é isso? Porque this na Nil classe refere-se a a
List<Nothing> e chamar Nil.cons(1) faria com 1 que fosse convertido em
Nothing . Isso não é possível porque Nothing é um subtipo de Int e não o con-
trário. Agora você tem dois problemas para resolver:
O compilador não permite que você use A na in posição, embora você saiba
que seria válido em alguns casos
O problema de elenco na Nil aula, que você deve evitar para eliminar um
caso em que surgiria o primeiro problema

Para entender o que está acontecendo no Nil class, você deve se lembrar que Ko-
tlin é uma linguagem estrita , o que significa que os argumentos da função são
avaliados independentemente de serem usados ​ou não. O problema não está na
implementação da função:

Contras (a, isso)

Antes de adicionar o novo A a ele, this , sendo um List<Nothing> , pode ser


convertido com segurança em um List<A> . O problema é com o argumento da
função:

anular contras divertidos (a: Nada)

Quando o A argumento é recebido pela função, ele é imediatamente convertido


para o tipo de argumento receptor Nothing , o que causa um erro. Esta é a con-
sequência do rigor. E isso é lamentável porque imediatamente depois, o elemento
teria sido adicionado a a List<A> (o resultado da conversão Nil em a
List<A> ). Nenhuma redução do argumento Nothing teria sido necessária. Para
resolver esse problema, você precisa de dois truques:

Impeça o compilador de reclamar assumindo a responsabilidade de usar A na


in posição. Isso pode ser feito usandoa @UnsafeVariance anotação:
contras divertidos(a: @UnsafeVariance A): List<A>

Remova o downcast de A colocando a implementação na classe pai:

classe selada List<out A> {


divertido contras(a: @UnsafeVariance A):Lista<A> = Contras(a, este)
...

Aqui, você está dizendo ao compilador que ele não deve se preocupar com um
problema de variância na cons função: você está assumindo a responsabilidade
e se algo der errado, a culpa é sua. Agora, você pode aplicar a mesma técnica para
funções setHead e concat :

classe selada List<out A> {

fun setHead(a: @UnsafeVariance A): List<A> = when (this) {


é Contras -> Contras(a, this.tail)
Nil -> throw IllegalStateException("setHead chamado em uma lista vazia")
}

fun contras(a: @UnsafeVariance A): List<A> = Contras(a, isso)

fun concat(list: List<@UnsafeVariance A>): List<A> = concat(this, list)

...

Você só precisa fazer isso para funções que recebem um parâmetro do tipo A , ou
List<A> . Você não precisa fazer isso para funções sem parâmetros, nem para
o dropWhile função recebendo um parâmetro do tipo (A) -> Boolean . Mas
você também deve garantir que, ao usar esse truque, nenhum lançamento inse-
guro falhará. Como sempre, com maior liberdade vem maior responsabilidade.

Note que existe outra possibilidade que consiste em criar uma Empty<A> classe
abstrata para representar listas vazias e então criar um Nil<Nothing> objeto
singleton. Isso permite definir funções abstratas na List classe pai com imple-
mentação específica em Cons ou Empty . Veja como a concat função pode ser
definida:

Lista de classes seladas<A> {

abstract fun concat(list: List<A>): List<A>

classe abstrata Vazio<A> : List<A>() {

override fun concat(list: List<A>): List<A> = list


}

objeto privado Nil: Empty<Nothing>()

class privada Cons<A>(private val head: A,


private val tail: List<A>): List<A>() {

override fun concat(list: List<A>): List<A> =


Cons(esta.cabeça, lista.concat(esta.cauda))
}
}
Esta solução pode ser mais atraente para ex-programadores Java porque evita a
verificação de tipos como neste exemplo:

fun <A> concat(lista1: Lista<A>, lista2: Lista<A>): Lista<A> = when (lista1) {


Nil -> lista2
é Contras -> Contras(lista1.cabeça, concat(lista1.cauda, ​
lista2))
}

De qualquer forma, graças ao uso da out variância, sua sum função agora está
perfeitamente correta!

Exercício 5.7

Escreva uma função para calcular o produto de todos os elementos de uma lista
de doubles usando recursão.

Solução

A definição recursiva do produto de todos os elementos de uma lista não vazia é


esta:

cabeça * produto da cauda

Mas o que deve retornar para uma lista vazia? Se você se lembra de seus cursos
de matemática, saberá a resposta. Caso contrário, você pode encontrar a resposta
no requisito anterior para uma lista não vazia.
Considere o que acontecerá quando você aplicar a fórmula recursiva a todos os
elementos. Você terá um resultado que terá que ser multiplicado pelo produto de
todos os elementos de uma lista vazia. Como você deseja obter esse resultado, não
tem escolha a não ser dizer que o produto de todos os elementos de uma lista va-
zia é 1.

Esta é a mesma situação do sum exemplo, quando você usa 0 como a soma de to-
dos os elementos de uma lista vazia. 0 é a identidade, ou elemento neutro, para a
operação de soma e 1 é a identidade, ou elemento neutro, para o produto. Su-
a product função pode ser escrita da seguinte forma:

fun product(ints: List<Int>): Int = when (ints) {


List.Nil -> 1
é List.Cons -> ints.head * product(ints.tail)
}

A operação produto é diferente da operação soma em um aspecto importante: ela


tem umelemento absorvente , que é um elemento que satisfaz a seguinte condição:
a * elemento absorvente = elemento absorvente * a = elemento absorvente .

O elemento absorvedor para a multiplicação é 0. Por analogia, o elemento absor-


vente de qualquer operação (se existir) também é chamado deelemento nulo . A
existência de um elemento zero permite escapar da computação, também chama-
dacurto-circuito , assim:

fun product(ints: List<Double>): Double = when (ints) {


List.Nil -> 1.0
é List.Cons -> if (ints.head == 0.0)
0,0
senão
ints.head * product(ints.tail)
}

Mas esqueça esta versão otimizada e veja as definições para sum e product .
Você consegue detectar um padrão que pode ser abstraído? Vejamos essas funções
lado a lado (depois de alterar o nome do parâmetro):

fun sum(ints: List<Int>): Int = when (ints) {


List.Nil -> 0
é List.Cons -> ints.head + sum(ints.tail)
}

fun product(ints: List<Double>): Double = when (ints) {


List.Nil -> 1.0
é List.Cons -> ints.head * product(ints.tail)
}

Agora vamos remover as diferenças e substituí-las por uma notação comum:

fun sum(list: List<Type>): Type = when (list) {


List.Nil -> identidade
é List.Cons -> operação do operador list.head(list.tail)
}

fun product(list: List<Type>): Type = when (list) {


List.Nil -> identidade
é List.Cons -> operação do operador ints.head(list.tail)
}

As duas funções são as mesmas com alguns valores diferentes para Type , ope-
ration , identity e operator . Se você puder encontrar uma maneira de abs-
trair essas partes comuns, terá que fornecer as informações variáveis ​para imple-
mentar ambas as funções sem se repetir. Essa função comum é chamada defold ,
que você estudou no capítulo 4. Nesse capítulo, você aprendeu que existem dois ti-
pos de dobras: foldRight e foldLeft , bem como uma relação entre essas duas
operações.

A Listagem 5.2 mostra as partes comuns das operações de soma e produto abstraí-
das em uma função chamada foldRight , tendo como parâmetros a lista a do-
brar, um elemento identidade e um HOF (Função de Ordem Superior) represen-
tando a operação usada para dobrar a lista. O elemento identidade é obviamente
a identidade para a operação dada, e a função está na forma curried. (Consulte o
capítulo 3 se não lembrar o que significa curried .) A listagem a seguir mostra a
função que representa a parte do operador do seu código.

Listagem 5.2 Implementando foldRight e usando para soma e produto

fun <A, B> foldRight(list: List<A>, ①


identity: B, ②
f: (A) -> (B) -> B): B = ③
quando (lista) {
List.Nil -> identidade
é List.Cons -> f(list.head)(foldRight(list.tail, identity, f))
}
fun sum(lista: List<Int>): Int = ④
foldRight(lista, 0) { x -> { y -> x + y } }

produto divertido(lista: List<Double>): Double = ④


foldRight(list, 1.0) { x -> { y -> x * y } }

① A e B representam os tipos.

② A identidade para a operação de dobra

③ A função f na forma atual, representando o operador

④ Os nomes (soma e produto) das operações

a Type variávelpart foi substituído por dois tipos aqui, A e B . Isso ocorre porque
o resultado da dobra nem sempre é do mesmo tipo dos elementos da lista. Aqui
está abstraído um pouco mais do que o necessário para as operações de soma e
produto, mas isso será útil em breve. o operation parte variável são os nomes
das duas funções.

A operação de dobra não é específica para cálculos aritméticos. Você pode usar
uma dobra para transformar uma lista de caracteres em uma string. Nesse caso,
A e B são dois tipos diferentes: Char e String . Mas você também pode usar
uma dobra para transformar uma lista de strings em uma única string. Você pode
ver agora como você poderia implementar concat ?
A propósito, foldRight é semelhante à própria lista encadeada individualmente.
Se você pensar na lista 1, 2, 3 como

Contras(1, Contras(2, Contras(3, Nil)))

você pode ver imediatamente que é semelhante a uma dobra à direita:

f(1, f(2, f(3, identidade)))

Mas talvez você já tenha percebido que Nil é a concatenação da lista de identida-
des, embora você possa passar sem ela, desde que a lista de listas a serem concate-
nadas não esteja vazia. Nesse caso, é chamado de reduzir em vez de dobrar . Isso
só é possível porque o resultado é do mesmo tipo que os elementos. Pode ser colo-
cado em prática passando Nil e como cons a foldRight identidade e a função
que se usa para dobrar:

foldRight(List(1, 2, 3), List()) { x: Int ->


{ y: Lista<Int> ->
y.cons(x)
}
}

Isso produz uma nova lista com os mesmos elementos na mesma ordem, como
você pode ver executando o seguinte código:

println(foldRight(List(1, 2, 3), List()) { x: Int ->


{ y: Lista<Int> ->
y.cons(x)
}
})

Esse código produz a seguinte saída:

[1, 2, 3, NIL]

Aqui está um traço do que está acontecendo em cada etapa:

foldRight(List(1, 2, 3), List(), x -> y -> y.cons(x));


foldRight(List(1, 2), List(3), x -> y -> y.cons(x));
foldRight(List(1), List(2, 3), x -> y -> y.cons(x));
foldRight(List(), List(1, 2, 3), x -> y -> y.cons(x));

Você deve colocar a foldRight função no objeto complementar e, em seguida,


adicionar uma função de instância com this seu argumento que chama fol-
dRight a List classe:

fun <B> foldRight(identity: B, f: (A) -> (B) -> B): B =


foldRight(this, identidade, f)

Exercício 5.8

Escreva uma função para calcular o comprimento de uma lista. Esta função usará
a foldRight função.
Solução

Esta função pode ser definida diretamente na List classe:

fun length(): Int = foldRight(0) { _ -> { it + 1} }

Como seu primeiro parâmetro, representando um elemento da lista, não é utili-


zado, a convenção é nomeá-lo _ . O segundo parâmetro é representado por it . A
cada passo da recursão, 1 é adicionado à contagem. Quando um parâmetro não é
usado, você também pode removê-lo. O código então se torna este:

fun length(): Int = foldRight(0) { { it + 1} }

Essa implementação, além de ser recursiva (o que significa que pode estourar a
pilha para listas longas), tem desempenho ruim. Mesmo se transformado em core-
cursivo, ainda é 0( n ), o que significa que o tempo necessário para retornar o
comprimento é proporcional ao comprimento da lista. Nos próximos capítulos,
você verá como obter o comprimento de uma lista encadeada em tempo
constante.

Exercício 5.9

A foldRight função usa recursão, mas não é recursiva de cauda, ​portanto, rapi-
damente transbordará a pilha. A rapidez depende de vários fatores, sendo o mais
importante o tamanho da pilha. Em vez de usar foldRight , crie um fol-
dLeft função que é corecursiva e segura em pilha. Aqui está sua assinatura:
public <B> foldLeft(identity: B, f: (B) -> (A) -> B): B

Dica

Se você não se lembra da diferença entre foldLeft e foldRight , consulte o ca-


pítulo 4.

Solução

A implementação da função auxiliar no objeto complementar é semelhante à


foldRight função, embora o tipo de função do parâmetro seja (B) -> (A) ->
B) em vez de (A) -> (B) -> B) . Se a lista recebida como segundo parâmetro
for Nil (lista vazia), a foldLeft função retorna o acc acumulador, exatamente
como a foldRight função faz. Se a lista não estiver vazia ( Cons ), a função
chama a si mesma depois de aplicar a função de parâmetro ao acumulador e ao
cabeçalho do parâmetro de lista:

tailrec fun <A, B> foldLeft(acc: B, list: List<A>, f: (B) -> (A) -> B): B =
quando (lista) {
List.Nil -> acc
é List.Cons -> foldLeft(f(acc)(list.head), list.tail, f)
}

Essa função auxiliar é chamada pela função principal da List classe:

fun <B> foldLeft(identity: B, f: (B) -> (A) -> B): B =


foldLeft(identidade, this, f)
Exercício 5.10

Use sua nova foldLeft função para criar novas versões de pilha segura de sum ,
product e length .

Solução

Esta é a função sum via foldLeft :

fun sum(lista: List<Int>): Int = list.foldLeft(0, { x -> { y -> x + y } })

A função product via foldLeft é a seguinte:

produto divertido(lista: List<Double>): Duplo =


list.foldLeft(1.0, { x -> { y -> x * y } })

E aqui está a função length via foldLeft :

fun length(): Int = foldLeft(0) { { _ -> it + 1} }

Mais uma vez, o segundo parâmetro da função length (que representa cada ele-
mento da lista em cada chamada da função) é ignorado. O primeiro parâmetro é
representado pelo it palavra-chave. Como resultado, você não pode remover o
parâmetro sublinhado sem usar um nome explícito para o primeiro parâmetro,
como
fun length(): Int = foldLeft(0) { i -> { i + 1} }

Essa função é quase tão ineficiente quanto a foldRight versão e não deve ser
usada no código de produção. Embora não exagere na pilha, é lento porque deve
contar os elementos da lista cada vez que é chamado.

Exercício 5.11

Use foldLeft para escrever uma função para inverter uma lista.

Dica

Esteja ciente do tipo de identidade. Você terá que indicá-lo explicitamente.

Solução

Inverter uma lista por meio de uma dobra à esquerda é simples, partindo de uma
lista vazia como acumulador e adicionando sucessivamente cada elemento da pri-
meira lista ao acumulador por meio de uma chamada à cons função:

fun reverse(): List<A> =


foldLeft(Nil as List<A>) { acc -> { acc.cons(it) } }

Como você pode ver, você precisa especificar o tipo de parâmetro Nil e convertê-
lo em um arquivo List . Caso contrário, Kotlin usa Nil como tipo de retorno. Na
versão anterior de reverse , você usava uma função auxiliar usando a
List como argumento, de modo que a conversão ocorria implicitamente. Você
não pode usar List() diretamente porque, na List classe, este seria o constru-
tor da classe, o que é impossível porque a classe é abstrata. Outra solução para
contornar esse problema é chamar o invoke função explicitamente:

fun reverse(): List<A> =


foldLeft(List.invoke()) { acc -> { acc.cons(it) } }

Exercício 5.12

Escreva foldRight em termos de foldLeft.

Dica

Use as funções que você acabou de implementar.

Solução

Essa implementação pode ser útil para obter uma versão segura para pilha, mas
mais lenta, de foldRight :

fun <B> foldRightViaFoldLeft(identity: B, f: (A) -> (B) -> B): B =


this.reverse().foldLeft(identity) { x -> { y -> f(y)(x) } }

Observe que você também pode definir foldLeft em termos de foldRight ,


embora pareça muito menos útil. Na verdade, a implementação de fol-
dRight via foldLeft também não é útil. Como você verá no capítulo 9, o princi-
pal uso de foldRight é dobrar coleções preguiçosas longas ou infinitas sem ava-
liá-las. Chamar reverse uma lista força a avaliação da lista, então a mágica écon-
taminado.

5.6.6 Criando uma versão recursiva segura de pilha de foldRight

ComoEu disse, a implementação recursiva foldRight é apenas para demonstrar


esses conceitos porque é baseada em pilha e não deve ser usada em produção. Ob-
serve também que esta é uma implementação estática. Uma implementação de
instância seria muito mais fácil de usar, permitindo encadear chamadas de fun-
ção com a notação de objeto.

Exercício 5.13

Use o que você aprendeu no capítulo 4 para escrever uma versão correcursiva da
foldRight função sem usar foldLeft explicitamente. Ligaresta função coFol-
dRight .

Dica

Escreva uma função auxiliar no objeto complementar e uma função principal na


List classe. Esteja ciente de que há um truque aqui, e é por isso que você deve
fazer a função auxiliar private .

Solução

O truque é chamar a função auxiliar com a lista de parâmetros invertida. A fun-


ção auxiliar, por si só, não faz o trabalho completo. Aqui está a função auxiliar:
private tailrec fun <A, B> cofoldRight(acc: B,
lista: Lista<A>,
identidade: B,
f: (A) -> (B) -> B): B =
quando (lista) {
List.Nil -> acc
é List.Cons ->
cofoldRight(f(list.head)(acc), list.tail, identity, f)
}

Em seguida, escreva a função principal que chama essa função auxiliar:

fun <B> cofoldRight(identidade: B, f: (A) -> (B) -> B): B =


cofoldRight(identidade, this.reverse(), identidade, f)

Infelizmente, esta implementação tem o mesmo problema da que está usando


foldLeft : ela força a avaliação da lista, o que anula o principal benefício de do-
brar corretamente.

Exercício 5.14

Implemente concat em termos de foldLeft ou foldRight . Coloque essa im-


plementação no objeto complementar, substituindo a implementação recursiva
anterior e, em seguida, chame essa função de uma função de extensão fora da
List classe.
Solução

A concat função pode ser implementada facilmente usando uma dobra à direita:

fun <A> concatViaFoldRight(lista1: Lista<A>, lista2: Lista<A>): Lista<A> =


foldRight(lista1, lista2) { x -> { y -> Cons(x, y) } }

Outra solução é usar uma dobra à esquerda. Neste caso, a implementação será a
mesma da reverse via foldLeft aplicada à primeira lista invertida, utilizando
a segunda lista como acumulador. Na implementação a seguir, observe o uso de
uma referência de função ( x::cons ):

fun <A> concatViaFoldLeft(list1: List<A>, list2: List<A>): List<A> =


list1.reverse().foldLeft(list2) { x -> x::cons }

Se você achar que usar uma referência de função torna o código menos legível,
você pode usar um lambda:

fun <A> concat(lista1: Lista<A>, lista2: Lista<A>): Lista<A> =


list1.reverse().foldLeft(list2) { x -> { y -> x.cons(y) } }

Você também pode usar o construtor de lista diretamente, como na implementa-


ção baseada em foldRight :

fun <A> concat(lista1: Lista<A>, lista2: Lista<A>): Lista<A> =


list1.reverse().foldLeft(list2) { x -> { y -> Cons(y, x) } }
A implementação baseada em foldLeft é menos eficiente porque deve primeiro
inverter a primeira lista. Por outro lado, é seguro para pilha porque é corecursivo
em vez de recursivo.

Exercício 5.15

Escreva uma função para nivelar uma lista de listas em uma lista contendo todos
os elementos de cada lista contida.

Dica

Esta operação consiste em uma série de concatenações. É semelhante a adicionar


todos os elementos de uma lista de inteiros, embora os inteiros sejam substituídos
por listas e a adição seja substituída por concatenação. Fora isso, é exatamente o
mesmo que o sum função.

Solução

Mais uma vez, você pode usar uma referência de função em vez de um lambda
para representar a segunda parte da função: { x -> x::concat } é equiva-
lente a { x -> { y -> x.concat(y) } } :

fun <A> flatten(lista: Lista<Lista<A>>): Lista<A> =


list.foldRight(Nil) { x -> x::concat }

Para tornar esta função segura em pilha, você pode usar o coFoldRight função
em vez de foldRight :
fun <A> flatten(lista: Lista<Lista<A>>): Lista<A> =
list.coFoldRight(Nil) { x -> x::concat }

5.6.7 Mapeamento e filtragem de listas

Vocêpode definir muitas abstrações úteis para trabalhar em listas. Uma abstração
consiste em alterar todos os elementos de uma lista aplicando uma função co-
mum a eles.

Exercício 5.16

Escreva uma função que receba uma lista de números inteiros e multiplique cada
um deles por 3.

Dica

Tente usar as funções que você definiu até agora. Não use (co)recursão explicita-
mente. O objetivo é abstrair a recursão de uma vez por todas, para que você possa
colocá-la em funcionamento sem precisar reimplementá-la todas as vezes.

Solução

Você precisa aplicar foldRight com uma lista vazia como valor de identidade e
uma função que adiciona cada elemento multiplicado por 3 a uma lista:

fun triple(list: List<Int>): List<Int> =


List.foldRight(lista, List()) { h ->
{ t: Lista<Int> ->
t.cons(h * 3)
}
}

Você precisa definir o tipo explicitamente, seja para a identidade ou para o t pa-
râmetro. Isso se deve à capacidade limitada do Kotlin em relação à inferência de
tipos. Você também pode usar uma dobra à esquerda, o que tornaria a pilha de
funções segura, mas também inverteria a lista, então você precisaria reverter o
resultado.

Exercício 5.17

Escreva uma função que transforme cada valor em a List<Double> em a


String .

Solução

Esta operação pode ser vista como uma concatenação de uma lista vazia do tipo
esperado ( List<String> ) com a lista original, com cada elemento sendo trans-
formado antes de adicioná-lo ao acumulador. Como resultado, a implementação é
semelhante ao que você fez com o concat função:

fun doubleToString(list: List<Double>): List<String> =


List.foldRight(lista, List()) { h ->
{ t: List<String> ->
t.cons(h.toString())
}
}
Exercício 5.18

Escreva uma função geral de segurança de pilha map na List classe que permite
modificar cada elemento de uma lista aplicando uma função especificada a ele.
Desta vez, torne-a uma função de instância de List . Aqui está sua assinatura:

fun <B> map(f: (A) -> B): List<B>

Solução

Para torná-lo seguro, você pode usar uma dobra à esquerda e inverter o
resultado:

fun <B> map(f: (A) -> B): List<B> =


foldLeft(Nil) { acc: List<B> -> { h: A -> Cons(f(h), acc) } }.reverse()

Alternativamente, você pode usar o stack-safe coFoldRight função que dá o


mesmo resultado (mas invertendo a lista antes de dobrar em vez de inverter o re-
sultado da dobra):

fun <B> map(f: (A) -> B): List<B> =


coFoldRight(Nil) { h -> { t: List<B> -> Cons(f(h), t) } }

Exercício 5.19

Escrevauma filter função que remove de uma lista os elementos que não satis-
fazem um determinado predicado. Mais uma vez, implemente isso como uma fun-
ção de instância com a seguinte assinatura:

fun filter(p: (A) -> Boolean): List<A>

Solução

Aqui está uma implementação na classe pai List usando coFoldRight :

fun filter(p: (A) -> Boolean): List<A> =


coFoldRight(Nil) { h -> { t: List<A> -> if (p(h)) Cons(h, t) else t } }

Exercício 5.20

Escreva uma flatMap função que se aplique a cada elemento de List<A> , uma
função de A a List<B> , e retorne a List<B> . Sua assinatura será esta:

fun <B> flatMap(f: (A) -> Lista<B>): Lista<B> =

Por exemplo, List(1,-1,2,-2,3,-3) .

List(1,2,3).flatMap { i -> List(i, -i) }

deve retornar

Lista(1,-1,2,-2,3,-3).
Solução

o flatMap A função pode ser vista como uma composição de map , que usa uma
função que retorna uma lista, e flatten , que converte uma lista de lista em uma
lista. A implementação mais simples (na List classe) é então

fun <B> flatMap(f: (A) -> List<B>): List<B> = flatten(map(f))

Exercício 5.21

Crie uma nova versão do filter baseado em flatMap .

Solução

Aqui está uma implementação possível:

fun filter(p: (A) -> Boolean): List<A> =


flatMap { a -> if (p(a)) List(a) else Nil }

Observe que há uma forte relação entre map , flatten, e flatMap . Se você ma-
pear uma função retornando uma lista para uma lista, obterá uma lista de listas.
Você pode então se inscrever flatten para obter uma única lista contendo todos
os elementos das listas anexas. Você obteria exatamente o mesmo resultado apli-
cando diretamente flatMap .
Resumo

As estruturas de dados são um dos conceitos mais importantes na programa-


ção porque permitem manipular vários dados como um todo.
A lista encadeada individualmente é uma estrutura de dados eficiente para
programação com funções. Tem o benefício de listas imutáveis ​ao mesmo
tempo em que permite algumas modificações, como a inserção e remoção de
um elemento na primeira posição em tempo constante (e curto). Isso ocorre
porque, ao contrário das listas (imutáveis) do Kotlin, essas operações não en-
volvem a cópia de elementos.
O uso do compartilhamento de dados permite alto desempenho para algumas
operações, embora não para todas.
Você pode criar outras estruturas de dados para obter um bom desempenho
para casos de uso específicos.
Você pode dobrar listas aplicando funções recursivamente.
Você pode usar corecursion para dobrar listas sem o risco de estourar a pilha.
Depois de definir foldRight e foldLeft , você não precisará usar (co) recur-
são novamente para lidar com listas. foldRight e foldLeft (co) recursão
abstrata para você.

1
 Jim Gray, “O conceito de transação: virtudes e limitações”, Relatório Técnico 81.3
(Tandem Computers, junho de 1981),
http://www.hpl.hp.com/techreports/tandem/TR-81.3.pdf .

You might also like