Scala através de exemplos
Transcrição
Scala através de exemplos
Scala Através de Exemplos DRAFT January 31, 2013 Martin Odersky P ROGRAMMING M ETHODS L ABORATORY EPFL S WITZERLAND T RADUZIDO POR : V INICIUS M IANA E A NTONIO B ASILE Índice 1 Introdução 1 2 Um primeiro exemplo 3 3 Programando com Atores e Mensagens 7 4 Expressões e Funções Simples 11 4.1 Expressões e Funções Simples . . . . . . . . . . . . . . . . . . . . . . . . 11 4.2 Parâmetros . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 4.3 Expressões Condicionais . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 4.4 Exemplo: Raiz Quadrada pelo Método de Newton . . . . . . . . . . . . . 15 4.5 Aninhamento de Funções . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 4.6 Recursão de Cauda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18 5 Funções de Primeira Classe 21 5.1 Funções Anônimas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 5.2 Currying . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 5.3 Exemplo: Encontrando Pontos Fixos de Funções . . . . . . . . . . . . . 25 5.4 Sumário . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28 5.5 Elementos da Linguagem Vistos Até Aqui . . . . . . . . . . . . . . . . . . 28 6 Classes e Objetos 31 7 Classes Case e Casamento de Padrões 43 7.1 Classes Case e Objetos Case . . . . . . . . . . . . . . . . . . . . . . . . . . 46 7.2 Casamento de Padrões . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48 8 Tipos Genéricos e Métodos 53 8.1 Parâmetros Tipo Ligados . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 8.2 Anotações de Variância . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58 iv ÍNDICE 8.3 Lower Bounds . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60 8.4 Tipos Minimais . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 8.5 Tuplas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 8.6 Funções . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63 9 Listas 65 9.1 Usando Listas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65 9.2 Definição da classe List I: Métodos de Primeira Ordem . . . . . . . . . . 67 9.3 Exemplo: Merge sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71 9.4 Definição da classe List II: Métodos de Alta Ordem . . . . . . . . . . . . 72 9.5 Sumário . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79 10 For-Comprehensions 81 10.1 O Problema das N-Rainhas . . . . . . . . . . . . . . . . . . . . . . . . . . 82 10.2 Pesquisando com For-Comprehensions . . . . . . . . . . . . . . . . . . . 83 10.3 Tradução de For-Comprehensions . . . . . . . . . . . . . . . . . . . . . . 84 10.4 Laços For . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 10.5 Generalizando For . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87 11 Estados Mutáveis 89 11.1 Objetos Mutáveis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89 11.2 Estruturas Imperativas de Controle . . . . . . . . . . . . . . . . . . . . . 93 11.3 Exemplo Estendido: Simulação de Eventos Discretos . . . . . . . . . . . 94 11.4 Sumário . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99 12 Computando com Streams 101 13 Iteradores 105 13.1 Métodos dos Iteradores . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105 13.2 Construindo Iteradores . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108 13.3 Usando Iteradores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109 14 Valores Preguiçosos (Lazy) 111 15 Parâmetros Implícitos e Conversões 115 ÍNDICE v 16 Inferência de Tipos de Hindley/Milner 119 17 Abstracões para Concorrência 127 17.1 Sinais e Monitores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127 17.2 SyncVars . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129 17.3 Futuros . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129 17.4 Computação Paralela . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130 17.5 Semáforos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131 17.6 Leitores/Escritores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131 17.7 Canais Assíncronos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132 17.8 Canais Síncronos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133 17.9 Trabalhadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134 17.10Caixas Postais . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136 17.11Actors . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139 Capítulo 1 Introdução Scala facilita a integração entre a programação orientada a objetos e a programação funcional. Foi criada para expressar padrões de programação comuns de forma concisa, elegante e fortemente tipada. Scala introduz diversas construções de linguagem inovadoras. Por exemplo: • Tipos abstratos e composições mixin unificam conceitos de objetos e módulos de sistema. • Casamento de padrões sobre hierarquias de classe unificam o acesso a dados funcional e orientado a objetos, simplificando bastante o processamento de árvores XML. • Sintaxe flexível e sistema de tipos que habilitam a construção de avançadas bibliotecas e um novas linguagens específicas a um domínio. Ao mesmo tempo, Scala é compatível com Java. Bibliotecas Java e frameworks podem ser usados sem código extra ou declarações adicionais. Este documento introduz Scala de um modo informal, através de uma sequência de exemplos. Os capítulos 2 e 3 salientam alguns dos aspectos que tornam Scala interessante. Os capítulos seguintes introduzem as construções da linguagem Scala de um modo mais completo, começando com expressões e funções simples, e desenvolvendo até objetos e classes, listas e streams, estado mutável, casamento de padrões até exemplos mais completos que mostram interessantes técnicas de programação. A presente exposição informal pretende ser complementada por um Manual de Referência da Linguagem Scala, que especificarão Scala de modo mais detalhado e preciso. Reconhecimento. Temos um grande débito ao maravilhoso livro de Abelson e Sussman “Structure and Interpretation of Computer Programs”[ASS96]. Muitos exemplos e exercícios deles também estão presentes aqui. Naturalmente a 2 Introdução linguagem utilizada em cada caso foi mudada do Scheme para o Scala. Além disso, os exemplos fazem uso de construções da orientação a objetos do Scala onde isto foi considerado apropriado. Capítulo 2 Um primeiro exemplo Para começar, apresentamos um primeiro exemplo, a implementação de Quicksort em Scala. def sort(xs: Array[Int]) { def swap(i: Int, j: Int) { val t = xs(i); xs(i) = xs(j); xs(j) = t } def sort1(l: Int, r: Int) { val pivot = xs((l + r) / 2) var i = l; var j = r while (i <= j) { while (xs(i) < pivot) i += 1 while (xs(j) > pivot) j -= 1 if (i <= j) { swap(i, j) i += 1 j -= 1 } } if (l < j) sort1(l, j) if (j < r) sort1(i, r) } sort1(0, xs.length - 1) } A implementação se parece muito com a que você faria em Java ou C. Nós usamos os mesmos operadores e estruturas de controle. Existem também algumas pequenas diferenças sintáticas, particularmente: • As declarações começam usando palavras reservadas. Particularmente, declarações de funções são iniciadas com a palavra def, declarações de 4 Um primeiro exemplo variáveis são iniciadas com a palavra var e declaração de constantes (chamadas de valores) são iniciadas com a palavra val. • O tipo de um parâmetro em uma função é declarado após o nome do parâmetro seguido de dois pontos(:). O tipo pode ser omitido quando o compilador for capaz de inferi-lo pelo contexto. • A declaração de vetores do tipo T é feita usando a expressão Array[T] ao invés de T[]. O i-ésimo elemento de um vetor a é acessado usando a(i) ao invés de a[i]. • Funções podem ser aninhadas umas dentro das outras. Funções aninhadas podem acessar parâmetros e variáveis locais de suas funções externas. Por exemplo, o nome do vetor xs é visível nas funções swap e sort1, e portanto não precisam ser passadas como um parâmetro para elas. Pelo que vimos, Scala se parece com uma linguagem bem convencional com algumas peculiaridades sintáticas. De fato é possível escrever programas em estilo imperativo ou orientado a objetos. Isto é importante, porque é uma das coisas que facilitam combinar componentes Scala com componentes escritos em linguagens convencionais, tais como Java, C# ou Visual Basic. Entretanto, também é possível escrever programas num estilo completamente diferente. Aqui está o Quicksort novamente, desta vez escrito em estilo funcional. def sort(xs: Array[Int]): Array[Int] = { if (xs.length <= 1) xs else { val pivot = xs(xs.length / 2) Array.concat( sort(xs filter (pivot >)), xs filter (pivot ==), sort(xs filter (pivot <))) } } O programa funcional captura a essência do algoritmo quicksort de modo conciso: • Se o vetor está vazio ou consiste de um único elemento, já está ordenado, então retorne imediatamente. • Se o vetor não está vazio, escolha um elemento do meio do vetor como pivô. • Particione o vetor em dois subvetores contendo, respectivamente os elementos que são menores que o elemento pivô, maiores que, e um terceiro vetor que contém elementos iguais ao pivô. • Ordene os dois primeiros subvetores por uma chamada recursiva da função de ordenação.1 1 Isso não é exatamente o que o algoritmo imperativo faz; este último particiona o vetor em dois subvetores contendo elementos menores que ou maiores ou iguais ao pivô. 5 • O resultado é obtido pela concatenação dos três subvetores. Tanto a implementação imperativa quanto a funcional tem mesma complexidade assintótica – O(N l og (N )) no caso médio e O(N 2 ) no pior caso. Mas onde a implementação imperativa opera localmente, modificando o vetor original, a implementação funcional retorna um novo vetor ordenado e deixa o vetor original inalterado. A implementação funcional, portanto, requer mais memória transiente que a imperativa. A implementação funcional faz parecer que Scala é uma linguagem especializada para operações funcionais sobre vetores. De fato, não é; todas as operações usadas no exemplo são simples métodos de biblioteca de uma classe sequência Seq[T] que é parte da biblioteca padrão Scala, e onde ela mesma é implementada em Scala. Como vetores são instâncias de Seq, todos os métodos de sequência estão disponíveis para eles. Em particular, há o método filter que recebe como argumento uma função predicado. Esta função predicado deve mapear elementos do vetor para valores boleanos. O resultado de filter é um vetor consistindo de todos os elementos do vetor original para o qual a função predicado é verdadeira. O método filter de um objeto tipo Array[T] portanto tem a assinatura. def filter(p: T => Boolean): Array[T] Aqui, T=>Boolean é o tipo das funções que recebem um elemento do tipo T e retornam um valor booleano do tipo Boolean. Funções como filter que recebem uma outra função como argumento ou retornam uma função como resultado são chamadas funções de alta ordem. Scala não faz distinção entre nomes de identificadores e nomes de operadores. um identificador pode ser ou uma sequência de letras ou digitos que começam com uma letra, ou podem ser uma sequência de caracteres especiais, tais como “+”, “*”, ou “:”. Qualquer identificador pode ser usado como operador infixo em Scala. A operação binária E op E 0 é sempre interpretado como a chamada de método E .op(E 0 ). Isso vale também para operadores binários infixos que iniciam com uma letra. Consequentemente, a expressão xs filter (pivot >) é equivalente à chamada de método xs.filter(pivot >). No programa quicksort, filter é aplicado três vezes a um argumento de função anônima. O primeiro argumento, pivot >, representa uma função que recebe um argumento x e retorna o valor pivot > x. Este é um exemplo de uma função parcialmente aplicada. Um outro modo, equivalente de se escrever esta função, que torna o argumento oculto explicito é x => pivot > x. A função é anônima, ou seja, não é definida com um nome. O tipo do parâmetro x é omitido porque um compilador Scala pode inferí-lo automáticamente a partir do contexto onde a função é usada. Resumindo, xs.filter(pivot >) retorna uma lista consistindo de todos os elementos da lista xs que são menores que pivot. 6 Um primeiro exemplo Olhando novamente em detalhes para a primeira, implementação imperativa do Quicksort, percebemos que muitos dos construtores da linguagem usados na segunda solução estão presentes, embora de modo distinto. Por exemplo, operadores binários padrão, tais como +, -, ou < não são tratados de modo especial. Assim como append, são métodos de seus operandos esquerdos. Consequentemente, a expressão i+1 é vista como a invocação de i.+(1) do método + do valor inteiro de i. De fato, um compilador é livre (se moderadamente esperto, ainda que esperado) para reconhecer o caso especial da chamada de método + sobre argumentos inteiros e para gerar código inline eficiente para isso. Por eficiência e melhor detecção de erros o laço while é um construtor primitivo em Scala. Mas em princípio, poderia do mesmo modo ser uma função predefinida. Aqui está uma possível implementação para ele: def While (p: => Boolean) (s: => Unit) { if (p) { s ; While(p)(s) } } A função While recebe como primeiro parâmetro uma função teste, que não recebe parâmetros e produz um valor boleano. Como segundo parâmetro recebe uma função comando que também não recebe parâmetros e produz como resultado o tipo Unit. While invoca a função comando enquanto a função teste produzir verdadeiro. O tipo Scala Unit corresponde grosseiramente ao void no Java; é usado sempre que uma função não retornar um resultado interessante. De fato, porque Scala é uma linguagem orientada a expressões, cada função retorna algum resultado. Se nenhuma expressão de retorno é explicitamente fornecida, o valor (), que é pronunciado “unit”, é assumido. Este valor é do tipo Unit. Funções que retornam “unit” são também chamadas procedimentos. Aqui está uma formulação mais “orientada a expressão” da função swap na primeira implementação do quicksort, que explicita isto: def swap(i: Int, j: Int) { val t = xs(i); xs(i) = xs(j); xs(j) = t () } O valor resultante desta função é simplesmente sua última expressão—uma palavra chave return não é necessária. Observe que funções que retornam um valor explícito sempre precisam de um “=” antes de seus corpos ou expressões de definição. Capítulo 3 Programando Mensagens com Atores e Aqui está um exemplo que mostra uma área de aplicação para a qual Scala é particularmente indicada. Considere a tarefa de implementar um serviço de leilão eletrônico. Podemos usar um modelo de processo no estilo actor do Erlang para implementar os participantes do leilão. Actors são objetos para os quais as mensagens são enviadas. Cada actor tem uma caixa de correio para suas mensagens de entrada que é representada para uma fila. Pode trabalhar sequencialmente nas mensagens da sua caixa de correio, ou buscar por mensagens que casam com algum padrão. Para cada item negociado há um actor leiloeiro que publica a informação sobre o item negociado, que aceita ofertas de clientes e que se comunica com o vendedor e com o vencedor do leilão para fechar a transação. Apresentamos uma visão superficial de uma implementação aqui. Como primeiro passo, definimos as mensagens que são trocadas durante um leilão. Há duas classes base abstratas AuctionMessage para mensagens de clientes do serviço de leilão, e AuctionReply para respostas do serviço aos clientes. Para ambas as classes base há um número de casos definidos na Figura 3.1. Para cada classe base, há um número de classes case que definem o formato de mensagens particulares dentro da classe. Estas mensagens podem em último caso ser mapeadas a pequenos documentos XML. Esperamos que hajam ferramentas automáticas que convertam entre documentos XML e estruturas de dados internas, tais como as definidas acima. A Figura 3.2 apresenta uma implementação Scala para a classe Auction para actors do leilão que coordenam os lances sobre um item. Objetos para esta classe são criados pela indicação • Um actor vendedor que precisa ser notificado quando o leilão terminou, 8 Programando com Atores e Mensagens import scala.actors.Actor abstract class AuctionMessage case class Offer(bid: Int, client: Actor) case class Inquire(client: Actor) extends AuctionMessage extends AuctionMessage abstract class AuctionReply case class Status(asked: Int, expire: Date) extends AuctionReply case object BestOffer extends AuctionReply case class BeatenOffer(maxBid: Int) extends AuctionReply case class AuctionConcluded(seller: Actor, client: Actor) extends AuctionReply case object AuctionFailed extends AuctionReply case object AuctionOver extends AuctionReply Listagem 3.1: Definição das Mensagens de um serviço de leilão • o lance mínimo, • a data de quando o leilão foi fechado. O comportamento do actor é definido por seu método act. Este método seleciona repetidamente (usando receiveWithin) uma mensagem e reage a ela, até que o leilão seja fechado, o que é sinalizado por uma mensagem TIMEOUT. Antes de finalmente parar, permanece ativo para um outro período determinado pela constante timeToShutdown e replica para ofertas posteriores que o leilão está fechado. Aqui estão algumas explicações extras sobre os construtores usados neste programa: • O método receiveWithin da classe Actor recebe como parâmetro um prazo dado em milisegundos e uma função que processa mensagens na caixa de correio. A função é dada por uma sequência de cases que especificam um padrão e uma ação para mensagens que casam com o padrão. O método receiveWithin seleciona a primeira mensagem da caixa de correio que casa com um destes padrões e aplica a ação correspondente a ele. • O último case de receiveWithin é guardado por um padrão TIMEOUT. Se nenhuma outra mensagem foi recebida nesse meio tempo, este padrão é disparado após o prazo que foi passado como argumento para o método envolvente receiveWithin. TIMEOUT é uma mensagem especial, que é disparada pela própria implementação do Actor. • Mensagens de resposta destino ! AlgumaMensagem. são enviadas usando sintaxe da forma ! é usado aqui como um operador binário 9 class Auction(seller: Actor, minBid: Int, closing: Date) extends Actor { val timeToShutdown = 36000000 // msec val bidIncrement = 10 def act() { var maxBid = minBid - bidIncrement var maxBidder: Actor = null var running = true while (running) { receiveWithin ((closing.getTime() - new Date().getTime())) { case Offer(bid, client) => if (bid >= maxBid + bidIncrement) { if (maxBid >= minBid) maxBidder ! BeatenOffer(bid) maxBid = bid; maxBidder = client; client ! BestOffer } else { client ! BeatenOffer(maxBid) } case Inquire(client) => client ! Status(maxBid, closing) case TIMEOUT => if (maxBid >= minBid) { val reply = AuctionConcluded(seller, maxBidder) maxBidder ! reply; seller ! reply } else { seller ! AuctionFailed } receiveWithin(timeToShutdown) { case Offer(_, client) => client ! AuctionOver case TIMEOUT => running = false } } } } } Listagem 3.2: Implementação do Serviço de Leilão 10 Programando com Atores e Mensagens com um actor e uma mensagem como argumentos. Isto é equivalente em Scala ao chamado de método destino.!(AlgumaMensagem), ou seja, a invocação do método ! do actor destino com a mensagem dada como parâmetro. A discussão precedente deu uma idéia de programação distribuída em Scala. Isso dá a sensação que Scala tem um rico conjunto de construtores que suportam processos actor, envio e recebimento de mensagens, programação com timeouts etc. De fato, o oposto é verdadeiro. Todos os construtores discutidos acima são oferecidos como métodos na classe biblioteca Actor. Aquela classe é ele mesma implementada em Scala, baseado no modelo thread subjacente à linguagem hospedeira (Java ou .NET). A implementação de todas as características da classe Actor usada aqui é dada na Seção 17.11. As vantagens da abordagem baseada em biblioteca são a relativa simplicidade da linguagem núcleo e a flexibilidade para os criadores de biblioteca. Como a linguagem núcleo não precisa especificar detalhes da comunicação dos processos de alto nível, pode ser mantida mais simples e geral. Como um modelo particular de mensagens numa caixa de correio é um módulo biblioteca, pode ser modificado livremente se um diferente modelo for necessário em algumas aplicações. A abordagem requer, entretanto, que a linguagem núcleo seja expressiva o suficiente para prover as abstrações linguísticas necessárias de um modo conveniente. Scala foi criada com isto em mente; um de seus maiores objetivos de design foi deixá-la flexível o suficiente para atuar como uma conveniente linguagem hospedeira para domínios de linguagens específicos implementados por módulos de biblioteca. Por exemplo, a construção de comunicação actor apresentada acima pode ser vista como um desses domínios específicos de linguagem, que conceitualmente estendem o núcleo Scala. Capítulo 4 Expressões e Funções Simples Os exemplos anteriores deram uma ideia do que pode ser feito com Scala. Agora, introduzimos as suas construções de linguagem uma a uma de uma maneira mais sistemática. Vamos começar com os menores elementos: expressões e funções. 4.1 Expressões e Funções Simples Scala vem com um interpretador que pode ser visto como uma calculadora sofisticada. O usuário interage com a calculadora digitando expressões. A calculadora retorna o resultado do cálculo e o seu tipo de dado. Por exemplo: scala> 87 + 145 unnamed0: Int = 232 scala> 5 + 2 * 3 unnamed1: Int = 11 scala> "hello" + " world!" unnamed2: java.lang.String = hello world! Também é possível dar nome a uma sub-expressão e usar o nome, ao invés da expressão, nas expressões seguintes: scala> def scale = 5 scale: Int scala> 7 * scale unnamed3: Int = 35 scala> def pi = 3.141592653589793 pi: Double 12 Expressões e Funções Simples scala> def radius = 10 radius: Int scala> 2 * pi * radius unnamed4: Double = 62.83185307179586 Definições começam com a palavra reservada def. Elas introduzem um nome que representa a expressão que vem depois do símbolo =. O intepretrador responde com o nome introduzido e o seu tipo de dado. Executando uma definição tal como def x = e não avaliará a expressão e. Ao invés e é avaliado sempre que x for usado. Alternativamente, Scala oferece um valor definição val x = e, o qual avalia o lado direito de e como parte da avaliação da definição. Se x é então usado subsequentemente, é imediatamente substituído pelo valor pré-computado de e, logo a expressão não precisa ser avaliada novamente. Como as expressões são avaliadas? Uma expressão consistindo de operadores e operandos é avaliada pela repetida aplicação dos seguintes passos de simplificação: • escolha a operação mais a esquerda. • avalie seu operando • aplique o operador aos valores do operando. Um nome definido por def é avaliado substituindo o nome pela definição do lado direito (não avaliada). Um nome definido por val é avaliado pela substituição do nome pelo valor da definição do lado direito. O processo de avaliação pára assim que encontrarmos um valor. Um valor é algum dado, tal como uma cadeia de caracteres, um número, um vetor, ou uma lista. Exemplo 4.1.1 Aqui está uma avaliação de uma expressão aritmética. → → → → (2 * pi) * radius (2 * 3.141592653589793) * radius 6.283185307179586 * radius 6.283185307179586 * 10 62.83185307179586 O processo de simplificar gradualmente expressões para valores é chamado redução. 4.2 Parâmetros Usando def, pode-se também definir funções com parâmetros. Por exemplo: 4.2 Parâmetros 13 scala> def square(x: Double) = x * x square: (Double)Double scala> square(2) unnamed0: Double = 4.0 scala> square(5 + 3) unnamed1: Double = 64.0 scala> square(square(4)) unnamed2: Double = 256.0 scala> def sumOfSquares(x: Double, y: Double) = square(x) + square(y) sumOfSquares: (Double,Double)Double scala> sumOfSquares(3, 2 + 2) unnamed3: Double = 25.0 Parâmetros de funções seguem o nome da função e sempre são envolvidos por parênteses. Cada parâmetro vem com um tipo que segue o nome do parâmetro e dois pontos. Até aqui, só precisamos de tipos numéricos, tal como o tipo scala.Double dos números de precisão dupla. Scala define tipos aliases para alguns tipos básicos, logo podemos escrever tipos numéricos como em Java. Por exemplo, double é um tipo alias de scala.Double e int é um tipo alias para scala.Int. Funções com parâmetros são avaliadas analogamente a operadores em expressões. Primeiro, os argumentos da função são avaliados (da esquerda para direita). Então, a aplicação da função é substituído pelo lado direito da função, e ao mesmo tempo todos os parâmetros formais são substituídos pelos seus argumentos atuais. Exemplo 4.2.1 sumOfSquares(3, 2+2) → sumOfSquares(3, 4) → square(3) + square(4) → 3 * 3 + square(4) → 9 + square(4) → 9 + 4 * 4 → 9 + 16 → 25 O exemplo mostra que o interpretador reduz argumentos de funções a valores antes de reescrever a aplicação da função. Pode-se ao invés escolher aplicar a função a argumentos não reduzidos. Isto levará a seguinte sequência de redução: sumOfSquares(3, 2+2) → square(3) + square(2+2) 14 Expressões e Funções Simples → 3 * → 9 + → 9 + → 9 + → 9 + → 9 + → 25 3 + square(2+2) square(2+2) (2+2) * (2+2) 4 * (2+2) 4 * 4 16 A segunda ordem de avaliação é conhecida como chamada por nome, e a primeira por chamada por valor. Para expressões que se utilizam apenas de funções e que portanto podem ser reduzidas com o modelo de substituição, ambos os esquemas levam ao mesmo valor final. Chamada por valor tem a vantagem de evitar avaliações repetidas de argumentos. Chamada por nome tem a vantagem de evitar avaliações de argumentos quando o parâmetro não é usado pela função. Chamada por valor é geralmente mais eficiente que chamada por nome, mas uma avaliação de chamada por valor pode entrar em laço infinito, enquanto uma avaliação de chamada por nome termina. Considere: scala> def loop: Int = loop loop: Int scala> def first(x: Int, y: Int) = x first: (Int,Int)Int Então first(1, loop) é reduzido com uma chamada por nome a 1, enquanto o mesmo termo, através de uma chamada por valor reduz a si mesmo repetidamente, logo a avaliação não termina. first(1, loop) → first(1, loop) → first(1, loop) → ... Scala usa chamada por valor por default, mas muda para avaliação de chamada por nome se o tipo do parâmetro for precedido por =>. Exemplo 4.2.2 scala> def constOne(x: Int, y: => Int) = 1 constOne: (Int,=> Int)Int scala> constOne(1, loop) unnamed0: Int = 1 scala> constOne(loop, 2) ^C // leva a laco infinito // para a execucao com Ctrl-C 4.3 Expressões Condicionais 4.3 15 Expressões Condicionais O if-else do Scala leva a uma escolha entre duas alternativas. Sua sintaxe é a mesma do if-else do Java. Mas onde o if-else do Java pode ser usado somente como uma alternativa entre comandos, Scala permite a mesma sintaxe para escolher entre duas expressões. Isso porque o if-else do Scala serve também como um substituto para a expressão condicional do Java ... ? ... : .... Exemplo 4.3.1 scala> def abs(x: Double) = if (x >= 0) x else -x abs: (Double)Double Expressões boleanas em Scala são similares as em Java; são formadas a partir de constantes. true e false, operadores de comparação, negação boleana ! e os operadores boleanos && and ||. 4.4 Exemplo: Raiz Quadrada pelo Método de Newton Agora ilustraremos elementos da linguagem intruduzidos até aqui na construção de um programa mais interessante. A tarefa é escrever um função def sqrt(x: Double): Double = ... que computa a raiz quadrada de x. Um modo comum de se computar raizes quadradas é pelo método das aproximações sucessivas de Newton. Inicia-se com um palpite inicial y (digamos: y = 1). Então melhora-se repetidamente o atual palpite y tomando-se a média de y e x/y. Como um exemplo, as próximas três colunas indicam p o palpite y, o quociente x/y, e suas médias para as primeiras aproximações para 2. 1 1.5 1.4167 1.4142 2/1 = 2 2/1.5 = 1.3333 2/1.4167 = 1.4118 ... y x/y 1.5 1.4167 1.4142 ... (y + x/y)/2 Este algoritmo pode ser implementado em Scala por um conjunto de pequenas funções, onde cada uma representa um dos elementos do algoritmo. Primeiro definimos uma função para iterar do palpite para o resultado: def sqrtIter(guess: Double, x: Double): Double = if (isGoodEnough(guess, x)) guess 16 Expressões e Funções Simples else sqrtIter(improve(guess, x), x) Observe que sqrtIter chama a si mesmo recursivamente. Laços em programas imperativos podem sempre ser modelados por recursão em programas funcionais. Observe também que a definição de sqrtIter contém um tipo retorno, que segue o seção de parâmetros. Tais tipos de retorno são mandatórios para funções recursivas. Para uma função não recursiva, o tipo de retorno é opcional; se estiver faltando o verificador de tipos o computará a partir do tipo do lado direito da função. Entretanto, mesmo para funções não recursivas é sempre boa idéia incluir um tipo de retorno para melhor documentação. Como segundo passo, definimos as duas funções chamadas por: sqrtIter: uma função para melhorar (improve) o palpite e um teste de terminação isGoodEnough. Aqui estão suas definições. def improve(guess: Double, x: Double) = (guess + x / guess) / 2 def isGoodEnough(guess: Double, x: Double) = abs(square(guess) - x) < 0.001 Finalmente, a própria função sqrt é definida como uma aplicação de sqrtIter. def sqrt(x: Double) = sqrtIter(1.0, x) Exercício 4.4.1 O teste isGoodEnough não é muito preciso para pequenos números e podem levar a não terminação para números muito grandes (por que?). Crie uma versão diferente para isGoodEnough que não tenham esses problemas. Exercício 4.4.2 Simule a execução da expressão sqrt(4). 4.5 Aninhamento de Funções A programação funcional encoraja a construção de muitas pequenas funções auxiliares. Nos últimos exemplos, a implementação de sqrt faz uso da funções auxiliares sqrtIter, improve e isGoodEnough. Os nomes destas funções são relevantes somente para a implementação de sqrt. Normalmente não queremos que os usuários de sqrt acessem estas funções diretamente. Nós podemos reforçar isto (e evitar poluição de nomes) incluindo funções auxiliares dentro das próprias funções: def sqrt(x: Double) = { def sqrtIter(guess: Double, x: Double): Double = if (isGoodEnough(guess, x)) guess 4.5 Aninhamento de Funções 17 else sqrtIter(improve(guess, x), x) def improve(guess: Double, x: Double) = (guess + x / guess) / 2 def isGoodEnough(guess: Double, x: Double) = abs(square(guess) - x) < 0.001 sqrtIter(1.0, x) } Neste programa, as chaves { ... } envolvem um bloco. Blocos em Scala são eles mesmos expressões. Cada bloco termina com um expressão resultado o qual define seu valor. A expressão resultado pode ser precedida por definições auxiliares, as quais são visíveis somente no próprio bloco. Cada definição no bloco pode ser seguida para um ponto e vírgula, o qual separa esta definição das definições subsequentes ou a expressão resultado. Entretanto, um ponto e vírgula é inserido implicitamente ao final de cada linha, a não ser que uma das condições a seguir seja verdadeira. 1. Ou a linha em questão termina com uma palavra tal que um ponto ou um operador infixo não são legais ao final da expressão. 2. Ou a próxima linha inicia com uma palavra que não pode iniciar uma expressão. 3. Ou estamos dentro de parênteses (...) ou chaves , porque estes não podem conter multiplos comandos de qualquer modo. Entretanto, os seguintes são legais: def f(x: Int) = x + 1; f(1) + f(2) def g1(x: Int) = x + 1 g(1) + g(2) def g2(x: Int) = {x + 1}; /* ‘;’ mandatorio */ g2(1) + g2(2) def h1(x) = x + y h1(1) * h1(2) def h2(x: Int) = ( x // parenteses mandatorio, senao um ponto e virgula + y // sera inserido apos o ‘x’. ) h2(1) / h2(2) 18 Expressões e Funções Simples Scala usa as regras usuais de escopo de bloco estruturado. Um nome definido em algum outro bloco é também visível em algum bloco interno, desde que não tenha sido redefinido lá. Esta regra nos permite simplificar nosso exemplo sqrt. Não precisamos passar x como parâmetro adicional de funções aninhadas, dado que está sempre visível nelas como um parâmetro da função externa sqrt. Aqui está o código simplificado: def sqrt(x: Double) = { def sqrtIter(guess: Double): Double = if (isGoodEnough(guess)) guess else sqrtIter(improve(guess)) def improve(guess: Double) = (guess + x / guess) / 2 def isGoodEnough(guess: Double) = abs(square(guess) - x) < 0.001 sqrtIter(1.0) } 4.6 Recursão de Cauda Considere a seguinte função para calcular o maior divisor comum entre dois números dados. def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) Usando nosso modelo de substituição da função de avaliação, gcd(14, 21) evaluates as follows: gcd(14, 21) → if (21 == 0) 14 else gcd(21, 14 % 21) → if (false) 14 else gcd(21, 14 % 21) → gcd(21, 14 % 21) → gcd(21, 14) → if (14 == 0) 21 else gcd(14, 21 % 14) → → gcd(14, 21 % 14) → gcd(14, 7) → if (7 == 0) 14 else gcd(7, 14 % 7) → → gcd(7, 14 % 7) → gcd(7, 0) → if (0 == 0) 7 else gcd(0, 7 % 0) → → 7 Contraste isto com a avaliação de uma outra função recursiva, factorial: def factorial(n: Int): Int = if (n == 0) 1 else n * factorial(n - 1) 4.6 Recursão de Cauda 19 A aplicação de factorial(5) é reescrita como segue: → → → → ... → → ... → → ... → → ... → → ... → → ... → factorial(5) if (5 == 0) 1 else 5 * factorial(5 - 1) 5 * factorial(5 - 1) 5 * factorial(4) 5 * (4 * factorial(3)) 5 * (4 * (3 * factorial(2))) 5 * (4 * (3 * (2 * factorial(1)))) 5 * (4 * (3 * (2 * (1 * factorial(0)))) 5 * (4 * (3 * (2 * (1 * 1)))) 120 Há uma importante diferença entre as duas sentenças reescritas: Os termos na sequência reescrita de gcd tem repetidamente a mesma forma. Conforme a avaliação prossegue, seu tamanho é limitado por uma constante. Diferentemente, na avaliação do fatorial obtemos cadeias cada vez mais longas de operandos que são então multiplicados na última parte da sequência de avaliação. Ainda que as atuais implementações de Scala não trabalhem reescrevendo termos, elas contudo devem ter o mesmo comportamento de espaço que nas sequências reescritas. Na implementação de gcd, nota-se que a chamada recursiva para gcd é a última ação realizada na avaliação do seu corpo. Pode-se também dizer que gcd é uma recursão de cauda (tail-recursive). A última chamada numa função recursiva de cauda pode ser implementada por um salto ao início da função. Os argumentos dessa chamada pode sobrescrever os parâmetros da atual instanciação de gcd, portanto nenhum espaço na pilha é necessário. Consequentemente, funções recursivas de cauda são processos iterativos, que podem ser executados em espaço constante. Em contraste, a chamada recursiva em factorial é seguida por uma multiplicação. consequentemente, um novo espaço na pilha é alocado para a instância recursiva de fatorial, e é desalocada após o término daquela instância. A formulação dada para a função fatorial não é recursiva de cauda; precisa de espaço proporcional aos seus parâmetros de entrada para sua execução. Genericamente, se a última ação da função é uma chamada a outra função (possivelmente a mesma), apenas um único espaço na pilha é requerido para ambas funções. Tais chamadas são denominadas “tail call”. Em princípio, tail calls sempre podem reutilizar o espaço da pilha da função de chamada. Entretanto, alguns ambientes de execução (tais como Java VM) carecem das primitivas para fazer um espaço de pilha reutilizável para tail calls eficientes. A produção de uma implementação Scala de qualidade é, portanto, requerida somente para reutilizar o espaço de pilha de uma função recursiva de cauda cuja última ação é chamar a si mesma. Outras chamadas de cauda podem ser otimizadas também, mas não deve-se confiar nisso ao longo das implementações. 20 Expressões e Funções Simples Exercício 4.6.1 Escreva uma implementação recursiva de cauda de factorial. Capítulo 5 Funções de Primeira Classe Uma função em Scala é um valor de primeira classe. Como qualquer outro valor, pode ser passado como um parâmetro ou retornado como um resultado. Funções que recebem outras funções como parâmetros ou as retornam como resultados são denominadas funções de alta ordem. Este capítulo introduz funções de alta ordem e mostra como elas fornecem um mecanismo flexível para a composição de programas. Como um exemplo de motivação, considere as três tarefas relacionadas a seguir: 1. Escreva uma função que some todos os inteiros entre dois dados números, a e b: def sumInts(a: Int, b: Int): Int = if (a > b) 0 else a + sumInts(a + 1, b) 2. Escreva uma função para somar os quadrados de todos os inteiros entre dois dados números, a e b: def square(x: Int): Int = x * x def sumSquares(a: Int, b: Int): Int = if (a > b) 0 else square(a) + sumSquares(a + 1, b) 3. Escreva uma função para somar as potências 2n de todos os n inteiros entre dois dados números a e b: def powerOfTwo(x: Int): Int = if (x == 0) 1 else 2 * powerOfTwo(x - 1) def sumPowersOfTwo(a: Int, b: Int): Int = if (a > b) 0 else powerOfTwo(a) + sumPowersOfTwo(a + 1, b) P Estas funções são todas instâncias de ba f (n) para diferentes valores de x. Podemos obter o padrão comum definindo uma função sum: 22 Funções de Primeira Classe def sum(f: Int => Int, a: Int, b: Int): Int = if (a > b) 0 else f(a) + sum(f, a + 1, b) O tipo Int => Int é o tipo de funções que recebem argumentos do tipo Int e retornam resultados do tipo Int. Então sum é uma função que recebe uma outra função como parâmetro. Em outras palavras, sum é uma função de alta ordem. Usando sum, podemos formular as três funções de soma como segue: def sumInts(a: Int, b: Int): Int = sum(id, a, b) def sumSquares(a: Int, b: Int): Int = sum(square, a, b) def sumPowersOfTwo(a: Int, b: Int): Int = sum(powerOfTwo, a, b) onde def id(x: Int): Int = x def square(x: Int): Int = x * x def powerOfTwo(x: Int): Int = if (x == 0) 1 else 2 * powerOfTwo(x - 1) 5.1 Funções Anônimas Parametrização por funções tende a criar várias pequenas funções. No exemplo anterior, definimos id, square e power como funções separadas, de tal modo que pudessem ser passadas como argumentos para sum. Ao invés de usarmos definições de funções nomeadas para estas pequenas funções argumentos, podemos formulá-las de um modo abreviado como funções anônimas. Uma função anônima é uma expressão que é avaliada para uma função; a função é definida sem receber um nome. Como exemplo considere a função anônima quadrado: (x: Int) => x * x A parte anterior a flecha ‘=>’ são os parâmetros da função, ao passo que a parte seguinte a ‘=>’ é o seu corpo. Por exemplo, aqui está uma função anônima que multiplica seus dois argumentos. (x: Int, y: Int) => x * y Usando funções anônimas, podemos reformular as duas funções soma sem funções auxiliares nomeadas: def sumInts(a: Int, b: Int): Int = sum((x: Int) => x, a, b) def sumSquares(a: Int, b: Int): Int = sum((x: Int) => x * x, a, b) Frequentemente, o compilador Scala pode deduzir o tipo do parâmetro a partir do contexto da função anônima nos casos em que foram omitidos. Por exemplo, 5.2 Currying 23 no caso de sumInts ou sumSquares, sabe-se a partir do tipo de sum que o primeiro parâmetro deve ser uma função de tipo Int => Int. Consequentemente, o tipo do parâmetro Int é redundante e pode ser omitido. Se houver um único parâmetro sem um tipo, também podemos omitir os parâmetros a sua volta. def sumInts(a: Int, b: Int): Int = sum(x => x, a, b) def sumSquares(a: Int, b: Int): Int = sum(x => x * x, a, b) Em geral, o termo em Scala (x1 : T1 , ..., xn : Tn ) => E define uma função que mapeia seus parâmetros x1 , ..., xn ao resultado da expressão E (onde E pode referir-se a x1 , ..., xn ). Funções anônimas não são elementos essenciais da linguagem Scala, dado que sempre podem ser expressos em termos de funções nomeadas. De fato, a função anônima (x1 : T1 , ..., xn : Tn ) => E é equivalente ao bloco { def f (x1 : T1 , ..., xn : Tn ) = E ; f _ } onde f é um novo nome que não é utilizado em qualquer outro lugar do programa. Também dizemos que funções anônimas são “syntactic sugar”. 5.2 Currying A última formulação das funções de soma já são bem compactas. Mas podemos fazer ainda melhor. Observe que a e b aparecem como parâmetros e argumentos de cada função, mas não parecem tomar parte de combinações interessantes. Há algum modo de nos livrarmos delas? Vamos tentar reescrever sum de tal modo que não receba os limites a e b como parâmetros: def sum(f: Int => Int): (Int, Int) => Int = { def sumF(a: Int, b: Int): Int = if (a > b) 0 else f(a) + sumF(a + 1, b) sumF } Nessa formulação, sum é uma função que retorna uma outra função, especificamente a função especializada soma sumF. Esta última função realiza todo o trabalho; recebe os limites a e b como parâmetros, aplica a função f, parâmetro de sum, a todos os inteiros entre eles, e soma os resultados. Usando esta nova formulação de sum, podemos agora definir: def sumInts = sum(x => x) 24 Funções de Primeira Classe def sumSquares = sum(x => x * x) def sumPowersOfTwo = sum(powerOfTwo) Ou, equivalentemente, com definições de valores: val sumInts = sum(x => x) val sumSquares = sum(x => x * x) val sumPowersOfTwo = sum(powerOfTwo) sumInts, sumSquares, e sumPowersOfTwo podem ser aplicados como qualquer outra função. Por exemplo, scala> sumSquares(1, 10) + sumPowersOfTwo(10, 20) unnamed0: Int = 2096513 Como funções que retornam funções são aplicadas? Como exemplo, na expressão sum(x => x * x)(1, 10) , a função sum é aplicada a função quadrada (x => x * x). O função resultante é então aplicada ao segunda lista de argumentos, (1, 10). Essa notação é possível porque aplicações de funções são associativas à esquerda. Ou seja, se args1 e args2 são listas de argumentos, então f (args1 )(args2 ) é equivalente a ( f (args1 ))(args2 ) Em nosso exemplo, sum(x => x * x)(1, 10) é equivalente a seguinte expressão: (sum(x => x * x))(1, 10). O estilo “funções que retornam funções” é tão útil que Scala tem sintaxe especial para ele. Por exemplo, a próxima definição de sum é equivalente a anterior, porém mais curta: def sum(f: Int => Int)(a: Int, b: Int): Int = if (a > b) 0 else f(a) + sum(f)(a + 1, b) Genericamente, uma definição de função currificada def f (args1 ) ... (argsn ) = E onde n > 1 expande para def f (args1 ) ... (argsn−1 ) = { def g (argsn ) = E ; g } onde g é um novo identificador. Ou, menor, usando uma função anônima: def f (args1 ) ... (argsn−1 ) = ( argsn ) => E . Realizando este passo n vezes dá aquela 5.3 Exemplo: Encontrando Pontos Fixos de Funções 25 def f (args1 ) ... (argsn ) = E é equivalente a def f = (args1 ) => ... => (argsn ) => E . Ou, equivalentemente, usando uma definição valor: val f = (args1 ) => ... => (argsn ) => E . Este estilo de definição de função e aplicação é chamado currificado depois que seu divulgador, Haskell B. Curry, um lógico do século 20, mesmo que a idéia nos leve de volta a Moses Schönfinkel e Gottlob Frege. O tipo de uma função que retorna função é expresso de modo análogo a sua lista de parâmetros. Tomando a última formulação de sum como exemplo, o tipo para sum é (Int => Int) => (Int, Int) => Int. Isso é possível porque tipos de funções são associativos à direita. Ou seja, T1 => T2 => T3 is equivalent to T1 => (T2 => T3 ) Exercício 5.2.1 1. A função sum usa uma recursiva linear. Você poderia alterá-la para uma função recursiva de cauda, preenchendo as lacunas marcadas com ’??’ abaixo? def sum(f: Int => Int)(a: Int, b: Int): Int = { def iter(a: Int, result: Int): Int = { if (??) ?? else iter(??, ??) } iter(??, ??) } Exercício 5.2.2 Escreva uma função produto que computa o produto de valores de funções em pontos sobre um dado intervalo. Exercício 5.2.3 Escreva fatorial em termos de produto. Exercício 5.2.4 Escreva uma função ainda mais genérica que generaliza ambos sum e produto. 5.3 Exemplo: Encontrando Pontos Fixos de Funções Um número x é chamado um ponto fixo de uma função f se 26 Funções de Primeira Classe f(x) = x . Para algumas funções f podemos encontrar os pontos fixos iniciando com um palpite inicial e então aplicando f repetidamente, até que o valor não mude mais (ou a mudança seja tolerável). Isso é possível na sequência x, f(x), f(f(x)), f(f(f(x))), ... converge para pontos fixos de f . Essa idéia é captada na “função achadora de pontos fixos” a seguir: val tolerance = 0.0001 def isCloseEnough(x: Double, y: Double) = abs((x - y) / x) < tolerance def fixedPoint(f: Double => Double)(firstGuess: Double) = { def iterate(guess: Double): Double = { val next = f(guess) if (isCloseEnough(guess, next)) next else iterate(next) } iterate(firstGuess) } Agora aplicaremos esta idéia na reformulação da função raiz quadrada. Vamos começar pela especificação de sqrt: sqrt(x) = = the y such that the y such that y * y = x y = x / y Consequentemente, sqrt(x) é um ponto fixo da função y => x / y. Isso sugere que sqrt(x) pode ser computado por uma iteração de ponto fixo: def sqrt(x: double) = fixedPoint(y => x / y)(1.0) Mas se tentarmos isso, percebemos que aquela computação não converge. Vamos agregar à função de ponto fixo um comando print que mostra o valor corrente de guess: def fixedPoint(f: Double => Double)(firstGuess: Double) = { def iterate(guess: Double): Double = { val next = f(guess) println(next) if (isCloseEnough(guess, next)) next else iterate(next) } iterate(firstGuess) } 5.3 Exemplo: Encontrando Pontos Fixos de Funções 27 Então, sqrt(2) dá: 2.0 1.0 2.0 1.0 2.0 ... Um modo de controlar tais oscilações é prevenir guess de mudar muito. Isso pode ser obtido pela média (função averaging) de sucessivos valores da sequência original: scala> def sqrt(x: Double) = fixedPoint(y => (y + x/y) / 2)(1.0) sqrt: (Double)Double scala> sqrt(2.0) 1.5 1.4166666666666665 1.4142156862745097 1.4142135623746899 1.4142135623746899 De fato, expandindo a função fixedPoint dá exatamente nossa prévia definição de ponto fixo da Seção 4.4. Os exemplos anteriores mostraram que o poder de expressividade de uma linguagem é consideravelmente aumentado se funções puderem ser passadas como argumentos. O próximo exemplo mostra que funções que retornam funções também podem ser muito úteis. p Considere novamente iterações de ponto fixo. Começamos observando que (x) é um ponto fixo da função y => x / y. Então fazemos a iteração convergir tirando a média de sucessivos valores. Essa técnica de amortecimento da média é tão genérica que pode ser denotada em outra função. def averageDamp(f: Double => Double)(x: Double) = (x + f(x)) / 2 Usando averageDamp, podemos reformular a função da raiz quadrada como segue. def sqrt(x: Double) = fixedPoint(averageDamp(y => x/y))(1.0) Isto expressa os elementos do algoritmo tão claramente quanto possível. Exercício 5.3.1 Escreva uma função para a raiz cúbica usando fixedPoint e averageDamp. 28 Funções de Primeira Classe 5.4 Sumário Vimos no capítulo anterior que funções são abstrações essenciais porque nos permitem introduzir métodos genéricos de computação como explícitos, elementos nomeados em nossa linguagem de programação. O presente capítulo mostrou que tais abstrações podem ser combinados por funções de alta ordem para criar mais abstrações. Como programadores, devemos procurar por oportunidades de reuso e abstração. O mais alto nível possível de abstração nem sempre é o melhor, mas é importante saber técnicas de abstração, de modo que possamos usá-las quando apropriado. 5.5 Elementos da Linguagem Vistos Até Aqui Os capítulos 4 e 5 trataram elementos da linguagem Scala para expressar tipos e expressões relacionados a tipos primitivos e funções. A sintaxe livre de contexto desses elementos da linguagem são dados abaixo em formato Backus-Naur estendido, onde ‘|’ denotam alternativas, [...] denotam opção (0 ou 1 ocorrência), e {...} denotam (0 ou mais ocorrências). Caracteres Programas em Scala são sequência de caracteres (Unicode). seguintes conjuntos de caracteres. Distinguimos os • espaço em branco, tal como ‘’, tabulação, ou caracter de mudança de linha (newline), • letras ‘a’ a ‘z’, ‘A’ a ‘Z’, • digitos ‘0’ a ‘9’, • caracteres delimitadores . , ; ( ) { } [ ] \ " ’ • caracteres operadores, tal como ‘#’, ‘+’ e ‘:’. Essencialmente, há caracteres imprimíveis que não estão em nenhum dos conjuntos de caracteres acima. Lexemas: ident literal = | | = letter {letter | digit} operator { operator } ident ’_’ ident “como em Java” 5.5 Elementos da Linguagem Vistos Até Aqui 29 Literais são como em Java. Eles definem números, caracteres, cadeias de caracteres, ou valores boleanos. Exemplos de literais tais como 0, 1.0e10, ’x’, "ele disse "oi!"", ou true. Identificadores podem ter duas formas. Eles ou iniciam por uma letra, que é seguida por uma sequência (possivelmente vazia) de letras ou símbolos, ou podem iniciar com um caracter operador, seguido por uma sequência (possivelmente vazia) de caracteres operadores. Ambas as formas podem conter caracteres “underscore” ‘_’. Além disso, um caracter underscore pode ser seguido por quaisquer identificadores. Consequentemente, todos a seguir são identificadores legais: x Room10a + -- foldl_: +_vector Segue desta regra que identificadores operadores subsequentes precisam ser separados por espaço em branco. Por exemplo, a entrada x+-y é entendida como a sequência de três “tokens” x, +- e y. Se desejamos expressar a soma de x com o valor negativo de y, precisamos acrescentar no mínimo um espaço, ou seja, x+ -y. O caracter $ é reservado para identificadores gerados pelo compilador; não devem ser usados em programas fontes. Os seguintes são palavras reservadas, e não devem ser usadas como identificadores: abstract do finally match package sealed try while _ : case else for new private super true with = => catch extends if null protected this type yield <<: <% class false implicit object requires throw val >: def final import override return trait var # @ Types: Type FunctionType SimpleType = = = Types = SimpleType | FunctionType SimpleType ’=>’ Type | ’(’ [Types] ’)’ ’=>’ Type Byte | Short | Char | Int | Long | Float | Double | Boolean | Unit | String Type {‘,’ Type} Tipos podem ser: • tipos numéricos Byte, Short, Char, Int, Long, Float and Double (com em Java), • o tipo Boolean com valores true e false, • o tipo Unit com o único valor (), 30 Funções de Primeira Classe • o tipo String, • tipos função, tal como (Int, Int) => Int ou String => Int => String. Expressões: Expr InfixExpr Operator PrefixExpr SimpleExpr FunctionExpr Bindings Binding Block = = = = = = = = = InfixExpr | FunctionExpr | if ’(’ Expr ’)’ Expr else Expr PrefixExpr | InfixExpr Operator InfixExpr ident [’+’ | ’-’ | ’!’ | ’~’ ] SimpleExpr ident | literal | SimpleExpr ’.’ ident | Block (Bindings | Id) ’=>’ Expr ‘(’ Binding {‘,’ Binding} ‘)’ ident [’:’ Type] ’{’ {Def ’;’} Expr ’}’ Expressões podem ser: • identificadores tais como x, isGoodEnough, *, ou +-, • literais, tais como 0, 1.0, ou "abc", • Seleções de campo e método, tal como System.out.println, • aplicações de função, tal como sqrt(x), • aplicações de operador, tais como -x ou y + x, • condicionais, tal como if (x < 0) -x else x, • blocos, tal como { val x = abs(y) ; x * 2 }, • funções anônimas, tais como x => x + 1 ou (x: Int, y: Int) => x + y. Definições: Def FunDef ValDef Parameters Parameter = = = = = FunDef | ValDef ’def’ ident {’(’ [Parameters] ’)’} [’:’ Type] ’=’ Expr ’val’ ident [’:’ Type] ’=’ Expr Parameter {’,’ Parameter} ident ’:’ [’=>’] Type Definições podem ser: • definições de função tal como def square(x: Int): Int = x * x, • definições de valor tal como val y = square(2). Capítulo 6 Classes e Objetos Scala não tem um tipo primitivo para números racionais, mas é fácil definir um, usando uma classe. Aqui está uma possível implementação. class Rational(n: Int, d: Int) { private def gcd(x: Int, y: Int): Int = { if (x == 0) y else if (x < 0) gcd(-x, y) else if (y < 0) -gcd(x, -y) else gcd(y % x, x) } private val g = gcd(n, d) val numer: Int = n/g val denom: Int = d/g def +(that: Rational) = new Rational(numer * that.denom + that.numer * denom, denom * that.denom) def -(that: Rational) = new Rational(numer * that.denom - that.numer * denom, denom * that.denom) def *(that: Rational) = new Rational(numer * that.numer, denom * that.denom) def /(that: Rational) = new Rational(numer * that.denom, denom * that.numer) } Isso define Rational como uma classe que recebe dois argumentos construtores n e d, contendo as partes numéricas do numerador e denominador. A classe provê campos que retornam estas partes, bem como métodos para a aritmética sobre números racionais. Cada método aritmético recebe como parâmetro o operando direito da operação. O operando esquerdo da operação sempre é o número racional 32 Classes e Objetos do método cujo é membro. Membros Privados. A implementação dos números racionais define um método privado gcd que computa o maior denominador comum de dois inteiros, bem como um campo privado g que contém o gcd dos argumentos construtores. Esses membros são inacessíveis de fora da classe Rational. São usados na implementação da classe para eliminar fatores comuns nos argumentos construtores para garantir que o numerador e o denominador estejam sempre em forma normalizada. Criando e Acessando Objetos. Como um exemplo de como os números racionais podem ser usados, aqui está um programa que imprime a soma de todos os números 1/i onde i está no intervalo de 1 a 10. var i = 1 var x = new Rational(0, 1) while (i <= 10) { x += new Rational(1, i) i += 1 } println("" + x.numer + "/" + x.denom) O + recebe como operando esquerdo uma cadeia de caracteres e como operando direito um valor de tipo arbitrário. Retorna o resultado de converter seu operando direito em uma cadeia de caracteres e concatená-la a seu operando esquerdo. Herança e Sobrecarga. Cada classe em Scala tem uma superclasse que ela estende. Se uma classe não menciona sua superclasse em sua definição, o tipo básico scala.AnyRef é implicitamente assumido (para implementações Java, este tipo é um apelido (alias) para java.lang.Object. Por exemplo, a classe Rational pode ser equivalentemente definida como class Rational(n: Int, d: Int) extends AnyRef { ... // como antes } Uma classe herda todos os membros de sua superclasse. Pode também redefinir (ou: sobrescrever) alguns membros herdados. Por exemplo, a classe java.lang.Object define um método toString que retorna uma representação do objeto como cadeia de caracteres (string): class Object { ... def toString: String = ... } 33 A implementação de toString em Object forma uma string que consiste do nome da classe do objeto e um número. Faz sentido redefinir este método para objetos que são números racionais: class Rational(n: Int, d: Int) extends AnyRef { ... // como antes override def toString = "" + numer + "/" + denom } Observe que, diferentemente de Java, definições que redefinem precisam ser precedidas por um modificador override. Se a classe A estende a classe B , então objetos do tipo A podem ser usados sempre que objetos do tipo B são esperados. Nesse caso dizemos que o tipo A está em conformidade com o tipo B . Por exemplo, Rational está em conformidade com AnyRef, logo é legal atribuir um valor Rational à variável do tipo AnyRef: var x: AnyRef = new Rational(1, 2) val r = new Rational(1,2) System.out.println(r.toString); // imprime ‘‘1/2’’ Distintamente de Java, métodos em Scala não necessariamente precisam receber uma lista de parâmetros. Um exemplo é o método abaixo square. Este método é invocado simplesmente mencionando seu nome. class Rational(n: Int, d: Int) extends AnyRef { ... // como antes def square = new Rational(numer*numer, denom*denom) } val r = new Rational(3, 4) println(r.square) // imprime ‘‘9/16’’* Ou seja, métodos sem parâmetros são acessados como campos valor, tais como numer são. A diferença entre valores e métodos sem parâmetros reside em suas definições. O lado direito de um valor é avaliado quando o objeto é criado, e o valor não muda depois. Um lado direito de um método sem parâmetros, por outro lado, é avaliado cada vez que o método é chamado. O acesso uniforme dos campos de métodos sem parâmetros resulta em crescente flexibilidade para o implementador de uma classe. Frequentemente, um campo em uma versão da classe torna-se um valor computado na próxima versão. O acesso uniforme garante que clientes não tenham de ser reescritos por conta desta mudança. 34 Classes e Objetos Classes Abstratas. Considere a tarefa de escrever uma classe para conjuntos de números inteiros com duas operações, incl e contains. (s incl x) deve retornar um novo conjunto que contém o elemento x juntamente com todos os elementos do conjunto s. (s contains x) deve retornar true se o conjunto s contiver o elemento x, e deve retornar false, caso contrário. A interface para tais conjuntos é dada por: abstract class IntSet { def incl(x: Int): IntSet def contains(x: Int): Boolean } IntSet é tido como uma classe abstrata. Isso tem duas consequências. Primeiro, classes abstratas podem ter membros protelados (deferred) que são declarados, mas que não tem uma implementação. No nosso caso, ambos incl e contains são tais membros. Segundo, porque uma classe abstrata pode ter membros não implementados, nenhum objeto daquela classe pode ser criado usando new. Em contraste, uma classe abstrata pode ser usada como uma classe base de alguma outra classe, que implementa os membros que foram postergados. Traits. Ao invés de classe abstrata frequentemente usa-se a palavra chave trait em Scala. Traits são classes abstratas que desejamos adicionar a alguma outra classe. Isso pode ser porque um trait adiciona alguns métodos ou campos para uma classe pai desconhecida. Por exemplo, um trait Bordered pode ser usado para adicionar uma borda a vários componentes gráficos. Um outro cenário de uso ocorre quando o trait coleta assinaturas de alguma funcionalidade provida por diferentes classes, do mesmo modo que uma interface Java faria. Como IntSet pertence a esta categoria, pode-se alternativamente definí-la como um trait: trait IntSet { def incl(x: Int): IntSet def contains(x: Int): Boolean } Implementando Classes Abstratas. Vamos dizer que planejamos implementar conjuntos como árvores binárias. Há duas possíveis formas de árvores. Uma árvore para o conjunto vazio, e uma árvore consistindo de um inteiro e duas subárvores. Aqui estão suas implementações: class EmptySet extends IntSet { def contains(x: Int): Boolean = false def incl(x: Int): IntSet = new NonEmptySet(x, new EmptySet, new EmptySet) } 35 class NonEmptySet(elem: Int, left: IntSet, right: IntSet) extends IntSet { def contains(x: Int): Boolean = if (x < elem) left contains x else if (x > elem) right contains x else true def incl(x: Int): IntSet = if (x < elem) new NonEmptySet(elem, left incl x, right) else if (x > elem) new NonEmptySet(elem, left, right incl x) else this } Ambos EmptySet e NonEmptySet estendem a classe IntSet. Isso implica que tipos EmptySet e NonEmptySet estão em conformidade com o tipo IntSet – um valor do tipo EmptySet ou NonEmptySet pode ser usado sempre que um valor do tipo IntSet é requerido. Exercício 6.0.1 Escreva os métodos uniao e intersecao para formar a união e interseção entre dois conjuntos. Exercício 6.0.2 Adicione um método def excl(x: Int) para retornar um dado conjunto sem o elemento x. Para isso, é interessante também implementar um método teste def isEmpty: Boolean para conjuntos. Ligação Dinâmica. Linguagens orientadas a objetos (inclusive Scala) usam despacho dinâmico para invocações de métodos. Ou seja, o código invocado por uma chamada de método depende do tipo em tempo de execução do objeto que contém o método. Por exemplo, considere a expressão s contains 7 onde s é um valor do tipo declarado s: IntSet. Qual código para contains é executado depende do tipo do valor de s em tempo de execução. Se for um valor EmptySet, é a implementação de contains na classe EmptySet que é executada, e analogamente para valores NonEmptySet. Este comportamento é consequência direta do nosso modelo de substituição da avaliação. Por exemplo, (new EmptySet).contains(7) -> (substituindo contains pelo seu corpo na classe EmptySet) 36 Classes e Objetos false Ou, new NonEmptySet(7, new EmptySet, new EmptySet).contains(1) (substituindo contains pelo seu corpo na classe NonEmptySet) -> if (1 < 7) new EmptySet contains 1 else if (1 > 7) new EmptySet contains 1 else true (reescrevendo o condicional) -> new EmptySet contains 1 (substituindo contains pelo seu corpo na classe EmptySet) -> false . Envio dinâmico de métodos é análogo a chamadas para funções de alta ordem. Em ambos os casos, a identidade do código a ser executado é conhecida somente em tempo de execução. Essa similaridade não é apenas superficial. Na verdade, Scala representa cada valor de função como um objeto (veja Seção 8.6). Objetos. Na implementação prévia de conjuntos de inteiros, conjuntos vazios eram expressos com new EmptySet; então um novo objeto era criado a cada vez que um valor conjunto vazio era requerido. Podemos evitar criações desnecessárias de objetos definindo um valor empty uma vez e então, usando este valor ao invés de cada ocorrência de new EmptySet. Por exemplo: val EmptySetVal = new EmptySet Um problema com esse tratamento é que uma definição de valor tal como a anterior não é uma definição top-level legal em Scala; tem de ser parte de uma outra classe ou objeto. Ainda, a definição de classe EmptySet agora parece um pouco exagerada – por que definir uma classe de objetos, se somente estamos interessados em um único objeto dessa classe? Um tratamento mais direto é usar uma definição de objeto. Aqui está uma alternativa mais adequada para a definição de conjunto vazio: object EmptySet extends IntSet { def contains(x: Int): Boolean = false def incl(x: Int): IntSet = new NonEmptySet(x, EmptySet, EmptySet) } 37 A sintaxe de uma definição de objeto segue a sintaxe da definição de uma classe; tem uma cláusula opcional extends, bem como um corpo opcional. Assim como em classes, a cláusula extends define membros herdados do objeto considerando que o corpo define novos membros ou sobrecarga. Entretanto, uma definição de objeto denota um único objeto somente se não for possível criar outros objetos com a mesma estrutura usando new. Então, definições de objeto também carecem de parâmetros construtores, que podem estar presentes nas definições das classes. Definições de objetos podem aparecer em qualquer lugar em um programa Scala; incluindo o top-level. Como não há ordem fixa de execução de entidades top-level em Scala, pode-se questionar quando exatamente o objeto definido pela definição de objeto é criado e inicializado. A resposta é que o objeto é criado assim que seus membros são acessados. Esta estratégia é chamada avaliação preguiçosa. Classes Padrão. Scala é uma linguagem orientada a objetos pura. Isso significa que cada valor em Scala pode ser visto como um objeto. De fato, mesmo tipos primitivos tais como int ou boolean não são tratados de modo especial. São definidos como apelidos de tipos de classes Scala no módulo Predef. type boolean = scala.Boolean type int = scala.Int type long = scala.Long ... Por eficiência, o compilador geralmente representa valor de tipo scala.Int por inteiros de 32 bits, valores de tipo scala.Boolean por boleanos Java etc. Mas converte essas representações especializadas para objetos quando requeridos, por exemplo, quando um valor primitivo Int é passada a uma função com um parâmetro de tipo AnyRef. Consequentemente, a representação de valores primitivos é apenas uma otimização, não muda o significado de um programa. Aqui está a especificação da classe Boolean. package scala abstract class Boolean { def && (x: => Boolean): Boolean def || (x: => Boolean): Boolean def ! : Boolean def def def def def def } == != < > <= >= (x: (x: (x: (x: (x: (x: Boolean) Boolean) Boolean) Boolean) Boolean) Boolean) : : : : : : Boolean Boolean Boolean Boolean Boolean Boolean 38 Classes e Objetos Boleanos podem ser definidos usando somente classes e objetos, sem referência ao tipos básicos para boleanos ou números. Uma possível implementação da claro Boolean é dada abaixo. Esta não é a implementação atual na biblioteca padrão Scala. Por razões de eficiência a implementação padrão usa boleanos primitivos. package scala abstract class Boolean { def ifThenElse(thenpart: => Boolean, elsepart: => Boolean) def && (x: => Boolean): Boolean def || (x: => Boolean): Boolean def ! : Boolean def def def def def def == != < > <= >= (x: (x: (x: (x: (x: (x: Boolean) Boolean) Boolean) Boolean) Boolean) Boolean) : : : : : : Boolean Boolean Boolean Boolean Boolean Boolean = = = ifThenElse(x, false) ifThenElse(true, x) ifThenElse(false, true) = = = = = = ifThenElse(x, x.!) ifThenElse(x.!, x) ifThenElse(false, x) ifThenElse(x.!, false) ifThenElse(x, true) ifThenElse(true, x.!) } case object True extends Boolean { def ifThenElse(t: => Boolean, e: => Boolean) = t } case object False extends Boolean { def ifThenElse(t: => Boolean, e: => Boolean) = e } Aqui está uma especificação parcial da classe Int. package scala abstract class Int extends AnyVal { def toLong: Long def toFloat: Float def toDouble: Double def def def def + + + + (that: (that: (that: (that: Double): Double Float): Float Long): Long Int): Int // analogo para -, *, /, % def << (cnt: Int): Int // analogo para def & (that: Long): Long def & (that: Int): Int // analogo para |, ^ def == (that: Double): Boolean def == (that: Float): Boolean >>, >>> 39 def == (that: Long): Boolean // analogo para !=, <, >, <=, >= } A classe Int pode em princípio também ser implementada usando apenas objetos e classes, sem referência a um tipo construído para inteiros. Para ver como, vamos considerar um problema um pouco mais simples, especificamente como implementar um tipo Nat de números naturais, ou seja, inteiros não negativo. Aqui está uma definição para uma classe abstrata Nat: abstract class Nat { def isZero: Boolean def predecessor: Nat def successor: Nat def + (that: Nat): Nat def - (that: Nat): Nat } Para implementar as operações da classe Nat, nós definimos um sub-objeto Zero e uma subclasse Succ (para sucessor). Cada número N é representado como N aplicações do construtor Succ até zero: new Succ( ... new Succ (Zero) ... ) {z } | N times A implementação do objeto Zero é direta: object Zero extends Nat { def isZero: Boolean = true def predecessor: Nat = error("negative number") def successor: Nat = new Succ(Zero) def + (that: Nat): Nat = that def - (that: Nat): Nat = if (that.isZero) Zero else error("negative number") } A implementação das funções predecessor e subtração sobre Zero leva a um Erro, que aborta o programa com uma mensagem de erro. Aqui está uma implementação da classe sucessor: class def def def def def } Succ(x: Nat) extends Nat { isZero: Boolean = false predecessor: Nat = x successor: Nat = new Succ(this) + (that: Nat): Nat = x + that.successor - (that: Nat): Nat = if (that.isZero) this else x - that.predecessor 40 Classes e Objetos Observe a implementação do método successor. Para criar o sucessor de um número, precisamos passar o próprio objeto como um argumento para o construtor Succ. O próprio objeto é referenciado por uma palavra reservada this. As implementações de + e -, cada uma contém uma chamada recursiva com o argumento do costrutor como destinatário. A recursão terminará assim que o destinatário for o objeto Zero (o que é garantido de acontecer eventualmente dado o modo com que os números são formados). Exercício 6.0.3 Escreva uma implementação Integer dos números inteiros. A implementação deve suportar todas as operações da classe Nat e mais dois métodos. def isPositive: Boolean def negate: Integer O primeiro método deve retornar true se o número for positivo. O segundo método deve negativar o número. Não utilize qualquer classe numérica padrão Scala em sua implementação. (Dica: Há duas formas para implementar Integer. Uma pode ou fazer uso da implementação existente de Nat, representando um inteiro como um número natural e um sinal. Ou pode-se generalizar a implementação dada de Nat para Integer, usando as três subclasses Zero para 0, Succ para números positivos e Pred para números negativos. Elementos da Linguagem Introduzidos Neste Capítulo Types: Type = ... | ident Tipos podem agora ser identificadores arbitrários que representam classes. Expressões: Expr = ... | Expr ’.’ ident | ’new’ Expr | ’this’ Uma expressão pode agora ser uma criação de objeto, ou uma seleção E.m de um membro m a partir de uma expressão valorada objeto E, ou pode ser a palavra reservada this. Definições e Declarações Def ClassDef TraitDef ObjectDef = FunDef | ValDef | ClassDef | TraitDef | ObjectDef = [’abstract’] ’class’ ident [’(’ [Parameters] ’)’] [’extends’ Expr] [‘{’ {TemplateDef} ‘}’] = ’trait’ ident [’extends’ Expr] [’{’ {TemplateDef} ’}’] = ’object’ ident [’extends’ Expr] [’{’ {ObjectDef} ’}’] 41 TemplateDef ObjectDef Modifier Dcl FunDcl ValDcl = = = = = = [Modifier] (Def | Dcl) [Modifier] Def ’private’ | ’override’ FunDcl | ValDcl ’def’ ident {’(’ [Parameters] ’)’} ’:’ Type ’val’ ident ’:’ Type Uma definição pode agora ser uma definição de classe, trait ou objeto, tal como class C(params) extends B { defs } trait T extends B { defs } object O extends B { defs } As definições defs na classe, trait ou objeto podem ser precedidas pelos modificadores private ou override. Classes abstratas e traits podem também conter declarações. Isso introduz funções deferred (postergadas) ou valores com seus tipos, mas não dão uma implementação. Membros deferred devem ser implementados nas subclasses antes que objetos de uma classe abstrata ou trait sejam criados. Capítulo 7 Classes Case e Casamento de Padrões Digamos, queremos escrever um interpretador para expressões aritméticas. Para deixar as coisas simples inicialmente, vamos nos restringir somente a números e operações +. Tais expressões podem ser representadas como hierarquia de classes, com uma classe abstrata base Expr como raiz, e duas subclasses Number e Sum. Então, uma expressão 1 + (3 + 7) é representada como new Sum(new Number(1), new Sum(new Number(3), new Number(7))) Agora, um avaliador de uma expressão como esta precisa saber de qual forma ela é (ou Sum ou Number) e também precisa acessar os componentes da expressão. A implementação a seguir provê todos os métodos necessários. abstract class Expr { def isNumber: Boolean def isSum: Boolean def numValue: Int def leftOp: Expr def rightOp: Expr } class Number(n: Int) extends Expr { def isNumber: Boolean = true def isSum: Boolean = false def numValue: Int = n def leftOp: Expr = error("Number.leftOp") def rightOp: Expr = error("Number.rightOp") } class Sum(e1: Expr, e2: Expr) extends Expr { def isNumber: Boolean = false 44 Classes Case e Casamento de Padrões def def def def isSum: Boolean = true numValue: Int = error("Sum.numValue") leftOp: Expr = e1 rightOp: Expr = e2 } Com esta classificação e métodos de acesso, escrever uma função avaliador é simples: def eval(e: Expr): Int = { if (e.isNumber) e.numValue else if (e.isSum) eval(e.leftOp) + eval(e.rightOp) else error("unrecognized expression kind") } Entretanto, definir todos esses métodos dentro das classes Sum e Number é um tanto quanto tedioso. Além do mais, o problema fica ainda pior se desejarmos adicionar novas formas de expressões. Por exemplo, considere adicionar uma nova forma de expressão Prod para produtos. Não apenas teremos de implementar uma nova classe Prod, com todos os métodos de acesso e classificação prévios; também teremos de introduzir um novo método abstrato isProduct dentro da classe Expr e implementar aquele método na subclasse Number, Sum, e Prod. Ter de modificar código existente quando um sistema cresce é sempre problemático, pois introduz problemas de versão e manutenção. A promessa da programação orientada a objetos é que tais modificações são desnecessárias, dado que podem ser evitadas pela reutilização de código existente e não modificado, através da herança. De fato, uma decomposição mais orientada a objetos para nosso problema resolve a questão. A idéia é tornar a operação de alto nível eval um método para cada classe expressão, ao invés de implementá-lo como uma função fora da hierarquia de classes expressões, como fizemos anteriormente. Como eval é agora um membro de todos os nós de expressão, quaisquer métodos de classificação e acesso tornam-se supérfluos, e a implementação é simplificada consideravelmente: abstract class Expr { def eval: Int } class Number(n: Int) extends Expr { def eval: Int = n } class Sum(e1: Expr, e2: Expr) extends Expr { def eval: Int = e1.eval + e2.eval } 45 Além disso, adicionar uma nova classe Prod não leva a qualquer mudança ao código existente: class Prod(e1: Expr, e2: Expr) extends Expr { def eval: Int = e1.eval * e2.eval } A conclusão que tiramos deste exemplo é que a decomposição orientada a objetos é a técnica de escolha para a construção de sistemas que devem ser estensíveis com novos tipos de dados. Mas há também um outro possível modo para estender a expressão exemplo. Podemos querer adicionar novas operações sobre expressões. Por exemplo, podemos querer adicionar uma operação que imprime uma árvore-expressão para a saída padrão. Se definimos todos os métodos de classificação e acesso, tal operação pode ser facilmente escrita como uma função externa. Aqui está um exemplo: def print(e: Expr) { if (e.isNumber) Console.print(e.numValue) else if (e.isSum) { Console.print("(") print(e.leftOp) Console.print("+") print(e.rightOp) Console.print(")") } else error("unrecognized expression kind") } Entretanto, se optamos por uma decomposição orientada a objetos das expressões, precisaremos adicionar um novo procedimento print para cada classe: abstract class Expr { def eval: Int def print } class Number(n: Int) extends Expr { def eval: Int = n def print { Console.print(n) } } class Sum(e1: Expr, e2: Expr) extends Expr { def eval: Int = e1.eval + e2.eval def print { Console.print("(") print(e1) Console.print("+") print(e2) Console.print(")") 46 Classes Case e Casamento de Padrões } } Consequentemente, decomposição orientada a objetos clássica requer modificação de todas as classes existentes quando um sistema for estendido com novas operações. Ainda como uma outra forma, podemos querer estender o interpretador. Considere a simplificação de expressões. Por exemplo, podemos querer criar uma função que reescreve expressões da forma a * b + a * c para a * (b + c). Esta operação requer inspeção de mais de um nó para a árvore de expressões ao mesmo tempo. Consequentemente, não pode ser implementada por um método dentro de cada tipo de expressão, a não ser que tal método também possa inspecionar outros nós. Portanto somos forçados a ter métodos de acesso e classificação neste caso. Isto parece nos levar para a casa inicial, com todos os problemas de estensibilidade e expressividade. Olhando mais de perto, observa-se que o único propósito das funções de acesso e classificação é reverter o processo de construção de dados. Elas nos permitem determinar, primeiro, qual subclasse de uma classe base abstrata foi usada e, segundo, quais foram os argumentos construtores. Como essa situação é bem comum, Scala tem um modo de automatizá-lo para classes case. 7.1 Classes Case e Objetos Case Classes Case e objetos case são definidos como classes e objetos normais, exceto que a definição é prefixada com um modificador case. Por exemplo, as definições: abstract class Expr case class Number(n: Int) extends Expr case class Sum(e1: Expr, e2: Expr) extends Expr introduzem Number e Sum como classes case. O modificador case em frente a uma definição de classe ou objeto tem os seguintes efeitos. 1. Classes case implicitamente vem com uma função construtora, com o mesmo nome da classe. No nosso exemplo, as duas funções def Number(n: Int) = new Number(n) def Sum(e1: Expr, e2: Expr) = new Sum(e1, e2) serão adicionadas. Consequentemente, pode-se agora árvores-expressões de modo um pouco mais conciso, como em Sum(Sum(Number(1), Number(2)), Number(3)) construir 7.1 Classes Case e Objetos Case 47 2. Classes case e objetos case implicitamente vem com implementações dos métodos toString, equals e hashCode, que sobrescrevem os métodos com o mesmo nome na classe AnyRef. A implementação desses métodos leva em consideração em cada caso a estrutura de um membro de uma classe case. O método toString representa uma árvore expressão do modo como foi construída. Então, Sum(Sum(Number(1), Number(2)), Number(3)) será convertida exatamente naquela cadeia de caracteres, onde a implementação default dentro da classe AnyRef retornará uma cadeia de caracteres consistindo construtor Sum mais externo e um número. O método equals trata dois membros case da classe case do mesmo modo, caso eles tenham sido construídos com o mesmo construtor e com argumentos que são par a par iguais. Isso também afeta a implementação de == e !=, que são implementados em termos de equals em Scala. Então, Sum(Number(1), Number(2)) == Sum(Number(1), Number(2)) dará true. Se Sum ou Number não fossem classes case, a mesma expressão seria false, pois a implementação padrão de equals na classe AnyRef sempre trata objetos criados por diferentes chamadas de construtores como sendo diferentes. O método hashCode segue a mesmo princípio dos outros dois métodos. Computa um código hash a partir do nome do construtor da classe case e os códigos hash dos argumentos do construtor, ao invés de o fazer a partir do endereço do objeto, que é o que a implementação default de hashCode faz. 3. Classes case implicitamente vem com métodos de acesso nulos que recuperam os argumentos do construtor. Em nosso exemplo, Number obteria um método de acesso def n: Int que retorna o parâmetro n do construtor, onde Sum obterá dois métodos de acesso def e1: Expr, e2: Expr Consequentemente, para um valor s de tipo Sum, digamos, podemos escrever s.e1 para acessar o operando esquerdo. Entretanto, para um valor e de tipo Expr, o termo e.e1 será ilegal, pois e1 é definido em Sum; não é um membro da classe base Expr. Então, como determinar o construtor e os argumentos do construtor de acesso para valores cujo tipo estático é a classe base Expr? 48 Classes Case e Casamento de Padrões Isso é resolvido pela quarta e última particularidade das classes case. 4. Classes case permitem construções de padrões que se referem ao construtor da classe case. 7.2 Casamento de Padrões O casamento de padrões é uma generalização do comando switch do C ou Java para hierarquias de classes. Ao invés de um comando switch, há um método padrão match, que é definido na classe raíz Scala Any, e portanto está disponível para todos os objetos. O método match recebe como argumento um número de cases. Por exemplo, aqui está uma implementação de eval usando casamento de padrões. def eval(e: Expr): Int = e match { case Number(n) => n case Sum(l, r) => eval(l) + eval(r) } Neste exemplo, há dois cases. Cada case associa um padrão a uma expressão. Padrões são casados contra o valor do seletor e. O primeiro padrão do nosso exemplo, Number(n), casa todos os valores da forma Number(v), onde v é um valor arbitrário. Naquele caso, a variável padrão n é ligada ao valor v. Similarmente, o padrão Sum(l, r) casa com todos os valores do seletor da forma Sum(v1 , v2 ) e liga as variáveis padrão l e r a v1 e v2 , respectivamente. Em geral, padrões são construídos a partir • Construtores de classes case, por exemplo Number, Sum, cujos argumentos são, novamente, padrões, • variáveis padrão, por exemplo n, e1, e2, • o padrão “coringa” _, • literais, tal como 1, true, "abc", • identificadores constantes, tais como MAXINT, EmptySet. Variáveis padrão sempre iniciam com uma letra minúscula, para que possamos distinguí-las de identificadores constantes, que iniciam com uma letra maiúscula. Cada nome de variável pode ocorrer somente uma vez em um padrão. Por exemplo, Sum(x, x) seria ilegal como padrão, pois a variável padrão x ocorre duas vezes dentro dele. Significado do Casamento de Padrões. Uma expressão de casamento de padrões e match { case p1 => e1 ... case pn => en } 7.2 Casamento de Padrões 49 casa os padrões p 1 , . . . , p n na ordem em que eles são escritos contra o valor seletor e. • Um construtor padrão C (p 1 , . . . , p n ) casa com todos os valores que são do tipo C (ou um subtipo dele) e que foram construídos com argumentos C casando com padrões p 1 , . . . , p n . • Uma variável padrão x casa com qualquer valor e liga o nome da variável àquele valor. • O caracter padrão ‘_’ casa com qualquer valor, mas não liga um nome àquele valor. • Um padrão constante C casa um valor que é igual (em termos de ==) para C. A expressão de casamento de padrões reescreve no lado direito do primeiro case cujo padrão casa com o valor seletor. Referências as variáveis padrão são substituídas pelos correspondentes argumentos construtores. Se nenhum dos padrões casar, a expressão de casamento de padrões é abortada com um erro MatchError. Exemplo 7.2.1 Nosso modelo de substituição de avaliação de programa estende de modo natural o casamento de padrões. Por exemplo, aqui temos como eval aplicado a uma única expressão é reescrito: eval(Sum(Number(1), Number(2))) -> (através da reescrita da aplicação) Sum(Number(1), Number(2)) match { case Number(n) => n case Sum(e1, e2) => eval(e1) + eval(e2) } -> (através da reescrita do casamento de padrões) eval(Number(1)) + eval(Number(2)) -> (através da reescrita da primeira aplicação) Number(1) match { case Number(n) => n case Sum(e1, e2) => eval(e1) + eval(e2) } + eval(Number(2)) -> (através da reescrita do casamento de padrões) 50 Classes Case e Casamento de Padrões 1 + eval(Number(2)) ->∗ 1 + 2 -> 3 Casamento de Padrões e Métodos. No exemplo anterior, usamos casamento de padrões numa função que era definida fora da hierarquia de classes da qual pertencia. De fato, é também possível definir uma função de casamento de padrões na sua própria hierarquia de classes. Por exemplo, podíamos ter definido eval como um método da classe base Expr, e ainda assim usar o casamento de padrões na sua implementação: abstract class Expr { def eval: Int = this match { case Number(n) => n case Sum(e1, e2) => e1.eval + e2.eval } } Exercício 7.2.2 Considere as seguintes definições representando árvores de inteiros. Estas definições podem ser vistas como uma representação alternativa para IntSet: abstract class IntTree case object EmptyTree extends IntTree case class Node(elem: Int, left: IntTree, right: IntTree) extends IntTree Complete as implementações a seguir da função contains e insert para IntTree. def contains(t: IntTree, v: Int): Boolean = t match { ... ... } def insert(t: IntTree, v: Int): IntTree = t match { ... ... } Funções Anônimas e Casamento de Padrões. Até aqui, expressões case sempre apareceram conjuntamente com uma operação match. Mas é também possível usar expressões case por elas mesmas. Um bloco de expressões case tal como { case P 1 => E 1 ... case P n => E n } é visto como uma função que casa seus argumentos contra os padrões P 1 , . . . , P n , e produz o resultado de um dos E 1 , . . . , E n . (Se nenhum padrão casar, a função 7.2 Casamento de Padrões 51 produzirá uma exceção MatchError. Em outras palavras, a expressão acima é vista como um atalho para a função anônima (x => x match { case P 1 => E 1 ... case P n => E n }) onde x é uma variável nova que não é usada a não ser dentro da expressão. Capítulo 8 Tipos Genéricos e Métodos Classes em Scala podem ter tipos como parâmetros. Demostraremos o uso de tipos parâmetros com pilhas funcionais como exemplo. Digamos, desejamos escrever um tipo de dados para pilhas de inteiros, com métodos push, top, pop, e isEmpty. Isso é conseguido pela seguinte hierarquia de classes: abstract class IntStack { def push(x: Int): IntStack = new IntNonEmptyStack(x, this) def isEmpty: Boolean def top: Int def pop: IntStack } class IntEmptyStack extends IntStack { def isEmpty = true def top = error("EmptyStack.top") def pop = error("EmptyStack.pop") } class IntNonEmptyStack(elem: Int, rest: IntStack) extends IntStack { def isEmpty = false def top = elem def pop = rest } De fato, faz sentido definir uma abstração para uma pilha de Strings. Para isso, pode-se tomar a abstração existente para IntStack, renomeá-la para StringStack e ao mesmo tempo renomear todas as ocorrências do tipo Int para String. Um modo melhor, que não leva a duplicação de código, é parametrizar as definições da pilha com o tipo do elemento. Parametrizações nos levam a generalizar a partir de uma instância específica de um problema para uma mais genérica. Até aqui, usamos parametrização somente para valores, mas também está disponível para tipos. Para obtermos uma versão genérica de Stack, a equiparemos com um 54 Tipos Genéricos e Métodos parâmetro tipo. abstract class Stack[A] { def push(x: A): Stack[A] = new NonEmptyStack[A](x, this) def isEmpty: Boolean def top: A def pop: Stack[A] } class EmptyStack[A] extends Stack[A] { def isEmpty = true def top = error("EmptyStack.top") def pop = error("EmptyStack.pop") } class NonEmptyStack[A](elem: A, rest: Stack[A]) extends Stack[A] { def isEmpty = false def top = elem def pop = rest } Nas definições acima, ‘A’ é um parâmetro tipo da classe Stack e suas subclasses. Parâmetros tipo são nomes arbitrários; eles são envolvidos por chaves ao invés de parênteses, portanto podem facilmente distinguidos de parâmetros valor. Aqui está um exemplo de como classes genéricas são usadas: val x = new EmptyStack[Int] val y = x.push(1).push(2) println(y.pop.top) A primeira linha cria uma nova pilha vazia de Int. Observe o tipo argumento [Int] que substitui o tipo parâmetro formal A. Também é possível parametrizar métodos com tipos. Como um exemplo, aqui está um método genérico que determina se uma pilha é um prefixo de outra. def isPrefix[A](p: Stack[A], s: Stack[A]): Boolean = { p.isEmpty || p.top == s.top && isPrefix[A](p.pop, s.pop) } Os parâmetros do método são chamados polimórficos. Métodos genéricos são também chamados polimórficos. O termo tem origem no Grego, onde significa “que tem muitas formas”. Para aplicar um método polimórfico tal como isPrefix, passamos parâmetros tipo, bem como parâmetros valor para ele. Por exemplo, val s1 = new EmptyStack[String].push("abc") val s2 = new EmptyStack[String].push("abx").push(s1.top) println(isPrefix[String](s1, s2)) 8.1 Parâmetros Tipo Ligados Inferência de Tipos Local. 55 Passar parâmetros de tipo tais como [Int] ou [String] o tempo todo pode tornar-se enfadonho em aplicações onde funções genéricas são muito utilizadas. Frequentemente, a informação dentro de um parâmetro de tipo é redundante, porque o parâmetro tipo correto pode também ser determinado pela inspeção dos parâmetros valores da função ou do tipo esperado do resultado. Tomando a expressão isPrefix[String](s1, s2) como um exemplo, sabemos que seus parâmetros valor são ambos do tipo Stack[String], portanto podemos deduzir que o parâmetro tipo deve ser String. Scala tem um poderoso mecanismo de inferência que nos permite omitir parâmetros tipo para funções polimórficas e construtores em situações como esta. No exemplo acima, poderíamos ter escrito isPrefix(s1, s2) e o tipo do argumento omitido [String] seria inserido pelo mecanismo de inferência de tipos. 8.1 Parâmetros Tipo Ligados Agora que sabemos como criar classes genéricas é natural generalizarmos algumas das classes escritas anteriormente. Por exemplo, a classe IntSet poderia ser generalizada para conjuntos com tipos arbitrários de elementos. Vamos tentar. A classe abstrata para conjuntos genéricos é facilmente escrita. abstract class Set[A] { def incl(x: A): Set[A] def contains(x: A): Boolean } Entretanto, se ainda quisermos implementar conjuntos como árvores binárias de busca, encontraremos um problema. Os métodos contains e incl, ambos comparam elementos usando métodos < e >. Para IntSet isto está OK, pois o tipo Int tem estes dois métodos. Mas para um tipo arbitrário de parâmetro a, não podemos garantir isso. Logo, a implementação anterior de, digamos, contains levará a um erro de compilação. def contains(x: Int): Boolean = if (x < elem) left contains x ^ < não é membro do tipo A. Um modo de resolver o problema é restringir os tipos legais que podem ser substituídos pelo tipo A àqueles que contenham os métodos < e > do tipos corretos. Na biblioteca de classes padrão do Scala há o trait Ordered[A] que representa valores que podem ser comparados (via < e >) a valores do tipo A. Esse trait é definido como segue: /** Uma classe com todos os dados ordenados. */ trait Ordered[A] { 56 Tipos Genéricos e Métodos /** * * * * */ def Resultado da comparacao de ‘this’ com o operando ‘that’. returna ‘x’ onde x < 0 iff this < that x == 0 iff this == that x > 0 iff this > that def def def def def < (that: A): Boolean = > (that: A): Boolean = <= (that: A): Boolean = >= (that: A): Boolean = compareTo(that: A): Int compare(that: A): Int (this compare that) (this compare that) (this compare that) (this compare that) = compare(that) < > <= >= 0 0 0 0 } Podemos forçar a compatibilidade de um tipo demandando que esse tipo seja subtipo de Ordered. Isto é feito dando um limite superior ao parâmetro tipo de Set: trait Set[A <: Ordered[A]] { def incl(x: A): Set[A] def contains(x: A): Boolean } A declaração de parâmetro A <: Ordered[A] introduz A como um parâmetro tipo que deve ser um subtipo de Ordered[A], ou seja, seus valores devem ser comparáveis a valores de mesmo tipo. Com esta restrição, podemos agora implementar o restante da abstração genérica de conjunto como fizemos anteriormente no caso de IntSet. class EmptySet[A <: Ordered[A]] extends Set[A] { def contains(x: A): Boolean = false def incl(x: A): Set[A] = new NonEmptySet(x, new EmptySet[A], new EmptySet[A]) } class NonEmptySet[A <: Ordered[A]] (elem: A, left: Set[A], right: Set[A]) extends Set[A] { def contains(x: A): Boolean = if (x < elem) left contains x else if (x > elem) right contains x else true def incl(x: A): Set[A] = if (x < elem) new NonEmptySet(elem, left incl x, right) else if (x > elem) new NonEmptySet(elem, left, right incl x) else this } 8.1 Parâmetros Tipo Ligados 57 Observe que deixamos de fora o tipo argumento na criações dos objetos new NonEmptySet(...). Do mesmo modo que para métodos polimórficos, tipos de argumentos faltantes nas chamadas de contrutores são inferidos a partir do valor dos argumentos e/ou o tipo esperado do resultado. Aqui está um exemplo que usa a abstração genérica de conjunto. Vamos primeiro criar uma subclasse de Ordered, como esta: case class Num(value: Double) extends Ordered[Num] { def compare(that: Num): Int = if (this.value < that.value) -1 else if (this.value > that.value) 1 else 0 } Então: val s = new EmptySet[Num].incl(Num(1.0)).incl(Num(2.0)) s.contains(Num(1.5)) Isto está OK, pois o tipo Num implementa o trait Ordered[Num]. Entretanto, o exemplo seguinte está errado. val s = new EmptySet[java.io.File] ^ java.io.File não implementa o tipo Ordered[java.io.File] definido no tipo do parâmetro. Um problema com ligações para parâmetros tipo é que elas requerem antecipação: se não declaramos Num uma subclasse deOrdered, não estaremos aptos a usar elementos Num dentro dos conjuntos. A partir do mesmo token, tipos herdados do Java, tais como Int, Double, ou String não são subclasses de Ordered, portanto valores destes tipos não podem ser usados como elementos de conjuntos. Um desenho mais flexível, que admite elementos destes tipos, usam ligações de visão ao invés de ligações plenas a tipos como temos visto. A única mudança que isto no leva no exemplo abaixo está nos parâmetros tipo: trait Set[A <% Ordered[A]] ... class EmptySet[A <% Ordered[A]] ... class NonEmptySet[A <% Ordered[A]] ... Ligações visão <% são mais fracas que ligações plenas <:: Uma ligação visão da cláusula do tipo parâmetro [A <% T] somente especifica que o tipo ligado A deve ser convertido ao tipo ligado T, usando uma conversão implícita. A biblioteca Scala predefine conversões implícitas para vários tipos, incluindo os tipos primitivos e String. Entretanto, o redesenho da abstração conjunto pode ser, do mesmo modo, instanciada com estes tipos. Mais explicações sobre conversões 58 Tipos Genéricos e Métodos implicitas e ligações visão são dadas na Seção 15. 8.2 Anotações de Variância A combinação de tipos parâmetros e subtipos levantam algumas questões interessantes. Por exemplo, Stack[String] deve ser um subtipo de Stack[AnyRef]? Intuitivamente, isto parece OK, pois uma pilha de Strings é um caso especial de uma pilha de AnyRefs. Mais genericamente, se T é um subtipo do tipo S, então, Stack[T] deve ser um subtipo de Stack[S]. Essa propriedade é chamada subtipificação co-variante. Em Scala, tipos genéricos tem por padrão subtipificação não variante. Ou seja, com Stack definido conforme acima, pilhas com tipos de elementos diferentes nunca estarão numa relação de subtipo. Entretanto, podemos forçar a subtipificação co-variante das pilhas mudando a primeira linha da definição da classe Stack como segue. class Stack[+A] { Prefixando um parâmetro tipo formal com um + indica que aquela subtipificação é covariante naquele parâmetro. Além do +, há também um prefixo - que indica subtipificação contravariante. Se Stack foi definida class Stack[-A] ..., então T, um subtipo do tipo S, poderia implicar que Stack[S] é um subtipo de Stack[T] (o que no caso de pilhas seria um tanto quanto surpreendente!). Em um mundo puramente funcional, todos os tipos podem ser covariantes. Entretanto, a situação muda quando introduzimos dados mutantes. Considere o caso de vetores em Java ou .NET. Tais vetores são representados em Scala por uma classe genérica Array. Aqui está uma definição parcial desta classe. class Array[A] { def apply(index: Int): A def update(index: Int, elem: A) } A classe acima define o modo que vetores em Scala são vistos a partir de programas Scala do usuário. O compilador Scala mapeará esta abstração aos vetores subjacentes do sistema hospedeiro sempre que possível. Em Java, vetores são, de fato, covariantes; ou seja, para os tipos referenciados T e S, se T é um subtipo de S, então Array[T] é um subtipo de Array[S]. Isso pode parecer natural, mas leva a problemas de segurança que requerem checagem especial em tempo de execução. Aqui está um exemplo: val x = new Array[String](1) val y: Array[Any] = x y(0) = new Rational(1, 2) // isto é syntactic sugar para 8.2 Anotações de Variância 59 // y.update(0, new Rational(1, 2)) Na primeira linha, um novo vetor de strings é criado. Na segunda linha, este vetor é ligado a uma variável y, de tipo Array[Any]. Assumindo vetores como covariantes, isto está OK, pois Array[String] é um subtipo de Array[Any]. Finalmente, na última linha, um número racional é guardado no vetor. Isso também está OK, pois o tipo Rational é um subtipo do tipo do elemento Any do vetor y. Acabamos por guardar um número racional em um vetor de strings, o que claramente viola a integridade do tipo. Java resolve este problema introduzindo checagem em tempo de execução na terceira linha que testa se o elemento guardado é compatível com o tipo de elemento para o qual o vetor foi criado. Vimos no exemplo que este tipo de elemento não é necessariamente o tipo estático de elemento do vetor que está sendo atualizado. Se o teste falhar, é dado um ArrayStoreException. Ao invés disto, Scala resolve este problema estáticamente, rejeitando a segunda linha em tempo de compilação, porque vetores em Scala tem subtipificação não variante. Isso nos leva a questão de como o compilador Scala verifica que anotações de variância são corretas. Se simplesmente declararmos vetores como covariantes, como detectar este potencial problema? Scala usa uma aproximação conservadora para verificar a integridade de anotações de variância. Um parâmetro tipo covariante de uma classe pode somente aparecer em posições covariantes dentro da classe. Apesar de posições covariantes serem tipos de valores na classe, o tipo resultante dos métodos na classe, e tipos argumentos para outros tipos covariantes. Não covariantes são tipos de parâmetros formais de métodos. Logo, a seguinte definição de classe seria rejeitada class Array[+A] { def apply(index: Int): A def update(index: Int, elem: A) ^ o tipo parâmetro covariante A aparece na posição contravariante. } Até aqui tudo bem. Intuitivamente, o compilador estava correto rejeitando o procedimento update na classe covariante, porque update potencialmente muda estado, e portanto mina a integridade da subtipificação covariante. Entretanto, há também métodos que não mudam estado, mas onde um parâmetro tipo ainda aparece contravariantemente. Como exemplo temos o push no tipo Stack. Novamente o compilador Scala rejeitará a definição deste método para pilhas covariantes. class Stack[+A] { def push(x: A): Stack[A] = ^ o tipo parâmetro covariante A 60 Tipos Genéricos e Métodos aparece na posição contravariante. Isto é uma pena, porque diferente de vetores, pilhas são estruturas de dados puramente funcionais e portanto devem habilitar a subtipificação covariante. Entretanto, há um modo de resolver o problema usando um método polimórfico com uma baixa ligação para tipos de parâmetros. 8.3 Lower Bounds Nós temos visto ligações fortes para tipos de parâmetros. Em uma declaração de tipo parâmetro tal como T <: U, o tipo parâmetro T é restrito ao intervalo somente sobre subtipos do tipo U. Simetrico a isso estão as ligações fracas em Scala. Em uma declaração de tipo parâmetro T >: S, o tipo parâmetro T está restrito ao intervalo somente sobre supertipos do tipo S. (Pode-se também combinar ligações fracas e fortes, como em T >: S <: U.) Usando ligações fracas, podemos generalizar o método push dentro de Stack como segue. class Stack[+A] { def push[B >: A](x: B): Stack[B] = new NonEmptyStack(x, this) Tecnicamente isso resolve nosso problema de variância, pois agora o tipo parâmetro A não mais aparece como um tipo parâmetro do método push. Ao invés, aparece como ligação fraca para um outro tipo parâmetro de um método, que é classificado como uma posição covariante. Logo, o compilador Scala aceita a nova definição de push. De fato, não apenas resolvemos o problema técnico da variância, mas também generalizamos a definição de push. Antes, só podíamos efetuar push em elementos com tipos que estivessem em conformidade com o tipo do elemento declarado da pilha. Agora, também podemos efetuar push sobre elementos de um supertipo deste tipo, mas o tipo da pilha retornada será alterado de acordo. Por exemplo, podemos agora efetuar push de AnyRef sobre uma pilha de Strings, mas a pilha resultante será uma pilha de AnyRefs ao invés de uma pilha de Strings! Em resumo, não devemos hesitar em adicionar anotações de variância às estruturas de dados, pois isso enriquece naturalmente relacionamentos de subtipificação. O compilador detectará problemas de integridade potenciais. Mesmo se a aproximação do compilador for muito conservadora, frequentemente sugerirá uma generalização útil do método contestado. 8.4 Tipos Minimais 8.4 61 Tipos Minimais Scala não nos permite parametrizar objetos com tipos. Este é o motivo pelo qual originalmente definimos uma classe genérica EmptyStack[A], ainda que um único valor denotando pilhas vazias de tipos arbitrários o fizesse. Para pilhas covariantes, entretanto, pode-se usar o seguinte idioma: object EmptyStack extends Stack[Nothing] { ... } O tipo base Nothing não contém valor, portanto o tipo Stack[Nothing] expressa o fato que uma EmptyStack não contém elementos. Além disso, Nothing é um subtipo de todos os outros tipos. Consequentemente, para pilhas covariantes, Stack[Nothing] é um subtipo de Stack[T], para qualquer outro tipo T. Isso torna possível usar um único objeto pilha vazia no código do usuário. Por exemplo: val s = EmptyStack.push("abc").push(new AnyRef()) Vamos analisar a atribuição de tipo para esta expressão em detalhes. O objeto EmptyStack é do tipo Stack[Nothing], o qual tem um método push[B >: Nothing](elem: B): Stack[B] . A inferência local de tipos determinará que o tipo parâmetro B deve ser instanciado para String na aplicação EmptyStack.push("abc"). O tipo resultado desta aplicação é, consequentemente, Stack[String], que por sua vez tem um método push[B >: String](elem: B): Stack[B] . A parte final da definição do valor acima é a aplicação deste método a new AnyRef(). A inferência local de tipos determinará que o tipo parâmetro b deve desta vez ser instanciado para AnyRef, com tipo resultado Stack[AnyRef]. Consequentemente, o tipo atribuído ao valor s é Stack[AnyRef]. Além de Nothing, que é um subtipo para cada outro tipo, há também o tipo Null, que é um subtipo de scala.AnyRef, e de cada classe derivada dele. O literal Null em Scala é o único valor deste tipo. Isto torna null compatível com cada tipo referência, mas não com um valor de tipo tal como Int. Concluímos esta seção com a definição completa melhorada de pilhas. Pilhas tem agora subtipificação covariante, o método push foi generalizado, e a pilha vazia é denotada por um único objeto. abstract class Stack[+A] { def push[B >: A](x: B): Stack[B] = new NonEmptyStack(x, this) def isEmpty: Boolean def top: A def pop: Stack[A] } 62 Tipos Genéricos e Métodos object EmptyStack extends Stack[Nothing] { def isEmpty = true def top = error("EmptyStack.top") def pop = error("EmptyStack.pop") } class NonEmptyStack[+A](elem: A, rest: Stack[A]) extends Stack[A] { def isEmpty = false def top = elem def pop = rest } Muitas classes na biblioteca Scala são genéricas. Agora apresentaremos duas comumente usadas famílias de classes genéricas, tuplas e funções. A discussão de uma outra classe bem comum, listas, é postergada para o próximo capítulo. 8.5 Tuplas Vez por outra, uma função precisa retornar mais de um resultado. Por exemplo, suponha a função divmod que retorna o quociente inteiro e o resto de dois argumentos inteiros dados. De fato, pode-se definir uma classe para pegar os dois resultados de divmod, como em: case class TwoInts(first: Int, second: Int) def divmod(x: Int, y: Int): TwoInts = new TwoInts(x / y, x % y) Entretanto, ter que definir uma nova classe para cada possível par de tipos de resultados é bastante tedioso. Em Scala pode-se usar, ao invés, uma classe genérica Tuple2, que é definida como segue: package scala case class Tuple2[A, B](_1: A, _2: B) Com Tuple2, o método divmod pode ser escrito como segue. def divmod(x: Int, y: Int) = new Tuple2[Int, Int](x / y, x % y) Como sempre, parâmetros tipos para construtores podem ser omitidos se forem dedutíveis a partir dos valores dos argumentos. Há também classes tuplas para cada outro número de elementos (a implementação Scala atual limita isto a tuplas de algum número razoável de elementos). Como os elementos de tuplas são acessados? Como tuplas são classes case, há duas possibilidades. Pode-se ou acessar os campos da tupla usando os nomes dos parâmetros dos construtores _i , como no seguinte exemplo: val xy = divmod(x, y) 8.6 Funções 63 println("quotient: " + xy._1 + ", rest: " + xy._2) Ou usa-se casamento de padrões sobre tuplas, como no seguinte exemplo: divmod(x, y) match { case Tuple2(n, d) => println("quotient: " + n + ", rest: " + d) } Observe que tipos parâmetros nunca são usados nos padrões; seria ilegal escrever case Tuple2[Int, Int](n, d). Tuplas são tão convenientes que Scala define uma sintaxe especial para elas. Para formar uma tupla com n elementos x 1 , . . . , x n pode-se escrever (x 1 , . . . , x n ). Isto é equivalente a Tuplen (x 1 , . . . , x n ). A sintaxe (...) funciona de modo equivalente para tipos e para padrões. Com esta sintaxe para tuplas, o exemplo divmod é escrito como segue: def divmod(x: Int, y: Int): (Int, Int) = (x / y, x % y) divmod(x, y) match { case (n, d) => println("quotient: " + n + ", rest: " + d) } 8.6 Funções Scala é uma linguagem funcional na qual funções são valores de primeira classe. Scala é também uma linguagem orientada a objetos na qual cada valor é um objeto. Segue daí que funções são objetos em Scala. Por exemplo, uma função do tipo String para o tipo Int é representada como uma instância do trait Funciton1[String, Int]. O trait Function1 é definido como segue. package scala trait Function1[-A, +B] { def apply(x: A): B } Ao lado de Funciton1, há também definições para funções de todas as outras aridades (a implementação corrente implementa isto somente até um limite razoável). Ou seja, há uma definição para cada possível número de parâmetros de funções. Sintaxe para tipos de funções em Scala (T1 , . . . , Tn ) => S é apenas uma abreviatura para o tipo parametrizado Functionn [T1 , . . . , Tn , S ] . Scala usa a mesma sintaxe f (x) para aplicações de funções, não importa se f é um método ou um objeto função. Isto é possível pela seguinte convenção: Uma aplicação de função f (x) onde f é um objeto (em contraste com um método) é tomado para ser um atalho para f .apply(x ). Consequentemente, o método apply 64 Tipos Genéricos e Métodos de um tipo de função é inserido automaticamente onde isso é necessário. Isso justifica o porquê definimos subscritos de vetores na Seção 8.2 através de um método apply. Para cada vetor a, a operação subscritora a(i) é tomada como um atalho para a.apply(i). Funções são exemplos em que uma declaração de um parâmetro tipo contravariante é útil. Por exemplo, considere o seguinte código: val f: (AnyRef => Int) val g: (String => Int) g("abc") = = x => x.hashCode() f É correto ligar o valor g de tipo String => Int a f, que é do tipo AnyRef => Int. De fato, tudo o que se pode fazer com uma função do tipo String => Int é passar-lhe uma string para se obter um inteiro. Isso demonstra que subtipificar funções é contravariante nos tipos dos argumentos, enquanto é covariante no tipo do seu resultado. Em resumo, S ⇒ T é um subtipo de S 0 ⇒ T 0 , desde que S 0 seja um subtipo de S e T seja um subtipo de T 0 . Exemplo 8.6.1 Considere o código Scala val plus1: (Int => Int) plus1(2) = (x: Int) => x + 1 Isso é expandido no seguinte código objeto. val plus1: Function1[Int, Int] = new Function1[Int, Int] { def apply(x: Int): Int = x + 1 } plus1.apply(2) Aqui, a criação do objeto new Function1[Int, Int]{ ... } representa uma instância de uma classe anônima. Combina a criação de um novo objeto Function1 com uma implementação do método apply (que é abstrato dentro de Function1). Equivalentemente, mas mais prolixo, podería-se usar uma classe local: val plus1: Function1[Int, Int] = { class Local extends Function1[Int, Int] { def apply(x: Int): Int = x + 1 } new Local: Function1[Int, Int] } plus1.apply(2) Capítulo 9 Listas Listas são uma importante estrutura de dados em muitos programas Scala. Uma lista contendo os elementos x1 , . . . , xn é escrita List(x1 , ..., xn ). Alguns exemplos: val val val val fruit nums diag3 empty = = = = List("apples", "oranges", "pears") List(1, 2, 3, 4) List(List(1, 0, 0), List(0, 1, 0), List(0, 0, 1)) List() Listas são similares a vetores em linguagens tais como C ou Java, mas há também três importantes diferenças. Primeiro, listas são imutáveis. Ou seja, elementos de uma lista não podem ser mudados por meio de atribuição. Segundo, listas tem uma estrutura recursiva, enquanto vetores são triviais. Terceiro, em geral, listas suportam um conjunto muito mais rico de operações que vetores. 9.1 Usando Listas O tipo lista. Do mesmo modo que com vetores, listas são homogêneas. Ou seja, os elementos de uma lista têm todos o mesmo tipo. O tipo de uma lista com elementos de tipo T é escrito List[T] (compare com T[] em Java). val val val val fruit: nums : diag3: empty: List[String] List[Int] List[List[Int]] List[Int] = = = = List("apples", "oranges", "pears") List(1, 2, 3, 4) List(List(1, 0, 0), List(0, 1, 0), List(0, 0, 1)) List() Construtores de listas. Todas as listas são construídas a partir de dois construtores fundamentais, Nil e :: (lê-se “cons”). Nil representa uma lista vazia. 66 Listas O operador infixo :: expressa a extensão da lista. Ou seja, x :: xs denota uma lista cujo primeiro elemento é x, e que é seguido pela (os elementos da) lista xs. Consequentemente, os valores da lista acima podem também ter sido definidos como segue (de fato essa definição prévia é apenas um facilitador sintático para as definições abaixo). val fruit val nums val diag3 val empty = "apples" :: ("oranges" :: ("pears" :: Nil)) = 1 :: (2 :: (3 :: (4 :: Nil))) = (1 :: (0 :: (0 :: Nil))) :: (0 :: (1 :: (0 :: Nil))) :: (0 :: (0 :: (1 :: Nil))) :: Nil = Nil A operação ‘::’ é associativa à direita: A :: B :: C é interpretada como A :: (B :: C). Por essa razão, podemos retirar os parênteses das definições acima. Por exemplo, podemos escrever de modo resumido val nums = 1 :: 2 :: 3 :: 4 :: Nil Operações básicas sobre listas. Todas as operações sobre listas podem ser expressas em termos das três a seguir: head tail isEmpty retorna o primeiro elemento de uma lista, retorna a lista que consiste de todos os elementos exceto o primeiro elemento, retorna true se e só se a lista for vazia. Estas operações são definidas como métodos de objetos listas. Portanto as invocamos escolhendo da lista aqueles que sofrerão a operação. Exemplos: empty.isEmpty fruit.isEmpty fruit.head fruit.tail.head diag3.head = = = = = true false "apples" "oranges" List(1, 0, 0) Os métodos head e tail são definidos somente para listas não vazias. Quando selecionados para uma lista vazia, eles lançam uma exceção. Como um exemplo de como listas podem ser processadas, considere ordenar os elementos de uma lista de números em ordem crescente. Um modo simples de fazer isso é usar o insertion sort, que trabalha da seguinte maneira: Para ordenar uma lista não vazia com primeiro elemento x e resto xs, ordene o restante xs e insira o elemento x na posição correta do resultado. Ordenar uma lista vazia dará uma lista vazia. Em Scala temos o código: def isort(xs: List[Int]): List[Int] = 9.2 Definição da classe List I: Métodos de Primeira Ordem 67 if (xs.isEmpty) Nil else insert(xs.head, isort(xs.tail)) Exercício 9.1.1 Escreva a função faltante insert. Listas e padrões. De fato, :: é definido como uma classe case na biblioteca padrão Scala. Consequentemente, é possível decompor listas através de casamento de padrões, usando padrões compostos a partir dos construtores Nil e ::. Por exemplo, isort pode ser escrito aternativamente como segue. def isort(xs: List[Int]): List[Int] = xs match { case List() => List() case x :: xs1 => insert(x, isort(xs1)) } onde def insert(x: Int, xs: List[Int]): List[Int] = xs match { case List() => List(x) case y :: ys => if (x <= y) x :: xs else y :: insert(x, ys) } 9.2 Definição da classe List I: Métodos de Primeira Ordem Listas não são construídas em Scala; elas são definidas por uma classe abstrata List, que vem com duas subclasses para :: e Nil. A seguir apresentaremos um tour através da classe List. package scala abstract class List[+A] { List é uma classe abstrata, logo não pode-se definir elementos chamando o construtor de List vazia (ou seja, através de new List). A classe tem uma tipo parâmetro a. É covariante nesse parâmetro, o que significa que List[S] <: List[T] para todos os tipos S e T tal que S <: T. A classe está no pacote scala. Este pacote contém as mais importantes classes Scala. List define um número de métodos, que são explicados a seguir. Decompondo listas. Primeiro, há os três métodos básicos isEmpty, head, tail. Suas implementações em termos de casamento de padrões são diretas: def isEmpty: Boolean = this match { case Nil => true 68 Listas case x :: xs => false } def head: A = this match { case Nil => error("Nil.head") case x :: xs => x } def tail: List[A] = this match { case Nil => error("Nil.tail") case x :: xs => xs } A próxima função computa o tamanho de uma lista. def length: Int = this match { case Nil => 0 case x :: xs => 1 + xs.length } Exercício 9.2.1 Escreva uma versão recursiva de cauda de length. As próximas duas funções são o complemento para head e tail. def last: A def init: List[A] xs.last retorna o último elemento da lista xs, enquanto xs.init retorna todos os elementos de xs exceto o último. Ambas as funções tem de atravessar toda a lista, e são, portanto, menos eficientes que seus análogos head e tail. Aqui está a implementação de last. def last: A = this case Nil => case x :: Nil => case x :: xs => } match { error("Nil.last") x xs.last A implementação de init é análoga. As próximas três funções retornam um prefixo da lista, ou um sufixo, ou ambos. def take(n: Int): List[A] = if (n == 0 || isEmpty) Nil else head :: tail.take(n-1) def drop(n: Int): List[A] = if (n == 0 || isEmpty) this else tail.drop(n-1) def split(n: Int): (List[A], List[A]) = (take(n), drop(n)) 9.2 Definição da classe List I: Métodos de Primeira Ordem 69 (xs take n) retorna os primeiros n elementos da lista xs, ou a lista inteira, caso seu tamanho seja menor que n. (xs drop n) retorna todos os elementos de xs exceto os n primeiros. Finalmente, (xs split n) retorna um par consistindo das listas resultantes de xs take n e xs drop n. A próxima função retorna um elemento de uma dada posição na lista. É, portanto, análoga ao subscrito de vetor. Índices começam em 0. def apply(n: Int): A = drop(n).head O método apply tem um significado especial em Scala. Um objeto com um método apply pode ser aplicado a argumentos como se fosse uma função. Por exemplo, para pegar o terceiro elemento de uma lista xs, pode-se escrever ou xs.apply(3) ou xs(3)—a última expressão expande na primeira. Com take e drop, podemos extrair sublistas consistindo de elementos consecutivos da lista original. Para extrair a sublista xs m , . . . , xs n−1 da lista xs, use: xs.drop(m).take(n - m) Zipando listas. A próxima função combina duas listas em uma lista de pares. Dadas duas listas xs = List(x1 , ..., xn ) ys = List(y1 , ..., yn ) ,e , xs zip ys constrói a lista List((x1 , y1 ), ..., (xn , yn )). Se as duas listas tiverem tamanhos diferentes, a maior das duas é truncada. Aqui está a definição de zip—observe que trata-se de um método polimórfico. def zip[B](that: List[B]): List[(a,b)] = if (this.isEmpty || that.isEmpty) Nil else (this.head, that.head) :: (this.tail zip that.tail) Consing listas.. Como qualquer outro operador infixo, :: também é implementado como um método de um objeto. Neste caso, o objeto é a lista que é estendida. Isto é possível porque operadores terminados com um caracter ‘:’ são tratados de modo especial em Scala. Todos esses operadores são tratados como métodos de seus operandos direitos. Ou seja, x :: y = y.::(x) enquanto que x + y = x.+(y) Observe, entretanto, que operandos de uma operação binária são em cada caso avaliados da esquerda para a direita. Logo, se D e E são expressões com possíveis efeitos colaterais, D :: E é traduzido para {val x = D; E.::(x)} de modo a manter a ordem esquerda-para-direita da avaliação dos operandos. 70 Listas Outra diferença entre operadores terminando com um ‘:’ e outros operadores é concernente à associatividade. Operadores terminados com ‘:’ são associativos à direita, enquanto outros operadores são associativos à esquerda. Isto é, x :: y :: z = x :: (y :: z) enquanto que x + y + z = (x + y) + z A definição de :: como um método na classe List é a seguinte: def ::[B >: A](x: B): List[B] = new scala.::(x, this) Observe que :: é definido para todos os elementos x de tipo B e listas do tipo List[A] tais como o tipo B de x é um supertipo dos elementos da lista de tipo A. O resultado neste caso é uma lista de Bs. Isto é expresso pelo tipo parâmetro B com ligação fraca A na assinatura de ::. Concatenando listas. Uma operação similar a :: é a concatenação de listas, escrita ‘:::’. O resultado de (xs ::: ys) é uma lista consistindo de todos os elementos de xs, seguidos para todos os elementos de ys. Como termina em dois pontos, ::: é associativo à direita e é considerado método de seu operando direito. Por isso, xs ::: ys ::: zs = = xs ::: (ys ::: zs) zs.:::(ys).:::(xs) Aqui está a implementação do método :::: def :::[B >: A](prefix: List[B]): List[B] = prefix match { case Nil => this case p :: ps => this.:::(ps).::(p) } Invertendo listas. Outra operação útil é a inversão de lista. Há um método reverse em List que tem esse efeito. Vamos tentar dar a implementação: def reverse[A](xs: List[A]): List[A] = xs match { case Nil => Nil case x :: xs => reverse(xs) ::: List(x) } Esta implementação tem a vantagem de ser mais simples, mas não é muito eficiente. Na verdade, uma concatenação é feita para cada elemento da lista. Concatenação de listas leva tempo proporcional ao tamanho do seu primeiro operando. Consequentemente, a complexidade de reverse(xs) é n + (n − 1) + ... + 1 = n(n + 1)/2 9.3 Exemplo: Merge sort 71 onde n é o tamanho de xs. Pode-se implementar reverse mais eficientemente? Veremos mais tarde que há uma outra implementação que tem complexidade linear. 9.3 Exemplo: Merge sort O insertion sort apresentado anteriormente neste capítulo é simples de formular, mas também não é muito eficiente. Sua complexidade média é proporcional ao quadrado do tamanho de sua lista de entrata. Agora escreveremos um programa para ordenar os elementos de uma lista que é mais eficiente que o insertion sort. Um bom algoritmo para isso é merge sort, que trabalha do seguinte modo. Primeiro, se a lista tem zero ou um elementos, já está ordenada, logo retornamos a lista sem modificações. Listas mais longas são divididas em duas sublistas, cada uma contendo por volta de metade dos elementos da lista original. Cada sublista é ordenada através de uma chamada recursiva para a função de ordenação, e as duas listas ordenadas resultantes são então combinadas em uma operação merge. Para uma implementação geral do merge sort, ainda temos que especificar o tipo dos elementos da lista a ser ordenada, bem como a função a ser usada na comparação dos elementos. Obtemos uma função de generalidade maximal passando estes dois itens como parâmetros. Isto leva a seguinte implementação. def msort[A](less: (A, A) => Boolean)(xs: List[A]): List[A] = { def merge(xs1: List[A], xs2: List[A]): List[A] = if (xs1.isEmpty) xs2 else if (xs2.isEmpty) xs1 else if (less(xs1.head, xs2.head)) xs1.head :: merge(xs1.tail, xs2) else xs2.head :: merge(xs1, xs2.tail) val n = xs.length/2 if (n == 0) xs else merge(msort(less)(xs take n), msort(less)(xs drop n)) } A complexidade do msort é O(N l og (N )), onde N é o tamanho da lista de entrada. Para ver porque, observe que dividir uma lista em duas e intercalar as duas listas ordenadas leva tempo proporcional ao tamanho das listas argumentos. Cada chamada recursiva de msort reduz a metade o número de elementos na sua entrada, logo há O(l og (N )) chamadas recursivas consecutivas, até que o caso base das listas de tamanho 1 seja alcançado. Entretanto, para listas mais longas, cada chamada gera duas outras chamadas. Somando tudo acima obtemos que a cada nível O(l og (N )) de chamada, cada elemento da lista original toma parte em uma operação de divisão e em uma operação merge. Consequentemente, cada nível de chamada tem um custo proporcional total de O(N ). Como há O(l og (N )) níveis de chamada, obtemos um custo total de O(N l og (N )). Este custo não depende da 72 Listas distribuição inicial dos elementos na lista, portanto o pior caso tem o mesmo custo que o caso médio. Isto torna o merge sort um algoritmo atraente para ordenação de listas. Aqui está um exemplo de como msort é usado. msort((x: Int, y: Int) => x < y)(List(5, 7, 1, 3)) A definição de msort está currificada para tornar sua especialização com funções de comparação. Por exemplo, val intSort = msort((x: Int, y: Int) => x < y) val reverseSort = msort((x: Int, y: Int) => x > y) 9.4 Definição da classe List II: Métodos de Alta Ordem Os exemplos encontrados até aqui mostram que funções sobre listas frequentemente tem estruturas similares. Podemos identificar vários padrões de computação sobre listas, tais como: • transformar cada elemento de uma lista de algum modo. • extrair de uma lista todos os elementos que satisfaçam uma critério. • combinar os elementos de uma lista usando algum operador. Linguagens de programação funcional habilitam programadores a escrever funções genéricas que implementam padrões como estes por meio de funções de alta ordem. Agora discutiremos um conjunto comumente usado em funções de alta ordem, que são implementados como métodos dentro da classe List. Mapping sobre listas. Um operação comum é transformar cada elemento de uma lista e então retornar a lista de resultados. Por exemplo, para multiplicar cada elemento de uma lista por um dado fator. def scaleList(xs: List[Double], factor: Double): List[Double] = xs match { case Nil => xs case x :: xs1 => x * factor :: scaleList(xs1, factor) } Este padrão pode ser generalizado para o método map da classe List: abstract class List[A] { ... def map[B](f: A => B): List[B] = this match { case Nil => this case x :: xs => f(x) :: xs.map(f) } 9.4 Definição da classe List II: Métodos de Alta Ordem 73 Usando map, scaleList pode ser mais concisamente escrito como segue. def scaleList(xs: List[Double], factor: Double) = xs map (x => x * factor) Como outro exemplo, considere o problema de retornar uma dada coluna de uma matriz que é representada como uma lista de linhas, onde cada linha é novamente uma lista. Isto é feito através da seguinte função column. def column[A](xs: List[List[A]], index: Int): List[A] = xs map (row => row(index)) Um método similar a map é o método foreach que aplica uma dada função a todos os elementos de uma lista, mas não constrói uma lista de resultados. A função é assim aplicada somente por seu efeito colateral. foreach é definido como segue. def foreach(f: A => Unit) { this match { case Nil => () case x :: xs => f(x); xs.foreach(f) } } Esta função pode ser usada para imprimir todos os elementos de uma lista, por exemplo: xs foreach (x => println(x)) Exercício 9.4.1 Considere uma função que eleva ao quadrado todos os elementos de uma lista e retorna uma lista com os resultados. Complete as duas definições a seguir de squareList. def squareList(xs: List[Int]): List[Int] = xs match { case List() => ?? case y :: ys => ?? } def squareList(xs: List[Int]): List[Int] = xs map ?? Filtrando Listas. Outra operação comum seleciona de uma lista todos os elemento que satisfazem um dado critério. Por exemplo, para retornar uma lista de todos os elementos positivos de algumas listas dadas de inteiros: def posElems(xs: List[Int]): List[Int] = xs match { case Nil => xs case x :: xs1 => if (x > 0) x :: posElems(xs1) else posElems(xs1) 74 Listas } Este padrão é generalizado para o método filter da classe List: def filter(p: A => Boolean): List[A] = this match { case Nil => this case x :: xs => if (p(x)) x :: xs.filter(p) else xs.filter(p) } Usando filter, posElems pode ser mais concisamente escrito como segue. def posElems(xs: List[Int]): List[Int] = xs filter (x => x > 0) Uma operação relacionada a filtering é testar se todos os elementos de uma lista satisfazem uma dada condição. Dualmente, pode-se também estar interessado na questão se há um elemento em uma lista que satisfaz uma dada condição. Estas operações são incorporadas nas funções de alta ordem forall e exists da classe List. def forall(p: A => Boolean): Boolean = isEmpty || (p(head) && (tail forall p)) def exists(p: A => Boolean): Boolean = !isEmpty && (p(head) || (tail exists p)) Para ilustrar o uso de forall, considere a questão se um número é primo. Lembre que um número n é primo se puder ser dividido, sem resto, somente por um e por ele mesmo. A tradução mais direta desta definição testará se n dividido por todos os números de 2 até, mas excluindo, ele mesmo dá resto diferente de zero. Esta lista de números pode ser gerada usando uma função List.range que é definida no objeto List como segue. package scala object List { ... def range(from: Int, end: Int): List[Int] = if (from >= end) Nil else from :: range(from + 1, end) Por exemplo, List.range(2, n) gera a lista de todos os inteiros de 2 até, excluindo, n. A função isPrime pode agora ser defida como segue. def isPrime(n: Int) = List.range(2, n) forall (x => n % x != 0) Vemos que a definição matemática de primalidade pode ser traduzida diretamente em código Scala. Exercício: Defina forall e exists em termos de filter. 9.4 Definição da classe List II: Métodos de Alta Ordem 75 Desdobrando (folding) e Reduzindo Listas. Uma outra operação comum é combinar os elementos de uma lista com algum operador. Por exemplo: sum(List(x1 , ..., xn )) product(List(x1 , ..., xn )) = = 0 + x1 + ... + xn 1 * x1 * ... * xn De fato, podemos implementar ambas as funções por meio de um esquema recursivo: def sum(xs: List[Int]): Int = xs match { case Nil => 0 case y :: ys => y + sum(ys) } def product(xs: List[Int]): Int = xs match { case Nil => 1 case y :: ys => y * product(ys) } Mas também podemos usar a generalização deste esquema de programa incorporado no método reduceLeft da classe List. Este método insere um dado operador binário entre elementos adjacentes de uma dada lista. Ou seja, List(x1 , ..., xn ).reduceLeft(op) = (...(x1 op x2 ) op ... ) op xn Usando reduceLeft, podemos tornar o padrão comum emas sum e product aparente: def sum(xs: List[Int]) def product(xs: List[Int]) = = (0 :: xs) reduceLeft {(x, y) => x + y} (1 :: xs) reduceLeft {(x, y) => x * y} Aqui está a implementação de reduceLeft. def reduceLeft(op: (A, A) => A): A = this match { case Nil => error("Nil.reduceLeft") case x :: xs => (xs foldLeft x)(op) } def foldLeft[B](z: B)(op: (B, A) => B): B = this match { case Nil => z case x :: xs => (xs foldLeft op(z, x))(op) } } Vemos que o método reduceLeft é definido em termos de um outro método, geralmente útil, foldLeft. O último pega como parâmetro adicional um acumulador z, que é retornado quando foldLeft é aplicado sobre uma lista vazia. Ou seja, (List(x1 , ..., xn ) foldLeft z)(op) = (...(z op x1 ) op ... ) op xn 76 Listas Os métodos sum e product podem ser defidos alternativamente usando foldLeft: def sum(xs: List[Int]) def product(xs: List[Int]) = = (xs foldLeft 0) {(x, y) => x + y} (xs foldLeft 1) {(x, y) => x * y} FoldRight e ReduceRight. Aplicações de foldLeft e reduceLeft expandem para árvores inclinadas à esquerda. . Eles têm duals foldRight e reduceRight que produzem árvores inclinadas à direita. List(x1 , ..., xn ).reduceRight(op) = (List(x1 , ..., xn ) foldRight acc)(op) = x1 op ( ... (xn−1 op xn )...) x1 op ( ... (xn op acc)...) Estes são definidos como segue. def reduceRight(op: (A, A) => A): A = this match { case Nil => error("Nil.reduceRight") case x :: Nil => x case x :: xs => op(x, xs.reduceRight(op)) } def foldRight[B](z: B)(op: (A, B) => B): B = this match { case Nil => z case x :: xs => op(x, (xs foldRight z)(op)) } A classe List define também duas abreviaturas simbólicas para foldLeft e foldRight: def /:[B](z: B)(f: (B, A) => B): B = foldLeft(z)(f) def :\[B](z: B)(f: (A, B) => B): B = foldRight(z)(f) Os nomes dos métodos ilustram a inclinação à esquerda/direita das árvores das operações fold através de barra simples ou invertida. O : aponta em cada caso para a lista de argumentos enquanto o fim da barra aponta para o acumulador (ou: zero) argumento z. Ou seja, (z /: List(x1 , ..., xn ))(op) = (...(z op x1 ) op ... ) op xn (List(x1 , ..., xn ) :\ z)(op) = x1 op ( ... (xn op z)...) Através de operadores associativos e comutativos, /: e :\ são equivalentes (mesmo sabendo que podem ser diferentes em eficiência). Exercício 9.4.2 Considere o problema de escrever uma função flatten, que recebe uma lista de listas como argumentos. O resultado de flatten deve ser a concatenação de todos os elementos listas em uma única lista. Aqui está uma implementação deste método em termos de :\. def flatten[A](xs: List[List[A]]): List[A] = 9.4 Definição da classe List II: Métodos de Alta Ordem 77 (xs :\ (Nil: List[A])) {(x, xs) => x ::: xs} Considere substituir o corpo de flatten por ((Nil: List[A]) /: xs) ((xs, x) => xs ::: x) Qual a diferença na complexidade assintótica entre as duas versões de flatten? De fato flatten é predefinido junto com um conjunto de outras funções úteis no objeto chamado List na biblioteca padrão Scala. Pode ser acessado de um programa usuário chamando List.flatten. Observe que flatten não é um método da classe List—não faz sentido, pois aplica-se somente a listas de listas, não a todas as listas em geral. Novamente Inversão de Lista. Vimos na Seção 9.2 uma implementação do método reverse cujo tempo de execução era quadrático para o tamanho da lista a ser invertida. Agora desenvolveremos uma nova implementação de reverse, cujo custo é linear. A idéia é usar uma operação foldLeft baseada no seguinte programa scheme. class List[+A] { ... def reverse: List[A] = (z? /: this)(op?) Agora falta preencher z? e op?. Vamos tentar deduzir a partir dos exemplos. = = = = Nil Nil.reverse (z /: Nil)(op) (Nil foldLeft z)(op) z // // // // pela pelo pela pela especifica\c{c}\~{a}o template para reverse defini\c{c}\~{a}o de /: defini\c{c}\~{a}o de foldLeft Consequentemente, z? deve ser Nil. Para deduzir o segundo operando, vamos estudar o inverso de uma lista de tamanho um. = = = = List(x) List(x).reverse (Nil /: List(x))(op) (List(x) foldLeft Nil)(op) op(Nil, x) // // // // pela pelo pela pela especifica\c{c}\~{a}o template para reverse, com z = Nil defini\c{c}\~{a}o de /: defini\c{c}\~{a}o de foldLeft Consequentemente, op(Nil, x) é igual a List(x), o que é o mesmo que x :: Nil. Isto sugere pegar como op o operador :: com seus operandos trocados. Consequentemente, chegamos a seguinte implementação para reverse, que tem complexidade linear. def reverse: List[A] = ((Nil: List[A]) /: this) {(xs, x) => x :: xs} 78 Listas (Obs: O tipo de anotação para Nil é necessário para que a inferência de tipos funcione.) Exercício 9.4.3 Preencha as expressões faltantes para completar as seguintes definições de algumas operações básicas de manipulação de listas como operações fold. def mapFun[A, B](xs: List[A], f: A => B): List[B] = (xs :\ List[B]()){ ?? } def lengthFun[A](xs: List[A]): int = (0 /: xs){ ?? } Mapeamentos Aninhados. Podemos empregar funções de processamento de listas de alta ordem para expressar muita computação que, normalmente é expressa através de aninhamento de laços nas linguagens imperativas. Como exemplo, considere o seguinte problema: Dado um inteiro positivo n, encontre todos os pares de inteiros positivos i e j , onde 1 ≤ j < i < n tal que i + j seja primo. Por exemplo, se n = 7, os pares são i j i+j 2 3 4 4 5 6 6 1 2 1 3 2 1 5 3 5 5 7 7 7 11 Um modo natural de resolver este problema consiste em dois passos. Num primeiro passo, gera-se a sequência de todos os pares (i , j ) de inteiros tal que 1 ≤ j < i < n. Num segundo passo filtra-se, a partir desta sequência, todos os pares (i , j ) tal que i + j é primo. Examinando o primeiro passo em detalhe, um modo natural de gerar a sequência de pares consiste de três sub-passos. Primeiro, gera-se todos os inteiros entre 1 e n para i . Segundo, para cada inteiro i entre 1 e n, gera-se a lista de pares (i , 1) up to (i , i − 1). Isso pode ser conseguido através de uma combinação de range e map: List.range(1, i) map (x => (i, x)) Finalmente, combina-se todas as sublistas usando foldRight com :::. Juntando tudo dá a seguinte expressão: List.range(1, n) .map(i => List.range(1, i).map(x => (i, x))) .foldRight(List[(Int, Int)]()) {(xs, ys) => xs ::: ys} .filter(pair => isPrime(pair._1 + pair._2)) 9.5 Sumário 79 Flattening Maps. A combinação entre mapeamento e a concatenação das sublistas resultantes do mapeamento é tão comum que há um método especial para isto na classe List: abstract class List[+A] { ... def flatMap[B](f: A => List[B]): List[B] = this match { case Nil => Nil case x :: xs => f(x) ::: (xs flatMap f) } } Com flatMap, os expressão dos “pares cuja soma dá um primo” pode ser escrita de modo mais sucinto como segue. List.range(1, n) .flatMap(i => List.range(1, i).map(x => (i, x))) .filter(pair => isPrime(pair._1 + pair._2)) 9.5 Sumário Este capítulo introduziu listas como uma estrutura de dados fundamental na programação. Como listas são imutáveis, elas são um tipo de dado comum na programação em linguagens funcionais. Elas têm importância comparável a vetores nas linguagens imperativas. Entretanto, o padrão de acesso é bem diferente entre os dois. Enquanto o acesso em vetores é sempre feito através de indexação, isto é muito incomum em listas. Nós vimos que scala.List define um método chamado apply para a indexação, entretanto, esta operação é muito mais custosa que no caso dos vetores (linear em comparação a tempo constante). Ao invés da indexação, listas são geralmente percorridas recursivamente, onde passos recursivos são em geral baseados em casamento de padrões sobre a lista percorrida. Há também um rico conjunto de combinadores de alta ordem que permitem a instanciação de um conjunto de padrões pré-definidos de computações sobre listas. Capítulo 10 For-Comprehensions O capítulo anterior mostrou que funções de alta ordem, tais como map, flatMap, filter provém poderosas construções para lidar com listas. Mas algumas vezes o nível de abstração requerido por estas funções tornam um programa difícil de entender. Para ajudar a inteligibilidade, Scala tem uma notação especial que simplifica padrões comuns de aplicações de funções de alta ordem. Esta notação cria uma ponte entre abrangência de conjuntos (set-comprehensions) na matemática e laços for em linguagens imperativas tais como C ou Java. Também lembram de perto a notação de pesquisa em bases de dados relacionais. Como um primeiro exemplo, digamos que nos é dada uma lista persons de pessoas com campos name e age (idade). Para imprimir os nomes de todas as pessoas na sequência para idades acima de 20, pode-se escrever: for (p <- persons if p.age > 20) yield p.name Isto é equivalente à seguinte expressão, que usa as funções de alta ordem filter e map: persons filter (p => p.age > 20) map (p => p.name) A abrangência for (for-comprehension) parece um pouco com um laço for nas linguagens imperativas, exceto que constrói uma lista de resultados de todas iterações. Geralmente, um for-comprehension tem a forma for ( s ) yield e Aqui, s é uma sequência de geradores, definições e filtros. Um gerador tem a forma val x <- e, onde e é uma expressão avaliada como lista. Liga x a sucessivos valores na lista. Uma definição tem a forma val x = e. Introduz x como um nome para o 82 For-Comprehensions valor de e no restante da abrangência. Um filtro é uma expressão f do tipo Boolean. Omite da consideração todas as ligações para as quais f é falso. A sequência s começa em caad case com um gerador. Se houver vários geradores na sequência, os geradores subsequentes variarão mais rapidamente que os anteriores. A sequência s pode também ser envolvida em chaves ao invés de parênteses, e os dois pontos entre geradores, definições e filtros podem ser omitidos. Aqui estão dois exemplos que mostram como for-comprehensions são usados. Primeiro, vamos refazer um exemplo do capítulo anterior: Dado um inteiro positivo n, encontre todos os pares de inteiros positivos i e j , onde 1 ≤ j < i < n such that i + j é primo. Com um for-comprehension este problema é resolvido como segue: for { i <- List.range(1, n) j <- List.range(1, i) if isPrime(i+j) } yield {i, j} Isto é discutivelmente mais claro que a solução usando map, flatMap e filter que desenvolvemos previamente. Como segundo exemplo, considere calcular o produto escalar de dois vetores xs e ys. Usando um for-comprehension, isto pode ser escrito como segue. sum(for ((x, y) <- xs zip ys) yield x * y) 10.1 O Problema das N-Rainhas For-comprehensions são especialmente úteis para resolver puzzles combinatórios. Um exemplo é o problema das 8-rainhas: Dado um tabuleiro de xadrez padrão, coloque 8 rainhas, tal que nenhuma rainha esteja atacada por nenhuma outra (uma rainha pode atacar qualquer outra peça se estiver na mesma coluna, linha ou diagonal que a mesma). Desenvolveremos agora uma solução para este problema, generalizando para tabuleiros de xadrez de tamanho arbitrário. Consequentemente, o problema é colocar n rainhas em um tabuleiro de xadrez de tamanho n × n. Para resolver este problema, observe que precisamos colocar uma rainha em cada linha. Logo podemos colocar rainhas em linhas sucessivas, cada vez checando que uma rainha recentemente colocada não esteja atacada por qualquer outra rainha que já se encontra no tabuleiro. No curso desta busca, pode acontecer que uma rainha a ser colocada na linha k esteja atacada em todas as casas desta linha por rainhas rainhas das linhas 1 até k −1. Neste caso, precisamos interromper esta parte da busca e continuar com uma configuração diferente de rainhas nas colunas 1 até k − 1. Isso sugere um algoritmo recursivo. Assuma que já geramos todas as soluções para colocar k − 1 rainhas em um tabuleiro de tamanho n × n. Podemos representar tal 10.2 Pesquisando com For-Comprehensions 83 solução por uma lista de tamanho k − 1 de números das colunas (no intervalo de 1 a n). Tratamos estas listas de soluções parciais como pilhas, onde o número da coluna da rainha na linha k − 1 vem primeiro dentro da lista, seguido pelo número da coluna da rainha na linha k − 2 etc. O fundo da pilha é o número da coluna da rainha colocada na primeira linha do tabuleiro. Todas as soluções juntas são então representadas como uma lista de listas, com um elemento para cada solução. Agora, para colocar a k-ésima rainha, geramos todas as possíveis extensões para cada solução prévia com uma rainha a mais. Isto leva a uma outra lista de listas soluções, desta vez de tamanho k. Continuamos o processo até atingirmos soluções do tamanho n do tabuleiro de xadrez. Esta idéia algorítmica é incorporada na função placeQueens abaixo: def queens(n: Int): List[List[Int]] = { def placeQueens(k: Int): List[List[Int]] = if (k == 0) List(List()) else for { queens <- placeQueens(k - 1) column <- List.range(1, n + 1) if isSafe(column, queens, 1) } yield column :: queens placeQueens(n) } Exercício 10.1.1 Escreva a função def isSafe(col: Int, queens: List[Int], delta: Int): Boolean que testa se uma rainha numa dada coluna col está segura com respeito às queens já colocadas. Aqui, delta é a diferença entre a linha da rainha a ser colocada e a linha da primeira rainha da lista. 10.2 Pesquisando com For-Comprehensions A notação for é essencialmente equivalente a operações comuns de linguagens de pesquisa de bases de dados. Por exemplo, digamos que temos uma base de dados books, representada como uma lista de livros, onde Book é definido como segue. case class Book(title: String, authors: List[String]) Aqui está um pequeno exemplo de base de dados: val books: List[Book] = List( Book("Structure and Interpretation of Computer Programs", List("Abelson, Harold", "Sussman, Gerald J.")), Book("Principles of Compiler Design", List("Aho, Alfred", "Ullman, Jeffrey")), Book("Programming in Modula-2", 84 For-Comprehensions List("Wirth, Niklaus")), Book("Introduction to Functional Programming"), List("Bird, Richard")), Book("The Java Language Specification", List("Gosling, James", "Joy, Bill", "Steele, Guy", "Bracha, Gilad"))) Então, para encontrar os títulos de todos os livros cujos autores tenham sobrenome “Ullman”: for (b <- books; a <- b.authors if a startsWith "Ullman") yield b.title (Aqui, startsWith é um método dentro em java.lang.String). Ou, para encontrar os títulos de todos os livros que tenham a cadeia de caracteres “Program” em seu título: for (b <- books if (b.title indexOf "Program") >= 0) yield b.title Ou, para encontrar os nomes de todos os autores que escreveram pelo menos dois livros, na base de dados. for (b1 <- books; b2 <- books if b1 != b2; a1 <- b1.authors; a2 <- b2.authors if a1 == a2) yield a1 A última solução ainda não é perfeita, porque autores aparecerão diversas vezes na lista de resultados. Ainda precisamos remover autores duplicados das listas resultantes. Isto pode ser obtido através da seguinte função. def removeDuplicates[A](xs: List[A]): List[A] = if (xs.isEmpty) xs else xs.head :: removeDuplicates(xs.tail filter (x => x != xs.head)) Observe que a última expressão no método removeDuplicates pode ser equivalentemente expresso usando um for-comprehension. xs.head :: removeDuplicates(for (x <- xs.tail if x != xs.head) yield x) 10.3 Tradução de For-Comprehensions Cada for-comprehension pode ser expresso em termos de três funções de alta-ordem: map, flatMap e filter. Aqui está o esquema de tradução, que também é usado pelo compilador Scala. • Um for-comprehension simples 10.3 Tradução de For-Comprehensions 85 for (x <- e) yield e’ é traduzido para e.map(x => e’) • Um for-comprehension for (x <- e if f; s) yield e’ onde f é um filtro e s é uma (possivelmente vazia) sequência de geradores ou filtros, é traduzida para for (x <- e.filter(x => f); s) yield e’ e então, a tradução continua com a última expressão. • Um for-comprehension for (x <- e; y <- e’; s) yield e’’ onde s é uma (possivelmente vazia) sequência de geradores ou filtros é traduzida para e.flatMap(x => for (y <- e’; s) yield e’’) e então, a tradução continua com a última expressão. Por exemplo, tomando nosso exemplo “pares de inteiros cuja soma é um primo”: for { i <- range(1, n) j <- range(1, i) if isPrime(i+j) } yield {i, j} Aqui está o que obtemos quando traduzimos esta expressão: range(1, n) .flatMap(i => range(1, i) .filter(j => isPrime(i+j)) .map(j => (i, j))) De modo inverso, também seria possível expressar as funções map, flatMap e filter usando for-comprehensions. Aqui estão as três funções novamente, desta vez implementadas usando for-comprehensions. object Demo { def map[A, B](xs: List[A], f: A => B): List[B] = 86 For-Comprehensions for (x <- xs) yield f(x) def flatMap[A, B](xs: List[A], f: A => List[B]): List[B] = for (x <- xs; y <- f(x)) yield y def filter[A](xs: List[A], p: A => Boolean): List[A] = for (x <- xs if p(x)) yield x } Não surpreendentemente, a tradução do for-comprehension no corpo de Demo.map produzirá uma chamada para map na classe List. Similarmente, Demo.flatMap e Demo.filter são traduzidos para flatMap e filter na classe List. Exercício 10.3.1 Defina a seguinte função em termos de for. def flatten[A](xss: List[List[A]]): List[A] = (xss :\ (Nil: List[A])) ((xs, ys) => xs ::: ys) Exercício 10.3.2 Traduza for (b <- books; a <- b.authors if a startsWith "Bird") yield b.title for (b <- books if (b.title indexOf "Program") >= 0) yield b.title para funções de alta ordem. 10.4 Laços For For-comprehensions lembram os laços for das linguagens imperativas, exceto que eles produzem uma lista de resultados. Algumas vezes, uma lista de resultados não é necessária, mas ainda gostaríamos da flexibilidade dos geradores e filtros nas iterações sobre listas. Isso é possível por uma variante da sintaxe dos for-comprehensions, os quais expressam laços for: for ( s ) e Esta construção é a mesma da sintaxe padrão do for-comprehension, exceto que a palavra chave yield está faltando. O laço for é executado pela execução da expressão e para cada elemento gerado da sequência de geradores e filtros s. Como um exemplo, a seguinte expressão imprime todos os elementos de uma matriz representada como uma lista de listas: for (xs <- xss) { for (x <- xs) print(x + "\t") println() } 10.5 Generalizando For 87 A tradução de laços for para métodos de alta ordem da classe List é similar à tradução de for-comprehensions, mas mais simples. Onde for-comprehensions são traduzidos para map e flatMap, laços for traduzem em cada caso para foreach. 10.5 Generalizando For Temos visto que a tradução de for-comprehensions somente ocorre na presença dos métodos map, flatMap, e filter. Portanto é possível aplicar a mesma notação para geradores que produzam outros objetos além de listas; tais objetos somente tem de atender as três funções-chave map, flatMap, e filter. A biblioteca padrão Scala tem diveras outras abstrações que suportam estes três métodos e com eles suportam for-comprehensions. Encontraremos alguns deles nos capítulos seguintes. Como programador você também pode usar este princípio para habilitar for-comprehensions para tipos que você definiu—estes tipos somente precisam suportar os métodos map, flatMap, e filter. Há muitos exemplos onde isto é útil: interfaces de base de dados, árvores XML, ou valores opcionais. Uma advertência: não há garantia automática que a tradução resultante de um for-comprehension seja bem tipada. Para garantir isso, os tipos de map, flatMap, e filter tem de ser essencialmente similares aos tipos desses métodos na classe List. Para tornar isto preciso, assuma que você tenha uma classe parametrizada C[A] para a qual você deseja habilitar for-comprehensions. Então C deve definir map, flatMap, e filter com os seguintes tipos: def map[B](f: A => B): C[B] def flatMap[B](f: A => C[B]): C[B] def filter(p: A => Boolean): C[A] Seria atrativo forçar estes tipos estáticamente dentro do compilador Scala, por exemplo, requerendo que qualquer tipo que suporte for-comprehensions implemente um trait padrão com estes métodos 1 . O problema é que tal trait padrão tem de abstrair sobre a identidade da classe C, por exemplo tomando C como um tipo parâmetro. Observe que este parâmetro deve ser um tipo construtor, que é aplicado a diversos diferentes tipos nas assinaturas dos métodos map e flatMap. Infelizmente, o sistema de tipos Scala é muito fraco para expressar este construtor, desde que pode lidar somente com tipos parâmetros que são tipos totalmente aplicados. 1 Na linguagem de programação Haskell, que tem construtores similares, esta abstração é chamada um “monada com zero” Capítulo 11 Estados Mutáveis A maioria dos programas apresentados até o momento não possui efeitos colaterais. 1 . Portanto, a noção de tempo não importou. Para um programa que termina, qualquer sequência de ações levará ao mesmo resultado! Isso também é refletido pelo modelo de computação de substituição, onde um passo de reescrita pode ser aplicado em qualquer lugar de um termo, e todas as reescritas que terminam levam a mesma solução. De fato, esta propriedade de confluência é um profundo resultado do cálculo λ, a teoria que embasa a programação funcional. Neste capítulo, introduziremos funções com efeitos colaterais e estudaremos seu comportamento. Veremos que como consequência temos fundamentalmente que modificar o modelo de substituição de computação empregado até aqui. 11.1 Objetos Mutáveis Normalmente vemos o mundo como um conjunto de objetos, alguns dos quais com estado que muda através do tempo. Normalmente, o estado é associado com um conjunto de variáveis que podem ser alteradas no curso de uma computação. Há também uma noção mais abstrata de estado, que não se refere a construções particulares de uma linguagem de programação: um objeto tem estado (ou: é mutável) se seu comportamento é influenciado por sua história. Por exemplo, uma objeto conta de banco tem estado, porque a questão “Eu posso sacar R$ 100?” pode ter diferentes respostas durante o tempo de vida da conta. Em Scala, todos os estados mutáveis são em última análise criados a partir das variáveis. Uma definição de variável é escrita como uma definição de valor, mas 1 Ignoramos o fato de que algums dos programas imprimem na tela, o que, tecnicamente, é um efeito colateral. 90 Estados Mutáveis começa com var ao invés de val. Por exemplo, os duas definições seguintes introduzem e inicializam duas variáveis x e count. var x: String = "abc" var count = 111 Como uma definição de valor, uma definição de variável associa um nome com um valor. Mas no caso da definição de variável, esta associação pode ser alterada posteriormente através de uma atribuição. Tais atribuições são escritas em C ou Java. Exemplos: x = "hello" count = count + 1 Em Scala, cada variável definida tem de ser inicializada no ponto de sua definição. Por exemplo, a declaração var x: Int; não é tida como uma definição de variável, porque o inicializador está ausente2 . Se não se sabe, ou não é importante, o inicializador apropriado, pode-se usar um caracter coringa no lugar. Ou seja, val x: T = _ inicializará x para algum valor default (null para tipos referência, false para boleanos, e a versão apropriada de 0 para valores de tipos numéricos). Objetos do mundo real com estado são representados em Scala através de objetos que tem variáveis como membros. Por exemplo, aqui está uma classe que representa contas bancárias. class BankAccount { private var balance = 0 def deposit(amount: Int) { if (amount > 0) balance += amount } def withdraw(amount: Int): Int = if (0 < amount && amount <= balance) { balance -= amount balance } else error("insufficient funds") } A classe define uma variável balance que contém o balanço corrente de uma conta. Métodos deposit e whithdraw mudam o valor desta variável através de atribuições. Observe que balance e private na classe BankAccount – consequentemente não 2 Se uma declaração como esta aparece numa classe, é tida como uma declaração de variável, que introduz métodos de acesso abstratos para a variável, mas não associa estes métodos com um pedaço do estado. 11.1 Objetos Mutáveis 91 pode ser acessado diretamente fora da classe. Para criar contas bancárias, usamos a notação usual para criação de objeto: val myAccount = new BankAccount Exemplo 11.1.1 Aqui está uma sessão scalaint que lida com contas bancárias. scala> :l bankaccount.scala Loading bankaccount.scala... defined class BankAccount scala> val account = new BankAccount account: BankAccount = BankAccount$class@1797795 scala> account deposit 50 unnamed0: Unit = () scala> account withdraw 20 unnamed1: Int = 30 scala> account withdraw 20 unnamed2: Int = 10 scala> account withdraw 15 java.lang.Error: insufficient funds at scala.Predef$error(Predef.scala:74) at BankAccount$class.withdraw(<console>:14) at <init>(<console>:5) scala> O exemplo mostra que aplicando a mesma operação (withdraw 20) duas vezes para uma conta gera resultados diferentes. Então, claramente, contas são objetos dinâmicos. Constância e Alteração. Atribuições colocam novos problemas na decisão de quando duas expressões são “a mesma”. Se atribuições são excluídas, e escrevemos val x = E; val y = E onde E é alguma expressão arbitrária, então x e y podem ser assumidos razoavelmente como sendo o mesmo. Ou seja, poderia-se equivalentemente escrever val x = E; val y = x (Esta propriedade é geralmente chamada transparência referencial). Mas uma vez que admitimos atribuições, as duas sequências de definições são diferentes. Considere: val x = new BankAccount; val y = new BankAccount 92 Estados Mutáveis Para responder a questão se x e y são o mesmo, precisamos ser mais precisos sobre o que significa constância. Este significado é capturado na noção de equivalência operacional, a qual, de modo um tanto quanto informal, é descrita como segue. Suponha que temos duas definições para x e y. Para testar se x e y definem o mesmo valor, proceda como segue. • Execute as definições seguidas para uma sequência arbitrária S de operações que envolvam x e y. Observe o resultado (se houver). • Então, execute as definições com uma outra sequência S’ resultante de S pela renomeação de todas as ocorrências de y em S para x. • Se os resultados da execução de S’ forem diferentes, então certamente x e y são diferentes. • Por outro lado, se todos os possíveis pares de sequências {S, S’} dão os mesmos resultados, então x e y são o mesmo. Em outras palavras, equivalência operacional levam duas definições x e y como definindo o mesmo valor, se nenhum experimento possível puder distinguir entre x e y. Um experimento neste contexto são duas versões de um programa arbitrário que usa ou x ou y. Dada esta definição, vamos testar se val x = new BankAccount; val y = new BankAccount definem valores x e y que são o mesmo. Aqui estão as definições novamente, seguidas por uma sequência de testes: > val x = new BankAccount > val y = new BankAccount > x deposit 30 30 > y withdraw 20 java.lang.RuntimeException: insufficient funds Agora, renomeie todas as ocorrências de y naquela sequência para x. Obtemos: > val x = new BankAccount > val y = new BankAccount > x deposit 30 30 > x withdraw 20 10 Como os resultados finais são diferentes, estabelecemos que x e y não são o mesmo. Por outro lado, se definimos 11.2 Estruturas Imperativas de Controle 93 val x = new BankAccount; val y = x então nenhuma sequência de operações pode distiguir entre x e y, portanto x e y são o mesmo neste caso. Atribuição e o Modelo de Substituição. Estes exemplos mostram que nosso modelo de substituição computacional não mais pode ser usado. Afinal de contas, sob este modelo nem sempre podemos substituir um valor de nome por sua expressão definida. Por exemplo em val x = new BankAccount; val y = x o x na definição de y pode ser substituído por new BankAccount. Mas vimos que esta alteração leva a programas distintos. Então o modelo de substituição deve ser inválido, uma vez que adicionemos atribuições. 11.2 Estruturas Imperativas de Controle Scala tem construções de laços while e do-while derivadas das linguagens de programação C e Java. Também há uma ramificação única if que deixa de fora a parte “else”, bem como uma declaração return que aborta uma função prematuramente. Isso torna possível programar no estilo imperativo convencional. Por exemplo, a seguinte função, que computa a enésima potência de um dado parâmetro x, é implementada usando while e um if simples. def power(x: Double, n: Int): Double = { var r = 1.0 var i = n var j = 0 while (j < 32) { r = r * r if (i < 0) r *= x i = i << 1 j += 1 } r } Estas construções de controle imperativo estão na linguagem por conveniência. Poderiam ser deixadas de fora, pois as mesmas construções podem ser implementadas usando apenas funções. Como exemplo, vamos desenvolver uma implementação funcional para o laço while. whileLoop deve ser uma função que recebe dois parâmetros: uma condição, de tipo Boolean, e um comando, de tipo 94 Estados Mutáveis Unit. Ambos, condição e comando, devem ser passados por nome, dado que serão avaliados repetidamente para cada iteração do laço. Isso leva a seguinte definição para whileLoop. def whileLoop(condition: => Boolean)(command: => Unit) { if (condition) { command; whileLoop(condition)(command) } else () } Observe que WhileLoop é uma recursão de cauda, logo opera em um espaço de pilha constante. Exercício 11.2.1 Escreva uma função repeatLoop, que deve ser aplicada como segue: repeatLoop { command } ( condition ) Há também um modo de se obter uma sintaxe de laço como a seguinte? repeatLoop { command } until ( condition ) Algumas outras construções de controle conhecidas do C e do Java estão ausentes em Scala: não há break e continue. Também não há laços “for” similares ao Java – estes foram substituídos por uma construção de laço “for” mais geral, discutida na Seção 10.4. 11.3 Exemplo Estendido: Simulação de Eventos Discretos Agora discutiremos um exemplo que demonstra como atribuições e funções de alta ordem podem ser combinados de maneiras interessantes. Construiremos um simulador para circuitos digitais. O exemplo é emprestado do livro do Abelson e Sussman [ASS96]. Nós expandimos o código básico (Scheme-) através de uma estrutura orientada a objetos que permite reuso de código por meio de herança. O exemplo também mostra como programas de simulação de eventos discretos em geral são estruturados e construídos. Começamos com uma pequena linguagem para descrever circuitos digitais. Um circuito digital é criado a partir de fios e caixas função. Fios carregam sinais que são transformados por caixas função. Representaremos sinais pelos boleanos true e false. Caixas função básicas (ou: portas) são: • Um inversor, o qual nega seu sinal. 11.3 Exemplo Estendido: Simulação de Eventos Discretos 95 • Uma porta E, o qual determina sua saída com base na conjunção da sua entrada. • Uma porta OU, o qual determina sua saída com base na disjunção da sua entrada. Outras portas lógicas pode ser construídas pela combinação das portas básicas. Portas tem delays, portanto a saída de uma porta somente mudará algum tempo após a mudança da sua entrada. Uma Linguagem para Circuitos Digitais. Descrevemos os elementos de um circuito digital através do seguinte conjunto de classes e funções Scala. Primeiro, há uma classe Wire para fios. Podemos construir fios como segue. val a = new Wire val b = new Wire val c = new Wire Segundo, há procedimentos def inverter(input: Wire, output: Wire) def andGate(a1: Wire, a2: Wire, output: Wire) def orGate(o1: Wire, o2: Wire, output: Wire) que “criam” as portas lógicas básicas que precisamos (como efeitos colaterais). Portas mais complicadas pode agora ser construídas a partir destas. Por exemplo, para construir um circuito meia soma, podemos definir: def halfAdder(a: Wire, b: Wire, s: Wire, c: Wire) { val d = new Wire val e = new Wire orGate(a, b, d) andGate(a, b, c) inverter(c, e) andGate(d, e, s) } Esta abstração pode ela mesma ser usada, por exemplo, na definição de um circuito soma completo: def fullAdder(a: Wire, b: Wire, cin: Wire, sum: Wire, cout: Wire) { val s = new Wire val c1 = new Wire val c2 = new Wire halfAdder(a, cin, s, c1) halfAdder(b, s, sum, c2) 96 Estados Mutáveis orGate(c1, c2, cout) } A classe Wire e as funções inverter, andGate, e orGate representam, portanto, uma pequena linguagem na qual usuários podem definir circuitos digitais. Agora damos implementações destas classes e funções, que nos permite simalar circuitos. Essa implementações são baseadas numa API simples e geral para simulação de eventos discretos. A API Simulação. A simulação de eventos discretos realiza ações definidas pelo usuário em tempos específicos. Uma ação é representada como uma função que não recebe parâmetros e retorna um resultado Unit: type Action = () => Unit O tempo é simulado; não é o horário atual do “relógio de parede”. Uma simulação concreta será efetuada dentro do objeto que herda a classe abstrata Simulation. Esta classe tem a seguinte assinatura: abstract class Simulation { def currentTime: Int def afterDelay(delay: Int, action: => Action) def run() } Aqui, currentTime retorna o tempo corrente como um número inteiro, afterDelay agenda uma ação para ser realizada com um atraso específico após currentTime, e run executa a simulação até que não haja mais ações a serem realizadas. A Classe Fio (Wire). Um fio precisa atender a três ações básicas. getSignal: Boolean retorna o sinal corrente sobre o fio. setSignal(sig: Boolean) atualiza o sinal do fio para sig. addAction(p: Action) junta o procedimento especificado p nas ações do fio. Todos os procedimentos juntados às ações serão executados toda vez que o sinal do fio mudar. Aqui está uma implementação da classe Wire (fio): class Wire { private var sigVal = false private var actions: List[Action] = List() def getSignal = sigVal def setSignal(s: Boolean) = 11.3 Exemplo Estendido: Simulação de Eventos Discretos 97 if (s != sigVal) { sigVal = s actions.foreach(action => action()) } def addAction(a: Action) { actions = a :: actions; a() } } Duas variáveis privadas compõem o estado de um fio. A variável sigVal denota o sinal corrente, e a variável actions denota os procedimentos de ação atualmente juntados ao fio. A Classe Inversor. Implementamos um inversor instalando uma ação ao seu fio de entrada, ou seja, a ação que coloca a entrada negada sobre o sinal de saída. A ação precisa ter efeito nas unidades de tempo simulado InverterDelay após a entrada mudar. Isso sugere a seguinte implementação: def inverter(input: Wire, output: Wire) { def invertAction() { val inputSig = input.getSignal afterDelay(InverterDelay) { output setSignal !inputSig } } input addAction invertAction } A Classe And-Gate (Porta E). Portas E são implementadas analogamente a inversores. A ação de uma andGate é criar na saída a conjunção dos seus sinais de entrada. Isso deve ocorrer nas unidades de tempo simulado AndGateDelay após qualquer de suas duas entradas mudar. Consequentemente, temos a seguinte implementação: def andGate(a1: Wire, a2: Wire, output: Wire) { def andAction() { val a1Sig = a1.getSignal val a2Sig = a2.getSignal afterDelay(AndGateDelay) { output setSignal (a1Sig & a2Sig) } } a1 addAction andAction a2 addAction andAction } Exercício 11.3.1 Escreva a implementação da porta lógica OU (orGate). 98 Estados Mutáveis Exercício 11.3.2 Um outro modo de se definir uma porta OU é pela combinação de inversores e portas E. Defina uma função orGate em termos de andGate e inverter. Qual o tempo de atraso desta função? A Classe Simulação. Agora, só falta implementar a classe Simulation. A idéia é manter dentro de um objeto Simulation uma agenda de ações a serem realizadas. A agenda é representada como uma lista de pares de ações e os tempos a serem executadas. A lista agenda é ordenada, portanto ações que devem ocorrer mais cêdo precedem as que devem ocorrer depois. abstract class Simulation { case class WorkItem(time: Int, action: Action) private type Agenda = List[WorkItem] private var agenda: Agenda = List() Há também uma variável privada curtime para acompanhar o tempo simulado corrente. private var curtime = 0 Uma aplicação do método afterDelay(delay, block) insere o elemento WorkItem(currentTime + delay, () => block) dentro da lista agenda no lugar apropriado. private def insert(ag: Agenda, item: WorkItem): Agenda = if (ag.isEmpty || item.time < ag.head.time) item :: ag else ag.head :: insert(ag.tail, item) def afterDelay(delay: Int)(block: => Unit) { val item = WorkItem(currentTime + delay, () => block) agenda = insert(agenda, item) } Uma aplicação para o método run remove sucessivos elementos da agenda e realiza suas ações. Continua até que a agenda esteja vazia: private def next() { agenda match { case WorkItem(time, action) :: rest => agenda = rest; curtime = time; action() case List() => } } def run() { afterDelay(0) { println("*** simulation started ***") } 11.4 Sumário 99 while (!agenda.isEmpty) next() } Executando o Simulador. Para executar o simulador, ainda precisamos de um modo de inspecionar mudanças de sinais sobre os fios. Para este propósito, escrevemos uma função probe (sonda). def probe(name: String, wire: Wire) { wire addAction { () => println(name + " " + currentTime + " new_value = " + wire.getSignal) } } Agora, para vermos o simulador em ação, vamos definir quatro fios, e colocar duas sondas sobre dois deles: scala> val input1, input2, sum, carry = new Wire scala> probe("sum", sum) sum 0 new_value = false scala> probe("carry", carry) carry 0 new_value = false Agora vamos definir um circuito meia soma conectando os fios: scala> halfAdder(input1, input2, sum, carry) Finalmente, setamos um após o outro os sinais sobre os dois fios de entrada para true e rodamos a simulação. scala> input1 setSignal true; run *** simulation started *** sum 8 new_value = true scala> input2 setSignal true; run carry 11 new_value = true sum 15 new_value = false 11.4 Sumário Vimos neste capítulo as construções que nos permitiram modelar estados em Scala – que são variáveis, atribuições, e estruturas de controle imperativo. Estados e Atribuições complicam nosso modelo mental de computação. Em particular, a 100 Estados Mutáveis transparência referencial é perdida. Por outro lado, atribuição nos dá novos meios para formular programas elegantemente. Como sempre, isso depende de qual funciona melhor para uma dada situação: programas puramente funcionais ou programas com atribuições. Capítulo 12 Computando com Streams Os capítulos anteriores apresentaram variáveis, atribuição e objetos com estado. Vimos como objetos no mundo real mudam com o tempo e podem ser modelados através da mudança de estado das suas variáveis durante a computação. Desta maneira, mudanças no tempo no mundo real são modeladas por mudanças no tempo durante a execução do programa. Obviamente, usa-se uma escala e estas mudanças no tempo são alargadas ou comprimidas, mas sua ordem relativa permanece a mesma. Isto pode parecer natural, mas existe um preço a se pagar: o modelo de substituição usado na computação funcional não pode ser aplicado ao se introduzir variáveis e atribuições. Será que não existe uma outra maneira? Não seria possível modelar mudanças de estado no mundo real usando somente funções imutáveis? Tomando matemática como um guia, a resposta claramente é sim: um quantidade que muda no tempo é modelada através da função f(t) que tem como parâmetro t para representar o tempo. O mesmo pode ser feito na computação. Ao invés de sobre-escrever uma variável com sucessivos valores, podemos representar estes valores como sucessivos elementos em uma lista. Desta forma, a variável mutável var x: T pode ser substituída por uma variável imutável val x: List[T]. De certa maneira, troca-se espaço por tempo – os diferentes valores da variável agora existem concorrentemente como diferentes elementos da lista. Uma das vantagens do modelo baseado em lista é a possibilidade de “viajar no tempo”, ou seja, de ver sucessivos valores da variável ao mesmo tempo. Uma outra vantagem é que podemos fazer uso da poderosa biblioteca de funções processamento de listas, que normalmente simplifica a computação. Por exemplo, considere a forma imperativa de computar a soma de todos os primos em um intervalo: def sumPrimes(start: Int, end: Int): Int = { var i = start var acc = 0 while (i < end) { 102 Computando com Streams if (isPrime(i)) acc += i i += 1 } acc } Note que a variável i “passa por” todos os valores do intervalo [start .. end-1]. Uma forma mais funcional, é representar a lista de valores da variável i diretamente como range(start, end). Então a função pode ser re-escrita da seguinte maneira: def sumPrimes(start: Int, end: Int) = sum(range(start, end) filter isPrime) Não dúvida que o programa é mais curto e mais claro! Porém, o programa funcional é também consideravelmente menos eficiente, uma vez que ele constrói a lista de todos os número no intervalo e, posteriormente, constrói uma outra lista com os números primos. Ainda pior sob o ponto de vista da eficiência é o seguinte exemplo: Para encontrar o segundo número primio entre 1000 e 10000: range(1000, 10000) filter isPrime at 1 Aqui, a lista de todos os números entre 1000 e 10000 é construída, mas a maior parte da lista nunca é usada! No entanto, pode-se obter uma execução eficiente para exemplos como este usando um truque: Nunca compute o resto (tail) de uma sequência a não ser que o resto seja realmente necessário para a computação. Para isso, definimos uma nova classe para sequências, que é chamada Stream. Streams são criados usando a constante empty e o construtor cons, ambos definidos no módulo scala.Stream. Por exemplo, a seguinte expressão constrói um stream como os elementos 1 e 2: Stream.cons(1, Stream.cons(2, Stream.empty)) Como um outro exemplo, este é o análogo de List.range, mas que retorna um stream ao invés de uma lista: def range(start: Int, end: Int): Stream[Int] = if (start >= end) Stream.empty else Stream.cons(start, range(start + 1, end)) (Esta função também foi definida como mostrado anteriormente no módulo Stream). Embora Stream.range e List.range se pareçam, seu comportamento em tempo de execução é completamente diferente: 103 Stream.range retorna imediatamente um objeto do tipo Stream cujo primeiro elemento é start. Todos os outros elementos são computados somente quando eles são demandados através da chamada do método tail (o que pode nunca acontecer). Streams são acessados exatamente como listas. Similarmente às listas, os métodos básicos de acesso são isEmpty, head e tail. Por exemplo, podemos imprimir todos os elementos de um stream da seguinte maneira. def print(xs: Stream[A]) { if (!xs.isEmpty) { Console.println(xs.head); print(xs.tail) } } Streams também oferecem praticamente todos os outros métodos definidos nas listas (veja a seguir onde os conjuntos de métodos são diferentes). Por exemplo, podemos encontrar o segundo número primo entre 1000 e 10000 através do uso dos métodos filter e apply no stream que fornece o intervalo: Stream.range(1000, 10000) filter isPrime at 1 A diferença entre a implementação anterior que usada lista, é que agora não construímos nem testamos se é primo nenhum número maior que 1013. Retorno e concatenação de streams. Há dois métodos na classe List que não estão presentes na classe Stream. São eles :: e :::. O motivo é que estes métodos são chamados pelo argumento da direita, o que significa que este argumento precisa ser computado antes que o método seja chamado. Por exemplo, no caso de x :: xs nas listas, o resto xs precisa ser computado antes de :: ser chamado e a nova lista ser construída. Isto não funciona para streams, onde queremos que o resto de um stream não seja computado até que seja demandado pela chamada ao método tail. O argumento pelo qual a concatenação de listas ::: não pode ser adaptado para streams é análogo. Ao invés de x :: xs, pode-se usar Stream.cons(x, xs) para construir um stream com o primeiro elemento x e o resto (não computado). Ao invés de xs ::: ys, pode-se usar xs append ys. Capítulo 13 Iteradores Iteradores são uma versão imperativa dos streams. Assim como streams, iteradores descrevem listas potencialmente infinitas. No entanto, não existe uma estrutura de dados que contenha os elementos de um iterador. Ao invés disso, os iteradores permitem que se ande somente um passo na sequência de cada vez, usando os métodos abstratos next e hasNext. trait Iterator[+A] { def hasNext: Boolean def next: A O método next retorna sucessivos elementos. O método hasNext indica se existem mais elementos a serem retornados por next. Iteradores também possuem outros métodos que serão explicados posteriormente. Como exemplo, esta é uma aplicação que imprime os quadrados de todos os números de 1 à 100. val it: Iterator[Int] = Iterator.range(1, 100) while (it.hasNext) { val x = it.next println(x * x) } 13.1 Métodos dos Iteradores Os iteradores possuem um conjunto de métodos além de next e hasNext, que são descritos a seguir. Muitos destes métodos oferecem uma funcionalidade correspondente à do método equivalente em uma lista. 106 Iteradores Append. O método append constrói uma iterador que começa com o código dado de iteração it depois que o iterador atual tem terminado. def append[B >: A](that: Iterator[B]): Iterator[B] = new Iterator[B] { def hasNext = Iterator.this.hasNext || that.hasNext def next = if (Iterator.this.hasNext) Iterator.this.next else that.next } Os termos Iterator.this.next e Iterator.this.hasNext na definição de append chamam os métodos correspondentes definidos na classe Iterator que contém o método append. Se o prefixo Iterator não fosse adicionado ao this, hasNext e next chamariam recursivamente os métodos definidos no resultado de append, o que não era o efeito desejado. Map, FlatMap, Foreach. O método map constrói um iterador que retorna todos os elementos do iterador original transformados por uma dada função f. def map[B](f: A => B): Iterator[B] = new Iterator[B] { def hasNext = Iterator.this.hasNext def next = f(Iterator.this.next) } O método flatMap é similar ao método map, exceto que a função de transformação f retorna um iterador ao invés de um elemento. O resultado de flatMap é um iterador resultante da anexação (append) de todos os iteradores resultantes das sucessivas chamadas de f. def flatMap[B](f: A => Iterator[B]): Iterator[B] = new Iterator[B] { private var cur: Iterator[B] = Iterator.empty def hasNext: Boolean = if (cur.hasNext) true else if (Iterator.this.hasNext) { cur = f(Iterator.this.next); hasNext } else false def next: B = if (cur.hasNext) cur.next else if (Iterator.this.hasNext) { cur = f(Iterator.this.next); next } else error("next on empty iterator") } Também relacionado à map, temos o método foreach que aplica uma dada função a todos elementos de um iterador, mas não constrói uma lista de resultados def foreach(f: A => Unit): Unit = while (hasNext) { f(next) } 13.1 Métodos dos Iteradores 107 Filter. O método filter constrói um iterador que retorna todos os elementos do iterador original que satisfazem um dado critério p. def filter(p: A => Boolean) = new BufferedIterator[A] { private val source = Iterator.this.buffered private def skip = { while (source.hasNext && !p(source.head)) { source.next } } def hasNext: Boolean = { skip; source.hasNext } def next: A = { skip; source.next } def head: A = { skip; source.head } } Na prática, filter retorna uma instância de uma sub-classe dos iteradores que trabalha com um “buffer”. Um objeto do tipo BufferedIterator é um iterador que tem adicionalmente um método head. Este método retorna um elemento que seria retornado pelo método next, porém não avança além daquele elemento. Com isso, o elemento retornado por head é retornado novamente pela próxima chamada de head ou next. Esta é a definição do trait BufferedIterator: trait BufferedIterator[+A] extends Iterator[A] { def head: A } Como map, flatMap, filter e foreach existem para iteradores, como consequência for-comprehensions e loops de for também podem ser usados em iteradores. Por exemplo, a aplicação que imprime os quadrados dos números entre 1 e 100 poderia ter sido expressada da seguinte forma: for (i <- Iterator.range(1, 100)) println(i * i) Zip. O método zip recebe um outro iterador e retorna um iterador que consiste de pares dos respectivos elementos retornados pelos dois iteradores. def zip[B](that: Iterator[B]) = new Iterator[(A, B)] { def hasNext = Iterator.this.hasNext && that.hasNext def next = (Iterator.this.next, that.next) } } 108 Iteradores 13.2 Construindo Iteradores As classes concretas de iteradores precisam implementar os dois métodos abstratos next e hasNext definidos na classe Iterator. O iterador mais simples é Iterator.empty que sempre retorna uma sequência vazia: object Iterator { object empty extends Iterator[Nothing] { def hasNext = false def next = error("next on empty iterator") } Um iterador um pouco mais interessante enumera todos os elementos de um vetor. Este iterador é construído a partir do método fromArray, que também foi definido no objeto Iterator def fromArray[A](xs: Array[A]) = new Iterator[A] { private var i = 0 def hasNext: Boolean = i < xs.length def next: A = if (i < xs.length) { val x = xs(i); i += 1; x } else error("next on empty iterator") } Um outro iterador enumera um intervalo de inteiros. A função Iterator.range retorna o iterador que caminha em um intervalo dado de valores inteiros. Sua definição é a seguinte: object Iterator { def range(start: Int, end: Int) = new Iterator[Int] { private var current = start def hasNext = current < end def next = { val r = current if (current < end) current += 1 else error("end of iterator") r } } } Todos os iteradores mostrados até agora tem um fim. Também é possível definir iteradores que nunca terminam. Por exemplo, o iterador a seguir retorna inteiros sucessivos a partir de um valor inicial. 1 . 1 Como a representação do tipo int é finita, os números acabaram em 231 . 13.3 Usando Iteradores 109 def from(start: Int) = new Iterator[Int] { private var last = start - 1 def hasNext = true def next = { last += 1; last } } 13.3 Usando Iteradores Existem mais 2 exemplos de como os iteradores são usados. Primeiro, para imprimir todos os elementos de um vetor xs: Array[Int], uma pessoa pode escrever: Iterator.fromArray(xs) foreach (x => println(x)) Ou, usando for-comprehension: for (x <- Iterator.fromArray(xs)) println(x) Como um segundo exemplo, considere o problema de encontrar os índices de todos os elementos de um vetor de doubles maiores que um dado limit. Os índices devem ser retornado na forma de um iterador. Isto pode ser obtido pela expressão: import Iterator._ fromArray(xs) .zip(from(0)) .filter(case (x, i) => x > limit) .map(case (x, i) => i) Ou usando for-comprehension: import Iterator._ for ((x, i) <- fromArray(xs) zip from(0); x > limit) yield i Capítulo 14 Valores Preguiçosos (Lazy) Valores preguiçosos oferecem uma maneira de postergar a inicialização de um valor até o primeiro momento em que seja acessado. Isto pode ser útil quando estiver lidando com valores que podem não ser necessários durante a execução e cujo custo computacional seja significativo. Como primeiro exemplo, vamos considerar um banco de dados de empregados contendo cada empregado e seu gestor e sua equipe. case class Employee(id: Int, name: String, managerId: Int) { val manager: Employee = Db.get(managerId) val team: List[Employee] = Db.team(id) } A classe Employee dada anteriormente irá tentar inicializar todos os seus campos, carregando todoa a tabela de empregados na memória. Isto certamente não é o ideal e pode ser melhorado como facilidade tornando os campos preguiçosos. Desta forma, atrasamos o acesso ao banco de dados até o momento em que ele seja realmente necessário, se isto ocorrer. case class Employee(id: Int, name: String, managerId: Int) { lazy val manager: Employee = Db.get(managerId) lazy val team: List[Employee] = Db.team(id) } Para ver o que realmente está acontecendo, podemos usar este banco de dados mock que mostra quando os registros são acessados: object Db { 112 Valores Preguiçosos (Lazy) val table = Map(1 2 3 4 5 -> -> -> -> -> (1, (2, (3, (4, (5, "Haruki Murakami", -1), "Milan Kundera", 1), "Jeffrey Eugenides", 1), "Mario Vargas Llosa", 1), "Julian Barnes", 2)) def team(id: Int) = { for (rec <- table.values.toList; if rec._3 == id) yield recToEmployee(rec) } def get(id: Int) = recToEmployee(table(id)) private def recToEmployee(rec: (Int, String, Int)) = { println("[db] fetching " + rec._1) Employee(rec._1, rec._2, rec._3) } } Ao rodar o programa, a saída confirma que ele retorna um empregado e que o banco somente é acessado quando é feita uma referência ao valor preguiçoso. Um outro uso dos valores preguiçosos é para resolver a ordem de inicialização de aplicações compostas de muitos módulos. Antes dos valores preguiçosos serem criados, o mesmo efeito era conquistado usando definições do tipo object. Como um segundo exemplo considere um compilador composto de diversos módulos. Olhamos primeiro para uma tabela de símbolos que definie uma classe para símbolos e duas funções pré-definidas. class Symbols(val compiler: Compiler) { import compiler.types._ val Add = new Symbol("+", FunType(List(IntType, IntType), IntType)) val Sub = new Symbol("-", FunType(List(IntType, IntType), IntType)) class Symbol(name: String, tpe: Type) { override def toString = name + ": " + tpe } } O módulo Symbols é parametrizado com uma instância de Compiler que permite o acesso a outros serviços, tais como o módulo de tipos. Em nosso exemplo, há somente duas funções pré-definidas, adição e subtração e suas definições dependem do módulo types. class Types(val compiler: Compiler) { import compiler.symtab._ 113 abstract class Type case class FunType(args: List[Type], res: Type) extends Type case class NamedType(sym: Symbol) extends Type case object IntType extends Type } Para conectar os dois componentes, um objeto do tipo compilador é criado e passado com parâmetro para os dois componentes. class Compiler { val symtab = new Symbols(this) val types = new Types(this) } Infelizmente, esta abordagem falha em tempo de execução, pois o módulo symtab depende do módulo types. De maneira geral, a dependência entre os módulos pode ficar complicada e conseguir a ordem correta de inicialização é difícil ou, até mesmo, impossível, quando existem dependências cíclicas. A maneira mais simples de corrigir este erro é tornar estes campos lazy e deixar o compilador descobrir qual é a ordem correta de inicialização. class Compiler { lazy val symtab = new Symbols(this) lazy val types = new Types(this) } Aogra os dois módulos são inicializados no primeiro acesso e o compilador pode executar da forma esperada. Sintaxe O modificador lazy é permitido apenas na definição de valores concretos. Todas as regras válidas para definição de valores se aplicam também para valores do tipo lazy, com uma restrição a menos: valores locais recursivos são permitidos. Capítulo 15 Parâmetros Conversões Implícitos e Parâmetros implícitos e conversões são ferramentas poderosas para personalizar bibliotecas existentes e para criar abstrações de alto-nível. Como exemplo, vamos começar com uma classe abstrata SemiGroup que contém uma operação não especificada chamada add. abstract class SemiGroup[A] { def add(x: A, y: A): A } Aqui está a sub-classe abstrata Monoid que herda de SemiGroup e inclui um novo elemento unit. abstract class Monoid[A] extends SemiGroup[A] { def unit: A } Aqui estão duas implementações de Monoid: object stringMonoid extends Monoid[String] { def add(x: String, y: String): String = x.concat(y) def unit: String = "" } object intMonoid extends Monoid[Int] { def add(x: Int, y: Int): Int = x + y def unit: Int = 0 } 116 Parâmetros Implícitos e Conversões O método sum, que funciona com monoids arbitrários pode ser escrito em Scala da seguinte forma: def sum[A](xs: List[A])(m: Monoid[A]): A = if (xs.isEmpty) m.unit else m.add(xs.head, sum(m)(xs.tail) O método sum pode ser chamado da seguinte forma: sum(List("a", "bc", "def"))(stringMonoid) sum(List(1, 2, 3))(intMonoid) Embora tudo isso funcione, o código não fica muito limpo. O problema é que as implementações de monoid tem que ser passada para todo o código que as usa. Nós gostaríamos que o sistema conseguisse descobrir os argumentos de forma correta e automática, semelhante ao que é feito quando o tipo de parâmetros é inferido. Isto é o que os parâmetros implícitos permite. Noções Básicas sobre Parâmetros Implícitos Na versão 2 de Scala, há uma nova palavra-chave (implicit) que pode ser usada no início de uma lista de parâmetros. Sintaxe: ParamClauses ::= {‘(’ [Param {‘,’ Param}] ’)’} [‘(’ implicit Param {‘,’ Param} ‘)’] Se esta palavra chave estiver presente, todos os parâmetros da lista serão implícitos. Por exemplo, a versão de sum a seguir tem m como um parâmetro implícito. def sum[A](xs: List[A])(implicit m: Monoid[A]): A = if (xs.isEmpty) m.unit else m.add(xs.head, sum(xs.tail)) Como pode ser visto no exemplo, é possível combinar parâmetros normais e implícitos. No entanto, pode haver apenas uma lista de parâmetros implícitos e ela deve vir por último. implicit também pode ser usado como um modificador de declarações e definições. Exemplos: implicit object stringMonoid extends Monoid[String] { def add(x: String, y: String): String = x.concat(y) def unit: String = "" } implicit object intMonoid extends Monoid[Int] { def add(x: Int, y: Int): Int = x + y def unit: Int = 0 } 117 A principal ideia por trás de parâmetros implícitos é que argumentos para eles podem ser omitidos em uma chamada de método. Se os argumentos estiverem ausentes, eles serão inferidos pelo compilador de Scala. Os argumento atuais que são elegíveis a serem passados implicitamente por parâmetro são todos os identificadores X que puderem ser acessado no ponto em que o método é chamado sem o uso de prefixo e que denotem uma definição implícita ou parâmetro. Se existem mais do que um argumento elegíveis que casam com o tipo do parâmetro, o compilador Scala vai escolher o mais específico usando as regras padrâo para resolução de sobrecarga. Por exemplo, dada a chamada sum(List(1, 2, 3)) está em um contexto onde stringMonoid e intMonoid estão visíveis. Nós sabemos que o tipo genérico A do método sum precisa ser instanciado usando int. O único valor elegível que casa com o parâmetro implícito Monoid[Int] é o intMonoid e por isso este objeto será passado como parâmetro implícito. A discussão mostra também que parâmetros implícitos são inferidos depois que o tipo dos outros parâmetros são inferidos. Conversões Implícitas Digamos que você tenha uma expressão E do tipo T onde é esperado um tipo S. T não é um sub-tipo de S e nem é conversível para S por alguma conversão pré-definida. Nesse caso, o compilador Scala irá tentar como um último recurso uma conversão implícita I (E ). Onde, I é um identificador que denota a definição implícita ou parâmetro que seja acessível sem prefixo no ponto da conversão e que contenha uma função ao qual podem ser usados como argumentos valores do tipo T e cujo resultado seja do tipo S ou sub-tipo do mesmo. Conversões Implícitas podem também ser usadas na seleção de membros. Dada a chamada E .x onde x não é um membro do tipo E , o compilador Scala irá tentar inserir uma conversão implícita I (E ).x, de maneira que x seja um membro de I (E ). Aqui está um exemplo de uma função de conversão implícita que converte inteiros em instâncias da classe scala.Ordered: implicit def int2ordered(x: Int): Ordered[Int] = new Ordered[Int] { def compare(y: Int): Int = if (x < y) -1 else if (x > y) 1 else 0 } 118 Parâmetros Implícitos e Conversões Parâmetros de Tipos Delimitados Parâmetros de Tipos Delimitados1 são uma sintaxe simplificada2 e conveniente para parâmetros implícitos. Considere por exemplo, um método de ordenação genérico: def sort[A <% Ordered[A]](xs: List[A]): List[A] = if (xs.isEmpty || xs.tail.isEmpty) xs else { val {ys, zs} = xs.splitAt(xs.length / 2) merge(ys, zs) } O parâmetros de tipo delimitado [A <% Ordered[A]] expressa que sort pode ser usado com listas do tipo A onde exista uma conversão implícita de A para Ordered[A]. A definição é tratada como um atalho para a seguinte assinatura de método com parâmetro implícito: def sort[A](xs: List[A])(implicit c : A => Ordered[A]): List[A] = ... (Aqui o nome do parâmetro c foi escolhido arbitrariamente, garantindo-se que não era um nome já usado no programa.) Como um exemplo mais detalhado, considere o método merge que vêm com o método sort citado anteriormente: def merge[A <% Ordered[A]](xs: List[A], ys: List[A]): List[A] = if (xs.isEmpty) ys else if (ys.isEmpty) xs else if (xs.head < ys.head) xs.head :: merge(xs.tail, ys) else if ys.head :: merge(xs, ys.tail) Depois de expandir os parâmetros de tipo delimitado e inserir as conversões implícitas a implementação deste método ficaria assim: def merge[A](xs: List[A], ys: List[A]) (implicit c : A => Ordered[A]): List[A] = if (xs.isEmpty) ys else if (ys.isEmpty) xs else if (c(xs.head) < ys.head) xs.head :: merge(xs.tail, ys) else if ys.head :: merge(xs, ys.tail)(c) As duas últimas linhas da definição do método ilustram dois diferentes usos do parâmetro implícito c. Ele é usado na conversão da condição na penúltima linha e passado como parãmetro implícito na chamada recursiva de merge na última linha. 1 2 View bounds Syntactic sugar Capítulo 16 Inferência de Hindley/Milner Tipos de Este capítulo demonstra os tipos de dados Scala e o casamento de padrôes através do desenvolvimento de um sistema de inferência de tipos no estilo de Hindley/Milner [Mil78]. A linguagem fonte para a inferência de tipos é o cálculo lambda com uma construção chamada Mini-ML. As árvoes de sintaxe abstrata para o Mini-ML são representadas através do tipo de dados Terms. abstract class Term {} case class Var(x: String) extends Term { override def toString = x } case class Lam(x: String, e: Term) extends Term { override def toString = "(\\" + x + "." + e + ")" } case class App(f: Term, e: Term) extends Term { override def toString = "(" + f + " " + e + ")" } case class Let(x: String, e: Term, f: Term) extends Term { override def toString = "let " + x + " = " + e + " in " + f } Há quatro construtores de termos: Var para variáveis, Lam para abstrações lambda, App para aplicação e Let para expressões de atribuição. Cada uma destas classes sobrescreve o método toString da classe Any, de forma que os termos podem ser impressos de forma legível. A seguir, definimos os tipos que serão computados pelo sistema de inferência. sealed abstract class Type {} case class Tyvar(a: String) extends Type { 120 Inferência de Tipos de Hindley/Milner override def toString = a } case class Arrow(t1: Type, t2: Type) extends Type { override def toString = "(" + t1 + "->" + t2 + ")" } case class Tycon(k: String, ts: List[Type]) extends Type { override def toString = k + (if (ts.isEmpty) "" else ts.mkString("[", ",", "]")) } Há três construtores de tipos: Tyvar para o tipo variável, Arrow para o tipo função e Tycon para o tipo construtor como, por exemplo, Boolean ou List. O tipo construtor tem como componente uma lista de tipos que contem seus parâmetros. Esta lista é vaiz para tipos constantes como Boolean. Assim como nos construtores de termos, implementamos o método toString para mostrar os tipos de forma legível. Note que Type foi declarada com o modificador sealed. Isto significa que nenhuma sub-classe ou construtores de dados que extendam Type podem ser declarados fora da sequência de definições em que Type foi definida. Isto torna Type um tipo algébrico fechado com exatas três alternativas. Em contraste, o tipo Term is um tipo algébrico aberto onde mais alternativas poderâo ser definidas. As principais partes da inferência de tipos estão contidas no objeto typeInfer. Começamos com uma função utilitária que cria novos tipos variáveis: object typeInfer { private var n: Int = 0 def newTyvar(): Type = { n += 1; Tyvar("a" + n) } Em seguinda, definimos uma classe para substituições. A substituição é uma função idempotente de tipos variáveis para tipos. Ela mapeia um número finito de tipos variáveis para alguns tipos e não modifica todos os outros tipos. O significado de uma substituição é extendido a partir de um mapeamento de tipos para tipos. Também extendemos o significado da substituição para ambientes que serão definidos posteriormente. abstract class Subst extends Function1[Type,Type] { def lookup(x: Tyvar): Type def apply(t: Type): Type = t match { case tv @ Tyvar(a) => val u = lookup(tv); if (t == u) t else apply(u) case Arrow(t1, t2) => Arrow(apply(t1), apply(t2)) case Tycon(k, ts) => Tycon(k, ts map apply) } def apply(env: Env): Env = env.map({ case (x, TypeScheme(tyvars, tpe)) => 121 // assume que tyvars nao ocorre nesta substituicao (x, TypeScheme(tyvars, apply(tpe))) }) def extend(x: Tyvar, t: Type) = new Subst { def lookup(y: Tyvar): Type = if (x == y) t else Subst.this.lookup(y) } } val emptySubst = new Subst { def lookup(t: Tyvar): Type = t } Representamos substituições como funções do tipo Type => Type. Isto pode ser obtido fazendo com que a classe Subst herde da tipo função unária Function1[Type, Type]1 . Para ser uma instância de Subst, uma substituição s tem que implementar o método apply que recebe como argumento um Type e retorna um outro Type como resultado. A função aplicação s(t) é interpretada como s.apply(t). O método lookup é abstrato na classe Subst. Existem duas formas concretas de substituição que diferem em como elas implementam este método. Uma forma é definida pelo valor emptySubst e a outra é definida pelo método extend na classe Subst. O próximo tipo de dado descreve esquemas de tipos, que consistem de um tipo e uma lista de nomes de tipos variáveis que aparecem universalmente quantificados no esquema de tipos. Por exemplo o esquema de tipos ∀a∀b.a → b seria representado no checador de tipos como: TypeScheme(List(Tyvar("a"), Tyvar("b")), Arrow(Tyvar("a"), Tyvar("b"))) . A definição da classe esquema de tipos não contém uma cláusula extends; isso quer dizer que um esquema de tipos herda diretamente da classe AnyRef. Embora exista apenas uma única maneira de construir um esquema de tipos, uma representação usando classe case foi escolhida, pois oferece formas convenientes de acessar as partes de uma instância deste tipo. case class TypeScheme(tyvars: List[Tyvar], tpe: Type) { def newInstance: Type = { (emptySubst /: tyvars) ((s, tv) => s.extend(tv, newTyvar())) (tpe) } } Os objetos de esquemas de tipos vem com o método newInstance, que retorna o tipo contido no esquema depois de todos os tipos variáveis tiverem sido renomeados para novas variáveis. A implementação deste método reduz (com /:) 1 A classe herda do tipo função como um mixin, ao invés de uma super-classe direta. Isto ocorre porque na implementação atual de Scala, o tipo Function1 é uma interface Java que não pode ser uma super-classe de uma outra classe. 122 Inferência de Tipos de Hindley/Milner os tipos variáveis do esquema de tipos com uma função que extende uma dada substituição s renomeando um dado tipo variável tv em um novo tipo variável. A substituição resultante renomeia todos os tipos variáveis do esquema em novos. Esta substituição é então aplicada o tipo do esquema de tipos. O último tipo que necessitamos no sistema de inferência de tipos é Env, um tipo para os ambientes, que associa nomes de variáveis à esquema de tipos. Eles são representados pelo tipo Env no módulo typeInfer: type Env = List[(String, TypeScheme)] Existem duas operações nos ambientes. A função lookup retorna o esquema de tipos associado com um dado nome ou null se o nome não foi registrado no ambiente. def lookup(env: Env, x: String): TypeScheme = env match { case List() => null case (y, t) :: env1 => if (x == y) t else lookup(env1, x) } A função gen retorna um esquema de tipos dado um tipo, quantificando todos os tipos variáveis que estão livres no tipo, mas não no ambiente. def gen(env: Env, t: Type): TypeScheme = TypeScheme(tyvars(t) diff tyvars(env), t) o conjunto de tipos variáveis livres é simplesmente o conjunto de todos os tipos variáveis que ocorrem no tipo. É representado por uma lista de tipos variáveis construído da seguinte maneira. def tyvars(t: Type): List[Tyvar] = t match { case tv @ Tyvar(a) => List(tv) case Arrow(t1, t2) => tyvars(t1) union tyvars(t2) case Tycon(k, ts) => (List[Tyvar]() /: ts) ((tvs, t) => tvs union tyvars(t)) } Note que a sintaxe tv @ ... no primeiro padrão introduz a variável que está ligada ao padrão seguinte. Note também que o tipo parâmetro [Tyvar] explícito na expressão da terceita cláusula é necessário para que a inferência de tipos locais funcione. O conjunto de tipos variáveis livres de um esquema de tipos é o conjunto de tipos variáveis livre do seu tipo componente, excluíndo-se quaisquer tipos variáveis quantificáveis. 123 def tyvars(ts: TypeScheme): List[Tyvar] = tyvars(ts.tpe) diff ts.tyvars Finalmente, o conjunto tipos variáveis livres de um ambiente é a união de todos os tipos variáveis livres de todos os esquemas de tipos registrados neste ambiente. def tyvars(env: Env): List[Tyvar] = (List[Tyvar]() /: env) ((tvs, nt) => tvs union tyvars(nt._2)) A principal operação da checagem de tipos de Hindley/Milner é a unificação, que computa a substituição para fazer que dois tipos dados se tornem iguais (esta subsituição é chamada um unificador2 ). A função mgu computa o unificador mais geral de dois tipos dados t e u sob um substituição pre-existente s. Isto é, ele retorna a substituição mais geral s 0 que herda de s, e que faz com que s 0 (t ) e s 0 (u) retornem tipos iguais. def mgu(t: Type, u: Type, s: Subst): Subst = (s(t), s(u)) match { case (Tyvar(a), Tyvar(b)) if (a == b) => s case (st @ Tyvar(a), su) if !(tyvars(su) contains st) => s.extend(st, su) case (_, Tyvar(a)) => mgu(u, t, s) case (Arrow(t1, t2), Arrow(u1, u2)) => mgu(t1, u1, mgu(t2, u2, s)) case (Tycon(k1, ts), Tycon(k2, us)) if (k1 == k2) => (s /: (ts zip us)) ((s, tu) => mgu(tu._1, tu._2, s)) case _ => throw new TypeError("cannot unify " + s(t) + " with " + s(u)) } A função mgu lança uma exceção do tipo TypeError se não existir uma substituição unificadora. Isto pode ocorrer quando os dois tipos tem diferentes tipos construtores em seus lugares correspondentes ou quando um tipo variável é unificado com um tipo que contém um tipo variável dele mesmo. Estas exceções foram modeladas como instâncias de classe case que herdam um classe Exception pré-definida. case class TypeError(s: String) extends Exception(s) {} A principal tarefa do checador de tipos é implementada pela função tp. Esta função recebe como parâmetro um ambiente env, um termo e, um tipo t , e uma substituição pre-existente s. A função retorna uma substituição s 0 que herda de s e que torna s 0 (env) ` e : s 0 (t ) num julgamento de tipos derivável de acordo com as regras de derivação do sistema de tipos de Hindley/Milner[Mil78]. Uma exceção do 2 unifier 124 Inferência de Tipos de Hindley/Milner tipo TypeError é lançada se não existe uma substituição com estas características. def tp(env: Env, e: Term, t: Type, s: Subst): Subst = { current = e e match { case Var(x) => val u = lookup(env, x) if (u == null) throw new TypeError("undefined: " + x) else mgu(u.newInstance, t, s) case Lam(x, e1) => val a, b = newTyvar() val s1 = mgu(t, Arrow(a, b), s) val env1 = {x, TypeScheme(List(), a)} :: env tp(env1, e1, b, s1) case App(e1, e2) => val a = newTyvar() val s1 = tp(env, e1, Arrow(a, t), s) tp(env, e2, a, s1) case Let(x, e1, e2) => val a = newTyvar() val s1 = tp(env, e1, a, s) tp({x, gen(s1(env), s1(a))} :: env, e2, t, s1) } } var current: Term = null Para auxiliar no diagnóstico de erros, a função tp guarda o sub-termo que está sendo analisado na variável current. Com isso, se a checagem de tipos for iterrompido por uma exceção do tipo TypeError, esta variável conterá o sub-termo que causou o problema. A última função do módulo de inferência de tipos , typeOf, é uma façade simplificada para tp. Ela computa o tipo de um dado termo e em um dado ambiente env. Ela o faz através da criação de um novo tipo variável a, e da computação da substituição de tipos que faz env ` e : a se tornar um tipo derivado e retorna o resultado da aplicação da subsituição em a. def typeOf(env: Env, e: Term): Type = { val a = newTyvar() tp(env, e, a, emptySubst)(a) } }// fim typeInfer 125 Para usar o sistema de inferência de tipos, é conveniente ter um ambiente pré-definido que contém as definições das constantes mais comumente usadas. O módulo predefined define um ambiente env que contém as definições dos tipos booleanos, números e listas assim como algumas operações primitivas sobre eles. Também define um operador de ponto fixo fix, que pode ser usado para representar uma recursão. object predefined { val booleanType = Tycon("Boolean", List()) val intType = Tycon("Int", List()) def listType(t: Type) = Tycon("List", List(t)) private def gen(t: Type): typeInfer.TypeScheme = typeInfer.gen(List(), t) private val a = typeInfer.newTyvar() val env = List( {"true", gen(booleanType)}, {"false", gen(booleanType)}, {"if", gen(Arrow(booleanType, Arrow(a, Arrow(a, a))))}, {"zero", gen(intType)}, {"succ", gen(Arrow(intType, intType))}, {"nil", gen(listType(a))}, {"cons", gen(Arrow(a, Arrow(listType(a), listType(a))))}, {"isEmpty", gen(Arrow(listType(a), booleanType))}, {"head", gen(Arrow(listType(a), a))}, {"tail", gen(Arrow(listType(a), listType(a)))}, {"fix", gen(Arrow(Arrow(a, a), a))} ) } Aqui está um exemplo de como o sistema de inferência de tipos pode ser usado. Vamos definir a função showType que retorna os tipos de um dado termo computado em um dado ambiente Predefined.env: object testInfer { def showType(e: Term): String = try { typeInfer.typeOf(predefined.env, e).toString } catch { case typeInfer.TypeError(msg) => "\n cannot type: " + typeInfer.current + "\n reason: " + msg } Então a aplicação > testInfer.showType(Lam("x", App(App(Var("cons"), Var("x")), Var("nil")))) 126 Inferência de Tipos de Hindley/Milner retornará a resposta > (a6->List[a6]) Exercício 16.0.1 Extenda o Mini-ML e o sistema de inferência de tipos com uma construção letrec que permita a definição recursiva de funções. Sintaxe: letrec ident "=" term in term . Os tipos de letrec devem ser como os de let, exceto que os identificadores definidos são visíveis na expressão que está sendo definida. Usando letrec, a função length para listas seria definida da seguinte maneira. letrec length = \xs. if (isEmpty xs) zero (succ (length (tail xs))) in ... Capítulo 17 Abstracões para Concorrência Esta seção revisa padrôes comuns de concorrência e mostra como eles podem ser implementados em Scala. 17.1 Sinais e Monitores Exemplo 17.1.1 Um monitor provê mecanismos básicos para processos mutuamente exclusivos em Scala. Toda instância de AnyRef pode ser usada como um monitor através da chamada de um ou mais dos métodos apresentados a seguir. def def def def def synchronized[A] (e: => A): A wait() wait(msec: Long) notify() notifyAll() O método synchronized computa e em modo mutuamente exclusivo – em um dado momento qualquer, somente uma thread pode executar um argumento synchronized em um dado monitor. Threads podem ser paradas dentro de um monitor e esperar por um sinal. Threads podem chamar o método wait e esperar até que o método notify do mesmo objeto seja chamado por alguma outra thread. Chamadas ao método notify quando não existam threads esperando por um sinal são ignoradas. Existe também uma forma do método wait baseada em tempo em que a execução é bloqueada enquanto nenhum sinal seja recebido ou um dado espaço de tempo (dado em milisegundos) tenha passado. Além disso, há o método notifyAll que desbloqueia todas as threads que estejam esperando por um sinal. Estes métodos, assim como a classe Monitor são primitivos em Scala, ou seja, eles 128 Abstracões para Concorrência são implementados usando os mecanismos internos do sistema de execução dos programas. Tipicamente, uma thread espera até que um certa condição ocorra. Se esta condição não ocorrer até o tempo definido na chamada do método wait, a thread fica com execução suspensa até que alguma outra thread estabeleça tal condição ou que o tempo definido tenha passado. É responsabilidade desta outra thread reiniciar os processos que estavam esperando através da chamada dos métodos notify ou notifyAll. Note que não existe garantia de que um processo em espera execute imediatamente após a chamada do método notify. Pode ocorrer de outros processos que executem antes invalidem novamente esta condição, deixando as threads suspensas. Portanto, a forma correta de esperar uma condição C usa um laço do tipo while: while (!C ) wait() Como um exemplo de como os monitores são usados, aqui está uma implementação de uma classe de buffer com limites. class BoundedBuffer[A](N: Int) { var in = 0, out = 0, n = 0 val elems = new Array[A](N) def put(x: A) while (n >= elems(in) = if (n == 1) } = synchronized { N) wait() x ; in = (in + 1) % N ; n = n + 1 notifyAll() def get: A = synchronized { while (n == 0) wait() val x = elems(out) ; out = (out + 1) % N ; n = n - 1 if (n == N - 1) notifyAll() x } } E aqui está um programa usando esta classe para comunicar entre processos consumidores e produtores. import scala.concurrent.ops._ ... val buf = new BoundedBuffer[String](10) spawn { while (true) { val s = produceString ; buf.put(s) } } spawn { while (true) { val s = buf.get ; consumeString(s) } } } 17.2 SyncVars 129 O método spawn dispara uma nova thread que executa a expressão passada como parâmetro. Foi definida no objeto concurrent.ops da seguinte maneira: def spawn(p: => Unit) { val t = new Thread() { override def run() = p } t.start() } 17.2 SyncVars Uma variável sincronizada (ou syncvar) oferece as operações get e put para ler e escrever na variável. As operações get bloqueiam a execução até que o valor da variável tenha sido definido. Um operação unset coloca o valor da variável em um valor indefinido. Aqui segue uma implementação padrão de variáveis sincronizadas: package scala.concurrent class SyncVar[A] { private var isDefined: Boolean = false private var value: A = _ def get = synchronized { while (!isDefined) wait() value } def set(x: A) = synchronized { value = x; isDefined = true; notifyAll() } def isSet: Boolean = synchronized { isDefined } def unset = synchronized { isDefined = false } } 17.3 Futuros Um futuro (future) é um valor que será computado em paralelo com alguma outra thread do cliente e que será usado pelo cliente em algum momento no futuro. Futuros são usados para utilizar melhor os recursos de processamento paralelo. O uso típico é: import scala.concurrent.ops._ 130 Abstracões para Concorrência ... val x = future(someLengthyComputation) anotherLengthyComputation val y = f(x()) + g(x()) O método future é definido no objeto scala.concurrent.ops da seguinte maneira. def future[A](p: => A): Unit => A = { val result = new SyncVar[A] fork { result.set(p) } (() => result.get) } O método future recebe como parâmetro uma computação p que precisa ser calculado. O tipo da computação é arbitrário e é representando pelo tipo genérico A. O método future define um guarda result, que recebe o parâmetro que representa o resultado da computação. Ao chegar neste ponto, ele abre uma nova thread que computa o resultado e invoca o guarda result quanto o processo terminar. Em paralelo à esta thread, a função retorna uma função anônima do tipo A. Quando chamada, esta função espera que o guarda resultado tenha sido chamada, e, quando isso ocoore, retorna o resultado. Ao mesmo tempo, a função re-invoca o guarda result com o mesmo argumento, de forma que, futuras chamadas à função possam retornar o resultado imediatamente. 17.4 Computação Paralela O próximo exemplo apresenta a função par que recebe um par de computaçoes como prarâmetros e retorna o resultado destas computações em um outro par. As duas computações são executadas paralelamente. A função estã definida no objeto scala.concurrent.ops da seguinte forma: def par[A, B](xp: => A, yp: => B): (A, B) = { val y = new SyncVar[B] spawn { y set yp } (xp, y.get) } Definida no mesmo objeto está a função replicate que executa um número de réplicas de uma computação em paralelo. Cada instância replicada é passada como um número inteiro que a identifica. def replicate(start: Int, end: Int)(p: Int => Unit) { if (start == end) () else if (start + 1 == end) 17.5 Semáforos 131 p(start) else { val mid = (start + end) / 2 spawn { replicate(start, mid)(p) } replicate(mid, end)(p) } } A função a seguir usa replicate para realizar uma computação paralela em todos os elementos de um vetor. def parMap[A,B](f: A => B, xs: Array[A]): Array[B] = { val results = new Array[B](xs.length) replicate(0, xs.length) { i => results(i) = f(xs(i)) } results } 17.5 Semáforos Um mecanismo comum para sincronização de processos é o uso de travas (lock) ou semáforo. Uma trava oferece duas operações atômicas: acquire e release. Aqui está a implementação de uma trava en Scala: package scala.concurrent class Lock { var available = true def acquire = synchronized { while (!available) wait() available = false } def release = synchronized { available = true notify() } } 17.6 Leitores/Escritores Uma forma mais complexa de sincronização distingue leitores (readers) que acessam um recurso comum sem modificá-lo e escritores (writers) que podem acessar e modificar esse recurso. Para sincronizar leitores e escritores, precisamos 132 Abstracões para Concorrência implementar as operações startRead, startWrite, endRead, endWrite, de tal forma que: • podem haver múltiplos leitores concorrentemente; • só pode haver um único escritor em um dado instante; • solicitações de escrita pendentes tem prioridade sobre solicitações de leitura pendentes, mas não interrompem operações de leitura que estejam ocorrendo. A implementação de travas para leitores/escritores a seguir é baseada no conceito de caixa postal (mailbox) (ver Seção 17.10). import scala.concurrent._ class ReadersWriters { val m = new MailBox private case class Writers(n: Int), Readers(n: Int) { m send this } Writers(0); Readers(0) def startRead = m receive { case Writers(n) if n == 0 => m receive { case Readers(n) => Writers(0); Readers(n+1) } } def startWrite = m receive { case Writers(n) => Writers(n+1) m receive { case Readers(n) if n == 0 => } } def endRead = m receive { case Readers(n) => Readers(n-1) } def endWrite = m receive { case Writers(n) => Writers(n-1); if (n == 0) Readers(0) } } 17.7 Canais Assíncronos Um modo fundamental de comunicação entre processos é o canal assíncrono. Sua implementação faz usa da seguinte classe para listas-ligadas: class LinkedList[A] { var elem: A = _ var next: LinkedList[A] = null 17.8 Canais Síncronos 133 } Para facilitar a inserção e remoção de elementos nas listas ligadas, cada referência na lista ligada aponta para o nó que precede o nó que conceitualmente forma o topo da lista. Listas ligadas vazias começam com um nó fantasma, cujo o sucessor é null. A classe canal usa a lista ligada para armazenar dados que foram enviados, mas ainda não foram lidos. No lado oposto, threads que necessitam ler de um canal vazio, registram sua presença incrementando o campo nreaders e esperando serem notificadas. package scala.concurrent class Channel[A] { class LinkedList[A] { var elem: A = _ var next: LinkedList[A] = null } private var written = new LinkedList[A] private var lastWritten = written private var nreaders = 0 def write(x: A) = synchronized { lastWritten.elem = x lastWritten.next = new LinkedList[A] lastWritten = lastWritten.next if (nreaders > 0) notify() } def read: A = synchronized { if (written.next == null) { nreaders = nreaders + 1; wait(); nreaders = nreaders - 1 } val x = written.elem written = written.next x } } 17.8 Canais Síncronos Aqui está uma implementação de canais síncronos, onde quem envia uma mensagem tem sua execução bloqueada até que esta mensagem seja recebida. Canais síncronos precisam apenas de uma única variável para armazenar as 134 Abstracões para Concorrência mensagens em trânsito, mas de três sinais para coordenar os processos de leitura e escrita. package scala.concurrent class SyncChannel[A] { private var data: A = _ private var reading = false private var writing = false def write(x: A) = synchronized { while (writing) wait() data = x writing = true if (reading) notifyAll() else while (!reading) wait() } def read: A = synchronized { while (reading) wait() reading = true while (!writing) wait() val x = data writing = false reading = false notifyAll() x } } 17.9 Trabalhadores Aqui está uma implementação de um servidor de computação em Scala. O servidor implementa um método future que computa uma dada expressão paralelamente com quem chamou o método. Diferenmente da implementação de futuros da seção 17.3 o servidor computa os futuros somente com um número pré-definido de threads. Uma possível implementação do servidor poderia executar cada thread em um processador separado, e com isso evitar o custo inerente à mudança de contexto entre muitas threads em um único processo. import scala.concurrent._, scala.concurrent.ops._ class ComputeServer(n: Int) { private abstract class Job { 17.9 Trabalhadores 135 type T def task: T def ret(x: T) } private val openJobs = new Channel[Job]() private def processor(i: Int) { while (true) { val job = openJobs.read job.ret(job.task) } } def future[A](p: => A): () => A = { val reply = new SyncVar[A]() openJobs.write{ new Job { type T = A def task = p def ret(x: A) = reply.set(x) } } () => reply.get } spawn(replicate(0, n) { processor }) } Expressões a serem computadas (ex.: parâmetros da chamada de um future) são escritos no canal openJobs . Um job é um objeto em que: • Um tipo abstrato T descreve o resultado de sua computação. • Um método sem parâmetros (task) do tipo t representa a expressão a ser computada. • Um método ret consome o resultado, quando este tiver sido computado. O servidor de computação cria n processos no processador como parte de sua inicialização. Cada um destes processos repetidamente consome um trabalho em aberto no canal openJobs, computa o métodotask e passa o resultado para o método ret. O método polimórfico future cria um novo job quando o método ret é implementado por um guarda chamado reply e insere este job no conjunto de trabalhos em aberto. Este espera até que o método guarda reply correspondente for chamado. 136 Abstracões para Concorrência O exemplo mostra o uso de tipos abstratos. Um tipo abstrato t mantém controle do tipo do resultado de um job que pode variar entre diferentes jobs. Sem os tipos abstratos seria impossível implementar a mesma classe para o usuário de uma forma que garantisse a segurança do sistemas de tipos estaticamente. Seriam necessários testes de tipos dinâmicos e o uso de casts. Aqui um trecho de código que usa o servidor de computação para calcular a expressão 41 + 1. object Test with Executable { val server = new ComputeServer(1) val f = server.future(41 + 1) println(f()) } 17.10 Caixas Postais Caixas postais são construções flexíveis de alto nível para comunicação e sincronização de processos. Elas permitem enviar e receber mensagens. Um mensagem neste contexto é um objeto arbitrário. Há uma mensagem especial, chamada TIMEOUT que é usada para sinalizar um time-out. case object TIMEOUT Caixas postais implentam os seguintes métodos class def def def } MailBox { send(msg: Any) receive[A](f: PartialFunction[Any, A]): A receiveWithin[A](msec: Long)(f: PartialFunction[Any, A]): A O estado de uma caixa postal consiste de vários conjuntos de mensagens. As mensagens são adicionadas à caix postal através do método send. As mensagens são removidas usando o método receive, que passa um processador de mensagens f como parâmetro. O processador de mensagens é uma função parcial que tenha como resultado um tipo arbitrário. Normalmente, esta função é implementada usando uma expressão de casamento de padrôes. O método receive suspende sua execução até que exista uma mensagem na caixa postal para o qual o seu processador de mensagens foi definido. A mensagem que casa com um processador é então removida da caixa postal e a thread suspensa é reiniciada aplicando o processador de mensagens à mensagem. Tanto mensagens enviadas e receptores são ordenados cronologicamente. Um receptor r é aplicado à uma mensagem m se for capaz de recebê-la (tipo) e somente se não existe um outro par {message, receiver} que preceda m, r no ordenamento cronológico parcial destes pares. 17.10 Caixas Postais 137 Como um simples exemplo de como caixas postais são usadas, considere o buffer de uma posição: class OnePlaceBuffer { private val m = new MailBox // Caixa postal interna private case class Empty, Full(x: Int) // Tipo das mensagens que pode tratar m send Empty // Inicializa\c{c}\~{a}o def write(x: Int) { m receive { case Empty => m send Full(x) } } def read: Int = m receive { case Full(x) => m send Empty; x } } Aqui como uma classe caixa postal pode ser implementada: class MailBox { private abstract class Receiver extends Signal { def isDefined(msg: Any): Boolean var msg = null } Definimos um classe interna para receptores com um método de teste isDefined, que indica se o receptor pode tratar uma dada mensagem. O receptor herda da classe Signal o método notify que serve para "acordar" a thread para receptora. Quanto a thread receptora é reiniciada, a mensagem que precisa ser aplicada é armazenada na variável msg do Receiver. private private private private val var val var sent = new LinkedList[Any] lastSent = sent receivers = new LinkedList[Receiver] lastReceiver = receivers Um classe caixa postal mantém duas lista ligadas, uma para mensagens enviadas e não consumidas e outra para mensagens esperando receptores. def send(msg: Any) = synchronized { var r = receivers, r1 = r.next while (r1 != null && !r1.elem.isDefined(msg)) { r = r1; r1 = r1.next } if (r1 != null) { r.next = r1.next; r1.elem.msg = msg; r1.elem.notify } else { lastSent = insert(lastSent, msg) } } 138 Abstracões para Concorrência Inicialmente, o método send verifica se há um receptor que pode ser aplicado à mensagem enviada. Se sim, o receptor é notificado. Se não, a mensagem é anexada à lista ligada de mensagens enviadas. def receive[A](f: PartialFunction[Any, A]): A = { val msg: Any = synchronized { var s = sent, s1 = s.next while (s1 != null && !f.isDefinedAt(s1.elem)) { s = s1; s1 = s1.next } if (s1 != null) { s.next = s1.next; s1.elem } else { val r = insert(lastReceiver, new Receiver { def isDefined(msg: Any) = f.isDefinedAt(msg) }) lastReceiver = r r.elem.wait() r.elem.msg } } f(msg) } Inicialmente, o método receive verifica se existe uma função processadora de mensagens f que pode ser aplicada à mensagem que já foi enviada, mas ainda não foi consumida. Se sim, a thread continua imediatamente aplicando a função f à mensagem. Se não, um novo receptor é criado adicionado à lista receivers e a thread esperará por uma notificação neste receptor. Uma vez que a thread seja reiniciada, ela continua aplicando a função f que foi armazenado no receptor à mensagem. O método insert na lista ligada é definido da seguinte maneira: def insert(l: LinkedList[A], x: A): LinkedList[A] = { l.next = new LinkedList[A] l.next.elem = x l.next.next = l.next l } A classe caixa postal também oferece o método receiveWithin que suspende a execução por um tempo máximo especificado. Se nenhuma mensagem for recebida no intervalo de tempo especificado (dado em milissegundos), o parâmetro do processador de mensagens f será desbloqueado com a mensagem especial TIMEOUT. A implementação de receiveWithin é bastante parecida com a de receive: 17.11 Actors 139 def receiveWithin[A](msec: Long)(f: PartialFunction[Any, A]): A = { val msg: Any = synchronized { var s = sent, s1 = s.next while (s1 != null && !f.isDefinedAt(s1.elem)) { s = s1; s1 = s1.next } if (s1 != null) { s.next = s1.next; s1.elem } else { val r = insert(lastReceiver, new Receiver { def isDefined(msg: Any) = f.isDefinedAt(msg) }) lastReceiver = r r.elem.wait(msec) if (r.elem.msg == null) r.elem.msg = TIMEOUT r.elem.msg } } f(msg) } } // fim MailBox A única diferença é a chamada ao método wait que recebe o tempo de espera e a linha de código que vem em seguida, onde a mensagem TIMEOUT é aplicada. 17.11 Actors O capítulo 3 apresentou um programa como exemplo de implementação de um serviço de leilão eletrônico. Este serviço foi baseado em atores quue representam processos de alto nível e trabalham inspecionando as mensagens em sua caixa-postal usando casamento de padrões. Uma implementação mais refinada e otimizada de atores pode ser encontrada no pacote scala.actors. Agora mostraremos um rascunho de uma versão simplificada da biblioteca de atores. O código apresentado a seguir é diferente da implementação presente no pacote scala.actors e, portanto, deve ser vista como um exemplo de como uma versão simplificada dos atores poderia ser implementada. Ela não descreve como os atores foram definidos e implementados na biblioteca padrão de Scala. Caso deseje essa informação, por favor consulte a documentação da API de Scala. Um ator simplificado é apenas uma thread cujas primitivas de comunicação são aquelas de uma caixa postal. Tal ator pode ser definido como uma composição mixin da extensão da classe Java padrão Thread com a classe MailBox. Nós também sobre-escrevemos o método run da classe Thread, de tal forma que ele execute o comportamento de um ator que é definido pelo método act. O método ! 140 Abstracões para Concorrência simplesmente chama o método send da classe MailBox: abstract class Actor extends Thread with MailBox { def act(): Unit override def run(): Unit = act() def !(msg: Any) = send(msg) } Bibliografia [ASS96] Harold Abelson, Gerald Jay Sussman, and Julie Sussman. The Structure and Interpretation of Computer Programs, 2nd edition. MIT Press, Cambridge, Massachusetts, 1996. [Mil78] Robin Milner. A Theory of Type Polymorphism in Programming. Journal of Computer and System Sciences, 17:348–375, Dec 1978.